b8e6bc83c5
scripts/probe_cartesia.py opens a WebSocket to Cartesia Sonic, requests TTS for a sample customer-service utterance, and measures first-audio-byte latency over N iterations (default 20). Prints min/median/p95/mean. Uses the raw Cartesia WS API (no SDK coupling). If CARTESIA_API_KEY is missing, prints KEY_MISSING and exits 0. Also fixes the R1 Deepgram probe to use the raw WS API instead of the churn-prone SDK listen client. ---ci--- phase: 1 milestone: v0.1 plan: 01 task: 01-03 status: execute persona: backend-engineer requirements: covered: [REQ-VOICE-03, REQ-NFR-LAT-01] ---/ci---
166 lines
5.2 KiB
Python
Executable File
166 lines
5.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""R2 probe — Cartesia Sonic TTS first-audio-byte latency.
|
|
|
|
Per PLAN.md SLICE-01 TASK-01-03: WebSocket to Cartesia Sonic, send a sample text
|
|
chunk, measure first-audio-byte latency over 20 iterations; log min/median/p95.
|
|
|
|
Exit code 0 in all cases:
|
|
- If CARTESIA_API_KEY is missing, print KEY_MISSING and exit 0.
|
|
- If present, run the live probe and print a latency table.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import statistics
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
try:
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
except ImportError: # pragma: no cover
|
|
pass
|
|
|
|
|
|
def _banner(msg: str) -> None:
|
|
print("\n" + "=" * 72)
|
|
print(msg)
|
|
print("=" * 72 + "\n")
|
|
|
|
|
|
def _require_key() -> str | None:
|
|
key = os.environ.get("CARTESIA_API_KEY", "").strip()
|
|
if not key:
|
|
_banner(
|
|
"KEY_MISSING — CARTESIA_API_KEY not set.\n"
|
|
" Cannot run live Cartesia probe. Probe infrastructure is built\n"
|
|
" and ready; live measurements are pending API key provisioning.\n"
|
|
" Set CARTESIA_API_KEY in .env (see .env.example) and re-run."
|
|
)
|
|
return None
|
|
return key
|
|
|
|
|
|
SAMPLE_TEXT = (
|
|
"Hi, I received my order yesterday and the item is cracked. "
|
|
"I want my money back."
|
|
)
|
|
|
|
CARTESIA_WS_URL = "wss://api.cartesia.ai/tts/websocket"
|
|
DEFAULT_VOICE_ID = "a3536a36-1d18-4efb-a95a-7c44b7b5e384"
|
|
|
|
|
|
async def _probe_once(api_key: str, voice_id: str, model_id: str) -> float | None:
|
|
"""Open Cartesia WS, request TTS, return ms-to-first-audio-byte."""
|
|
import websockets
|
|
|
|
headers = [("x-api-key", api_key), ("cartesia-version", "2024-06-10")]
|
|
t0 = time.perf_counter()
|
|
first_audio_ms: float | None = None
|
|
|
|
try:
|
|
async with websockets.connect(
|
|
CARTESIA_WS_URL, additional_headers=headers, open_timeout=10
|
|
) as ws:
|
|
req = {
|
|
"model_id": model_id,
|
|
"transcript": SAMPLE_TEXT,
|
|
"voice": {"id": voice_id},
|
|
"output_format": {
|
|
"container": "raw",
|
|
"encoding": "pcm_s16le",
|
|
"sample_rate": 24000,
|
|
},
|
|
"stream": True,
|
|
}
|
|
await ws.send(json.dumps(req))
|
|
# Read frames until we get the first audio chunk.
|
|
while True:
|
|
msg = await asyncio.wait_for(ws.recv(), timeout=10)
|
|
if isinstance(msg, (bytes, bytearray)):
|
|
first_audio_ms = (time.perf_counter() - t0) * 1000.0
|
|
break
|
|
# JSON control messages (e.g. done) — ignore until audio.
|
|
if isinstance(msg, str):
|
|
data = json.loads(msg)
|
|
if data.get("type") == "done":
|
|
break
|
|
except Exception as exc: # pragma: no cover - network/auth errors
|
|
print(f" [probe] Cartesia connection failed: {exc}")
|
|
return None
|
|
|
|
return first_audio_ms
|
|
|
|
|
|
async def run_live(api_key: str, iterations: int, voice_id: str, model_id: str) -> list[float]:
|
|
samples: list[float] = []
|
|
print(f" Running {iterations} Cartesia Sonic iterations (voice={voice_id})...")
|
|
for i in range(iterations):
|
|
ms = await _probe_once(api_key, voice_id, model_id)
|
|
if ms is not None:
|
|
samples.append(ms)
|
|
print(f" [{i + 1:2d}/{iterations}] first-audio: {ms:6.1f} ms")
|
|
else:
|
|
print(f" [{i + 1:2d}/{iterations}] no audio received (skipped)")
|
|
await asyncio.sleep(0.3)
|
|
return samples
|
|
|
|
|
|
def _summarize(samples: list[float], label: str) -> dict:
|
|
if not samples:
|
|
print(f"\n {label}: no samples collected.\n")
|
|
return {"label": label, "n": 0}
|
|
s = sorted(samples)
|
|
p95 = s[int(0.95 * (len(s) - 1))]
|
|
row = {
|
|
"label": label,
|
|
"n": len(s),
|
|
"min_ms": round(min(s), 1),
|
|
"median_ms": round(statistics.median(s), 1),
|
|
"p95_ms": round(p95, 1),
|
|
"mean_ms": round(statistics.mean(s), 1),
|
|
}
|
|
print(
|
|
f" {label}: n={row['n']} min={row['min_ms']:.1f} "
|
|
f"median={row['median_ms']:.1f} p95={row['p95_ms']:.1f} "
|
|
f"mean={row['mean_ms']:.1f} (ms)"
|
|
)
|
|
return row
|
|
|
|
|
|
async def amain() -> int:
|
|
parser = argparse.ArgumentParser(description="R2 Cartesia Sonic latency probe")
|
|
parser.add_argument("--iterations", type=int, default=20)
|
|
parser.add_argument("--voice-id", default=os.environ.get("CARTESIA_VOICE_ID", DEFAULT_VOICE_ID))
|
|
parser.add_argument("--model-id", default="sonic-2")
|
|
parser.add_argument("--out", default=None)
|
|
args = parser.parse_args()
|
|
|
|
_banner("R2 PROBE — Cartesia Sonic first-audio-byte latency")
|
|
api_key = _require_key()
|
|
if api_key is None:
|
|
return 0
|
|
|
|
samples = await run_live(api_key, args.iterations, args.voice_id, args.model_id)
|
|
summary = _summarize(samples, "cartesia_sonic_first_audio")
|
|
print()
|
|
if args.out:
|
|
Path(args.out).write_text(json.dumps(summary, indent=2))
|
|
print(f" Wrote {args.out}")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
return asyncio.run(amain())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main()) |