[Edge][WP0] Add offline streaming-latency benchmark for chunked TTS

Adds benchmarks/tts/stream_latency_bench.py (AsyncOmni driver reusing the
Qwen3-TTS example's prompt builder; --model override, warm-up/repeat,
--init-only, per-chunk arrivals, peak RSS and per-process GPU memory) and
benchmarks/tts/stream_latency_metrics.py (TTFA, underrun-free playback start,
stall at TTFA, RTF streamed/total, chunk gaps, percentiles) with CPU unit
tests. Results go under benchmarks/tts/edge_results/stream_latency/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
tzhouamandClaude Fable 5.1 committed 2026-09-09 10:24:53 +08:00
1 parent 7be014bce6
commit b70207f57f
6 files changed
+675

No files matched your search

+25
View File
@@ -280,3 +280,28 @@ benchmarks/tts/
- Upstream seed-tts-eval integration: vllm-project/vllm-omni#2558
- Pipeline + Deploy schema: vllm-project/vllm-omni#2383
- Concurrency cliff RFC: vllm-project/vllm-omni#272
## Offline streaming latency (`stream_latency_bench.py`)
Measures chunk-streamed generation through `AsyncOmni` without a server, reusing the
Qwen3-TTS example's prompt builder. Per request it records every chunk arrival and derives:
| Metric | Definition |
|---|---|
| `ttfa_ms` | arrival of the first audio-carrying chunk |
| `playback_start_ms` | earliest underrun-free playback start, `max_i (t_i - audio_before_i)` |
| `stall_at_ttfa_ms` | total stall if playback starts at TTFA (the 1→25-frame cliff on the default schedule) |
| `rtf_streamed` / `rtf_total` | wall time over streamed audio / over streamed + tail audio |
| `gap_ms` | inter-chunk arrival gaps |
```bash
# one GPU through the scheduler; every example flag is passed through
gpu run --gpus 1 --timeout 40m --note "stream latency 0.6B" -- env HF_HUB_OFFLINE=1 \
python benchmarks/tts/stream_latency_bench.py --model Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice \
--query-type CustomVoice --txt-prompts benchmarks/tts/prompts_12.txt --warmup 1 --repeat 3 \
--init-timeout 1500 --stage-init-timeout 900 --tag baseline
```
Results are written under `benchmarks/tts/edge_results/stream_latency/<model>/<deploy>/`.
`--init-only` initializes, runs the warm-ups and exits (used by `init_profile.py`);
`VLLM_OMNI_INIT_TIMELINE=<path>` embeds the initialization timeline JSON when available.
+9
View File
@@ -0,0 +1,9 @@
# Edge benchmark results
Committed JSON produced by the edge branch benchmarks (kept small; WAVs are never committed):
- `stream_latency/<model>/<deploy>/<timestamp>[_tag].json` — `stream_latency_bench.py` output (schema 1): `meta` (model, deploy config, git SHA, GPU), `init` (`init_s`, optional init timeline), `resources` (peak RSS, peak GPU MiB, sampling mode), `requests[]` (per-chunk arrivals, TTFA, playback-start, stall at TTFA, RTF streamed / total) and `summary` (p50/p90/max).
- `init_profile/<timestamp>/summary.json` — `init_profile.py` comparison of initialization phases per configuration.
- `hw_emulation/`, `scheduling/` — per-work-package comparison tables.
Metric definitions live in `benchmarks/tts/stream_latency_metrics.py`.
+12
View File
@@ -0,0 +1,12 @@
Hello, welcome to the voice synthesis benchmark test.
She said she would be here by noon, but nobody showed up.
The quick brown fox jumps over the lazy dog near the riverbank.
I can't believe how beautiful the sunset looks from up here on the mountain.
Please remember to bring your identification documents to the appointment tomorrow morning.
Have you ever wondered what it would be like to travel through time and visit ancient civilizations?
The restaurant on the corner serves the best pasta I have ever tasted in my entire life.
After the meeting, we should discuss the quarterly results and plan for the next phase.
Learning a new language takes patience, practice, and a genuine curiosity about other cultures.
The train leaves at half past seven, so we need to arrive at the station before then.
Could you please turn down the music a little bit, I'm trying to concentrate on my work.
It was a dark and stormy night when the old lighthouse keeper heard a knock at the door.
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""Offline streaming-latency benchmark for chunk-streamed TTS pipelines (AsyncOmni).
Reuses the Qwen3-TTS example's input builder so prompts/templates are identical to
``examples/offline_inference/text_to_speech/qwen3_tts/end2end.py``; every flag that
example accepts is passed through (``--query-type``, ``--txt-prompts``,
``--deploy-config``, ``--init-timeout``, ``--stage-init-timeout``, ...).
Bench-only flags:
--model override the model id chosen by the example (e.g. the 0.6B variants)
--warmup N warm-up requests (excluded from statistics), default 1
--repeat K repeat the prompt list K times, default 1
--init-only initialize the engine, run the warm-ups, write JSON, exit
--tag free-form tag appended to the result file name
--output-dir results root (default benchmarks/tts/edge_results/stream_latency)
--save-wav also write the streamed audio per request
--gpu-poll-interval-s resource poller interval (default 0.2)
Metrics per request: TTFA, chunk arrivals, uninterrupted-playback start, stall at
TTFA, RTF (streamed and streamed+tail), see ``stream_latency_metrics.py``.
Example (one GPU through the scheduler):
gpu run --gpus 1 --timeout 40m --note "wp0 baseline" -- env HF_HUB_OFFLINE=1 \\
python benchmarks/tts/stream_latency_bench.py --model Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice \\
--query-type CustomVoice --txt-prompts benchmarks/tts/prompts_12.txt --warmup 1 --repeat 3
"""
from __future__ import annotations
import argparse
import asyncio
import datetime as _dt
import json
import os
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any
import torch
BENCH_DIR = Path(__file__).resolve().parent
REPO_ROOT = BENCH_DIR.parent.parent
EXAMPLE_DIR = REPO_ROOT / "examples" / "offline_inference" / "text_to_speech" / "qwen3_tts"
sys.path.insert(0, str(BENCH_DIR))
sys.path.insert(0, str(EXAMPLE_DIR))
import stream_latency_metrics as metrics # noqa: E402
SCHEMA_VERSION = 1
# --------------------------------------------------------------------------- args
def parse_bench_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]:
p = argparse.ArgumentParser(add_help=False)
p.add_argument("--model", default=None)
p.add_argument("--warmup", type=int, default=1)
p.add_argument("--repeat", type=int, default=1)
p.add_argument("--init-only", action="store_true")
p.add_argument("--tag", default="")
p.add_argument("--output-dir", default=str(BENCH_DIR / "edge_results" / "stream_latency"))
p.add_argument("--save-wav", action="store_true")
p.add_argument("--gpu-poll-interval-s", type=float, default=0.2)
p.add_argument("--parallel-stage-init", action="store_true", help="Pass parallel_stage_init=True to the engine")
p.add_argument("--deploy-profile", default=None, help="Named deploy profile (e.g. edge); forwarded to Omni")
p.add_argument("--bench-help", action="store_true")
bench, rest = p.parse_known_args(argv)
if bench.bench_help:
p.print_help()
sys.exit(0)
return bench, rest
def build_inputs(example_args: argparse.Namespace, model_override: str | None) -> tuple[str, list[dict[str, Any]]]:
import end2end # example module, imported lazily so --bench-help works without deps
model_name, inputs = end2end._build_inputs(example_args)
if model_override and model_override != model_name:
model_name = model_override
rebuilt = []
for item in inputs:
info = item["additional_information"]
rebuilt.append(
{
"prompt_token_ids": [0] * end2end._estimate_prompt_len(info, model_name),
"additional_information": info,
}
)
inputs = rebuilt
return model_name, inputs
# ----------------------------------------------------------------- resources
class ResourcePoller(threading.Thread):
"""Samples RSS of this process tree and GPU memory of its processes."""
def __init__(self, interval_s: float = 0.2):
super().__init__(daemon=True)
self.interval_s = interval_s
self._stop_evt = threading.Event()
self.peak_rss_bytes = 0
self.peak_gpu_mem_mib = 0
self.gpu_mem_samples = 0
self.gpu_mem_mode = "none"
try:
import psutil
self._proc = psutil.Process()
except Exception: # pragma: no cover - psutil is a vllm dependency
self._proc = None
def _pids(self) -> set[int]:
if self._proc is None:
return {os.getpid()}
try:
return {self._proc.pid, *(c.pid for c in self._proc.children(recursive=True))}
except Exception:
return {os.getpid()}
def _sample_rss(self, pids: set[int]) -> int:
if self._proc is None:
return 0
import psutil
total = 0
for pid in pids:
try:
total += psutil.Process(pid).memory_info().rss
except Exception:
continue
return total
def _sample_gpu(self, pids: set[int]) -> int | None:
try:
out = subprocess.run(
["nvidia-smi", "--query-compute-apps=pid,used_memory", "--format=csv,noheader,nounits"],
capture_output=True,
text=True,
timeout=5,
)
except Exception:
return None
if out.returncode != 0:
return None
total = 0
matched = False
for line in out.stdout.strip().splitlines():
parts = [x.strip() for x in line.split(",")]
if len(parts) != 2:
continue
try:
pid, mem = int(parts[0]), int(parts[1])
except ValueError:
continue
if pid in pids:
total += mem
matched = True
if matched:
self.gpu_mem_mode = "per-process"
return total
# fallback: whole-GPU usage of the first visible device
idx = (os.environ.get("CUDA_VISIBLE_DEVICES", "0").split(",")[0] or "0").strip()
try:
out = subprocess.run(
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits", "-i", idx],
capture_output=True,
text=True,
timeout=5,
)
if out.returncode == 0 and out.stdout.strip():
self.gpu_mem_mode = "whole-gpu"
return int(out.stdout.strip().splitlines()[0])
except Exception:
return None
return None
def run(self) -> None:
while not self._stop_evt.is_set():
pids = self._pids()
self.peak_rss_bytes = max(self.peak_rss_bytes, self._sample_rss(pids))
gpu = self._sample_gpu(pids)
if gpu is not None:
self.gpu_mem_samples += 1
self.peak_gpu_mem_mib = max(self.peak_gpu_mem_mib, gpu)
self._stop_evt.wait(self.interval_s)
def stop(self) -> dict[str, Any]:
self._stop_evt.set()
self.join(timeout=5)
return {
"peak_rss_bytes": self.peak_rss_bytes,
"peak_gpu_mem_mib": self.peak_gpu_mem_mib,
"gpu_mem_samples": self.gpu_mem_samples,
"gpu_mem_mode": self.gpu_mem_mode,
}
def _git_sha(path: Path) -> str | None:
try:
return (
subprocess.run(
["git", "-C", str(path), "rev-parse", "--short", "HEAD"], capture_output=True, text=True, timeout=5
).stdout.strip()
or None
)
except Exception:
return None
def _gpu_name() -> str | None:
try:
out = subprocess.run(
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], capture_output=True, text=True, timeout=5
)
return out.stdout.strip().splitlines()[0] if out.returncode == 0 and out.stdout.strip() else None
except Exception:
return None
# --------------------------------------------------------------------- audio
def _cat_audio(audio: Any) -> torch.Tensor | None:
if audio is None:
return None
if isinstance(audio, list):
parts = [a for a in audio if a is not None]
if not parts:
return None
audio = torch.cat([torch.as_tensor(a).reshape(-1) for a in parts], dim=-1)
return torch.as_tensor(audio).reshape(-1).float().cpu()
def _sample_rate(mm: dict[str, Any] | None, default: int = 24000) -> int:
if not mm or mm.get("sr") is None:
return default
s = mm["sr"]
s = s[-1] if isinstance(s, list) and s else s
return int(s.item() if hasattr(s, "item") else s)
# ----------------------------------------------------------------------- run
async def run_request(omni: Any, prompt: dict[str, Any], request_id: str) -> tuple[dict[str, Any], torch.Tensor | None]:
t_start = time.perf_counter()
t_prev = t_start
chunks: list[dict[str, Any]] = []
streamed: list[torch.Tensor] = []
tail: torch.Tensor | None = None
sr = 24000
t_end = t_start
async for so in omni.generate(prompt, request_id=request_id):
now = time.perf_counter()
mm = so.outputs[0].multimodal_output if so.outputs else None
sr = _sample_rate(mm, sr)
audio = _cat_audio(mm.get("audio") if mm else None)
if not so.finished:
n = 0 if audio is None else int(audio.numel())
chunks.append(
{"idx": len(chunks), "t_ms": (now - t_start) * 1e3, "gap_ms": (now - t_prev) * 1e3, "samples": n}
)
if audio is not None and n:
streamed.append(audio)
t_prev = now
else:
t_end = now
tail = audio
total_wall_s = t_end - t_start
tail_s = (tail.numel() / sr) if tail is not None else 0.0
rec = {
"request_id": request_id,
"sr": sr,
"chunks": chunks,
"t_final_ms": total_wall_s * 1e3,
**metrics.request_metrics(chunks, sr, total_wall_s, tail_audio_s=tail_s),
}
text = prompt.get("additional_information", {}).get("text")
rec["text"] = text[0] if isinstance(text, list) and text else text
audio_out = torch.cat(streamed) if streamed else None
return rec, audio_out
async def main() -> int:
bench, rest = parse_bench_args(sys.argv[1:])
sys.argv = [sys.argv[0], *rest]
import end2end
example_args = end2end.parse_args()
model_name, inputs = build_inputs(example_args, bench.model)
omni_kwargs = vars(example_args).copy()
omni_kwargs["model"] = model_name
if bench.parallel_stage_init:
omni_kwargs["parallel_stage_init"] = True
if bench.deploy_profile:
omni_kwargs["deploy_profile"] = bench.deploy_profile
from vllm_omni import AsyncOmni
poller = ResourcePoller(bench.gpu_poll_interval_s)
poller.start()
t0 = time.perf_counter()
omni = AsyncOmni(**omni_kwargs)
init_s = time.perf_counter() - t0
results: list[dict[str, Any]] = []
wavs: dict[str, torch.Tensor] = {}
plan: list[tuple[str, dict[str, Any]]] = []
for i in range(bench.warmup):
plan.append((f"w{i}", inputs[i % len(inputs)]))
if not bench.init_only:
for r in range(bench.repeat):
for i, prompt in enumerate(inputs):
plan.append((f"r{r}_{i}", prompt))
for rid, prompt in plan:
rec, audio = await run_request(omni, prompt, rid)
rec["warmup"] = rid.startswith("w")
results.append(rec)
if audio is not None:
wavs[rid] = audio
print(
f"[bench] {rid}: TTFA={rec['ttfa_ms']} ms playback_start={rec['playback_start_ms']} ms "
f"stall@ttfa={rec['stall_at_ttfa_ms']} ms chunks={rec['n_chunks']} audio={rec['streamed_audio_s']:.2f}s "
f"wall={rec['total_wall_s']:.3f}s rtf_total={rec['rtf_total']}",
flush=True,
)
resources = poller.stop()
measured = [r for r in results if not r["warmup"]]
summary = metrics.summarize(measured)
timeline = None
tl_path = os.environ.get("VLLM_OMNI_INIT_TIMELINE")
if tl_path and os.path.exists(tl_path):
try:
with open(tl_path) as f:
timeline = json.load(f)
except Exception:
timeline = None
deploy_cfg = getattr(example_args, "deploy_config", None)
model_tag = model_name.split("/")[-1]
deploy_tag = (
Path(deploy_cfg).stem
if deploy_cfg
else (f"profile_{bench.deploy_profile}" if bench.deploy_profile else "default")
)
ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
out_dir = Path(bench.output_dir) / model_tag / deploy_tag
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / (f"{ts}_{bench.tag}.json" if bench.tag else f"{ts}.json")
doc = {
"schema": SCHEMA_VERSION,
"meta": {
"model": model_name,
"deploy_config": deploy_cfg,
"query_type": getattr(example_args, "query_type", None),
"txt_prompts": getattr(example_args, "txt_prompts", None),
"git_sha": _git_sha(REPO_ROOT),
"gpu_name": _gpu_name(),
"cuda_visible_devices": os.environ.get("CUDA_VISIBLE_DEVICES"),
"timestamp": ts,
"argv": sys.argv[1:],
"bench_args": vars(bench),
"warmup": bench.warmup,
"repeat": bench.repeat,
"init_only": bench.init_only,
},
"init": {"init_s": init_s, "init_timeline": timeline},
"resources": resources,
"requests": results,
"summary": summary,
}
with open(out_path, "w") as f:
json.dump(doc, f, indent=1)
if bench.save_wav:
import soundfile as sf
for rid, audio in wavs.items():
sf.write(str(out_dir / f"{ts}_{rid}.wav"), audio.numpy(), results[0]["sr"], format="WAV")
print(f"[bench] init_s={init_s:.1f} resources={resources}", flush=True)
print(f"[bench] SUMMARY {json.dumps(summary)}", flush=True)
print(f"[bench] wrote {out_path}", flush=True)
shutdown = getattr(omni, "shutdown", None)
if callable(shutdown):
shutdown()
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+160
View File
@@ -0,0 +1,160 @@
"""Pure-Python metrics for streaming TTS latency (no torch / engine imports).
A streamed utterance is a list of chunk records ``{"t_ms": float, "samples": int}``
where ``t_ms`` is the arrival time of the chunk relative to request submission and
``samples`` is the number of PCM samples it carries at sample rate ``sr``.
Definitions
-----------
TTFA
Arrival time of the first chunk that carries audio.
playback_start_ms
The earliest time a player could start such that it never underruns given the
observed arrivals: ``max_i (t_i - C_i)`` where ``C_i`` is the audio duration
(ms) accumulated before chunk ``i``. Always ``>= TTFA``.
stall_at_ttfa_ms
Total stall a player would suffer if it started at TTFA instead of
``playback_start_ms`` (this is the "1 -> 25 frame cliff" measured on the
default Qwen3-TTS schedule).
RTF
Wall time divided by generated audio seconds; lower is faster than real time.
"""
from __future__ import annotations
import math
from typing import Any
def _audio_chunks(chunks: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [c for c in chunks if int(c.get("samples", 0)) > 0]
def chunk_durations_ms(chunks: list[dict[str, Any]], sr: int) -> list[float]:
"""Audio duration in ms of each audio-carrying chunk, in arrival order."""
return [1000.0 * int(c["samples"]) / sr for c in _audio_chunks(chunks)]
def cumulative_audio_before_ms(chunks: list[dict[str, Any]], sr: int) -> list[float]:
"""``C_i``: audio (ms) delivered before chunk ``i`` (audio chunks only)."""
out: list[float] = []
acc = 0.0
for d in chunk_durations_ms(chunks, sr):
out.append(acc)
acc += d
return out
def ttfa_ms(chunks: list[dict[str, Any]]) -> float | None:
audio = _audio_chunks(chunks)
return float(audio[0]["t_ms"]) if audio else None
def playback_start_ms(chunks: list[dict[str, Any]], sr: int) -> float | None:
"""Earliest underrun-free playback start; ``None`` when no audio arrived."""
audio = _audio_chunks(chunks)
if not audio:
return None
before = cumulative_audio_before_ms(chunks, sr)
return max(float(c["t_ms"]) - b for c, b in zip(audio, before))
def underruns_if_start_at(chunks: list[dict[str, Any]], sr: int, start_ms: float) -> list[tuple[int, float]]:
"""Stalls a player starting at ``start_ms`` would suffer: ``[(chunk_index, stall_ms)]``.
After each stall the playback cursor shifts by the stall, so later chunks are
judged against the shifted schedule (this models a real ring-buffer player).
"""
audio = _audio_chunks(chunks)
before = cumulative_audio_before_ms(chunks, sr)
stalls: list[tuple[int, float]] = []
cursor = float(start_ms)
for i, (c, b) in enumerate(zip(audio, before)):
needed_at = cursor + b
t = float(c["t_ms"])
if t > needed_at + 1e-9:
stall = t - needed_at
stalls.append((i, stall))
cursor += stall
return stalls
def stall_at_ttfa_ms(chunks: list[dict[str, Any]], sr: int) -> float | None:
t0 = ttfa_ms(chunks)
if t0 is None:
return None
return sum(s for _, s in underruns_if_start_at(chunks, sr, t0))
def audio_seconds(chunks: list[dict[str, Any]], sr: int) -> float:
return sum(int(c["samples"]) for c in _audio_chunks(chunks)) / sr
def rtf(total_wall_s: float, audio_s: float) -> float | None:
if audio_s <= 0:
return None
return total_wall_s / audio_s
def inter_chunk_gaps_ms(chunks: list[dict[str, Any]]) -> list[float]:
"""Arrival gaps between consecutive audio chunks (excludes the first chunk)."""
audio = _audio_chunks(chunks)
return [float(b["t_ms"]) - float(a["t_ms"]) for a, b in zip(audio, audio[1:])]
def percentile(values: list[float], q: float) -> float | None:
"""Nearest-rank percentile (``q`` in [0, 1]); ``None`` for an empty list."""
if not values:
return None
ordered = sorted(values)
rank = max(1, math.ceil(q * len(ordered)))
return ordered[min(rank, len(ordered)) - 1]
def _stats(values: list[float]) -> dict[str, float | None]:
vals = [float(v) for v in values if v is not None]
return {
"n": len(vals),
"p50": percentile(vals, 0.50),
"p90": percentile(vals, 0.90),
"max": max(vals) if vals else None,
"mean": (sum(vals) / len(vals)) if vals else None,
}
def request_metrics(
chunks: list[dict[str, Any]], sr: int, total_wall_s: float, tail_audio_s: float = 0.0
) -> dict[str, Any]:
"""Derive all per-request metrics from a chunk timeline."""
streamed_s = audio_seconds(chunks, sr)
t_last = max((float(c["t_ms"]) for c in _audio_chunks(chunks)), default=None)
return {
"ttfa_ms": ttfa_ms(chunks),
"playback_start_ms": playback_start_ms(chunks, sr),
"stall_at_ttfa_ms": stall_at_ttfa_ms(chunks, sr),
"n_chunks": len(_audio_chunks(chunks)),
"streamed_audio_s": streamed_s,
"tail_audio_s": tail_audio_s,
"total_wall_s": total_wall_s,
"rtf_streamed": rtf(t_last / 1000.0, streamed_s) if t_last is not None else None,
"rtf_total": rtf(total_wall_s, streamed_s + tail_audio_s),
"gap_ms": inter_chunk_gaps_ms(chunks),
}
def summarize(records: list[dict[str, Any]]) -> dict[str, Any]:
"""Aggregate per-request metric dicts (as produced by :func:`request_metrics`)."""
gaps: list[float] = []
for r in records:
gaps.extend(r.get("gap_ms") or [])
return {
"n_requests": len(records),
"ttfa_ms": _stats([r.get("ttfa_ms") for r in records]),
"playback_start_ms": _stats([r.get("playback_start_ms") for r in records]),
"stall_at_ttfa_ms": _stats([r.get("stall_at_ttfa_ms") for r in records]),
"rtf_streamed": _stats([r.get("rtf_streamed") for r in records]),
"rtf_total": _stats([r.get("rtf_total") for r in records]),
"gap_ms": _stats(gaps),
"total_audio_s": sum(float(r.get("streamed_audio_s") or 0.0) for r in records),
"total_wall_s": sum(float(r.get("total_wall_s") or 0.0) for r in records),
}
@@ -0,0 +1,81 @@
"""Tests for benchmarks/tts/stream_latency_metrics.py (pure Python, CPU)."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "benchmarks" / "tts"))
import stream_latency_metrics as m # noqa: E402
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
SR = 24000
FRAME = 1920 # one 12.5 Hz codec frame at 24 kHz = 80 ms
def test_measured_default_schedule_has_80ms_cliff():
# Shape measured on L20X with the default 1 -> 25 frame schedule.
chunks = [
{"t_ms": 30.0, "samples": FRAME},
{"t_ms": 190.0, "samples": 25 * FRAME},
{"t_ms": 348.0, "samples": 25 * FRAME},
]
assert m.ttfa_ms(chunks) == 30.0
assert m.playback_start_ms(chunks, SR) == pytest.approx(110.0)
stalls = m.underruns_if_start_at(chunks, SR, 30.0)
assert stalls == [(1, pytest.approx(80.0))]
assert m.stall_at_ttfa_ms(chunks, SR) == pytest.approx(80.0)
def test_fast_arrivals_start_at_ttfa():
chunks = [
{"t_ms": 30.0, "samples": 4 * FRAME},
{"t_ms": 100.0, "samples": 8 * FRAME},
{"t_ms": 200.0, "samples": 25 * FRAME},
]
assert m.playback_start_ms(chunks, SR) == 30.0
assert m.underruns_if_start_at(chunks, SR, 30.0) == []
assert m.stall_at_ttfa_ms(chunks, SR) == 0.0
def test_late_middle_chunk_dominates_and_shifts_cursor():
chunks = [{"t_ms": 10.0, "samples": FRAME}, {"t_ms": 500.0, "samples": FRAME}, {"t_ms": 520.0, "samples": FRAME}]
# C = [0, 80, 160]; t - C = [10, 420, 360] -> 420
assert m.playback_start_ms(chunks, SR) == pytest.approx(420.0)
stalls = m.underruns_if_start_at(chunks, SR, 10.0)
# chunk 1 needed at 90, arrives 500 -> stall 410; cursor 420; chunk 2 needed at 580, arrives 520 -> ok
assert stalls == [(1, pytest.approx(410.0))]
def test_zero_sample_chunks_are_ignored():
chunks = [{"t_ms": 5.0, "samples": 0}, {"t_ms": 30.0, "samples": FRAME}, {"t_ms": 40.0, "samples": 0}]
assert m.ttfa_ms(chunks) == 30.0
assert m.chunk_durations_ms(chunks, SR) == [pytest.approx(80.0)]
assert m.inter_chunk_gaps_ms(chunks) == []
assert m.playback_start_ms([], SR) is None
assert m.stall_at_ttfa_ms([{"t_ms": 1.0, "samples": 0}], SR) is None
def test_request_metrics_and_summary():
chunks = [{"t_ms": 30.0, "samples": FRAME}, {"t_ms": 190.0, "samples": 25 * FRAME}]
r = m.request_metrics(chunks, SR, total_wall_s=0.4, tail_audio_s=0.4)
assert r["n_chunks"] == 2
assert r["streamed_audio_s"] == pytest.approx(26 * 0.08)
assert r["rtf_streamed"] == pytest.approx(0.190 / (26 * 0.08))
assert r["rtf_total"] == pytest.approx(0.4 / (26 * 0.08 + 0.4))
assert r["gap_ms"] == [pytest.approx(160.0)]
s = m.summarize([r, r, r])
assert s["n_requests"] == 3
assert s["ttfa_ms"]["p50"] == 30.0
assert s["gap_ms"]["n"] == 3
assert s["stall_at_ttfa_ms"]["max"] == pytest.approx(80.0)
def test_percentile_nearest_rank():
assert m.percentile([], 0.5) is None
assert m.percentile([1.0], 0.9) == 1.0
assert m.percentile([1.0, 2.0, 3.0, 4.0], 0.5) == 2.0
assert m.percentile([1.0, 2.0, 3.0, 4.0], 0.9) == 4.0