- Pipeline-down fallback: dialplan plays gogo-fallback after a failed AudioSocket (§15) + script to synthesize the Bosnian prompt via TTS - Voice heartbeat file → /health/voice endpoint + live status in admin panel - Retention job also purges stale call registrations; chat sessions metered; 100%-usage super-admin alert email (GOGO_ADMIN_ALERT_EMAIL) - .env.example, docs/DEPLOY.md (compose stack, GPU node, backups pg_dump, update procedure, security notes) - §16 Definition of Done as an automated test: softphone-style call → fake partner availability → request pushed → webhook confirm → console SMS - M0 PoC scripts: STT accuracy harness (CER/WER + spoken-digit check; dry-run verified with espeak-ng samples + faster-whisper small on CPU — phone number extracted exactly), TTS bake-off (Azure vs ElevenLabs, latency+cost), and end-to-end latency smoke test speaking real AudioSocket to the live pipeline Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
83 lines
3.4 KiB
Python
83 lines
3.4 KiB
Python
"""M0 PoC #2 — TTS bake-off: Azure Neural vs ElevenLabs Flash (§14 Phase 0).
|
||
|
||
Synthesizes typical agent sentences with every candidate voice, saves WAVs for
|
||
the founder to listen to, and prints latency + rough per-character cost.
|
||
|
||
Env: GOGO_AZURE_SPEECH_KEY / GOGO_AZURE_SPEECH_REGION and/or GOGO_ELEVENLABS_API_KEY.
|
||
Run: python scripts/poc/tts_bakeoff.py [out_dir]
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
|
||
from gogo.config import get_settings # noqa: E402
|
||
from gogo.voice.audio import pcm_to_wav # noqa: E402
|
||
from gogo.voice.tts import AzureTTS, ElevenLabsTTS # noqa: E402
|
||
|
||
SENTENCES = [
|
||
"Dobar dan, dobili ste salon Merima. Ja sam Gogo, virtuelni asistent — razgovor se snima. Kako vam mogu pomoći?",
|
||
"U srijedu poslijepodne slobodno je u dva i trideset ili u pet. Šta vam više odgovara?",
|
||
"Farbanje cijele dužine je od šezdeset do devedeset maraka, zavisno od dužine kose.",
|
||
"Važi. Na koje ime da zavedem zahtjev?",
|
||
"Prosljeđujem salonu zahtjev: šišanje i feniranje, srijeda u pet. Kontaktiraće vas u najkraćem roku radi potvrde. Hvala na pozivu i prijatno!",
|
||
"Izvinite, nisam vas dobro razumio. Možete li ponoviti?",
|
||
]
|
||
|
||
AZURE_VOICES = ["sr-RS-SophieNeural", "sr-RS-NicholasNeural", "hr-HR-GabrijelaNeural", "hr-HR-SreckoNeural"]
|
||
ELEVEN_VOICES = ["JBFqnCBsd6RMkjVDRZzb"] # replace with shortlisted hr/sr voices
|
||
|
||
# rough public prices (2026): Azure Neural ~$15/1M chars, ElevenLabs Flash ~$50/1M chars
|
||
COST_PER_CHAR_USD = {"azure": 15 / 1_000_000, "elevenlabs": 50 / 1_000_000}
|
||
|
||
|
||
async def bake(provider_name: str, provider, voices: list[str], out: Path) -> None:
|
||
for voice in voices:
|
||
total_chars = total_ms = 0
|
||
for i, sentence in enumerate(SENTENCES, 1):
|
||
t0 = time.monotonic()
|
||
try:
|
||
pcm = await provider.synthesize(sentence, voice)
|
||
except Exception as e: # noqa: BLE001
|
||
print(f" {voice} #{i}: FAILED — {e}")
|
||
continue
|
||
ms = int((time.monotonic() - t0) * 1000)
|
||
total_chars += len(sentence)
|
||
total_ms += ms
|
||
path = out / f"{provider_name}-{voice.replace(':', '_')}-{i}.wav"
|
||
path.write_bytes(pcm_to_wav(pcm))
|
||
print(f" {voice} #{i}: {ms} ms, {len(pcm) // 16} ms audio → {path.name}")
|
||
if total_chars:
|
||
cost = total_chars * COST_PER_CHAR_USD[provider_name]
|
||
print(
|
||
f" {voice}: avg latency {total_ms // len(SENTENCES)} ms, "
|
||
f"~${cost:.4f} for all {total_chars} chars "
|
||
f"(~${cost / len(SENTENCES) * 1000:.3f} per 1000-char call)\n"
|
||
)
|
||
|
||
|
||
async def main() -> None:
|
||
out = Path(sys.argv[1] if len(sys.argv) > 1 else "./poc-tts-out")
|
||
out.mkdir(parents=True, exist_ok=True)
|
||
s = get_settings()
|
||
if s.azure_speech_key:
|
||
print("== Azure Neural ==")
|
||
await bake("azure", AzureTTS(), AZURE_VOICES, out)
|
||
else:
|
||
print("(GOGO_AZURE_SPEECH_KEY not set — skipping Azure)")
|
||
if s.elevenlabs_api_key:
|
||
print("== ElevenLabs Flash ==")
|
||
await bake("elevenlabs", ElevenLabsTTS(), ELEVEN_VOICES, out)
|
||
else:
|
||
print("(GOGO_ELEVENLABS_API_KEY not set — skipping ElevenLabs)")
|
||
print(f"\nListen to the WAVs in {out}/ and pick 2–3 voices for the dashboard (§6.1).")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|