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())
|