7.4 KiB
Project Notes
Architecture: Hub Model in Rust (Call as Centerpiece)
The call hub lives in the Rust proxy-engine (rust/crates/proxy-engine/). TypeScript is the control plane only — it configures the engine, sends high-level commands (hangup, make_call, webrtc_offer, etc.), and receives events (incoming_call, call_answered, device_registered, webrtc_audio_rx, …). No raw SIP/RTP ever touches TypeScript.
The Call is still the central entity: it owns N legs and a central mixer task that provides mix-minus audio to all participants. Legs can be SipProvider, SipDevice, WebRtc, or Tool (recording/transcription observer).
Key Rust files (rust/crates/proxy-engine/src/)
call_manager.rs— singleton registry, call factory methods, SIP routing (inbound/outbound/passthrough), B2BUA state machine, inbound route resolutioncall.rs— theCallhub +LegInfostruct, owns legs and the mixer tasksip_leg.rs— full SIP dialog management for B2BUA legs (INVITE, 407 auth retry, BYE, CANCEL, early media)rtp.rs— RTP port pool (usesWeak<UdpSocket>so calls auto-release ports on drop) + RTP header helpersmixer.rs— 20 ms-tick mix-minus engine (48 kHz f32 internal, per-leg transcoding viacodec-lib, per-leg denoising)jitter_buffer.rs— per-leg reordering/packet-loss compensationleg_io.rs— spawns inbound/outbound RTP I/O tasks per SIP legwebrtc_engine.rs— browser WebRTC sessions (werift-rs based), ICE/DTLS/SRTPprovider.rs— SIP trunk registrations, public-IP detection via Viareceived=registrar.rs— accepts REGISTER from SIP phones, tracks contacts (push-based device status)config.rs—AppConfigdeserialized from TS, route resolvers (resolve_outbound_route,resolve_inbound_route)main.rs— IPC command dispatcher (handle_command), event emitter, top-level SIP packet routersip_transport.rs— owning wrapper around the main SIP UDP socketvoicemail.rs/recorder.rs/audio_player.rs/tts.rs— media subsystemstool_leg.rs— per-source observer audio for recording/transcription toolsipc.rs— event-emission helper used throughout
Key TS files (control plane)
ts/sipproxy.ts— entrypoint, wires the proxy engine bridge + web UI + WebRTC signalingts/proxybridge.ts—@push.rocks/smartrustbridge to the Rust binary, typedTProxyCommandsmapts/config.ts— JSON config loader (IAppConfig,IProviderConfig, etc.), sent to Rust viaconfigurets/voicebox.ts— voicemail metadata persistence (WAV files live in.nogit/voicemail/{boxId}/)ts/webrtcbridge.ts— browser WebSocket signaling, browser device registry (deviceIdToWs)ts/call/prompt-cache.ts— the only remaining file underts/call/(IVR prompt caching)
Rust SIP protocol library
rust/crates/sip-proto/ is a zero-dependency SIP data library (parse/build/mutate/serialize messages, dialog management, SDP helpers, digest auth). Do not add transport or timer logic there — it's purely data-level.
Event-push architecture for device status
Device status flows via push events, not pull-based IPC queries:
- Rust emits
device_registeredwhen a phone REGISTERs - TS
sipproxy.tsmaintains adeviceStatusesMap, updated from the event - Map snapshot goes into the WebSocket
statusbroadcast - Web UI (
ts_web/elements/sipproxy-devices.ts) reads it from the push stream
There used to be a get_status pull IPC for this, but it was never called from TS and has been removed. If a new dashboard ever needs a pull-based snapshot, the push Map is the right source to read from.
Inbound routing (wired in Commit 4 of the cleanup PR)
Inbound route resolution goes through config.resolve_inbound_route(provider_id, called_number, caller_number) inside create_inbound_call (call_manager.rs). The result carries a ring_browsers flag that propagates to the incoming_call event; ts/sipproxy.ts gates the webrtc-incoming browser fan-out behind that flag.
Known limitations / TODOs (documented in code at create_inbound_call):
- Multi-target inbound fork is not yet implemented — only the first registered device from
route.device_idsis rung. ring_browsersis informational only: browsers see a toast but do not race the SIP device to answer. True first-to-answer-wins requires a multi-leg fork + per-leg CANCEL, which is not built yet.voicemail_box,ivr_menu_id,no_answer_timeoutare resolved but not yet honored downstream.
WebRTC Browser Call Flow (Critical)
The browser call signaling order is strict:
- Browser initiates outbound via a TS API (e.g.
POST /api/call) — TS creates a pending call in the Rust engine viamake_calland notifies the browser with awebrtc-incomingpush. - Browser sends
webrtc-offer(with its ownsessionId) → Rusthandle_webrtc_offercreates a standalone WebRTC session (NOT attached to any call yet). - Browser sends
webrtc_link(withcallId+sessionId) → Rust links the standalone session to the Call and wires the WebRTC leg through the mixer.
The WebRTC leg cannot be fully attached at call-creation time because the browser's session ID is unknown until the webrtc-offer arrives.
WebRTC audio return channel (Critical)
The SIP→browser audio path goes through the mixer, not a direct RTP relay:
- Provider sends RTP → received on the provider leg's UDP socket (
leg_io::spawn_sip_inbound) - Packet flows through
jitter_buffer→ mixer's inbound mpsc channel - Mixer decodes/resamples/denoises, computes mix-minus per leg
- WebRTC leg receives its mix-minus frame, encodes to Opus, and pushes via the WebRTC engine's peer connection sender
Browser→SIP works symmetrically: ontrack.onReceiveRtp → WebRTC leg's outbound mpsc → mixer → other legs' inbound channels.
SDP/Record-Route NAT (fixed in Commit 3 of the cleanup PR)
The proxy tracks a public_ip: Option<String> on every LegInfo (populated from provider-leg construction sites). When route_passthrough_message rewrites SDP (c= line) or emits a Record-Route, it picks advertise_ip based on the destination leg's kind:
SipProvider→other.public_ip.unwrap_or(lan_ip)(provider reaches us via public IP)SipDevice/WebRtc/Tool/Media→lan_ip(everything else is LAN or proxy-internal)
This fixed a real NAT-traversal bug where the proxy advertised its RFC1918 LAN IP to the provider in SDP, causing one-way or no audio for device-originated inbound traffic behind NAT.
Build & development
- Build:
pnpm run buildRust(nevercargo builddirectly — tsrust cross-compiles for bothx86_64-unknown-linux-gnuandaarch64-unknown-linux-gnu) - Cross-compile setup: the aarch64 target requires
gcc-aarch64-linux-gnu+libstdc++6-arm64-cross(Debian/Ubuntu). Seerust/.cargo/config.tomlfor the linker wiring. A committed symlink atrust/.cargo/crosslibs/aarch64/libstdc++.so→/usr/aarch64-linux-gnu/lib/libstdc++.so.6avoids needing thelibstdc++-13-dev-arm64-crosspackage. - Bundle web UI:
pnpm run bundle(esbuild, output:dist_ts_web/bundle.js) - Full build:
pnpm run build(=buildRust && bundle) - Start server:
pnpm run start(runstsx ts/sipproxy.ts)
Persistent files
.nogit/config.json— app config (providers, devices, routes, voiceboxes, IVR menus).nogit/voicemail/{boxId}/— voicemail WAV files +messages.jsonindex.nogit/prompts/— cached TTS prompts for IVR menus