#!/usr/bin/env python3 """mesm-tts text-to-speech with onnxruntime: ten English voices, 2.3-6.5 MB each, CPU only, no GPU or PyTorch. pip install -r requirements.txt python tts.py "Hello there. How are you?" --voice hadley --out hello.wav python tts.py --list from tts import MesmTTS tts = MesmTTS("miles") # downloads the voice's ONNX file on first use if it is not next to this script audio = tts.synth("Good evening.") # float32 numpy array, 24 kHz mono tts.save("Good evening.", "evening.wav") Pipeline (the same one the mesmer.tools browser engine runs, see web/): text -> sentences -> misaki G2P (US English, eSpeak NG fallback for words outside its lexicon; exactly the front end the models were trained on) -> phoneme ids (tokenizer.json, '$' = 0 at both ends, at most 512 per call) -> ONNX model (input_ids int64 [1, T], speed float32 [1] -> waveform float32 [1, samples] @ 24 kHz) -> natural sentence joins (Phonon voices) -> per-voice level (gain_db) with a soft knee above 0.9. The models draw their own noise, so two runs of the same text differ slightly in the noisy parts. """ import argparse, json, os, re, sys, time, wave import numpy as np import onnxruntime as ort REPO_ID = "mesmertech/mesm-tts" HERE = os.path.dirname(os.path.abspath(__file__)) SR = 24000 MAX_IDS = 512 def _file(path, repo_id=REPO_ID): """A repo file: next to this script if present (a clone or snapshot of the repo), else from the Hugging Face Hub.""" local = os.path.join(HERE, path) if os.path.exists(local): return local from huggingface_hub import hf_hub_download return hf_hub_download(repo_id, path) # ---- text: sentences, as web/v2/paradee.mjs splitSentences ------------------------------------------------------------ # Split after . ! ? … (and any closing quotes / brackets) followed by a capital, digit, quote or bracket (the unit the models # were trained on), except after titles, common abbreviations and initials ("Dr. Smith", "J. R. R. Tolkien", "U.S. Army"). _ABBREV = re.compile(r'(?:^|[\s"“(])(?:Mr|Mrs|Ms|Dr|St|Jr|Sr|Prof|Rev|Gen|Gov|Sen|Rep|Capt|Lt|Col|Sgt|Adm|Mt|Ft|No|Nos|vs|etc|' r'Inc|Ltd|Co|Corp|Bros|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec|[A-Z]|(?:[A-Za-z]\.)+[A-Za-z])\.$') _END = re.compile(r'[.!?…]["”’\')\]]*$') _BREAK = re.compile(r'\s+(?=[A-Z"“(\d])') def split_sentences(text): t = re.sub(r"\s+", " ", text).strip() pieces, start = [], 0 for m in _BREAK.finditer(t): if _END.search(t, 0, m.start()): pieces.append(t[start:m.start()]); start = m.end() pieces.append(t[start:]) out = [] for p in pieces: if out and _ABBREV.search(out[-1]): out[-1] += " " + p else: out.append(p) return [s.strip() for s in out if s.strip()] def split_phonemes(ps, limit=MAX_IDS - 12): """A phoneme string longer than the model's 512-id window is cut at the last clause punctuation, else the last word break.""" out = [] while len(ps) > limit: head = ps[:limit] k = max(head.rfind(p) for p in (", ", "; ", ": ", "— ", "… ")) k = k + 1 if k > limit / 3 else head.rfind(" ") if k <= 0: k = len(head) out.append(ps[:k].strip()); ps = ps[k:].strip() if ps: out.append(ps) return out class Tokenizer: """tokenizer.json is a character map: drop characters outside its set, one id per character, '$' (0) at both ends.""" def __init__(self, path): j = json.load(open(path, encoding="utf-8")) self.vocab = j["model"]["vocab"] self.strip = re.compile(j["normalizer"]["pattern"]["Regex"]) def __call__(self, ps): s = self.strip.sub("", ps) return np.array(([0] + [self.vocab[c] for c in s if c in self.vocab] + [0])[:MAX_IDS], np.int64) def make_g2p(): """misaki's English G2P exactly as Kokoro's KPipeline(lang_code="a") builds it, which is what produced the training data.""" from misaki import en try: from misaki import espeak fallback = espeak.EspeakFallback(british=False) except Exception as e: # eSpeak NG missing: words outside misaki's lexicon (mostly rare names) are skipped print(f"note: no eSpeak NG fallback ({e}); words outside misaki's lexicon will be skipped", file=sys.stderr) fallback = None g2p = en.G2P(trf=False, british=False, fallback=fallback, unk="") return lambda sentence: g2p(sentence)[0] # ---- audio: natural sentence joins (web/pv/flow.mjs = the reference pv_flow.join), level --------------------------------- _FR = 240 # 10 ms frames _FADE = np.linspace(0, 1, _FR, dtype=np.float32) def _edges(y): """[first speech sample, end of the last speech frame]; speech = 10 ms frames within 35 dB of the loudest one.""" n = len(y) // _FR db = 10 * np.log10((y[:n * _FR].reshape(n, _FR) ** 2).mean(1) + np.float32(1e-12)) sp = np.where(db > db.max() - 35)[0] return sp[0] * _FR, (sp[-1] + 1) * _FR def join(parts, gap_scale=1.0, lead_keep_s=0.02): """Sentences 2..n keep lead_keep_s of leading silence; sentences 1..n-1 keep gap_scale of their trailing silence.""" if not parts: return np.zeros(0, np.float32) if gap_scale == 1.0: return np.concatenate(parts) lead, out = round(lead_keep_s * SR), [] for i, y in enumerate(parts): y = y.copy() a, _ = _edges(y) if i > 0 and a > lead: y = y[a - lead:]; y[:_FR] *= _FADE _, b = _edges(y) tail = len(y) - b; keep = int(tail * gap_scale) if i < len(parts) - 1 and keep < tail: y = y[:b + keep]; y[-_FR:] *= _FADE[::-1] out.append(y) return np.concatenate(out) def level(y, gain_db): """The voice's gain, then a soft knee above 0.9 (identity below; 0.9 + 0.1 tanh((|x| - 0.9) / 0.1) above): never clips.""" y = y * np.float32(10 ** (gain_db / 20)) a = np.abs(y) return np.where(a <= 0.9, y, np.sign(y) * (0.9 + 0.1 * np.tanh((a - 0.9) / 0.1))).astype(np.float32) def write_wav(path, y, sr=SR): pcm = (np.clip(y, -1, 1) * 32767).round().astype(" [(sentence, phonemes, ids)], long sentences split to fit the model's window.""" out = [] for s in split_sentences(text): for ps in split_phonemes(self.g2p(s)): ids = self.tokenize(ps) if len(ids) > 2: out.append((s, ps, ids)) return out def synth_ids(self, ids, speed=1.0): """One window of phoneme ids -> raw float32 audio (no join, no gain).""" return self.session.run(None, {"input_ids": ids[None], "speed": np.array([speed], np.float32)})[0][0] def synth(self, text, speed=1.0): """Whole text -> float32 audio @ 24 kHz, joined and levelled as the browser plays it. speed > 1 talks faster.""" parts = [self.synth_ids(ids, speed) for _, _, ids in self.plan(text)] j = self.join or {} return level(join(parts, j.get("gap_scale", 1.0), j.get("lead_keep_s", 0.02)), self.gain_db) def save(self, text, path, speed=1.0): y = self.synth(text, speed) write_wav(path, y) return y def main(): ap = argparse.ArgumentParser(description="mesm-tts text-to-speech (onnxruntime, CPU).") ap.add_argument("text", nargs="?", help="text to speak (or --file)") ap.add_argument("-v", "--voice", default="heart", help="voice id (see --list); default heart") ap.add_argument("-o", "--out", default="out.wav") ap.add_argument("-f", "--file", help="read the text from this file") ap.add_argument("--model", help="Heart only: v2f_lite (default), v2f or v2g") ap.add_argument("--fp32-compute", action="store_true", help="Heart only: the *_noint8 twin (no int8 matrix products)") ap.add_argument("--speed", type=float, default=1.0, help="speaking rate (1 = as trained; 1.2 = 20%% faster)") ap.add_argument("--threads", type=int, default=1, help="onnxruntime threads (default 1; the models are small)") ap.add_argument("--repo", default=REPO_ID, help="Hugging Face repo to fetch files from when they are not local") ap.add_argument("--list", action="store_true", help="list the voices and exit") a = ap.parse_args() if a.list: for k, v in load_voices(a.repo).items(): print(f"{k:8s} {v['name']:7s} {v['gender']:6s} {v['accent'].upper():3s} {v['tag']:9s} from {v['teacher']}") return text = open(a.file, encoding="utf-8").read() if a.file else a.text if not text: ap.error("give the text to speak, or --file") t0 = time.perf_counter() tts = MesmTTS(a.voice, a.model, a.fp32_compute, a.threads, a.repo) t1 = time.perf_counter() plan = tts.plan(text) t2 = time.perf_counter() parts = [tts.synth_ids(ids, a.speed) for _, _, ids in plan] t3 = time.perf_counter() j = tts.join or {} y = level(join(parts, j.get("gap_scale", 1.0), j.get("lead_keep_s", 0.02)), tts.gain_db) write_wav(a.out, y) dur = len(y) / SR print(f"{a.out}: {dur:.1f} s of audio ({tts.info['name']}). load {1e3 * (t1 - t0):.0f} ms, phonemes {1e3 * (t2 - t1):.0f} ms, " f"synthesis {1e3 * (t3 - t2):.0f} ms = {dur / max(t3 - t2, 1e-9):.0f}x real time on {a.threads} thread(s)") if __name__ == "__main__": main()