streaming-vits 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- streaming_vits-0.1.0/LICENSE +21 -0
- streaming_vits-0.1.0/PKG-INFO +266 -0
- streaming_vits-0.1.0/README.md +241 -0
- streaming_vits-0.1.0/pyproject.toml +39 -0
- streaming_vits-0.1.0/setup.cfg +4 -0
- streaming_vits-0.1.0/streaming_vits/__init__.py +17 -0
- streaming_vits-0.1.0/streaming_vits/audio.py +106 -0
- streaming_vits-0.1.0/streaming_vits/calibrate.py +240 -0
- streaming_vits-0.1.0/streaming_vits/cli.py +353 -0
- streaming_vits-0.1.0/streaming_vits/engine.py +189 -0
- streaming_vits-0.1.0/streaming_vits/graph.py +327 -0
- streaming_vits-0.1.0/streaming_vits/phonemize.py +132 -0
- streaming_vits-0.1.0/streaming_vits/profile.py +212 -0
- streaming_vits-0.1.0/streaming_vits.egg-info/PKG-INFO +266 -0
- streaming_vits-0.1.0/streaming_vits.egg-info/SOURCES.txt +18 -0
- streaming_vits-0.1.0/streaming_vits.egg-info/dependency_links.txt +1 -0
- streaming_vits-0.1.0/streaming_vits.egg-info/entry_points.txt +3 -0
- streaming_vits-0.1.0/streaming_vits.egg-info/requires.txt +12 -0
- streaming_vits-0.1.0/streaming_vits.egg-info/top_level.txt +1 -0
- streaming_vits-0.1.0/tests/test_streaming.py +291 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abrar Ahmed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: streaming-vits
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Streaming inference for VITS/piper TTS — a drop-in replacement for sherpa-onnx-offline-tts that starts playing before synthesis finishes
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: tts,vits,piper,streaming,onnx,sherpa-onnx,speech
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: numpy>=1.21
|
|
16
|
+
Requires-Dist: onnx>=1.14
|
|
17
|
+
Requires-Dist: onnxruntime>=1.15
|
|
18
|
+
Provides-Extra: play
|
|
19
|
+
Requires-Dist: sounddevice>=0.4; extra == "play"
|
|
20
|
+
Provides-Extra: fast-phonemes
|
|
21
|
+
Requires-Dist: piper-phonemize>=1.1; extra == "fast-phonemes"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# streaming-vits
|
|
27
|
+
|
|
28
|
+
Streaming inference for VITS / [piper](https://github.com/rhasspy/piper) TTS models.
|
|
29
|
+
|
|
30
|
+
Feed in a whole paragraph, get audio out incrementally — and the result is
|
|
31
|
+
**numerically identical** to a full-paragraph forward pass. No sentence
|
|
32
|
+
splitting, no prosody loss.
|
|
33
|
+
|
|
34
|
+
A drop-in replacement for `sherpa-onnx-offline-tts`: same model file, same flags,
|
|
35
|
+
same WAV. It just starts producing audio before synthesis finishes.
|
|
36
|
+
|
|
37
|
+
```diff
|
|
38
|
+
- sherpa-onnx-offline-tts \
|
|
39
|
+
+ streaming-vits-offline-tts \
|
|
40
|
+
--vits-model=./en_US-libritts_r-medium.onnx \
|
|
41
|
+
--vits-tokens=./tokens.txt \
|
|
42
|
+
--vits-data-dir=./espeak-ng-data \
|
|
43
|
+
--num-threads=4 --sid=0 \
|
|
44
|
+
--output-filename=./test-0.wav \
|
|
45
|
+
"The quick brown fox jumped skillfully over the lazy dog..."
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
Elapsed seconds: 1.012
|
|
50
|
+
Audio duration: 9.532 s
|
|
51
|
+
Real-time factor (RTF): 1.012/9.532 = 0.106
|
|
52
|
+
Time to first audio: 141 ms <- 9.5 s of audio, first sound in 141 ms
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Add `--play` to hear it live as it generates.
|
|
56
|
+
|
|
57
|
+
## Why this is possible
|
|
58
|
+
|
|
59
|
+
VITS is not autoregressive, so streaming looks like it shouldn't work. But
|
|
60
|
+
`SynthesizerTrn.infer()` splits cleanly at the monotonic alignment:
|
|
61
|
+
|
|
62
|
+
| half | what it does | context needed | share of compute |
|
|
63
|
+
|---|---|---|---|
|
|
64
|
+
| **frontend** | text encoder → duration predictor → alignment → prior expansion → `z_p` | global, over text | ~2% |
|
|
65
|
+
| **decoder** | flow → HiFi-GAN | **finite receptive field over frames** | ~98% |
|
|
66
|
+
|
|
67
|
+
Everything that carries paragraph-level prosody lives in the cheap frontend,
|
|
68
|
+
which runs once on the whole input. The expensive half is a plain CNN over the
|
|
69
|
+
frame axis — no recurrence, no global attention — so it can be evaluated in
|
|
70
|
+
chunks, and with enough overlap context the chunks concatenate *exactly*.
|
|
71
|
+
|
|
72
|
+
This is the opposite trade-off from sentence chunking. Splitting text throws away
|
|
73
|
+
the global context that produces good prosody. Splitting in *frame* space costs
|
|
74
|
+
nothing, because the frame-space network cannot see beyond its receptive field
|
|
75
|
+
anyway.
|
|
76
|
+
|
|
77
|
+
One detail makes it exact rather than approximate: both `RandomNormalLike` nodes
|
|
78
|
+
(duration noise and prior noise) land in the frontend, so all sampling happens
|
|
79
|
+
once, for every frame, before any chunk is decoded. Two overlapping chunk decodes
|
|
80
|
+
necessarily see identical noise on the frames they share.
|
|
81
|
+
|
|
82
|
+
## Install
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install streaming-vits
|
|
86
|
+
|
|
87
|
+
# phonemization backend (one of):
|
|
88
|
+
brew install espeak-ng # macOS
|
|
89
|
+
apt install espeak-ng # debian/ubuntu
|
|
90
|
+
pip install piper-phonemize # faster, no subprocess
|
|
91
|
+
|
|
92
|
+
pip install 'streaming-vits[play]' # optional: live playback
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Point it at any piper voice — the same tarballs sherpa-onnx uses:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
wget https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-libritts_r-medium.tar.bz2
|
|
99
|
+
tar xf vits-piper-en_US-libritts_r-medium.tar.bz2
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The monolithic `.onnx` is split into a frontend and a decoder graph on first use
|
|
103
|
+
and cached next to the model. You don't have to do anything.
|
|
104
|
+
|
|
105
|
+
## Calibrate for your device
|
|
106
|
+
|
|
107
|
+
The chunk schedule is the whole game for latency, and the right one depends on
|
|
108
|
+
how fast the device is. Run this once per device:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
streaming-vits calibrate \
|
|
112
|
+
--vits-model=./en_US-libritts_r-medium.onnx \
|
|
113
|
+
--vits-tokens=./tokens.txt \
|
|
114
|
+
--vits-data-dir=./espeak-ng-data \
|
|
115
|
+
--num-threads=4
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
It measures two different things:
|
|
119
|
+
|
|
120
|
+
**Margins** are a property of the model's weights — how much context does the
|
|
121
|
+
decoder actually need before its output stops changing? Swept empirically,
|
|
122
|
+
because it is not guessable: trained weights use far more of their nominal
|
|
123
|
+
receptive field than an untrained network suggests.
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
[1/3] receptive field (model property, device independent)
|
|
127
|
+
seed 1234 -- deterministic across runs
|
|
128
|
+
lookahead rel RMS err
|
|
129
|
+
4 32.835%
|
|
130
|
+
8 11.672%
|
|
131
|
+
12 4.197%
|
|
132
|
+
16 1.047%
|
|
133
|
+
20 0.174%
|
|
134
|
+
24 0.006%
|
|
135
|
+
-> left 34, right 28 (325 ms lookahead)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The frontend sampling is pinned to a fixed seed for this sweep (`--seed`), so
|
|
139
|
+
repeated calibrations of the same model give the same margins. Normal synthesis
|
|
140
|
+
is unaffected and stays stochastic.
|
|
141
|
+
|
|
142
|
+
**Schedule** is a property of the device. An affine cost model
|
|
143
|
+
`decode(n) = α + β·(n + margins)` is fitted, then the schedule that reaches the
|
|
144
|
+
speaker soonest without ever starving playback is solved for:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
[2/3] decode cost on this device
|
|
148
|
+
fit: 1.26 ms/call + 0.442 ms/frame (a frame is 11.61 ms of audio)
|
|
149
|
+
|
|
150
|
+
predicted time to first audio 162 ms
|
|
151
|
+
steady-state RTF per chunk 0.052
|
|
152
|
+
survives a device 2.0x slower than this one
|
|
153
|
+
steady-state decode overhead 1.21x (the ramp costs more early)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`--safety N` sets how much slower than measured to plan for; the prebuffer is
|
|
157
|
+
sized so headroom is a guarantee rather than an observation. Raise it on devices
|
|
158
|
+
with contended CPUs.
|
|
159
|
+
|
|
160
|
+
Margins are reproducible run to run, but the *schedule* is not quite: it depends
|
|
161
|
+
on wall-clock timings, which move with machine load. Calibrate on an otherwise
|
|
162
|
+
idle device, and treat `--safety` as the thing that absorbs the rest.
|
|
163
|
+
|
|
164
|
+
The profile is written next to the model and picked up automatically.
|
|
165
|
+
|
|
166
|
+
## Results
|
|
167
|
+
|
|
168
|
+
`en_US-libritts_r-medium`, 4 threads, M-series Mac, 15.2 s utterance:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
non-streaming 0.701s RTF 0.046 <- you wait this long before any sound
|
|
172
|
+
|
|
173
|
+
time to first audio 0.163s (4.3x sooner than non-streaming)
|
|
174
|
+
total wall clock 1.301s RTF 0.085 (1.86x the work)
|
|
175
|
+
playback no underrun
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Time-to-first-audio for the non-streaming path scales with the length of the
|
|
179
|
+
paragraph. For the streamed path it is constant, so the gap widens the more you
|
|
180
|
+
ask it to say.
|
|
181
|
+
|
|
182
|
+
Equivalence, on the real weights:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
$ streaming-vits verify --vits-model=en_US-libritts_r-medium.onnx ...
|
|
186
|
+
max |streamed - full| = 8.00e-05 OK
|
|
187
|
+
|
|
188
|
+
$ streaming-vits verify --vits-model=es_MX-claude-high.onnx ...
|
|
189
|
+
max |streamed - full| = 1.79e-05 OK
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Commands
|
|
193
|
+
|
|
194
|
+
| command | what it does |
|
|
195
|
+
|---|---|
|
|
196
|
+
| `streaming-vits-offline-tts …` | drop-in for `sherpa-onnx-offline-tts` |
|
|
197
|
+
| `streaming-vits speak … --play` | synthesise, optionally play live |
|
|
198
|
+
| `streaming-vits calibrate …` | benchmark this device, write a profile |
|
|
199
|
+
| `streaming-vits bench …` | streamed vs non-streaming latency |
|
|
200
|
+
| `streaming-vits verify …` | check chunked output still equals a full decode |
|
|
201
|
+
| `streaming-vits info …` | show split graphs and active profile |
|
|
202
|
+
| `streaming-vits clear-cache …` | delete cached split graphs |
|
|
203
|
+
|
|
204
|
+
Accepted-and-ignored sherpa flags (`--vits-lexicon`, `--vits-dict-dir`,
|
|
205
|
+
`--tts-rule-fsts`, `--max-num-sentences`) print a note rather than failing, so
|
|
206
|
+
existing command lines keep working.
|
|
207
|
+
|
|
208
|
+
## Python API
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from streaming_vits import StreamingTTS
|
|
212
|
+
|
|
213
|
+
tts = StreamingTTS("en_US-libritts_r-medium.onnx", tokens_path="tokens.txt")
|
|
214
|
+
|
|
215
|
+
for chunk in tts.stream("Hello there. This plays before it has finished."):
|
|
216
|
+
speaker.write(chunk.audio) # float32, mono, tts.sample_rate
|
|
217
|
+
print(chunk.index, chunk.frames, chunk.ready_at)
|
|
218
|
+
|
|
219
|
+
audio = tts.synthesize("Same thing, one array.")
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Limits
|
|
223
|
+
|
|
224
|
+
- **Extra work.** Each chunk re-decodes its margin frames: ~1.2× in steady
|
|
225
|
+
state, more during the ramp. Cached-state streaming convolutions would cut
|
|
226
|
+
this to ~1.05× but are considerably more implementation.
|
|
227
|
+
- **TTFA is dominated by the frontend**, not by chunk size. On the numbers above
|
|
228
|
+
the frontend is ~130 ms of a 162 ms budget; smaller first chunks will not help.
|
|
229
|
+
Speeding up the stochastic duration predictor is the next lever.
|
|
230
|
+
- **Streaming text *in* is a separate problem.** The duration predictor needs the
|
|
231
|
+
whole utterance before any audio exists. If text arrives from an LLM token
|
|
232
|
+
stream you still chunk at clause level — but you can feed the previous clause
|
|
233
|
+
as context and discard its audio to soften the seam.
|
|
234
|
+
- **The bundled phonemizer is pragmatic.** It shells out to `espeak-ng` per
|
|
235
|
+
sentence and re-inserts punctuation, where piper drives libespeak-ng directly
|
|
236
|
+
and gets clause terminators back. Install `piper-phonemize` for the faithful
|
|
237
|
+
path. This does not affect the streaming claim — `verify` compares on identical
|
|
238
|
+
phoneme ids.
|
|
239
|
+
- Verified on two piper voices: `en_US-libritts_r-medium` (904 speakers) and
|
|
240
|
+
`es_MX-claude-high` (single speaker, different exporter version). Other VITS
|
|
241
|
+
exports should work if the graph has the same seam; `streaming-vits info` will
|
|
242
|
+
tell you before you rely on it.
|
|
243
|
+
|
|
244
|
+
## How the split is found
|
|
245
|
+
|
|
246
|
+
`streaming_vits/graph.py` locates the seam structurally rather than by hardcoded
|
|
247
|
+
node names: find the last `RandomNormalLike` (the prior noise), walk forward to
|
|
248
|
+
the `Add` that forms `z_p`, then collect whatever the downstream subgraph still
|
|
249
|
+
needs from upstream. It also reads the decoder config back off the weights —
|
|
250
|
+
piper's `config.json` does not record it, and it varies by voice
|
|
251
|
+
(`libritts_r-medium` uses 3 upsample stages, `[8,8,4]`, not the 4 in the
|
|
252
|
+
reference VITS config).
|
|
253
|
+
|
|
254
|
+
Two things are deliberately not read from node names, because those vary between
|
|
255
|
+
piper exporter versions even when the weights do not: the upsample config comes
|
|
256
|
+
from `dec.ups.N.weight`, and the seam comes from graph topology. Single-speaker
|
|
257
|
+
voices have no speaker embedding and no `sid` input, so the decoder takes two
|
|
258
|
+
inputs instead of three; that is detected rather than configured.
|
|
259
|
+
|
|
260
|
+
`research/` holds the original proof of concept, including `verify_streaming.py`,
|
|
261
|
+
which proves the same equivalence against the reference PyTorch VITS
|
|
262
|
+
implementation independently of ONNX.
|
|
263
|
+
|
|
264
|
+
## License
|
|
265
|
+
|
|
266
|
+
MIT
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# streaming-vits
|
|
2
|
+
|
|
3
|
+
Streaming inference for VITS / [piper](https://github.com/rhasspy/piper) TTS models.
|
|
4
|
+
|
|
5
|
+
Feed in a whole paragraph, get audio out incrementally — and the result is
|
|
6
|
+
**numerically identical** to a full-paragraph forward pass. No sentence
|
|
7
|
+
splitting, no prosody loss.
|
|
8
|
+
|
|
9
|
+
A drop-in replacement for `sherpa-onnx-offline-tts`: same model file, same flags,
|
|
10
|
+
same WAV. It just starts producing audio before synthesis finishes.
|
|
11
|
+
|
|
12
|
+
```diff
|
|
13
|
+
- sherpa-onnx-offline-tts \
|
|
14
|
+
+ streaming-vits-offline-tts \
|
|
15
|
+
--vits-model=./en_US-libritts_r-medium.onnx \
|
|
16
|
+
--vits-tokens=./tokens.txt \
|
|
17
|
+
--vits-data-dir=./espeak-ng-data \
|
|
18
|
+
--num-threads=4 --sid=0 \
|
|
19
|
+
--output-filename=./test-0.wav \
|
|
20
|
+
"The quick brown fox jumped skillfully over the lazy dog..."
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
Elapsed seconds: 1.012
|
|
25
|
+
Audio duration: 9.532 s
|
|
26
|
+
Real-time factor (RTF): 1.012/9.532 = 0.106
|
|
27
|
+
Time to first audio: 141 ms <- 9.5 s of audio, first sound in 141 ms
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Add `--play` to hear it live as it generates.
|
|
31
|
+
|
|
32
|
+
## Why this is possible
|
|
33
|
+
|
|
34
|
+
VITS is not autoregressive, so streaming looks like it shouldn't work. But
|
|
35
|
+
`SynthesizerTrn.infer()` splits cleanly at the monotonic alignment:
|
|
36
|
+
|
|
37
|
+
| half | what it does | context needed | share of compute |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| **frontend** | text encoder → duration predictor → alignment → prior expansion → `z_p` | global, over text | ~2% |
|
|
40
|
+
| **decoder** | flow → HiFi-GAN | **finite receptive field over frames** | ~98% |
|
|
41
|
+
|
|
42
|
+
Everything that carries paragraph-level prosody lives in the cheap frontend,
|
|
43
|
+
which runs once on the whole input. The expensive half is a plain CNN over the
|
|
44
|
+
frame axis — no recurrence, no global attention — so it can be evaluated in
|
|
45
|
+
chunks, and with enough overlap context the chunks concatenate *exactly*.
|
|
46
|
+
|
|
47
|
+
This is the opposite trade-off from sentence chunking. Splitting text throws away
|
|
48
|
+
the global context that produces good prosody. Splitting in *frame* space costs
|
|
49
|
+
nothing, because the frame-space network cannot see beyond its receptive field
|
|
50
|
+
anyway.
|
|
51
|
+
|
|
52
|
+
One detail makes it exact rather than approximate: both `RandomNormalLike` nodes
|
|
53
|
+
(duration noise and prior noise) land in the frontend, so all sampling happens
|
|
54
|
+
once, for every frame, before any chunk is decoded. Two overlapping chunk decodes
|
|
55
|
+
necessarily see identical noise on the frames they share.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install streaming-vits
|
|
61
|
+
|
|
62
|
+
# phonemization backend (one of):
|
|
63
|
+
brew install espeak-ng # macOS
|
|
64
|
+
apt install espeak-ng # debian/ubuntu
|
|
65
|
+
pip install piper-phonemize # faster, no subprocess
|
|
66
|
+
|
|
67
|
+
pip install 'streaming-vits[play]' # optional: live playback
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Point it at any piper voice — the same tarballs sherpa-onnx uses:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
wget https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-libritts_r-medium.tar.bz2
|
|
74
|
+
tar xf vits-piper-en_US-libritts_r-medium.tar.bz2
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The monolithic `.onnx` is split into a frontend and a decoder graph on first use
|
|
78
|
+
and cached next to the model. You don't have to do anything.
|
|
79
|
+
|
|
80
|
+
## Calibrate for your device
|
|
81
|
+
|
|
82
|
+
The chunk schedule is the whole game for latency, and the right one depends on
|
|
83
|
+
how fast the device is. Run this once per device:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
streaming-vits calibrate \
|
|
87
|
+
--vits-model=./en_US-libritts_r-medium.onnx \
|
|
88
|
+
--vits-tokens=./tokens.txt \
|
|
89
|
+
--vits-data-dir=./espeak-ng-data \
|
|
90
|
+
--num-threads=4
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
It measures two different things:
|
|
94
|
+
|
|
95
|
+
**Margins** are a property of the model's weights — how much context does the
|
|
96
|
+
decoder actually need before its output stops changing? Swept empirically,
|
|
97
|
+
because it is not guessable: trained weights use far more of their nominal
|
|
98
|
+
receptive field than an untrained network suggests.
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
[1/3] receptive field (model property, device independent)
|
|
102
|
+
seed 1234 -- deterministic across runs
|
|
103
|
+
lookahead rel RMS err
|
|
104
|
+
4 32.835%
|
|
105
|
+
8 11.672%
|
|
106
|
+
12 4.197%
|
|
107
|
+
16 1.047%
|
|
108
|
+
20 0.174%
|
|
109
|
+
24 0.006%
|
|
110
|
+
-> left 34, right 28 (325 ms lookahead)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The frontend sampling is pinned to a fixed seed for this sweep (`--seed`), so
|
|
114
|
+
repeated calibrations of the same model give the same margins. Normal synthesis
|
|
115
|
+
is unaffected and stays stochastic.
|
|
116
|
+
|
|
117
|
+
**Schedule** is a property of the device. An affine cost model
|
|
118
|
+
`decode(n) = α + β·(n + margins)` is fitted, then the schedule that reaches the
|
|
119
|
+
speaker soonest without ever starving playback is solved for:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
[2/3] decode cost on this device
|
|
123
|
+
fit: 1.26 ms/call + 0.442 ms/frame (a frame is 11.61 ms of audio)
|
|
124
|
+
|
|
125
|
+
predicted time to first audio 162 ms
|
|
126
|
+
steady-state RTF per chunk 0.052
|
|
127
|
+
survives a device 2.0x slower than this one
|
|
128
|
+
steady-state decode overhead 1.21x (the ramp costs more early)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`--safety N` sets how much slower than measured to plan for; the prebuffer is
|
|
132
|
+
sized so headroom is a guarantee rather than an observation. Raise it on devices
|
|
133
|
+
with contended CPUs.
|
|
134
|
+
|
|
135
|
+
Margins are reproducible run to run, but the *schedule* is not quite: it depends
|
|
136
|
+
on wall-clock timings, which move with machine load. Calibrate on an otherwise
|
|
137
|
+
idle device, and treat `--safety` as the thing that absorbs the rest.
|
|
138
|
+
|
|
139
|
+
The profile is written next to the model and picked up automatically.
|
|
140
|
+
|
|
141
|
+
## Results
|
|
142
|
+
|
|
143
|
+
`en_US-libritts_r-medium`, 4 threads, M-series Mac, 15.2 s utterance:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
non-streaming 0.701s RTF 0.046 <- you wait this long before any sound
|
|
147
|
+
|
|
148
|
+
time to first audio 0.163s (4.3x sooner than non-streaming)
|
|
149
|
+
total wall clock 1.301s RTF 0.085 (1.86x the work)
|
|
150
|
+
playback no underrun
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Time-to-first-audio for the non-streaming path scales with the length of the
|
|
154
|
+
paragraph. For the streamed path it is constant, so the gap widens the more you
|
|
155
|
+
ask it to say.
|
|
156
|
+
|
|
157
|
+
Equivalence, on the real weights:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
$ streaming-vits verify --vits-model=en_US-libritts_r-medium.onnx ...
|
|
161
|
+
max |streamed - full| = 8.00e-05 OK
|
|
162
|
+
|
|
163
|
+
$ streaming-vits verify --vits-model=es_MX-claude-high.onnx ...
|
|
164
|
+
max |streamed - full| = 1.79e-05 OK
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Commands
|
|
168
|
+
|
|
169
|
+
| command | what it does |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `streaming-vits-offline-tts …` | drop-in for `sherpa-onnx-offline-tts` |
|
|
172
|
+
| `streaming-vits speak … --play` | synthesise, optionally play live |
|
|
173
|
+
| `streaming-vits calibrate …` | benchmark this device, write a profile |
|
|
174
|
+
| `streaming-vits bench …` | streamed vs non-streaming latency |
|
|
175
|
+
| `streaming-vits verify …` | check chunked output still equals a full decode |
|
|
176
|
+
| `streaming-vits info …` | show split graphs and active profile |
|
|
177
|
+
| `streaming-vits clear-cache …` | delete cached split graphs |
|
|
178
|
+
|
|
179
|
+
Accepted-and-ignored sherpa flags (`--vits-lexicon`, `--vits-dict-dir`,
|
|
180
|
+
`--tts-rule-fsts`, `--max-num-sentences`) print a note rather than failing, so
|
|
181
|
+
existing command lines keep working.
|
|
182
|
+
|
|
183
|
+
## Python API
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from streaming_vits import StreamingTTS
|
|
187
|
+
|
|
188
|
+
tts = StreamingTTS("en_US-libritts_r-medium.onnx", tokens_path="tokens.txt")
|
|
189
|
+
|
|
190
|
+
for chunk in tts.stream("Hello there. This plays before it has finished."):
|
|
191
|
+
speaker.write(chunk.audio) # float32, mono, tts.sample_rate
|
|
192
|
+
print(chunk.index, chunk.frames, chunk.ready_at)
|
|
193
|
+
|
|
194
|
+
audio = tts.synthesize("Same thing, one array.")
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Limits
|
|
198
|
+
|
|
199
|
+
- **Extra work.** Each chunk re-decodes its margin frames: ~1.2× in steady
|
|
200
|
+
state, more during the ramp. Cached-state streaming convolutions would cut
|
|
201
|
+
this to ~1.05× but are considerably more implementation.
|
|
202
|
+
- **TTFA is dominated by the frontend**, not by chunk size. On the numbers above
|
|
203
|
+
the frontend is ~130 ms of a 162 ms budget; smaller first chunks will not help.
|
|
204
|
+
Speeding up the stochastic duration predictor is the next lever.
|
|
205
|
+
- **Streaming text *in* is a separate problem.** The duration predictor needs the
|
|
206
|
+
whole utterance before any audio exists. If text arrives from an LLM token
|
|
207
|
+
stream you still chunk at clause level — but you can feed the previous clause
|
|
208
|
+
as context and discard its audio to soften the seam.
|
|
209
|
+
- **The bundled phonemizer is pragmatic.** It shells out to `espeak-ng` per
|
|
210
|
+
sentence and re-inserts punctuation, where piper drives libespeak-ng directly
|
|
211
|
+
and gets clause terminators back. Install `piper-phonemize` for the faithful
|
|
212
|
+
path. This does not affect the streaming claim — `verify` compares on identical
|
|
213
|
+
phoneme ids.
|
|
214
|
+
- Verified on two piper voices: `en_US-libritts_r-medium` (904 speakers) and
|
|
215
|
+
`es_MX-claude-high` (single speaker, different exporter version). Other VITS
|
|
216
|
+
exports should work if the graph has the same seam; `streaming-vits info` will
|
|
217
|
+
tell you before you rely on it.
|
|
218
|
+
|
|
219
|
+
## How the split is found
|
|
220
|
+
|
|
221
|
+
`streaming_vits/graph.py` locates the seam structurally rather than by hardcoded
|
|
222
|
+
node names: find the last `RandomNormalLike` (the prior noise), walk forward to
|
|
223
|
+
the `Add` that forms `z_p`, then collect whatever the downstream subgraph still
|
|
224
|
+
needs from upstream. It also reads the decoder config back off the weights —
|
|
225
|
+
piper's `config.json` does not record it, and it varies by voice
|
|
226
|
+
(`libritts_r-medium` uses 3 upsample stages, `[8,8,4]`, not the 4 in the
|
|
227
|
+
reference VITS config).
|
|
228
|
+
|
|
229
|
+
Two things are deliberately not read from node names, because those vary between
|
|
230
|
+
piper exporter versions even when the weights do not: the upsample config comes
|
|
231
|
+
from `dec.ups.N.weight`, and the seam comes from graph topology. Single-speaker
|
|
232
|
+
voices have no speaker embedding and no `sid` input, so the decoder takes two
|
|
233
|
+
inputs instead of three; that is detected rather than configured.
|
|
234
|
+
|
|
235
|
+
`research/` holds the original proof of concept, including `verify_streaming.py`,
|
|
236
|
+
which proves the same equivalence against the reference PyTorch VITS
|
|
237
|
+
implementation independently of ONNX.
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "streaming-vits"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Streaming inference for VITS/piper TTS — a drop-in replacement for sherpa-onnx-offline-tts that starts playing before synthesis finishes"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["tts", "vits", "piper", "streaming", "onnx", "sherpa-onnx", "speech"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"numpy>=1.21",
|
|
22
|
+
"onnx>=1.14",
|
|
23
|
+
"onnxruntime>=1.15",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
play = ["sounddevice>=0.4"]
|
|
28
|
+
fast-phonemes = ["piper-phonemize>=1.1"]
|
|
29
|
+
dev = ["pytest>=7"]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
streaming-vits = "streaming_vits.cli:main"
|
|
33
|
+
streaming-vits-offline-tts = "streaming_vits.cli:main_offline_tts"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
include = ["streaming_vits*"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Streaming inference for VITS / piper TTS models.
|
|
2
|
+
|
|
3
|
+
Whole paragraph in, audio out incrementally, numerically identical to a
|
|
4
|
+
full-paragraph forward pass -- no sentence splitting, no prosody loss.
|
|
5
|
+
|
|
6
|
+
from streaming_vits import StreamingTTS
|
|
7
|
+
|
|
8
|
+
tts = StreamingTTS("en_US-libritts_r-medium.onnx", tokens_path="tokens.txt")
|
|
9
|
+
for chunk in tts.stream("Hello there, this plays before it finishes."):
|
|
10
|
+
play(chunk.audio)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .engine import Chunk, StreamingTTS
|
|
14
|
+
from .profile import Profile
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
__all__ = ["StreamingTTS", "Chunk", "Profile", "__version__"]
|