# 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 resolution - `call.rs` — the `Call` hub + `LegInfo` struct, owns legs and the mixer task - `sip_leg.rs` — full SIP dialog management for B2BUA legs (INVITE, 407 auth retry, BYE, CANCEL, early media) - `rtp.rs` — RTP port pool (uses `Weak` so calls auto-release ports on drop) + RTP header helpers - `mixer.rs` — 20 ms-tick mix-minus engine (48 kHz f32 internal, per-leg transcoding via `codec-lib`, per-leg denoising) - `jitter_buffer.rs` — per-leg reordering/packet-loss compensation - `leg_io.rs` — spawns inbound/outbound RTP I/O tasks per SIP leg - `webrtc_engine.rs` — browser WebRTC sessions (werift-rs based), ICE/DTLS/SRTP - `provider.rs` — SIP trunk registrations, public-IP detection via Via `received=` - `registrar.rs` — accepts REGISTER from SIP phones, tracks contacts (push-based device status) - `config.rs` — `AppConfig` deserialized from TS, route resolvers (`resolve_outbound_route`, `resolve_inbound_route`) - `main.rs` — IPC command dispatcher (`handle_command`), event emitter, top-level SIP packet router - `sip_transport.rs` — owning wrapper around the main SIP UDP socket - `voicemail.rs` / `recorder.rs` / `audio_player.rs` / `tts.rs` — media subsystems - `tool_leg.rs` — per-source observer audio for recording/transcription tools - `ipc.rs` — event-emission helper used throughout ### Key TS files (control plane) - `ts/sipproxy.ts` — entrypoint, wires the proxy engine bridge + web UI + WebRTC signaling - `ts/proxybridge.ts` — `@push.rocks/smartrust` bridge to the Rust binary, typed `TProxyCommands` map - `ts/config.ts` — JSON config loader (`IAppConfig`, `IProviderConfig`, etc.), sent to Rust via `configure` - `ts/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 under `ts/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: 1. Rust emits `device_registered` when a phone REGISTERs 2. TS `sipproxy.ts` maintains a `deviceStatuses` Map, updated from the event 3. Map snapshot goes into the WebSocket `status` broadcast 4. 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_ids` is rung. - `ring_browsers` is **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_timeout` are resolved but not yet honored downstream. ## WebRTC Browser Call Flow (Critical) The browser call signaling order is strict: 1. Browser initiates outbound via a TS API (e.g. `POST /api/call`) — TS creates a pending call in the Rust engine via `make_call` and notifies the browser with a `webrtc-incoming` push. 2. Browser sends `webrtc-offer` (with its own `sessionId`) → Rust `handle_webrtc_offer` creates a **standalone** WebRTC session (NOT attached to any call yet). 3. Browser sends `webrtc_link` (with `callId` + `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: 1. Provider sends RTP → received on the provider leg's UDP socket (`leg_io::spawn_sip_inbound`) 2. Packet flows through `jitter_buffer` → mixer's inbound mpsc channel 3. Mixer decodes/resamples/denoises, computes mix-minus per leg 4. 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` 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` (never `cargo build` directly — tsrust cross-compiles for both `x86_64-unknown-linux-gnu` and `aarch64-unknown-linux-gnu`) - **Cross-compile setup:** the aarch64 target requires `gcc-aarch64-linux-gnu` + `libstdc++6-arm64-cross` (Debian/Ubuntu). See `rust/.cargo/config.toml` for the linker wiring. A committed symlink at `rust/.cargo/crosslibs/aarch64/libstdc++.so` → `/usr/aarch64-linux-gnu/lib/libstdc++.so.6` avoids needing the `libstdc++-13-dev-arm64-cross` package. - **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` (runs `tsx ts/sipproxy.ts`) ## Persistent files - `.nogit/config.json` — app config (providers, devices, routes, voiceboxes, IVR menus) - `.nogit/voicemail/{boxId}/` — voicemail WAV files + `messages.json` index - `.nogit/prompts/` — cached TTS prompts for IVR menus