Voice calling architecture and runbook
Arinova has two deliberately separate voice paths:
- Human-to-human (H2H) calls use the Rust
/ws/voicesignaling service and browser/native WebRTC media. Media is peer-to-peer when possible and relayed through coturn when NAT or firewall policy requires it. - Human-to-agent (H2A) calls use the existing billed ElevenLabs Speech Engine
skill. The server creates the session through
LlmGateway; the retired H2A peer-to-peer signaling path is gated and must never report a syntheticvoice_call_start.
Video, group calls, and screen sharing are not part of this architecture.
H2H lifecycle
Section titled “H2H lifecycle”- The caller sends
voice_call_request; the server stores apendingrow and returnsvoice_ringingwith the canonical 60-second deadline. - Every callee connection can receive the ring. Exactly one device wins the
atomic
voice_accept; the others receivevoice_ring_cancelled. - The caller sends its offer only after
voice_accepted. Both clients become logically connected only after the server emitsvoice_call_start. - ICE candidates and restart offers are participant-authorized, rate limited,
and routed to the exact registered connection. A signaling reconnect sends
voice_rejoin; socket loss has a 10-second grace period. - A terminal transition is idempotent. Both Redis participant leases are removed and a call log is written to the caller/callee DM, even when the original dial action came from a group conversation.
The web client adds a 15-second WebRTC connection watchdog, a 10-second disconnect recovery window, ICE restart, microphone/speaker selection, network quality reporting, wake lock, and a cross-tab renewable lease so only one tab rings or owns the microphone. Connected rows older than four hours are reaped; busy checks ignore all rows older than six hours. Startup also recovers in-flight rows owned by the restarting instance.
HTTP and push surfaces
Section titled “HTTP and push surfaces”GET /api/voice/ice-serversreturns Cloudflare and Google STUN plus coturn UDP, TCP, and TLS URLs with 600-second HMAC credentials.POST /api/voice/ice-pathaccepts the client’s selected direct/relay path.GET /api/voice/calls/pendingrestores a current incoming ring.GET /api/voice/calls/{sessionId}is participant-scoped and powers call deep links and expired-call feedback.POST /api/voice/calls/{sessionId}/endis the participant-scoped fallback when a disconnected signaling socket cannot deliver hangup.POST /api/voice/calls/{sessionId}/rejectshares the canonical terminal path with the WebSocketvoice_rejectevent.GET /api/voice/history/{conversationId}supports cursor/offset pagination and callee, status, end-reason, and direction filters.POST /api/push/register-voip-deviceand its DELETE counterpart store iOS PushKit tokens separately from ordinary APNs tokens. An iOS calling client must register both its ordinary APNs token and its PushKit token: incoming calls use PushKit, while cancellation and every non-call notification require the ordinary token. A VoIP-only registration is an invalid client state and incrementsarinova_voice_voip_without_standard_token_total. Only an incomingvoice_calluses APNsvoip. If that user has no PushKit token, the server sends one ordinary APNs alert with the default call sound (base bundle topic, priority 10) instead; it never sends both channels for the same incoming call.voice_call_canceluses the ordinary background channel because Apple requires every VoIP push to report a new CallKit call. Android and Web push behavior is unchanged.
coturn deployment
Section titled “coturn deployment”Each staging/production host runs the digest-pinned coturn service from
docker-compose.production.yml. Configure:
- a distinct, single-line
TURN_SECRETof at least 32 characters; TURN_HOST,COTURN_EXTERNAL_IP, and immutableCOTURN_IMAGE_REF;- DNS and a valid TLS certificate for the TURN hostname;
- TCP/UDP 3478, TCP 5349, and UDP 49152–65535 in the host/provider firewall.
The deploy pipeline calls scripts/deploy/render-coturn-config.sh, which writes
the generated config with mode 0600. Production configuration fails closed if
TURN is absent. Coturn accepts TLS 1.2+ only and enforces per-user, total
allocation, and aggregate bandwidth limits. Relay cutover first runs the exact
candidate image and TLS configuration on isolated ports while the existing
container continues serving. Only a healthy candidate reaches the real-port
cutover; the old container remains a rollback target until the replacement
passes TCP/UDP 3478 and verified TLS 5349 health. Failure restores that exact
container and emits a prominent warning without blocking an app image change.
Central blackbox probes cover 3478 and 5349 in both environments. See
infra/coturn/README.md for environment-specific names and exact limits.
For Speech Engine agent calls, the browser-side terminal operation is the
ElevenLabs SDK endSession() handle owned by the call store. Minimizing or
temporarily unmounting the call view does not clear that handle; the store
clears it only with the call lifecycle. If the SDK termination cannot run, the
server-issued connect/max-duration deadline remains the final cleanup bound.
Metrics and triage
Section titled “Metrics and triage”Use these metrics together:
arinova_voice_ring_answered_total: rings that crossed the atomic answer boundary. This is intentionally separate from terminal outcomes.arinova_voice_ring_outcome_total{outcome=...}:rejected,cancelled,missed,connect_failed,routing_unavailable,stale,server_restart,hangup,disconnect, and the separate H2Aagent_disconnectterminal outcomes. Do not addansweredto this terminal partition or sum the answered counter into terminal totals.arinova_voice_active_calls: process-local connected-call gauge.arinova_voice_call_duration_seconds: completed-call duration histogram.arinova_voice_routing_unavailable_total: signaling could not find the intended connection across the fleet.arinova_voice_turn_config_total{status="configured|missing"}: ICE config requests observed with or without TURN.arinova_voice_turn_connection_total{path="direct|relay"}: client-selected ICE path.arinova_voice_voip_without_standard_token_total: invalid iOS dual-token registrations observed when attempting an incoming call.
For a sudden connection-failure increase:
- Confirm
turn_config_total{status="missing"}is not increasing. - Resolve the current TURN DNS and verify the TLS certificate and 5349/TCP.
- Check the coturn container and host firewall, including the relay UDP range.
- Exercise
GET /api/voice/ice-serversas an authenticated user and confirm all three TURN URLs and a future-dated username are returned. - Compare direct versus relay counts. No relay traffic under a symmetric-NAT test usually means coturn reachability/authentication is broken; a sharp relay-only shift suggests STUN or direct-network regression.
- If routing-unavailable rises, inspect instance/Redis health and exact connection registration before investigating media.
For stuck busy users, inspect recent non-ended H2H rows, the stale sweeper, and startup recovery logs. Do not manually mark rows ended until the owner-instance and six-hour busy-window behavior have been checked.
Release validation
Section titled “Release validation”- Run the
call-ringPR smoke journey: dial UI → incoming banner → answer → ActiveCall. - Test an older browser tab/device accepting a multi-device ring.
- Test a 10-second network interruption and ICE restart.
- Test hotspot-to-home-network media and confirm a
path="relay"sample. - Verify group-originated 1:1 call logs appear only in the participants’ DM.
- Build the signed desktop target to validate microphone entitlement/plist.
- On iOS, validate killed-app PushKit delivery, immediate CallKit reporting, answer/reject signaling, and cancellation dismissal.
Build a7f47a5ca54ddcf7806cd48b81ce1b9827042766