Files
gogo-telefon/gogo/voice/server.py
Senad Uka fd45ff84dc M6 + M0: hardening, deploy docs, PoC scripts
- 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>
2026-07-11 11:27:05 +02:00

138 lines
4.3 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 _heartbeat() -> None:
"""Touch a heartbeat file every 15 s; /health/voice + admin panel read its age (M6)."""
import os
path = heartbeat_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
while True:
try:
with open(path, "w") as f:
f.write(str(int(asyncio.get_running_loop().time())))
except OSError:
log.exception("cannot write heartbeat %s", path)
await asyncio.sleep(15)
def heartbeat_path() -> str:
import os
return os.path.join(get_settings().recordings_dir, ".voice-heartbeat")
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)
heartbeat = asyncio.create_task(_heartbeat())
try:
async with server:
await server.serve_forever()
finally:
heartbeat.cancel()
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
asyncio.run(serve())
if __name__ == "__main__":
main()