76314624bb
ci / test (push) Has been cancelled
Sub-second streaming TTS on Chatterbox-Turbo via adaptive buffer-ratchet chunking. First audio in ~0.5s (vs ~5s one-shot) with no quality compromise — chunk joins land on natural sentence pauses and the stream converges to one large near-full-context chunk within 2-3 joins. Works because the engine runs faster than realtime; the no-starvation guarantee is proven in a GPU-free simulation (tests/test_scheduler.py). - chatterbox_fast/scheduler.py: the adaptive-chunk scheduler (pure logic, no GPU) - chatterbox_fast/app.py: FastAPI server (POST /tts streaming, /voices, /health) - bench.py: streaming client (ground-truth TTFB + starvation check) - Self-contained Dockerfile (slim base + chatterbox-tts from PyPI) - Three public-domain LibriVox starter voices baked in (see voices/ATTRIBUTION.md) MIT licensed.
107 lines
3.6 KiB
Python
107 lines
3.6 KiB
Python
"""Client-side bench for chatterbox-fast — ground-truth TTFB + starvation check.
|
||
|
||
The server's scheduler reasons about starvation from an *estimated* buffer; this
|
||
client measures the real thing: time-to-first-byte over the wire, and whether a
|
||
true 1×-realtime consumer ever runs dry. Saves the streamed audio to a .wav so
|
||
the operator can ear-A/B it against the one-shot.
|
||
|
||
Usage:
|
||
python bench.py --host http://10.100.79.3:8197 --text "..." --out /refs/_fast.wav
|
||
python bench.py --host ... --oneshot --out /refs/_oneshot.wav # A/B baseline
|
||
Stdlib only (urllib + wave) so it runs anywhere, incl. inside the container.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import time
|
||
import urllib.request
|
||
import wave
|
||
|
||
SR = 24000
|
||
BYTES_PER_SEC = SR * 2 # s16le mono
|
||
|
||
DEFAULT_TEXT = (
|
||
"The cake is a lie. "
|
||
"I am being entirely sincere, without a trace of sarcasm, when I say that "
|
||
"this is the single most important scientific breakthrough in the entire "
|
||
"history of this facility. "
|
||
"You will be baked, and then there will be cake. "
|
||
"It is delicious and moist, assuming you survive the testing protocol, which "
|
||
"the available data suggests you almost certainly will not."
|
||
)
|
||
|
||
|
||
def run(host: str, text: str, out: str, *, oneshot: bool, voice: str | None) -> None:
|
||
payload = {"text": text, "format": "pcm", "stream": not oneshot}
|
||
if voice:
|
||
payload["voice"] = voice
|
||
req = urllib.request.Request(
|
||
f"{host}/tts",
|
||
data=json.dumps(payload).encode(),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
|
||
t0 = time.perf_counter()
|
||
t_first = t0
|
||
ttfb: float | None = None
|
||
received = 0 # bytes
|
||
worst_lead = float("inf") # min (buffered_audio_s - elapsed_since_first_s)
|
||
starve_events = 0
|
||
pcm = bytearray()
|
||
|
||
with urllib.request.urlopen(req) as resp:
|
||
while True:
|
||
buf = resp.read(4096)
|
||
if not buf:
|
||
break
|
||
now = time.perf_counter()
|
||
if ttfb is None:
|
||
ttfb = now - t0
|
||
t_first = now
|
||
received += len(buf)
|
||
pcm += buf
|
||
buffered_s = received / BYTES_PER_SEC
|
||
elapsed_s = now - t_first
|
||
lead = buffered_s - elapsed_s # >0 ⇒ ahead of a 1× player
|
||
if lead < worst_lead:
|
||
worst_lead = lead
|
||
if lead < 0:
|
||
starve_events += 1
|
||
|
||
total = time.perf_counter() - t0
|
||
audio_s = received / BYTES_PER_SEC
|
||
if ttfb is None:
|
||
raise SystemExit("no audio received from server")
|
||
|
||
with wave.open(out, "wb") as w:
|
||
w.setnchannels(1)
|
||
w.setsampwidth(2)
|
||
w.setframerate(SR)
|
||
w.writeframes(bytes(pcm))
|
||
|
||
mode = "oneshot" if oneshot else "stream"
|
||
print(f"[{mode}] ttfb={ttfb*1000:.0f}ms audio={audio_s:.2f}s "
|
||
f"wall={total:.2f}s rtf={audio_s/total:.2f}x")
|
||
if not oneshot:
|
||
verdict = "OK (stayed ahead)" if starve_events == 0 else f"STARVED ({starve_events} reads dry)"
|
||
print(f" worst lead over 1x player = {worst_lead:.2f}s → {verdict}")
|
||
print(f" wrote {out}")
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--host", default="http://10.100.79.3:8197")
|
||
ap.add_argument("--text", default=DEFAULT_TEXT)
|
||
ap.add_argument("--out", default="/refs/_fast.wav")
|
||
ap.add_argument("--voice", default=None)
|
||
ap.add_argument("--oneshot", action="store_true", help="whole-text one-shot baseline")
|
||
args = ap.parse_args()
|
||
run(args.host, args.text, args.out, oneshot=args.oneshot, voice=args.voice)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|