b77536aa5e
---ci--- phase: 1 milestone: v0.1 status: complete requirements: covered: [REQ-VOICE-01, REQ-VOICE-02, REQ-VOICE-03, REQ-VOICE-04, REQ-SCEN-01, REQ-STATE-01, REQ-LLM-01, REQ-LLM-02, REQ-DEBRIEF-01, REQ-ORCH-01, REQ-ORCH-02, REQ-SCEN-FMT-01, REQ-NFR-LAT-01, REQ-NFR-SAFE-01, REQ-NFR-COST-01] partial: [] ---/ci---
348 lines
13 KiB
Python
Executable File
348 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""R4 probe — integrated three-hop end-to-end latency.
|
|
|
|
Per PLAN.md SLICE-01 TASK-01-05: feed a sample ASR transcript → Ollama
|
|
gemma4:cloud streaming → Cartesia TTS streaming; measure end-to-end
|
|
(transcript-in → first-audio-out). Run 10 iterations. Also measure the same
|
|
path with Piper self-hosted (if Piper can be stood up locally; otherwise note
|
|
as pending and pre-stage in SLICE-02).
|
|
|
|
Exit code 0 in all cases:
|
|
- If OLLAMA_API_KEY or CARTESIA_API_KEY is missing, print KEY_MISSING and
|
|
exit 0 (the probe infrastructure is the deliverable).
|
|
- If present, run the live integrated probe and print e2e latency.
|
|
|
|
The Piper leg is invoked only if PRAXIS_TTS=piper is set AND a Piper voice
|
|
model is available; otherwise it is documented as pre-staged (R4 mitigation).
|
|
"""
|
|
|
|
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 _missing(keys: list[str]) -> None:
|
|
_banner(
|
|
"KEY_MISSING — " + ", ".join(keys) + " not set.\n"
|
|
" Cannot run live integrated e2e probe. Probe infrastructure is built\n"
|
|
" and ready; live measurements are pending API key provisioning.\n"
|
|
" Set the missing key(s) in .env (see .env.example) and re-run."
|
|
)
|
|
|
|
|
|
CHAT_URL = os.environ.get("OLLAMA_CHAT_URL", "https://ollama.com/api/chat")
|
|
CARTESIA_WS_URL = "wss://api.cartesia.ai/tts/websocket"
|
|
ROLEPLAY_MODEL = os.environ.get("OLLAMA_ROLEPLAY_MODEL", "gemma4:cloud")
|
|
DEFAULT_VOICE_ID = "a3536a36-1d18-4efb-a95a-7c44b7b5e384"
|
|
|
|
# The "transcript-in" — a realistic ASR final transcript from the learner.
|
|
SAMPLE_TRANSCRIPT = "Hi, I want to help you with your order. What happened?"
|
|
SYSTEM_PROMPT = (
|
|
"You are Jordan, a customer who received a damaged product. "
|
|
"You are frustrated but not abusive. Stay in character. Keep responses "
|
|
"to 1-2 sentences."
|
|
)
|
|
|
|
|
|
async def _ollama_first_token(api_key: str) -> tuple[str | None, float | None, str | None]:
|
|
"""Stream Ollama gemma4:cloud, return (full_text, ttft_ms, error)."""
|
|
import httpx
|
|
|
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
body = {
|
|
"model": ROLEPLAY_MODEL,
|
|
"messages": [
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{"role": "user", "content": SAMPLE_TRANSCRIPT},
|
|
],
|
|
"stream": True,
|
|
}
|
|
t0 = time.perf_counter()
|
|
ttft_ms: float | None = None
|
|
chunks: list[str] = []
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
async with client.stream("POST", CHAT_URL, headers=headers, json=body) as resp:
|
|
if resp.status_code != 200:
|
|
text = await resp.aread()
|
|
return None, None, f"HTTP {resp.status_code}: {text[:200]!r}"
|
|
async for line in resp.aiter_lines():
|
|
if not line:
|
|
continue
|
|
try:
|
|
chunk = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
content = chunk.get("message", {}).get("content", "")
|
|
if content:
|
|
if ttft_ms is None:
|
|
ttft_ms = (time.perf_counter() - t0) * 1000.0
|
|
chunks.append(content)
|
|
except Exception as exc: # pragma: no cover
|
|
return None, None, f"connection error: {exc}"
|
|
|
|
return "".join(chunks), ttft_ms, None
|
|
|
|
|
|
async def _cartesia_first_audio(
|
|
api_key: str, text: str, voice_id: str, model_id: str
|
|
) -> tuple[float | None, str | None]:
|
|
"""Send text to Cartesia WS, return (first_audio_ms_from_t0, error)."""
|
|
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": text,
|
|
"voice": {"id": voice_id},
|
|
"output_format": {
|
|
"container": "raw",
|
|
"encoding": "pcm_s16le",
|
|
"sample_rate": 24000,
|
|
},
|
|
"stream": True,
|
|
}
|
|
await ws.send(json.dumps(req))
|
|
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
|
|
if isinstance(msg, str):
|
|
data = json.loads(msg)
|
|
if data.get("type") == "done":
|
|
break
|
|
except Exception as exc: # pragma: no cover
|
|
return None, f"cartesia error: {exc}"
|
|
|
|
return first_audio_ms, None
|
|
|
|
|
|
async def _piper_first_audio(text: str) -> tuple[float | None, str | None]:
|
|
"""Synthesize via Piper self-hosted, return (first_audio_ms, error).
|
|
|
|
Piper pre-staging note (R4 mitigation): Piper is pre-staged as the
|
|
production v0.1 TTS fallback per ARCHITECTURE.md. The voice model must be
|
|
downloaded separately (see docs/latency-report.md). If not available,
|
|
returns an error string that the caller documents as pending.
|
|
"""
|
|
try:
|
|
from piper import PiperVoice # type: ignore
|
|
except ImportError:
|
|
return None, "piper-tts not installed (pre-staged for SLICE-02)"
|
|
|
|
model_path = os.environ.get("PIPER_VOICE_MODEL", "")
|
|
if not model_path or not Path(model_path).exists():
|
|
return None, "PIPER_VOICE_MODEL not set or file missing (pre-staged for SLICE-02)"
|
|
|
|
import io
|
|
|
|
t0 = time.perf_counter()
|
|
try:
|
|
voice = PiperVoice.load(model_path)
|
|
wav_bytes = io.BytesIO()
|
|
for chunk in voice.synthesize(text):
|
|
wav_bytes.write(chunk.audio_int16_bytes)
|
|
first_audio_ms = (time.perf_counter() - t0) * 1000.0
|
|
return first_audio_ms, None
|
|
except Exception as exc: # pragma: no cover
|
|
return None, f"piper error: {exc}"
|
|
|
|
|
|
async def _e2e_once_cartesia(ollama_key: str, cartesia_key: str, voice_id: str, model_id: str) -> dict:
|
|
"""Run the integrated ASR-transcript → Ollama → Cartesia path once."""
|
|
t_start = time.perf_counter()
|
|
text, ttft_ms, llm_err = await _ollama_first_token(ollama_key)
|
|
if llm_err or not text:
|
|
return {"ok": False, "error": llm_err or "empty LLM output", "ttft_ms": None}
|
|
tts_ms, tts_err = await _cartesia_first_audio(cartesia_key, text, voice_id, model_id)
|
|
if tts_err or tts_ms is None:
|
|
return {"ok": False, "error": tts_err or "no TTS audio", "ttft_ms": ttft_ms}
|
|
e2e_ms = (time.perf_counter() - t_start) * 1000.0
|
|
return {
|
|
"ok": True,
|
|
"ttft_ms": ttft_ms,
|
|
"tts_first_audio_ms": tts_ms,
|
|
"e2e_ms": e2e_ms,
|
|
"llm_text": text[:80],
|
|
}
|
|
|
|
|
|
async def _e2e_once_piper(ollama_key: str) -> dict:
|
|
"""Run the integrated ASR-transcript → Ollama → Piper path once."""
|
|
t_start = time.perf_counter()
|
|
text, ttft_ms, llm_err = await _ollama_first_token(ollama_key)
|
|
if llm_err or not text:
|
|
return {"ok": False, "error": llm_err or "empty LLM output", "ttft_ms": None}
|
|
tts_ms, tts_err = await _piper_first_audio(text)
|
|
if tts_err or tts_ms is None:
|
|
return {"ok": False, "error": tts_err or "no TTS audio", "ttft_ms": ttft_ms, "piper_pending": True}
|
|
e2e_ms = (time.perf_counter() - t_start) * 1000.0
|
|
return {
|
|
"ok": True,
|
|
"ttft_ms": ttft_ms,
|
|
"tts_first_audio_ms": tts_ms,
|
|
"e2e_ms": e2e_ms,
|
|
"llm_text": text[:80],
|
|
}
|
|
|
|
|
|
def _summarize(samples: list[float], label: str) -> dict:
|
|
if not samples:
|
|
print(f" {label}: no samples collected.")
|
|
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="R4 integrated e2e latency probe")
|
|
parser.add_argument("--iterations", type=int, default=10)
|
|
parser.add_argument("--voice-id", default=os.environ.get("CARTESIA_VOICE_ID", DEFAULT_VOICE_ID))
|
|
parser.add_argument("--cartesia-model", default="sonic-2")
|
|
parser.add_argument("--out", default=None)
|
|
parser.add_argument("--piper", action="store_true", help="also run the Piper leg")
|
|
args = parser.parse_args()
|
|
|
|
_banner("R4 PROBE — integrated three-hop e2e (transcript → Ollama → TTS)")
|
|
ollama_key = os.environ.get("OLLAMA_API_KEY", "").strip()
|
|
cartesia_key = os.environ.get("CARTESIA_API_KEY", "").strip()
|
|
|
|
missing = []
|
|
if not ollama_key:
|
|
missing.append("OLLAMA_API_KEY")
|
|
if not cartesia_key:
|
|
missing.append("CARTESIA_API_KEY")
|
|
if missing:
|
|
_missing(missing)
|
|
return 0
|
|
|
|
# ── Cartesia leg ────────────────────────────────────────────────────────
|
|
print(f"\n Cartesia leg — {args.iterations} iterations:")
|
|
e2e_samples: list[float] = []
|
|
ttft_samples: list[float] = []
|
|
tts_samples: list[float] = []
|
|
for i in range(args.iterations):
|
|
r = await _e2e_once_cartesia(ollama_key, cartesia_key, args.voice_id, args.cartesia_model)
|
|
if r.get("ok"):
|
|
e2e_samples.append(r["e2e_ms"])
|
|
ttft_samples.append(r["ttft_ms"])
|
|
tts_samples.append(r["tts_first_audio_ms"])
|
|
print(f" [{i + 1:2d}/{args.iterations}] e2e={r['e2e_ms']:.1f}ms "
|
|
f"(llm_ttft={r['ttft_ms']:.1f}, tts={r['tts_first_audio_ms']:.1f})")
|
|
else:
|
|
print(f" [{i + 1:2d}/{args.iterations}] error: {r.get('error')}")
|
|
await asyncio.sleep(0.5)
|
|
|
|
print()
|
|
e2e_summary = _summarize(e2e_samples, "e2e_cartesia")
|
|
ttft_summary = _summarize(ttft_samples, "e2e_cartesia_llm_ttft")
|
|
tts_summary = _summarize(tts_samples, "e2e_cartesia_tts_first_audio")
|
|
|
|
# ── Piper leg (optional / pre-staged) ───────────────────────────────────
|
|
piper_summary: dict = {}
|
|
if args.piper:
|
|
print(f"\n Piper leg — {args.iterations} iterations:")
|
|
p_e2e: list[float] = []
|
|
p_ttft: list[float] = []
|
|
p_tts: list[float] = []
|
|
for i in range(args.iterations):
|
|
r = await _e2e_once_piper(ollama_key)
|
|
if r.get("ok"):
|
|
p_e2e.append(r["e2e_ms"])
|
|
p_ttft.append(r["ttft_ms"])
|
|
p_tts.append(r["tts_first_audio_ms"])
|
|
print(f" [{i + 1:2d}/{args.iterations}] e2e={r['e2e_ms']:.1f}ms")
|
|
elif r.get("piper_pending"):
|
|
print(f" [{i + 1:2d}/{args.iterations}] Piper pre-staged (pending voice model) — skipping")
|
|
break
|
|
else:
|
|
print(f" [{i + 1:2d}/{args.iterations}] error: {r.get('error')}")
|
|
await asyncio.sleep(0.5)
|
|
print()
|
|
piper_summary = _summarize(p_e2e, "e2e_piper")
|
|
else:
|
|
print("\n Piper leg not requested (--piper). Piper is pre-staged as the R4 "
|
|
"mitigation per ARCHITECTURE.md; live Piper measurement pending "
|
|
"voice-model provisioning (see docs/latency-report.md).")
|
|
|
|
# ── Budget comparison ───────────────────────────────────────────────────
|
|
budget = 600.0
|
|
print(f"\n Latency budget: {budget:.0f}ms")
|
|
if e2e_samples:
|
|
med = statistics.median(e2e_samples)
|
|
over = med > budget
|
|
print(f" Cartesia median e2e: {med:.1f}ms — {'OVER' if over else 'WITHIN'} budget "
|
|
f"(delta {med - budget:+.1f}ms)")
|
|
if piper_summary.get("n"):
|
|
# type: ignore
|
|
med = piper_summary.get("median_ms")
|
|
if med:
|
|
over = med > budget
|
|
print(f" Piper median e2e: {med:.1f}ms — {'OVER' if over else 'WITHIN'} budget "
|
|
f"(delta {med - budget:+.1f}ms)")
|
|
|
|
print()
|
|
if args.out:
|
|
result = {
|
|
"e2e_cartesia": e2e_summary,
|
|
"e2e_cartesia_llm_ttft": ttft_summary,
|
|
"e2e_cartesia_tts_first_audio": tts_summary,
|
|
"e2e_piper": piper_summary,
|
|
"budget_ms": budget,
|
|
}
|
|
Path(args.out).write_text(json.dumps(result, indent=2))
|
|
print(f" Wrote {args.out}")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
return asyncio.run(amain())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main()) |