File size: 9,810 Bytes
7c5ba5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
#!/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("<i2").tobytes())


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0], formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("text"); ap.add_argument("-o", "--out", default="out.wav"); ap.add_argument("--voice", default="male_voxcpm_2")
    ap.add_argument("--precision", choices=["int8", "fp32"], default="int8"); ap.add_argument("--steps", type=int, choices=[2, 4], default=2)
    ap.add_argument("--threads", type=int); ap.add_argument("--no-stream", action="store_true", help="decode whole sentences")
    ap.add_argument("--chunk", type=int, help="mel frames per chunk (default 43 = 0.5 s)"); ap.add_argument("--ode-ctx", type=int, help="decoder context frames per side (16; 8–32)")
    ap.add_argument("--voc-ctx", type=int, help="vocoder context frames (12 = exact; 8–12)"); ap.add_argument("--first-chunk", type=int)
    ap.add_argument("--min-sentence-chars", type=int, help="merge shorter sentences with the next (40)"); ap.add_argument("--no-split", action="store_true")
    ap.add_argument("--temperature", type=float); ap.add_argument("--tempo", type=float, help="length scale, lower = faster (0.8)")
    ap.add_argument("--style", type=int, help="style token (8 = neutral; see voices.json)"); ap.add_argument("--seed", type=int)
    ap.add_argument("--no-english", action="store_true", help="read English words as Polish"); ap.add_argument("--play", action="store_true")
    a = ap.parse_args()
    os.environ.setdefault("PHONEMIZER_ESPEAK_LIBRARY", "/opt/homebrew/lib/libespeak-ng.dylib" if sys.platform == "darwin" else "")
    if not os.environ["PHONEMIZER_ESPEAK_LIBRARY"]:
        del os.environ["PHONEMIZER_ESPEAK_LIBRARY"]
    t_load = time.perf_counter(); tts = EdgeTTS(a.voice, a.precision, a.steps, a.threads); tts.warmup(); t_load = time.perf_counter() - t_load
    player = None
    if a.play:
        import sounddevice as sd
        player = sd.OutputStream(samplerate=SR, channels=1, dtype="float32"); player.start()
    t0 = time.perf_counter(); first, pieces = None, []
    for piece in tts.stream(a.text, stream=not a.no_stream, chunk=a.chunk, ode_ctx=a.ode_ctx, voc_ctx=a.voc_ctx, first=a.first_chunk,
                            min_sentence_chars=a.min_sentence_chars, temperature=a.temperature, tempo=a.tempo, style=a.style, seed=a.seed,
                            english=not a.no_english, split=not a.no_split):
        if first is None:
            first = time.perf_counter() - t0
        pieces.append(piece)
        if player:
            player.write(piece[:, None])
    total = time.perf_counter() - t0; audio = np.concatenate(pieces); write_wav(a.out, audio)
    print(f"{a.out}: {len(audio) / SR:.2f} s audio · first audio after {1000 * first:.0f} ms · compute {1000 * total:.0f} ms (RTF {total / (len(audio) / SR):.3f})"
          f" · start-up {t_load:.1f} s (once)")
    if player:
        player.stop()


if __name__ == "__main__":
    main()