voice_audio_multi/tools/streaming/measure_tts_streaming_client.py
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

418 lines
16 KiB
Python

#!/usr/bin/env python3
"""Measure streaming TTS with an auditable incremental-text client."""
from __future__ import annotations
import argparse
import base64
import csv
import json
import re
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
import wave
from datetime import datetime
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[2]
LOG_ROOT = REPO_ROOT / "logs" / "streaming_test"
DEFAULT_SERVER_BIN = REPO_ROOT / "build" / "debug" / "bin" / "audiocpp_server"
DEFAULT_SAMPLE_RATE = 48000
def timestamp_slug() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def path_is_under(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
return True
except ValueError:
return False
def split_incremental_text(text: str, max_chars: int) -> list[str]:
if max_chars <= 0:
raise RuntimeError("--max-chars must be positive")
sentences = [part.strip() for part in re.split(r"(?<=[.!?])\s+", text.strip()) if part.strip()]
chunks: list[str] = []
current = ""
for sentence in sentences:
if not current:
current = sentence
elif len(current) + 1 + len(sentence) <= max_chars:
current = current + " " + sentence
else:
chunks.append(current)
current = sentence
while len(current) > max_chars:
split_at = current.rfind(" ", 0, max_chars + 1)
if split_at <= 0:
split_at = max_chars
chunks.append(current[:split_at].strip())
current = current[split_at:].strip()
if current:
chunks.append(current)
if not chunks:
raise RuntimeError("input text produced no chunks")
return chunks
def gpu_memory_mib() -> int:
output = subprocess.check_output(
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
text=True,
timeout=5,
)
values = [int(line.strip()) for line in output.splitlines() if line.strip()]
if not values:
raise RuntimeError("nvidia-smi returned no GPU memory values")
return max(values)
class VramSampler:
def __init__(self, path: Path, interval_s: float) -> None:
self.path = path
self.interval_s = interval_s
self.stop_event = threading.Event()
self.thread: threading.Thread | None = None
def __enter__(self) -> "VramSampler":
if self.interval_s <= 0.0:
raise RuntimeError("--vram-sample-ms must be positive")
self.path.parent.mkdir(parents=True, exist_ok=True)
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
return self
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
self.stop_event.set()
if self.thread is not None:
self.thread.join(timeout=5)
def _run(self) -> None:
start = time.perf_counter()
with self.path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["elapsed_ms", "vram_mib"])
while not self.stop_event.is_set():
writer.writerow([round((time.perf_counter() - start) * 1000.0, 3), gpu_memory_mib()])
handle.flush()
time.sleep(self.interval_s)
def read_vram_summary(path: Path) -> dict[str, Any]:
values: list[int] = []
with path.open("r", encoding="utf-8") as handle:
for row in csv.DictReader(handle):
values.append(int(row["vram_mib"]))
if not values:
raise RuntimeError(f"VRAM sampler wrote no samples: {path}")
return {
"vram_start_mib": values[0],
"vram_peak_mib": max(values),
"vram_end_mib": values[-1],
"vram_samples": len(values),
}
def wait_for_health(base_url: str, timeout_s: float) -> None:
deadline = time.monotonic() + timeout_s
last_error: Exception | None = None
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(base_url + "/health", timeout=1) as response:
if response.status == 200:
return
except Exception as exc:
last_error = exc
time.sleep(0.5)
raise RuntimeError(f"server health timeout for {base_url}: {last_error}")
def stream_speech_request(base_url: str, model: str, text: str, sample_rate: int) -> dict[str, Any]:
payload = {
"model": model,
"input": text,
"response_format": "pcm",
"stream_format": "sse",
"options": {"retry_badcase": False},
}
request = urllib.request.Request(
base_url + "/v1/audio/speech",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", "Accept": "text/event-stream"},
method="POST",
)
start = time.perf_counter()
pcm = bytearray()
delta_events = 0
first_delta_ms: float | None = None
done_ms: float | None = None
ttft_ms: float | None = None
done_event: dict[str, Any] | None = None
errors: list[str] = []
try:
with urllib.request.urlopen(request, timeout=1200) as response:
for raw_line in response:
line = raw_line.decode("utf-8", "replace").strip()
if not line or not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
event = json.loads(data)
event_ms = (time.perf_counter() - start) * 1000.0
event_type = event.get("type")
if event_type == "error":
errors.append(event.get("error", {}).get("message", json.dumps(event)))
elif event_type == "speech.audio.delta":
delta_events += 1
if first_delta_ms is None:
first_delta_ms = event_ms
pcm.extend(base64.b64decode(event["audio"]))
elif event_type == "speech.audio.done":
done_event = event
done_ms = event_ms
ttft_ms = event.get("timing", {}).get("ttft_ms")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "replace")
raise RuntimeError(f"HTTP {exc.code}: {body}") from exc
elapsed_ms = (time.perf_counter() - start) * 1000.0
if errors:
raise RuntimeError("; ".join(errors))
if ttft_ms is None:
raise RuntimeError("streaming speech response did not include ttft_ms")
if not pcm:
raise RuntimeError("streaming speech response produced no audio")
return {
"request": payload,
"elapsed_ms": elapsed_ms,
"ttft_ms": ttft_ms,
"client_first_delta_ms": first_delta_ms,
"client_done_ms": done_ms,
"audio_duration_s": len(pcm) / (2.0 * sample_rate),
"delta_events": delta_events,
"pcm_bytes": len(pcm),
"done_event": done_event,
"pcm": bytes(pcm),
}
def write_wav(path: Path, pcm: bytes, sample_rate: int = DEFAULT_SAMPLE_RATE) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(sample_rate)
wav.writeframes(pcm)
def percentile(values: list[float], q: float) -> float:
if not values:
raise RuntimeError("percentile requires values")
ordered = sorted(values)
index = min(len(ordered) - 1, max(0, round((len(ordered) - 1) * q)))
return ordered[index]
def run_measurement(args: argparse.Namespace) -> dict[str, Any]:
output_dir = args.output_dir
if not path_is_under(output_dir, LOG_ROOT):
raise RuntimeError(f"--output-dir must be under {LOG_ROOT}")
if args.max_chars <= 0:
raise RuntimeError("--max-chars must be positive")
output_dir.mkdir(parents=True, exist_ok=True)
config = load_json(args.server_config)
host = args.host or config.get("host", "127.0.0.1")
port = int(args.port or config["port"])
models = config.get("models") or []
if not models and not args.model:
raise RuntimeError("server config contains no models and --model was not provided")
model = args.model or models[0]["id"]
base_url = f"http://{host}:{port}"
write_json(output_dir / "server_config_snapshot.json", config)
input_text = args.input_text.read_text(encoding="utf-8")
chunks = split_incremental_text(input_text, args.max_chars)
(output_dir / "input_text.txt").write_text(input_text, encoding="utf-8")
write_json(
output_dir / "input_chunks.json",
{
"source": str(args.input_text),
"input_chars": len(input_text),
"max_chars": args.max_chars,
"chunks": [{"index": index, "chars": len(chunk), "text": chunk} for index, chunk in enumerate(chunks)],
},
)
requests_path = output_dir / "requests.jsonl"
if requests_path.exists():
requests_path.unlink()
server: subprocess.Popen[str] | None = None
server_log_handle = None
server_command: list[str] | None = None
try:
if args.start_server:
server_command = [
str(args.server_bin),
"--config",
str(args.server_config),
"--log-file",
str(output_dir / "framework.log"),
]
server_log_handle = (output_dir / "server_stdout.log").open("w", encoding="utf-8")
server = subprocess.Popen(
server_command,
cwd=args.repo_root,
stdout=server_log_handle,
stderr=subprocess.STDOUT,
text=True,
)
wait_for_health(base_url, args.health_timeout_s)
warmup = stream_speech_request(base_url, model, args.warmup_text, args.sample_rate)
write_wav(output_dir / "warmup_discard.wav", warmup["pcm"], args.sample_rate)
warmup_meta = {key: value for key, value in warmup.items() if key != "pcm"}
write_json(output_dir / "warmup_discard.json", warmup_meta)
measured: list[dict[str, Any]] = []
merged_pcm = bytearray()
sequence_start = time.perf_counter()
with VramSampler(output_dir / "measured_vram.csv", args.vram_sample_ms / 1000.0):
for index, chunk in enumerate(chunks):
result = stream_speech_request(base_url, model, chunk, args.sample_rate)
chunk_pcm = result.pop("pcm")
merged_pcm.extend(chunk_pcm)
write_wav(output_dir / f"chunk_{index:03d}.wav", chunk_pcm, args.sample_rate)
record = {
"index": index,
"input_chars": len(chunk),
"input_text": chunk,
**result,
}
measured.append(record)
with requests_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
sequence_elapsed_ms = (time.perf_counter() - sequence_start) * 1000.0
write_wav(output_dir / "merged.wav", bytes(merged_pcm), args.sample_rate)
ttfts = [float(item["ttft_ms"]) for item in measured]
summary = {
"measurement_kind": "incremental_client_text_chunks_to_streaming_sse_tts",
"repo": str(args.repo_root),
"server_config": str(args.server_config),
"server_command": server_command,
"model": model,
"memory_saver": args.memory_saver_label,
"host": host,
"port": port,
"input_text": str(output_dir / "input_text.txt"),
"input_text_source": str(args.input_text),
"input_chars": len(input_text),
"chunk_max_chars": args.max_chars,
"chunk_count": len(chunks),
"warmup": warmup_meta,
"measured_sequence_elapsed_ms": sequence_elapsed_ms,
"sample_rate": args.sample_rate,
"total_audio_duration_s": len(merged_pcm) / (2.0 * args.sample_rate),
"ttft_first_chunk_ms": ttfts[0],
"ttft_min_ms": min(ttfts),
"ttft_p50_ms": percentile(ttfts, 0.50),
"ttft_p95_ms": percentile(ttfts, 0.95),
"ttft_max_ms": max(ttfts),
"ttft_mean_ms": sum(ttfts) / len(ttfts),
"client_first_delta_first_chunk_ms": measured[0]["client_first_delta_ms"],
"chunks": measured,
"artifacts": {
"summary": str(output_dir / "summary.json"),
"server_config_snapshot": str(output_dir / "server_config_snapshot.json"),
"framework_log": str(output_dir / "framework.log") if args.start_server else None,
"server_stdout": str(output_dir / "server_stdout.log") if args.start_server else None,
"input_text": str(output_dir / "input_text.txt"),
"chunks": str(output_dir / "input_chunks.json"),
"requests": str(requests_path),
"warmup_wav": str(output_dir / "warmup_discard.wav"),
"warmup_json": str(output_dir / "warmup_discard.json"),
"merged_wav": str(output_dir / "merged.wav"),
"vram_csv": str(output_dir / "measured_vram.csv"),
},
}
summary.update(read_vram_summary(output_dir / "measured_vram.csv"))
write_json(output_dir / "summary.json", summary)
return summary
finally:
if server is not None:
server.terminate()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
server.wait(timeout=10)
if server_log_handle is not None:
server_log_handle.close()
def parse_args() -> argparse.Namespace:
default_output = LOG_ROOT / f"tts_streaming_{timestamp_slug()}"
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo-root", type=Path, default=REPO_ROOT)
parser.add_argument("--server-bin", type=Path, default=DEFAULT_SERVER_BIN)
parser.add_argument("--server-config", type=Path, required=True)
parser.add_argument("--start-server", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--host", default="")
parser.add_argument("--port", type=int, default=0)
parser.add_argument("--model", default="")
parser.add_argument("--input-text", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, default=default_output)
parser.add_argument("--memory-saver-label", required=True)
parser.add_argument("--max-chars", type=int, default=480)
parser.add_argument("--warmup-text", default="Short warmup request for streaming timing.")
parser.add_argument("--sample-rate", type=int, default=DEFAULT_SAMPLE_RATE)
parser.add_argument("--health-timeout-s", type=float, default=180.0)
parser.add_argument("--vram-sample-ms", type=float, default=200.0)
return parser.parse_args()
def main() -> int:
try:
summary = run_measurement(parse_args())
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
keys = (
"memory_saver",
"chunk_count",
"measured_sequence_elapsed_ms",
"total_audio_duration_s",
"ttft_first_chunk_ms",
"ttft_p50_ms",
"ttft_p95_ms",
"ttft_max_ms",
"client_first_delta_first_chunk_ms",
"vram_peak_mib",
)
print(json.dumps({key: summary[key] for key in keys}, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())