#!/usr/bin/env python3 """Measure Drama 3 / SGLang TTS streaming: TTFB, burst sizes, real-time ratio. Streams /v1/audio/speech as raw PCM and reports how the audio arrives, so the vocoder window schedule (stream_stride / ramp / followup) and the real-time ratio can be checked. Run against the local SGLang-Omni server. Two modes: (default) one request for the whole text -> only the server's own vocoder window seams are present. --chunked split the text on sentence boundaries and issue one request per sentence, concatenating the raw PCM like the engine does -> adds the engine's per-chunk request boundaries on top of the seams. Comparing the two WAVs tells whether an artifact comes from the vocoder (present in both) or from the engine's chunking (only in the chunked one). """ import array import re import sys import time import wave import requests URL = "http://localhost:8000/v1/audio/speech" MODEL = "fishaudio/drama-3" SR = 44100 BYTES_PER_SAMPLE = 2 # Voice-cloning reference (matches the engine's .env defaults). REF_AUDIO = "/root/piperust/audio-agent-engine/voice_profiles/solis.wav" REF_TEXT = "/root/piperust/audio-agent-engine/voice_profiles/solis.txt" def load_ref_text(path: str) -> str: try: with open(path, "r", encoding="utf-8") as f: return f.read().strip() except OSError: return "" def save_wav(path: str, pcm: bytes, sr: int) -> None: with wave.open(path, "wb") as w: w.setnchannels(1) w.setsampwidth(BYTES_PER_SAMPLE) w.setframerate(sr) w.writeframes(pcm) def analyze_seams(pcm: bytes, boundaries: list[int]) -> None: """Compare the sample-to-sample jump exactly at each boundary against the jumps inside the windows. A boundary jump no larger than the interior distribution means no amplitude discontinuity there (the vocoder crossfade / the engine's crossfade are doing their job).""" s = array.array("h") s.frombytes(pcm[: len(pcm) // BYTES_PER_SAMPLE * BYTES_PER_SAMPLE]) n = len(s) if n < 2: return deltas = sorted(abs(s[i] - s[i - 1]) for i in range(1, n)) p999 = deltas[int(len(deltas) * 0.999)] global_max = deltas[-1] bounds = [b for b in boundaries[:-1] if 0 < b < n] bj = [abs(s[b] - s[b - 1]) for b in bounds] max_b = max(bj) if bj else 0 rank = deltas.index(max_b) / len(deltas) * 100 if bj else 0 print(f"seam analysis: boundaries={len(bounds)} max_boundary_jump={max_b} " f"global_max_jump={global_max} global_p99.9={p999} " f"max_boundary_percentile={rank:.2f}") def stream_pcm(text: str, use_ref: bool, t0: float): """POST one text and return (pcm_bytes, events, sample_rate). events = list of (t_seconds, bytes_cumulative, chunk_bytes).""" body = { "model": MODEL, "input": text, "voice": "default", "response_format": "pcm", "stream": True, } if use_ref: body["references"] = [ {"audio_path": REF_AUDIO, "text": load_ref_text(REF_TEXT)} ] resp = requests.post(URL, json=body, stream=True, timeout=120) resp.raise_for_status() sr = int(resp.headers.get("x-sample-rate") or SR) pcm = bytearray() events = [] total = 0 with resp: for chunk in resp.iter_content(chunk_size=None): if not chunk: continue total += len(chunk) pcm += chunk events.append((time.perf_counter() - t0, total, len(chunk))) return bytes(pcm), events, sr def summarise_events(events) -> None: ttfb = events[0][0] tlast = events[-1][0] audio_s = (events[-1][1] // BYTES_PER_SAMPLE) / SR print(f"chunks={len(events)} ttfb={ttfb*1000:.0f}ms " f"last={tlast*1000:.0f}ms audio={audio_s:.2f}s " f"wall={tlast:.2f}s ratio={tlast/audio_s:.3f}x") def measure(text: str, use_ref: bool = True, out: str | None = None) -> None: print(f"[single] text={text!r} ref={use_ref}") t0 = time.perf_counter() pcm, events, sr = stream_pcm(text, use_ref, t0) print("x-sample-rate:", sr) summarise_events(events) boundaries = [cum // BYTES_PER_SAMPLE for _, cum, _ in events] analyze_seams(pcm, boundaries) if out: save_wav(out, pcm, sr) print(f"saved WAV -> {out}") def measure_chunked(text: str, use_ref: bool = True, out: str | None = None) -> None: parts = [p for p in re.split(r"(?<=[.!?;])\s+", text.strip()) if p] print(f"[chunked] parts={len(parts)} ref={use_ref}") t0 = time.perf_counter() pcm = bytearray() events = [] sr = SR total = 0 for i, part in enumerate(parts): t_req = time.perf_counter() p, ev, sr = stream_pcm(part, use_ref, t0) ttfb = ev[0][0] - (t_req - t0) print(f" part {i}: {len(part):3d} chars ttfb={ttfb*1000:4.0f}ms " f"audio={(len(p)//BYTES_PER_SAMPLE)/sr:.2f}s part={part[:40]!r}") pcm += p for t, cum, cb in ev: total += cb events.append((t, total, cb)) print("x-sample-rate:", sr) summarise_events(events) boundaries = [cum // BYTES_PER_SAMPLE for _, cum, _ in events] analyze_seams(bytes(pcm), boundaries) if out: save_wav(out, bytes(pcm), sr) print(f"saved WAV -> {out}") if __name__ == "__main__": args = sys.argv[1:] use_ref = "--no-ref" not in args def arg_value(flag): return args[args.index(flag) + 1] if flag in args else None out = arg_value("--out") text = arg_value("--text") or ( "Ciao, questa e' una prova per misurare la latenza del motore vocale. " "Contiamo insieme da uno fino a venti: uno, due, tre, quattro, cinque, " "sei, sette, otto, nove, dieci, undici, dodici, tredici, quattordici, " "quindici, sedici, diciassette, diciotto, diciannove, venti.") if "--chunked" in args: measure_chunked(text, use_ref=use_ref, out=out) else: measure(text, use_ref=use_ref, out=out)