- AudioSocket server (asyncio TCP, Asterisk wire protocol) bridging calls into the same M2 agent used by chat - Call session engine: greeting → energy-VAD utterance collection → STT → agent turn → TTS playback with barge-in (120ms of caller speech stops playback); inactivity + max-duration guards; unintelligible audio reaches the agent as '[nerazumljivo]' so the two-attempt rule stays in the prompt - STTProvider (faster-whisper, lang hint 'sr', GPU/CPU via env) and TTSProvider (Azure Neural raw-8k PCM / ElevenLabs Flash) + test fakes - Recording (mixed caller+agent WAV), transcripts, per-turn latency trace, metering via record_agent_call (§12) - Internal dialplan API: /internal/calls/register (ring targets computed from working hours + ring settings §5.2), /answered (human outcome, no minutes), /hangup (abandoned → missed-call SMS §5.5); shared-token auth - Asterisk config generator (pjsip.conf + extensions.conf from DB): worker endpoints, per-tenant test-caller endpoint, register→Dial→AudioSocket dialplan with h-extension reporting — validated against a real Asterisk 20 container (modules, dialplan, endpoints all load) - docker-compose 'voice' profile (voice server + Asterisk), Dockerfile.voice, docs/VOICE_TESTING.md softphone runbook - 16 new tests incl. full fake-call e2e and a real-TCP AudioSocket wire test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
108 lines
3.4 KiB
Python
108 lines
3.4 KiB
Python
"""TTSProvider interface (§4): Azure Neural + ElevenLabs Flash + test fake.
|
|
|
|
Both return SLIN 8 kHz PCM ready for AudioSocket. Default provider/voice comes
|
|
from settings; per-tenant voice ids look like "azure:sr-RS-SophieNeural" or
|
|
"elevenlabs:<voice_id>".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Protocol
|
|
from xml.sax.saxutils import escape
|
|
|
|
import httpx
|
|
|
|
from gogo.config import get_settings
|
|
from gogo.voice.audio import SAMPLE_RATE, resample, tone
|
|
|
|
log = logging.getLogger("gogo.voice.tts")
|
|
|
|
DEFAULT_AZURE_VOICE = "sr-RS-SophieNeural"
|
|
|
|
|
|
class TTSProvider(Protocol):
|
|
async def synthesize(self, text: str, voice: str = "") -> bytes:
|
|
"""Text → SLIN 8 kHz PCM."""
|
|
...
|
|
|
|
|
|
class AzureTTS:
|
|
async def synthesize(self, text: str, voice: str = "") -> bytes:
|
|
s = get_settings()
|
|
voice = voice or DEFAULT_AZURE_VOICE
|
|
ssml = (
|
|
f"<speak version='1.0' xml:lang='sr-RS'>"
|
|
f"<voice name='{voice}'>{escape(text)}</voice></speak>"
|
|
)
|
|
url = (
|
|
f"https://{s.azure_speech_region}.tts.speech.microsoft.com/"
|
|
"cognitiveservices/v1"
|
|
)
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.post(
|
|
url,
|
|
content=ssml.encode(),
|
|
headers={
|
|
"Ocp-Apim-Subscription-Key": s.azure_speech_key,
|
|
"Content-Type": "application/ssml+xml",
|
|
"X-Microsoft-OutputFormat": "raw-8khz-16bit-mono-pcm",
|
|
"User-Agent": "gogotelefon",
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.content
|
|
|
|
|
|
class ElevenLabsTTS:
|
|
async def synthesize(self, text: str, voice: str = "") -> bytes:
|
|
s = get_settings()
|
|
voice_id = voice or "JBFqnCBsd6RMkjVDRZzb"
|
|
async with httpx.AsyncClient(timeout=20) as client:
|
|
resp = await client.post(
|
|
f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}",
|
|
params={"output_format": "pcm_16000"},
|
|
headers={"xi-api-key": s.elevenlabs_api_key},
|
|
json={"text": text, "model_id": "eleven_flash_v2_5"},
|
|
)
|
|
resp.raise_for_status()
|
|
return resample(resp.content, 16000, SAMPLE_RATE)
|
|
|
|
|
|
class FakeTTS:
|
|
"""Test fake: N ms of tone per character (deterministic, VAD-audible)."""
|
|
|
|
def __init__(self, ms_per_char: int = 5):
|
|
self.ms_per_char = ms_per_char
|
|
self.spoken: list[str] = []
|
|
|
|
async def synthesize(self, text: str, voice: str = "") -> bytes:
|
|
self.spoken.append(text)
|
|
return tone(440, max(40, self.ms_per_char * len(text)))
|
|
|
|
|
|
def make_tts(provider_and_voice: str = "") -> tuple[TTSProvider, str]:
|
|
"""Resolve a tenant voice id 'provider:voice' → (provider instance, voice)."""
|
|
s = get_settings()
|
|
provider_name, _, voice = (provider_and_voice or "").partition(":")
|
|
if not voice:
|
|
provider_name, voice = s.tts_provider, ""
|
|
if provider_name == "elevenlabs":
|
|
return ElevenLabsTTS(), voice
|
|
return AzureTTS(), voice or DEFAULT_AZURE_VOICE
|
|
|
|
|
|
_tts: TTSProvider | None = None
|
|
|
|
|
|
def get_tts(tenant_voice: str = "") -> tuple[TTSProvider, str]:
|
|
if _tts is not None: # test override
|
|
_, _, voice = (tenant_voice or "").partition(":")
|
|
return _tts, voice
|
|
return make_tts(tenant_voice)
|
|
|
|
|
|
def set_tts(provider: TTSProvider | None) -> None:
|
|
global _tts
|
|
_tts = provider
|