- 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>
114 lines
3.7 KiB
Python
114 lines
3.7 KiB
Python
"""AudioSocket server — Asterisk connects here per agent-handled call (M4).
|
|
|
|
AudioSocket wire protocol (Asterisk `AudioSocket()` app):
|
|
1 byte kind | 2 bytes big-endian payload length | payload
|
|
kind 0x00 = terminate, 0x01 = UUID (16 raw bytes), 0x10 = SLIN audio,
|
|
0xff = error. Audio is 16-bit LE mono PCM @ 8 kHz, ~20 ms per message.
|
|
|
|
Run: python -m gogo.voice.server
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
|
|
from gogo.config import get_settings
|
|
from gogo.voice.call import CallSession, Transport
|
|
|
|
log = logging.getLogger("gogo.voice.server")
|
|
|
|
KIND_TERMINATE = 0x00
|
|
KIND_UUID = 0x01
|
|
KIND_AUDIO = 0x10
|
|
KIND_ERROR = 0xFF
|
|
|
|
|
|
class AudioSocketTransport(Transport):
|
|
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
self.reader = reader
|
|
self.writer = writer
|
|
self._closed = False
|
|
|
|
async def read_message(self) -> tuple[int, bytes] | None:
|
|
try:
|
|
header = await self.reader.readexactly(3)
|
|
except (asyncio.IncompleteReadError, ConnectionResetError):
|
|
return None
|
|
kind = header[0]
|
|
length = int.from_bytes(header[1:3], "big")
|
|
payload = b""
|
|
if length:
|
|
try:
|
|
payload = await self.reader.readexactly(length)
|
|
except (asyncio.IncompleteReadError, ConnectionResetError):
|
|
return None
|
|
return kind, payload
|
|
|
|
async def read_frame(self) -> bytes | None:
|
|
"""Next audio frame; skips non-audio messages; None on hangup/close."""
|
|
while True:
|
|
msg = await self.read_message()
|
|
if msg is None:
|
|
return None
|
|
kind, payload = msg
|
|
if kind == KIND_AUDIO:
|
|
return payload
|
|
if kind == KIND_TERMINATE:
|
|
return None
|
|
if kind == KIND_ERROR:
|
|
log.warning("audiosocket error frame: %s", payload[:64])
|
|
|
|
async def send_audio(self, pcm: bytes) -> None:
|
|
if self._closed:
|
|
return
|
|
try:
|
|
self.writer.write(bytes([KIND_AUDIO]) + len(pcm).to_bytes(2, "big") + pcm)
|
|
await self.writer.drain()
|
|
except (ConnectionResetError, BrokenPipeError):
|
|
self._closed = True
|
|
|
|
async def hangup(self) -> None:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
try:
|
|
self.writer.write(bytes([KIND_TERMINATE, 0, 0]))
|
|
await self.writer.drain()
|
|
self.writer.close()
|
|
except (ConnectionResetError, BrokenPipeError):
|
|
pass
|
|
|
|
|
|
async def handle_connection(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
transport = AudioSocketTransport(reader, writer)
|
|
msg = await transport.read_message()
|
|
if msg is None or msg[0] != KIND_UUID or len(msg[1]) != 16:
|
|
log.warning("connection without UUID handshake — dropping")
|
|
await transport.hangup()
|
|
return
|
|
registration_id = uuid.UUID(bytes=msg[1])
|
|
log.info("call connected: %s", registration_id)
|
|
session = CallSession(transport, registration_id)
|
|
await session.run()
|
|
|
|
|
|
async def serve() -> None:
|
|
s = get_settings()
|
|
server = await asyncio.start_server(handle_connection, s.voice_host, s.voice_port)
|
|
addrs = ", ".join(str(sock.getsockname()) for sock in server.sockets)
|
|
log.info("AudioSocket server listening on %s", addrs)
|
|
# health heartbeat file for the admin panel / monitoring (M6)
|
|
async with server:
|
|
await server.serve_forever()
|
|
|
|
|
|
def main() -> None:
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
asyncio.run(serve())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|