#!/usr/bin/env python3 """ Matcha-TTS-PL-Edge: Polish text-to-speech on a CPU (onnxruntime + numpy), whole sentences or streamed. python tts.py "Dzień dobry! W czym mogę pomóc?" -o out.wav # int8, 2 ODE steps, streamed python tts.py "…" --precision fp32 --steps 4 --no-stream -o out.wav # best quality, whole sentences python tts.py "…" --play # play while it is generated (pip install sounddevice) from tts import EdgeTTS tts = EdgeTTS(voice="male_voxcpm_2", precision="int8") for audio in tts.stream("Dzień dobry! Mam dla ciebie kilka informacji."): # float32 pieces at 22 050 Hz play(audio) wav = tts.synthesize("Dzień dobry!") # the whole utterance at once Pipeline per sentence: text encoder + duration predictor on the whole sentence -> flow-matching decoder (2 or 4 ODE steps) -> HiFi-GAN V2. Streaming runs the decoder on chunks of `chunk` mel frames (11.6 ms each) with `ode_ctx` frames of context on each side, and the vocoder on the mel as it arrives, `voc_ctx` frames behind (12 = bit-exact); text is split into sentences (merged up to `min_sentence_chars`) and the next sentence is prepared while the previous one plays. Defaults are in each voice's voices.json ("stream"): chunk 43, ode_ctx 16 (suggested 8–32), voc_ctx 12 (suggested 8–12), min_sentence_chars 40. """ import argparse import json import os import re import sys import time import wave from pathlib import Path import numpy as np HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE / "frontend")) SR, HOP = 22050, 256 def split_sentences(text, min_chars=40): """sentences; one shorter than min_chars is merged with the next (very short inputs synthesise less stably)""" parts = [x.strip() for x in re.findall(r"[^.!?…]+[.!?…]+[”\"»)]*|[^.!?…]+$", text) if x.strip()] out = [] for x in parts: if out and len(out[-1]) < min_chars: out[-1] += " " + x else: out.append(x) if len(out) > 1 and len(out[-1]) < min_chars: last = out.pop(); out[-1] += " " + last return out or [text] class EdgeTTS: def __init__(self, voice="male_voxcpm_2", precision="int8", steps=2, threads=None, vocoder=None, root=HERE, tagger=None): import onnxruntime as ort self.dir = Path(root) / voice; self.vj = json.loads((self.dir / "voices.json").read_text(encoding="utf-8")) g = self.vj["graphs"][precision] so = ort.SessionOptions(); so.inter_op_num_threads = 1 if threads: so.intra_op_num_threads = int(threads) mk = lambda f: ort.InferenceSession(str(self.dir / f), so, providers=["CPUExecutionProvider"]) # noqa: E731 self.enc, self.dec = mk(g["encoder"]), mk(g["decoder"][str(steps)]) self.voc = mk(self.vj["graphs"]["vocoder"][vocoder or g["vocoder"]]) self.defaults = {**self.vj["defaults"], **self.vj["stream"]} self.tagger = tagger def speaker(self, style=None): s = self.defaults["style"] if style is None else style return (np.array(self.vj["speakers"]["0"]["emb"]) + np.array(self.vj["styles"][str(s)]["emb"])).astype(np.float32)[None] def stream(self, text, stream=True, chunk=None, ode_ctx=None, voc_ctx=None, first=None, min_sentence_chars=None, gap_s=0.2, temperature=None, tempo=None, style=None, seed=None, english=True, split=True): """yields float32 audio pieces; stream=False: one piece per sentence (whole-sentence decoding)""" from text import text_to_ids d = self.defaults; chunk = chunk or d["chunk"]; ode_ctx = d["ode_ctx"] if ode_ctx is None else ode_ctx voc_ctx = d["voc_ctx"] if voc_ctx is None else voc_ctx; first = first or chunk temperature = d["temperature"] if temperature is None else temperature; tempo = d["length_scale"] if tempo is None else tempo spk = self.speaker(style); rng = np.random.default_rng(seed) sents = split_sentences(text, d["min_sentence_chars"] if min_sentence_chars is None else min_sentence_chars) if split else [text] for si, sentence in enumerate(sents): ids = text_to_ids(sentence, english=english, tagger=self.tagger) mu, ml = self.enc.run(None, {"x": np.array([ids], np.int64), "x_lengths": np.array([len(ids)], np.int64), "scales": np.array([temperature, tempo], np.float32), "spk_emb": spk}) T = int(ml[0]); mu = mu[..., :T]; z = rng.standard_normal((1, 80, T)).astype(np.float32) gap = np.zeros(int(gap_s * SR), np.float32) if si < len(sents) - 1 else np.zeros(0, np.float32) if not stream: yield np.concatenate([self._vocode(self._decode(mu, z, 0, T, 0, spk, temperature, T), 0, T, T, 0), gap]); continue mel = np.zeros((1, 80, T), np.float32); bounds = [0] while bounds[-1] < T: bounds.append(min(T, bounds[-1] + (first if len(bounds) == 1 else chunk))) p = 0 for s, e in zip(bounds[:-1], bounds[1:]): mel[..., s:e] = self._decode(mu, z, s, e, ode_ctx, spk, temperature, T) q = T if e >= T else e - voc_ctx # the vocoder runs voc_ctx frames behind the decoder if q > p: a = self._vocode(mel, p, q, T, voc_ctx); p = q yield np.concatenate([a, gap]) if q >= T else a def warmup(self): """load the phonemizer / word lists and run every graph once (the first call is slow); a robot does this at start-up""" for _ in self.stream("Dzień dobry, jak się masz?", seed=0): pass def synthesize(self, text, **kw): return np.concatenate(list(self.stream(text, **kw))) def _decode(self, mu, z, s, e, ctx, spk, temperature, T): a, b = max(0, s - ctx), min(T, e + ctx); L = (b - a + 3) // 4 * 4 mu_c = np.zeros((1, 80, L), np.float32); z_c = np.zeros((1, 80, L), np.float32); m_c = np.zeros((1, 1, L), np.float32) mu_c[..., :b - a] = mu[..., a:b]; z_c[..., :b - a] = z[..., a:b]; m_c[..., :b - a] = 1 return self.dec.run(None, {"mu": mu_c, "mask": m_c, "z": z_c, "spk_emb": spk, "temperature": np.array(temperature, np.float32)})[0][..., s - a:e - a] def _vocode(self, mel, p, q, T, ctx): a, b = max(0, p - ctx), min(T, q + ctx) w = self.voc.run(None, {"mel": np.ascontiguousarray(mel[..., a:b])})[0].reshape(-1)[(p - a) * HOP:(q - a) * HOP] return np.clip(w, -1, 1).astype(np.float32) def write_wav(path, audio): with wave.open(str(path), "wb") as f: f.setnchannels(1); f.setsampwidth(2); f.setframerate(SR); f.writeframes((np.clip(audio, -1, 1) * 32767).astype("