10 Commits

Author SHA1 Message Date
cff70ab179 v1.23.0
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-14 10:45:59 +00:00
51f7560730 feat(runtime): refactor runtime state and proxy event handling for typed WebRTC linking and shared status models 2026-04-14 10:45:59 +00:00
5a280c5c41 v1.22.0
Some checks failed
Docker (tags) / release (push) Failing after 2s
2026-04-12 20:45:08 +00:00
59d8c2557c feat(proxy-engine): add on-demand TTS caching for voicemail and IVR prompts 2026-04-12 20:45:08 +00:00
cfadd7a2b6 v1.21.0
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-11 20:04:56 +00:00
80f710f6d8 feat(providers): replace provider creation modal with a guided multi-step setup flow 2026-04-11 20:04:56 +00:00
9ea57cd659 v1.20.5
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-11 19:20:14 +00:00
c40c726dc3 fix(readme): improve architecture and call flow documentation with Mermaid diagrams 2026-04-11 19:20:14 +00:00
37ba7501fa v1.20.4
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-11 19:17:39 +00:00
24924a1aea fix(deps): bump @design.estate/dees-catalog to ^3.71.1 2026-04-11 19:17:38 +00:00
28 changed files with 1911 additions and 1430 deletions

View File

@@ -1,5 +1,40 @@
# Changelog
## 2026-04-14 - 1.23.0 - feat(runtime)
refactor runtime state and proxy event handling for typed WebRTC linking and shared status models
- extract proxy event handling into dedicated runtime modules for status tracking and WebRTC session-to-call linking
- introduce shared typed proxy event and status interfaces used by both backend and web UI
- update web UI server initialization to use structured options and await async config save hooks
- simplify browser signaling by routing WebRTC offer/ICE handling through frontend-to-Rust integration
- align device status rendering with the new address/port fields in dashboard views
## 2026-04-12 - 1.22.0 - feat(proxy-engine)
add on-demand TTS caching for voicemail and IVR prompts
- Route inbound calls directly to configured IVR menus and track them with a dedicated IVR call state
- Generate voicemail greetings and IVR menu prompts inside the Rust proxy engine on demand instead of precomputing prompts in TypeScript
- Add cacheable TTS output with sidecar metadata and enable Kokoro CMUdict support for improved prompt generation
- Extend proxy configuration to include voiceboxes and IVR menus, and update documentation to reflect Kokoro-only prompt generation
## 2026-04-11 - 1.21.0 - feat(providers)
replace provider creation modal with a guided multi-step setup flow
- Adds a stepper-based provider creation flow with provider type selection, connection, credentials, advanced settings, and review steps.
- Applies built-in templates for Sipgate and O2/Alice from the selected provider type instead of separate add actions.
- Adds a final review step with generated provider ID preview and duplicate ID collision handling before saving.
## 2026-04-11 - 1.20.5 - fix(readme)
improve architecture and call flow documentation with Mermaid diagrams
- Replace ASCII architecture and audio pipeline diagrams with Mermaid diagrams for better readability
- Document the WebRTC browser call setup sequence, including offer handling and session-to-call linking
## 2026-04-11 - 1.20.4 - fix(deps)
bump @design.estate/dees-catalog to ^3.71.1
- Updates the @design.estate/dees-catalog dependency from ^3.70.0 to ^3.71.1 in package.json.
## 2026-04-11 - 1.20.3 - fix(ts-config,proxybridge,voicebox)
align voicebox config types and add missing proxy bridge command definitions

View File

@@ -1,6 +1,6 @@
{
"name": "siprouter",
"version": "1.20.3",
"version": "1.23.0",
"private": true,
"type": "module",
"scripts": {
@@ -13,7 +13,7 @@
"restartBackground": "pnpm run buildRust && pnpm run bundle; test -f .server.pid && kill $(cat .server.pid) 2>/dev/null; sleep 1; rm -f sip_trace.log proxy.out && nohup tsx ts/sipproxy.ts > proxy.out 2>&1 & echo $! > .server.pid; sleep 2; cat proxy.out"
},
"dependencies": {
"@design.estate/dees-catalog": "^3.70.0",
"@design.estate/dees-catalog": "^3.77.0",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/smartrust": "^1.3.2",
"@push.rocks/smartstate": "^2.3.0",

36
pnpm-lock.yaml generated
View File

@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@design.estate/dees-catalog':
specifier: ^3.70.0
version: 3.70.0(@tiptap/pm@2.27.2)
specifier: ^3.77.0
version: 3.77.0(@tiptap/pm@2.27.2)
'@design.estate/dees-element':
specifier: ^2.2.4
version: 2.2.4
@@ -81,8 +81,8 @@ packages:
'@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.70.0':
resolution: {integrity: sha512-bNqOxxl83FNCCV+7QoUj6oeRC0VTExWOClrLrHNMoLIU0TCtzhcmQqiuJhdWrcCwZ5RBhXHGMSFsR62d2RcWpw==}
'@design.estate/dees-catalog@3.77.0':
resolution: {integrity: sha512-2IfvH390WXCF733XcmEcUP9skqogTz9xlqQw5PUJZy0u2Hf6+hJTyQOi4mcKmhpTE/kCpaD51uw21Lr4ncW6cg==}
'@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -93,8 +93,8 @@ packages:
'@design.estate/dees-element@2.2.4':
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
'@design.estate/dees-wcctools@3.8.0':
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
'@design.estate/dees-wcctools@3.8.4':
resolution: {integrity: sha512-KpFK/azK+a/Xpq33pXKcho+tdFKVHhKZM5ArvHqo9QMwTczgp5DZZgowTDUuqAofjZwnuVfCPHK/Pw9e64N46A==}
'@emnapi/core@1.9.2':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
@@ -1566,8 +1566,8 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=}
ibantools@4.5.2:
resolution: {integrity: sha512-is+8TgZcKS/AMv/z9nW1zz0bhjhoyjpA1p0nc3A6GkW/InOdcQiUZpkufADzh/aO/LY/TOD/P3oPWncNRn5QMA==}
ibantools@4.5.4:
resolution: {integrity: sha512-6jX1gh4aH6XH+o0ey+wtkMTzkcvsEta7DakIOZSng9voZYpMw3U+gK1+tZChk3aRcPcloEt0NOzksjaRZiqXbw==}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
@@ -1694,8 +1694,8 @@ packages:
resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==}
engines: {node: 20 || >=22}
lucide@0.577.0:
resolution: {integrity: sha512-PpC/m5eOItp/WU/GlQPFBXDOhq6HibL73KzYP37OX3LM7VmzWQF8voEj8QRWUFvy9FIKfeDQkWYoyS1D/MdWFA==}
lucide@1.8.0:
resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
@@ -2462,7 +2462,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
'@cloudflare/workers-types': 4.20260409.1
'@design.estate/dees-catalog': 3.70.0(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.77.0(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.0
'@push.rocks/smartdelay': 3.0.5
@@ -2529,11 +2529,11 @@ snapshots:
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.70.0(@tiptap/pm@2.27.2)':
'@design.estate/dees-catalog@3.77.0(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0
'@design.estate/dees-wcctools': 3.8.4
'@fortawesome/fontawesome-svg-core': 7.2.0
'@fortawesome/free-brands-svg-icons': 7.2.0
'@fortawesome/free-regular-svg-icons': 7.2.0
@@ -2551,9 +2551,9 @@ snapshots:
'@tsclass/tsclass': 9.5.0
echarts: 5.6.0
highlight.js: 11.11.1
ibantools: 4.5.2
ibantools: 4.5.4
lightweight-charts: 5.1.0
lucide: 0.577.0
lucide: 1.8.0
monaco-editor: 0.55.1
pdfjs-dist: 4.10.38
xterm: 5.3.0
@@ -2610,7 +2610,7 @@ snapshots:
- supports-color
- vue
'@design.estate/dees-wcctools@3.8.0':
'@design.estate/dees-wcctools@3.8.4':
dependencies:
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
@@ -4369,7 +4369,7 @@ snapshots:
dependencies:
ms: 2.1.3
ibantools@4.5.2: {}
ibantools@4.5.4: {}
iconv-lite@0.4.24:
dependencies:
@@ -4487,7 +4487,7 @@ snapshots:
lru-cache@11.3.3: {}
lucide@0.577.0: {}
lucide@1.8.0: {}
make-dir@3.1.0:
dependencies:

109
readme.md
View File

@@ -20,7 +20,7 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
- 🎯 **Adaptive Jitter Buffer** — Per-leg jitter buffering with sequence-based reordering, adaptive depth (60120ms), Opus PLC for lost packets, and hold/resume detection
- 📧 **Voicemail** — Configurable voicemail boxes with TTS greetings, recording, and web playback
- 🔢 **IVR Menus** — DTMF-navigable interactive voice response with nested menus, routing actions, and custom prompts
- 🗣️ **Neural TTS** — Kokoro-powered announcements and greetings with 25+ voice presets, backed by espeak-ng fallback
- 🗣️ **Neural TTS** — Kokoro-powered greetings and IVR prompts with 25+ voice presets
- 🎙️ **Call Recording** — Per-source separated WAV recording at 48kHz via tool legs
- 🖥️ **Web Dashboard** — Real-time SPA with 9 views: live calls, browser phone, routing, voicemail, IVR, contacts, providers, and streaming logs
@@ -28,39 +28,26 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
## 🏗️ Architecture
```
┌─────────────────────────────────────┐
Browser Softphone
(WebRTC via WebSocket signaling) │
└──────────────┬──────────────────────┘
│ Opus/WebRTC
┌──────────────────────────────────────┐
siprouter │
TypeScript Control Plane │
│ ┌────────────────────────────────┐ │
│ │ Config · WebRTC Signaling │ │
│ REST API · Web Dashboard │ │
│ Voicebox Manager · TTS Cache │ │
└────────────┬───────────────────┘ │
│ JSON-over-stdio IPC │
┌────────────┴───────────────────┐ │
Rust proxy-engine (data plane) │ │
│ │
│ │ SIP Stack · Dialog SM · Auth │ │
│ │ Call Manager · N-Leg Mixer │ │
│ │ 48kHz f32 Bus · Jitter Buffer │ │
│ │ Codec Engine · RTP Port Pool │ │
│ │ WebRTC Engine · Kokoro TTS │ │
│ │ Voicemail · IVR · Recording │ │
│ └────┬──────────────────┬────────┘ │
└───────┤──────────────────┤───────────┘
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ SIP Devices │ │ SIP Trunk │
│ (HT801 etc) │ │ Providers │
└─────────────┘ └─────────────┘
```mermaid
flowchart TB
Browser["🌐 Browser Softphone<br/>(WebRTC via WebSocket signaling)"]
Devices["📞 SIP Devices<br/>(HT801, desk phones, ATAs)"]
Trunks["☎️ SIP Trunk Providers<br/>(sipgate, easybell, …)"]
subgraph Router["siprouter"]
direction TB
subgraph TS["TypeScript Control Plane"]
TSBits["Config · WebRTC Signaling<br/>REST API · Web Dashboard<br/>Voicebox Manager · TTS Cache"]
end
subgraph Rust["Rust proxy-engine (data plane)"]
RustBits["SIP Stack · Dialog SM · Auth<br/>Call Manager · N-Leg Mixer<br/>48kHz f32 Bus · Jitter Buffer<br/>Codec Engine · RTP Port Pool<br/>WebRTC Engine · Kokoro TTS<br/>Voicemail · IVR · Recording"]
end
TS <-->|"JSON-over-stdio IPC"| Rust
end
Browser <-->|"Opus / WebRTC"| TS
Rust <-->|"SIP / RTP"| Devices
Rust <-->|"SIP / RTP"| Trunks
```
### 🧠 Key Design Decisions
@@ -71,6 +58,37 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
- **Per-Session Codec Isolation** — Each call leg gets its own encoder/decoder/resampler/denoiser state — no cross-call corruption.
- **SDP Codec Negotiation** — Outbound encoding uses the codec actually negotiated in SDP answers, not just the first offered codec.
### 📲 WebRTC Browser Call Flow
Browser calls are set up in a strict three-step dance — the WebRTC leg cannot be attached at call-creation time because the browser's session ID is only known once the SDP offer arrives:
```mermaid
sequenceDiagram
participant B as Browser
participant TS as TypeScript (sipproxy.ts)
participant R as Rust proxy-engine
participant P as SIP Provider
B->>TS: POST /api/call
TS->>R: make_call (pending call, no WebRTC leg yet)
R-->>TS: call_created
TS-->>B: webrtc-incoming (callId)
B->>TS: webrtc-offer (sessionId, SDP)
TS->>R: handle_webrtc_offer
R-->>TS: webrtc-answer (SDP)
TS-->>B: webrtc-answer
Note over R: Standalone WebRTC session<br/>(not yet attached to call)
B->>TS: webrtc_link (callId + sessionId)
TS->>R: link session → call
R->>R: wire WebRTC leg through mixer
R->>P: SIP INVITE
P-->>R: 200 OK + SDP
R-->>TS: call_answered
Note over B,P: Bidirectional Opus ↔ codec-transcoded<br/>audio flows through the mixer
```
---
## 🚀 Getting Started
@@ -80,7 +98,6 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
- **Node.js** ≥ 20 with `tsx` globally available
- **pnpm** for package management
- **Rust** toolchain (for building the proxy engine)
- **espeak-ng** (optional, for TTS fallback)
### Install & Build
@@ -172,7 +189,7 @@ Create `.nogit/config.json`:
### TTS Setup (Optional)
For neural announcements and voicemail greetings, download the Kokoro TTS model:
For neural voicemail greetings and IVR prompts, download the Kokoro TTS model:
```bash
mkdir -p .nogit/tts
@@ -182,7 +199,7 @@ curl -L -o .nogit/tts/voices.bin \
https://github.com/mzdk100/kokoro/releases/download/V1.0/voices.bin
```
Without the model files, TTS falls back to `espeak-ng`. Without either, announcements are skipped — everything else works fine.
Without the model files, TTS prompts (IVR menus, voicemail greetings) are skipped — everything else works fine.
### Run
@@ -209,7 +226,6 @@ siprouter/
│ ├── frontend.ts # Web dashboard HTTP/WS server + REST API
│ ├── webrtcbridge.ts # WebRTC signaling layer
│ ├── registrar.ts # Browser softphone registration
│ ├── announcement.ts # TTS announcement generator (espeak-ng / Kokoro)
│ ├── voicebox.ts # Voicemail box management
│ └── call/
│ └── prompt-cache.ts # Named audio prompt WAV management
@@ -246,9 +262,17 @@ The `proxy-engine` binary handles all real-time audio processing with a **48kHz
### Audio Pipeline
```
Inbound: Wire RTP → Jitter Buffer → Decode → Resample to 48kHz → Denoise (RNNoise) → Mix Bus
Outbound: Mix Bus → Mix-Minus → Resample to codec rate → Encode → Wire RTP
```mermaid
flowchart LR
subgraph Inbound["Inbound path (per leg)"]
direction LR
IN_RTP["Wire RTP"] --> IN_JB["Jitter Buffer"] --> IN_DEC["Decode"] --> IN_RS["Resample → 48 kHz"] --> IN_DN["Denoise (RNNoise)"] --> IN_BUS["Mix Bus"]
end
subgraph Outbound["Outbound path (per leg)"]
direction LR
OUT_BUS["Mix Bus"] --> OUT_MM["Mix-Minus"] --> OUT_RS["Resample → codec rate"] --> OUT_ENC["Encode"] --> OUT_RTP["Wire RTP"]
end
```
- **Adaptive jitter buffer** — per-leg `BTreeMap`-based buffer keyed by RTP sequence number. Delivers exactly one frame per 20ms mixer tick in sequence order. Adaptive target depth starts at 3 frames (60ms) and adjusts between 26 frames based on observed network jitter. Handles hold/resume by detecting large forward sequence jumps and resetting cleanly.
@@ -262,13 +286,12 @@ Outbound: Mix Bus → Mix-Minus → Resample to codec rate → Encode → Wire
## 🗣️ Neural TTS
Announcements and voicemail greetings are synthesized using [Kokoro TTS](https://github.com/mzdk100/kokoro) — an 82M parameter neural model running via ONNX Runtime directly in the Rust process:
Voicemail greetings and IVR prompts are synthesized using [Kokoro TTS](https://github.com/mzdk100/kokoro) — an 82M parameter neural model running via ONNX Runtime directly in the Rust process:
- **24 kHz, 16-bit mono** output
- **25+ voice presets** — American/British, male/female (e.g., `af_bella`, `am_adam`, `bf_emma`, `bm_george`)
- **~800ms** synthesis time for a 3-second phrase
- Lazy-loaded on first use — no startup cost if TTS is unused
- Falls back to `espeak-ng` if the ONNX model is not available
---

10
rust/Cargo.lock generated
View File

@@ -532,6 +532,15 @@ dependencies = [
"cc",
]
[[package]]
name = "cmudict-fast"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9f73004e928ed46c3e7fd7406d2b12c8674153295f08af084b49860276dc02"
dependencies = [
"thiserror",
]
[[package]]
name = "codec-lib"
version = "0.1.0"
@@ -1730,6 +1739,7 @@ dependencies = [
"bincode 2.0.1",
"cc",
"chinese-number",
"cmudict-fast",
"futures",
"jieba-rs",
"log",

View File

@@ -19,7 +19,7 @@ regex-lite = "0.1"
webrtc = "0.8"
rand = "0.8"
hound = "3.5"
kokoro-tts = { version = "0.3", default-features = false }
kokoro-tts = { version = "0.3", default-features = false, features = ["use-cmudict"] }
ort = { version = "=2.0.0-rc.11", default-features = false, features = [
"std", "download-binaries", "copy-dylibs", "ndarray",
"tls-native-vendored"

View File

@@ -23,6 +23,7 @@ pub enum CallState {
Ringing,
Connected,
Voicemail,
Ivr,
Terminated,
}
@@ -37,6 +38,7 @@ impl CallState {
Self::Ringing => "ringing",
Self::Connected => "connected",
Self::Voicemail => "voicemail",
Self::Ivr => "ivr",
Self::Terminated => "terminated",
}
}

View File

@@ -12,13 +12,16 @@ use crate::mixer::spawn_mixer;
use crate::registrar::Registrar;
use crate::rtp::RtpPortPool;
use crate::sip_leg::{SipLeg, SipLegAction, SipLegConfig};
use crate::tts::TtsEngine;
use sip_proto::helpers::{build_sdp, generate_call_id, generate_tag, parse_sdp_endpoint, SdpOptions};
use sip_proto::message::{ResponseOptions, SipMessage};
use sip_proto::rewrite::{rewrite_sdp, rewrite_sip_uri};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use tokio::net::UdpSocket;
use tokio::sync::Mutex;
/// Result of creating an inbound call — carries both the call id and
/// whether browsers should be notified (flows from the matched inbound
@@ -681,6 +684,7 @@ impl CallManager {
rtp_pool: &mut RtpPortPool,
socket: &UdpSocket,
public_ip: Option<&str>,
tts_engine: Arc<Mutex<TtsEngine>>,
) -> Option<InboundCallCreated> {
let call_id = self.next_call_id();
let lan_ip = &config.proxy.lan_ip;
@@ -710,10 +714,27 @@ impl CallManager {
// - `ring_browsers` is informational only — browsers see a toast but
// do not race the SIP device. 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 not honored.
let route = config.resolve_inbound_route(provider_id, &called_number, &caller_number);
let ring_browsers = route.ring_browsers;
// IVR routing: if the route targets an IVR menu, go there directly.
if let Some(ref ivr_menu_id) = route.ivr_menu_id {
if let Some(ivr) = &config.ivr {
if ivr.enabled {
if let Some(menu) = ivr.menus.iter().find(|m| m.id == *ivr_menu_id) {
let call_id = self
.route_to_ivr(
&call_id, invite, from_addr, &caller_number,
provider_id, provider_config, config, rtp_pool, socket,
public_ip, menu, &tts_engine,
)
.await?;
return Some(InboundCallCreated { call_id, ring_browsers });
}
}
}
}
// Pick the first registered device from the matched targets, or fall
// back to any-registered-device if the route has no resolved targets.
let device_addr = route
@@ -726,10 +747,17 @@ impl CallManager {
Some(addr) => addr,
None => {
// No device registered → voicemail.
// Resolve greeting WAV on-demand (may trigger TTS generation).
let greeting_wav = resolve_greeting_wav(
config,
route.voicemail_box.as_deref(),
&tts_engine,
).await;
let call_id = self
.route_to_voicemail(
&call_id, invite, from_addr, &caller_number,
provider_id, provider_config, config, rtp_pool, socket, public_ip,
greeting_wav,
)
.await?;
return Some(InboundCallCreated { call_id, ring_browsers });
@@ -1536,6 +1564,7 @@ impl CallManager {
rtp_pool: &mut RtpPortPool,
socket: &UdpSocket,
public_ip: Option<&str>,
greeting_wav: Option<String>,
) -> Option<String> {
let lan_ip = &config.proxy.lan_ip;
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
@@ -1630,8 +1659,6 @@ impl CallManager {
.as_millis();
let recording_dir = "nogit/voicemail/default".to_string();
let recording_path = format!("{recording_dir}/msg-{timestamp}.wav");
let greeting_wav = find_greeting_wav();
let out_tx = self.out_tx.clone();
let call_id_owned = call_id.to_string();
let caller_owned = caller_number.to_string();
@@ -1648,6 +1675,211 @@ impl CallManager {
Some(call_id.to_string())
}
// -----------------------------------------------------------------------
// IVR routing
// -----------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
async fn route_to_ivr(
&mut self,
call_id: &str,
invite: &SipMessage,
from_addr: SocketAddr,
caller_number: &str,
provider_id: &str,
provider_config: &ProviderConfig,
config: &AppConfig,
rtp_pool: &mut RtpPortPool,
socket: &UdpSocket,
public_ip: Option<&str>,
menu: &crate::config::IvrMenuConfig,
tts_engine: &Arc<Mutex<TtsEngine>>,
) -> Option<String> {
let lan_ip = &config.proxy.lan_ip;
let rtp_alloc = match rtp_pool.allocate().await {
Some(a) => a,
None => {
let resp = SipMessage::create_response(503, "Service Unavailable", invite, None);
let _ = socket.send_to(&resp.serialize(), from_addr).await;
return None;
}
};
let codec_pt = provider_config.codecs.first().copied().unwrap_or(9);
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions {
ip: pub_ip,
port: rtp_alloc.port,
payload_types: &provider_config.codecs,
..Default::default()
});
let response = SipMessage::create_response(
200, "OK", invite,
Some(sip_proto::message::ResponseOptions {
to_tag: Some(sip_proto::helpers::generate_tag()),
contact: Some(format!("<sip:{}:{}>", lan_ip, config.proxy.lan_port)),
body: Some(sdp),
content_type: Some("application/sdp".to_string()),
..Default::default()
}),
);
let _ = socket.send_to(&response.serialize(), from_addr).await;
let provider_media = if invite.has_sdp_body() {
parse_sdp_endpoint(&invite.body)
.and_then(|ep| format!("{}:{}", ep.address, ep.port).parse().ok())
} else {
Some(from_addr)
};
let provider_media = provider_media.unwrap_or(from_addr);
// Create call with IVR state.
let (mixer_cmd_tx, mixer_task) = spawn_mixer(call_id.to_string(), self.out_tx.clone());
let mut call = Call::new(
call_id.to_string(),
CallDirection::Inbound,
provider_id.to_string(),
mixer_cmd_tx.clone(),
mixer_task,
);
call.state = CallState::Ivr;
call.caller_number = Some(caller_number.to_string());
let provider_leg_id = format!("{call_id}-prov");
call.legs.insert(
provider_leg_id.clone(),
LegInfo {
id: provider_leg_id.clone(),
kind: LegKind::SipProvider,
state: LegState::Connected,
codec_pt,
sip_leg: None,
sip_call_id: Some(invite.call_id().to_string()),
webrtc_session_id: None,
rtp_socket: Some(rtp_alloc.socket.clone()),
rtp_port: rtp_alloc.port,
public_ip: public_ip.map(|s| s.to_string()),
remote_media: Some(provider_media),
signaling_addr: Some(from_addr),
metadata: HashMap::new(),
},
);
self.sip_index.insert(
invite.call_id().to_string(),
(call_id.to_string(), provider_leg_id.clone()),
);
self.calls.insert(call_id.to_string(), call);
// Emit leg_added for the provider leg.
if let Some(call) = self.calls.get(call_id) {
for leg in call.legs.values() {
emit_leg_added_event(&self.out_tx, call_id, leg);
}
}
// Generate IVR prompt on-demand via TTS (cached).
let voice = menu.prompt_voice.as_deref().unwrap_or("af_bella");
let prompt_output = format!(".nogit/tts/ivr-menu-{}.wav", menu.id);
let prompt_params = serde_json::json!({
"model": ".nogit/tts/kokoro-v1.0.onnx",
"voices": ".nogit/tts/voices.bin",
"voice": voice,
"text": &menu.prompt_text,
"output": &prompt_output,
"cacheable": true,
});
let prompt_wav = {
let mut tts = tts_engine.lock().await;
match tts.generate(&prompt_params).await {
Ok(_) => Some(prompt_output),
Err(e) => {
eprintln!("[ivr] TTS generation failed: {e}");
None
}
}
};
// Load prompt and run interaction via the mixer.
let out_tx = self.out_tx.clone();
let call_id_owned = call_id.to_string();
let expected_digits: Vec<char> = menu
.entries
.iter()
.filter_map(|e| e.digit.chars().next())
.collect();
let timeout_ms = menu.timeout_sec.unwrap_or(5) * 1000;
tokio::spawn(async move {
// Load prompt PCM frames if available.
let prompt_frames = prompt_wav.as_ref().and_then(|wav| {
crate::audio_player::load_prompt_pcm_frames(wav).ok()
});
if let Some(frames) = prompt_frames {
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
let _ = mixer_cmd_tx
.send(crate::mixer::MixerCommand::StartInteraction {
leg_id: provider_leg_id.clone(),
prompt_pcm_frames: frames,
expected_digits: expected_digits.clone(),
timeout_ms,
result_tx,
})
.await;
// Wait for digit or timeout.
let safety = tokio::time::Duration::from_millis(timeout_ms as u64 + 30000);
let result = match tokio::time::timeout(safety, result_rx).await {
Ok(Ok(r)) => r,
Ok(Err(_)) => crate::mixer::InteractionResult::Cancelled,
Err(_) => crate::mixer::InteractionResult::Timeout,
};
match &result {
crate::mixer::InteractionResult::Digit(d) => {
eprintln!("[ivr] caller pressed '{d}' on call {call_id_owned}");
emit_event(
&out_tx,
"ivr_digit",
serde_json::json!({
"call_id": call_id_owned,
"digit": d.to_string(),
}),
);
}
crate::mixer::InteractionResult::Timeout => {
eprintln!("[ivr] timeout on call {call_id_owned}");
emit_event(
&out_tx,
"ivr_timeout",
serde_json::json!({ "call_id": call_id_owned }),
);
}
crate::mixer::InteractionResult::Cancelled => {
eprintln!("[ivr] cancelled on call {call_id_owned}");
}
}
} else {
eprintln!("[ivr] no prompt available for call {call_id_owned}, ending");
emit_event(
&out_tx,
"ivr_error",
serde_json::json!({
"call_id": call_id_owned,
"error": "no prompt available",
}),
);
}
});
Some(call_id.to_string())
}
// -----------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------
@@ -1662,13 +1894,56 @@ impl CallManager {
}
}
fn find_greeting_wav() -> Option<String> {
let candidates = [
/// Resolve the greeting WAV for a voicemail box.
///
/// Priority:
/// 1. Pre-recorded WAV from voicebox config (`greetingWavPath`)
/// 2. On-demand TTS generation from greeting text (cached via `cacheable: true`)
/// 3. Legacy hardcoded paths (`.nogit/voicemail/default/greeting.wav`, etc.)
/// 4. None — voicemail session plays beep only
async fn resolve_greeting_wav(
config: &AppConfig,
voicebox_id: Option<&str>,
tts_engine: &Arc<Mutex<TtsEngine>>,
) -> Option<String> {
// 1. Look up voicebox config.
let vb = voicebox_id
.and_then(|id| config.voiceboxes.iter().find(|v| v.id == id && v.enabled));
if let Some(vb) = vb {
// 2. Pre-recorded WAV takes priority.
if let Some(ref wav) = vb.greeting_wav_path {
if Path::new(wav).exists() {
return Some(wav.clone());
}
}
// 3. TTS on-demand with caching.
let text = vb.greeting_text.as_deref().unwrap_or(
"The person you are trying to reach is not available. Please leave a message after the tone.",
);
let voice = vb.greeting_voice.as_deref().unwrap_or("af_bella");
let output = format!(".nogit/tts/voicemail-greeting-{}.wav", vb.id);
let params = serde_json::json!({
"model": ".nogit/tts/kokoro-v1.0.onnx",
"voices": ".nogit/tts/voices.bin",
"voice": voice,
"text": text,
"output": &output,
"cacheable": true,
});
let mut tts = tts_engine.lock().await;
if tts.generate(&params).await.is_ok() {
return Some(output);
}
}
// 4. Fallback: legacy hardcoded paths.
for path in &[
".nogit/voicemail/default/greeting.wav",
".nogit/voicemail/greeting.wav",
];
for path in &candidates {
if std::path::Path::new(path).exists() {
] {
if Path::new(path).exists() {
return Some(path.to_string());
}
}

View File

@@ -159,6 +159,10 @@ pub struct AppConfig {
pub providers: Vec<ProviderConfig>,
pub devices: Vec<DeviceConfig>,
pub routing: RoutingConfig,
#[serde(default)]
pub voiceboxes: Vec<VoiceboxConfig>,
#[serde(default)]
pub ivr: Option<IvrConfig>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -166,6 +170,59 @@ pub struct RoutingConfig {
pub routes: Vec<Route>,
}
// ---------------------------------------------------------------------------
// Voicebox config
// ---------------------------------------------------------------------------
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct VoiceboxConfig {
pub id: String,
#[serde(default)]
pub enabled: bool,
#[serde(rename = "greetingText")]
pub greeting_text: Option<String>,
#[serde(rename = "greetingVoice")]
pub greeting_voice: Option<String>,
#[serde(rename = "greetingWavPath")]
pub greeting_wav_path: Option<String>,
#[serde(rename = "maxRecordingSec")]
pub max_recording_sec: Option<u32>,
}
// ---------------------------------------------------------------------------
// IVR config
// ---------------------------------------------------------------------------
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct IvrConfig {
pub enabled: bool,
pub menus: Vec<IvrMenuConfig>,
#[serde(rename = "entryMenuId")]
pub entry_menu_id: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct IvrMenuConfig {
pub id: String,
#[serde(rename = "promptText")]
pub prompt_text: String,
#[serde(rename = "promptVoice")]
pub prompt_voice: Option<String>,
pub entries: Vec<IvrMenuEntry>,
#[serde(rename = "timeoutSec")]
pub timeout_sec: Option<u32>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct IvrMenuEntry {
pub digit: String,
pub action: String,
pub target: Option<String>,
}
// ---------------------------------------------------------------------------
// Pattern matching (ported from ts/config.ts)
// ---------------------------------------------------------------------------

View File

@@ -50,11 +50,12 @@ struct ProxyEngine {
registrar: Registrar,
call_mgr: CallManager,
rtp_pool: Option<RtpPortPool>,
tts_engine: Arc<Mutex<tts::TtsEngine>>,
out_tx: OutTx,
}
impl ProxyEngine {
fn new(out_tx: OutTx) -> Self {
fn new(out_tx: OutTx, tts_engine: Arc<Mutex<tts::TtsEngine>>) -> Self {
Self {
config: None,
transport: None,
@@ -62,6 +63,7 @@ impl ProxyEngine {
registrar: Registrar::new(out_tx.clone()),
call_mgr: CallManager::new(out_tx.clone()),
rtp_pool: None,
tts_engine,
out_tx,
}
}
@@ -88,15 +90,16 @@ async fn main() {
// Emit ready event.
emit_event(&out_tx, "ready", serde_json::json!({}));
// Shared engine state (SIP side).
let engine = Arc::new(Mutex::new(ProxyEngine::new(out_tx.clone())));
// TTS engine — separate internal lock, lazy-loads model on first use.
let tts_engine = Arc::new(Mutex::new(tts::TtsEngine::new()));
// Shared engine state (SIP side). TTS engine is stored inside so the
// SIP packet handler path can reach it for on-demand voicemail/IVR generation.
let engine = Arc::new(Mutex::new(ProxyEngine::new(out_tx.clone(), tts_engine)));
// WebRTC engine — separate lock to avoid deadlock with SIP handlers.
let webrtc = Arc::new(Mutex::new(WebRtcEngine::new(out_tx.clone())));
// TTS engine — separate lock, lazy-loads model on first use.
let tts_engine = Arc::new(Mutex::new(tts::TtsEngine::new()));
// Read commands from stdin.
let stdin = tokio::io::stdin();
let reader = BufReader::new(stdin);
@@ -117,12 +120,11 @@ async fn main() {
let engine = engine.clone();
let webrtc = webrtc.clone();
let tts_engine = tts_engine.clone();
let out_tx = out_tx.clone();
// Handle commands — some are async, so we spawn.
tokio::spawn(async move {
handle_command(engine, webrtc, tts_engine, &out_tx, cmd).await;
handle_command(engine, webrtc, &out_tx, cmd).await;
});
}
}
@@ -130,7 +132,6 @@ async fn main() {
async fn handle_command(
engine: Arc<Mutex<ProxyEngine>>,
webrtc: Arc<Mutex<WebRtcEngine>>,
tts_engine: Arc<Mutex<tts::TtsEngine>>,
out_tx: &OutTx,
cmd: Command,
) {
@@ -155,8 +156,8 @@ async fn handle_command(
"add_tool_leg" => handle_add_tool_leg(engine, out_tx, &cmd).await,
"remove_tool_leg" => handle_remove_tool_leg(engine, out_tx, &cmd).await,
"set_leg_metadata" => handle_set_leg_metadata(engine, out_tx, &cmd).await,
// TTS command — lock tts_engine only (no SIP/WebRTC contention).
"generate_tts" => handle_generate_tts(tts_engine, out_tx, &cmd).await,
// TTS command — gets tts_engine from inside ProxyEngine.
"generate_tts" => handle_generate_tts(engine, out_tx, &cmd).await,
_ => respond_err(out_tx, &cmd.id, &format!("unknown command: {}", cmd.method)),
}
}
@@ -325,8 +326,10 @@ async fn handle_sip_packet(
ref registrar,
ref mut call_mgr,
ref mut rtp_pool,
ref tts_engine,
..
} = *eng;
let tts_clone = tts_engine.clone();
let rtp_pool = rtp_pool.as_mut().unwrap();
let inbound = call_mgr
.create_inbound_call(
@@ -339,6 +342,7 @@ async fn handle_sip_packet(
rtp_pool,
socket,
public_ip.as_deref(),
tts_clone,
)
.await;
@@ -1231,10 +1235,11 @@ async fn handle_set_leg_metadata(
/// Handle `generate_tts` — synthesize text to a WAV file using Kokoro TTS.
async fn handle_generate_tts(
tts_engine: Arc<Mutex<tts::TtsEngine>>,
engine: Arc<Mutex<ProxyEngine>>,
out_tx: &OutTx,
cmd: &Command,
) {
let tts_engine = engine.lock().await.tts_engine.clone();
let mut tts = tts_engine.lock().await;
match tts.generate(&cmd.params).await {
Ok(result) => respond_ok(out_tx, &cmd.id, result),

View File

@@ -1,8 +1,13 @@
//! Text-to-speech engine — synthesizes text to WAV files using Kokoro neural TTS.
//!
//! The model is loaded lazily on first use. If the model/voices files are not
//! present, the generate command returns an error and the TS side falls back
//! to espeak-ng.
//! present, the generate command returns an error and the caller skips the prompt.
//!
//! Caching is handled internally via a `.meta` sidecar file next to each WAV.
//! When `cacheable` is true, the engine checks whether the existing WAV was
//! generated from the same text+voice; if so it returns immediately (cache hit).
//! Callers never need to check for cached files — that is entirely this module's
//! responsibility.
use kokoro_tts::{KokoroTts, Voice};
use std::path::Path;
@@ -32,6 +37,8 @@ impl TtsEngine {
/// - `voice`: voice name (e.g. "af_bella")
/// - `text`: text to synthesize
/// - `output`: output WAV file path
/// - `cacheable`: if true, skip synthesis when the output WAV already
/// matches the same text+voice (checked via a `.meta` sidecar file)
pub async fn generate(&mut self, params: &serde_json::Value) -> Result<serde_json::Value, String> {
let model_path = params.get("model").and_then(|v| v.as_str())
.ok_or("missing 'model' param")?;
@@ -43,11 +50,19 @@ impl TtsEngine {
.ok_or("missing 'text' param")?;
let output_path = params.get("output").and_then(|v| v.as_str())
.ok_or("missing 'output' param")?;
let cacheable = params.get("cacheable").and_then(|v| v.as_bool())
.unwrap_or(false);
if text.is_empty() {
return Err("empty text".into());
}
// Cache check: if cacheable and the sidecar matches, return immediately.
if cacheable && self.is_cache_hit(output_path, text, voice_name) {
eprintln!("[tts] cache hit: {output_path}");
return Ok(serde_json::json!({ "output": output_path }));
}
// Check that model/voices files exist.
if !Path::new(model_path).exists() {
return Err(format!("model not found: {model_path}"));
@@ -56,6 +71,11 @@ impl TtsEngine {
return Err(format!("voices not found: {voices_path}"));
}
// Ensure parent directory exists.
if let Some(parent) = Path::new(output_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
// Lazy-load or reload if paths changed.
if self.tts.is_none()
|| self.loaded_model_path != model_path
@@ -95,9 +115,41 @@ impl TtsEngine {
}
writer.finalize().map_err(|e| format!("WAV finalize: {e}"))?;
// Write sidecar for future cache checks.
if cacheable {
self.write_cache_meta(output_path, text, voice_name);
}
eprintln!("[tts] wrote {output_path}");
Ok(serde_json::json!({ "output": output_path }))
}
// -----------------------------------------------------------------------
// Cache helpers
// -----------------------------------------------------------------------
/// Check if the WAV + sidecar on disk match the given text+voice.
fn is_cache_hit(&self, output_path: &str, text: &str, voice: &str) -> bool {
let meta_path = format!("{output_path}.meta");
if !Path::new(output_path).exists() || !Path::new(&meta_path).exists() {
return false;
}
match std::fs::read_to_string(&meta_path) {
Ok(contents) => contents == Self::cache_key(text, voice),
Err(_) => false,
}
}
/// Write the sidecar `.meta` file next to the WAV.
fn write_cache_meta(&self, output_path: &str, text: &str, voice: &str) {
let meta_path = format!("{output_path}.meta");
let _ = std::fs::write(&meta_path, Self::cache_key(text, voice));
}
/// Build the cache key from text + voice.
fn cache_key(text: &str, voice: &str) -> String {
format!("{}\0{}", text, voice)
}
}
/// Map voice name string to Kokoro Voice enum variant.

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: 'siprouter',
version: '1.20.3',
version: '1.23.0',
description: 'undefined'
}

View File

@@ -1,137 +0,0 @@
/**
* TTS announcement module — generates announcement WAV files at startup.
*
* Engine priority: espeak-ng (formant TTS, fast) → Kokoro neural TTS via
* proxy-engine → disabled.
*
* The generated WAV is left on disk for Rust's audio_player / start_interaction
* to play during calls. No encoding or RTP playback happens in TypeScript.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { sendProxyCommand, isProxyReady } from './proxybridge.ts';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
const ANNOUNCEMENT_TEXT = "Hello. I'm connecting your call now.";
const CACHE_WAV = path.join(TTS_DIR, 'announcement.wav');
// Kokoro fallback constants.
const KOKORO_MODEL = 'kokoro-v1.0.onnx';
const KOKORO_VOICES = 'voices.bin';
const KOKORO_VOICE = 'af_bella';
// ---------------------------------------------------------------------------
// TTS generators
// ---------------------------------------------------------------------------
/** Check if espeak-ng is available on the system. */
function isEspeakAvailable(): boolean {
try {
execSync('which espeak-ng', { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/** Generate announcement WAV via espeak-ng (primary engine). */
function generateViaEspeak(wavPath: string, text: string, log: (msg: string) => void): boolean {
log('[tts] generating announcement audio via espeak-ng...');
try {
execSync(
`espeak-ng -v en-us -s 150 -w "${wavPath}" "${text}"`,
{ timeout: 10000, stdio: 'pipe' },
);
log('[tts] espeak-ng WAV generated');
return true;
} catch (e: any) {
log(`[tts] espeak-ng failed: ${e.message}`);
return false;
}
}
/** Generate announcement WAV via Kokoro TTS (fallback, runs inside proxy-engine). */
async function generateViaKokoro(wavPath: string, text: string, log: (msg: string) => void): Promise<boolean> {
const modelPath = path.join(TTS_DIR, KOKORO_MODEL);
const voicesPath = path.join(TTS_DIR, KOKORO_VOICES);
if (!fs.existsSync(modelPath) || !fs.existsSync(voicesPath)) {
log('[tts] Kokoro model/voices not found — Kokoro fallback unavailable');
return false;
}
if (!isProxyReady()) {
log('[tts] proxy-engine not ready — Kokoro fallback unavailable');
return false;
}
log('[tts] generating announcement audio via Kokoro TTS (fallback)...');
try {
await sendProxyCommand('generate_tts', {
model: modelPath,
voices: voicesPath,
voice: KOKORO_VOICE,
text,
output: wavPath,
});
log('[tts] Kokoro WAV generated (via proxy-engine)');
return true;
} catch (e: any) {
log(`[tts] Kokoro failed: ${e.message}`);
return false;
}
}
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
/**
* Pre-generate the announcement WAV file.
* Must be called after the proxy engine is initialized.
*
* Engine priority: espeak-ng → Kokoro → disabled.
*/
export async function initAnnouncement(log: (msg: string) => void): Promise<boolean> {
fs.mkdirSync(TTS_DIR, { recursive: true });
try {
if (!fs.existsSync(CACHE_WAV)) {
let generated = false;
// Try espeak-ng first.
if (isEspeakAvailable()) {
generated = generateViaEspeak(CACHE_WAV, ANNOUNCEMENT_TEXT, log);
} else {
log('[tts] espeak-ng not installed — trying Kokoro fallback');
}
// Fall back to Kokoro (via proxy-engine).
if (!generated) {
generated = await generateViaKokoro(CACHE_WAV, ANNOUNCEMENT_TEXT, log);
}
if (!generated) {
log('[tts] no TTS engine available — announcements disabled');
return false;
}
}
log('[tts] announcement WAV ready');
return true;
} catch (e: any) {
log(`[tts] init error: ${e.message}`);
return false;
}
}
/** Get the path to the cached announcement WAV, or null if not generated. */
export function getAnnouncementWavPath(): string | null {
return fs.existsSync(CACHE_WAV) ? CACHE_WAV : null;
}

View File

@@ -1,275 +0,0 @@
/**
* PromptCache — manages named audio prompt WAV files for IVR and voicemail.
*
* Generates WAV files via espeak-ng (primary) or Kokoro TTS through the
* proxy-engine (fallback). Also supports loading pre-existing WAV files
* and programmatic tone generation.
*
* All audio playback happens in Rust (audio_player / start_interaction).
* This module only manages WAV files on disk.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import { sendProxyCommand, isProxyReady } from '../proxybridge.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A cached prompt — just a WAV file path and metadata. */
export interface ICachedPrompt {
/** Unique prompt identifier. */
id: string;
/** Path to the WAV file on disk. */
wavPath: string;
/** Total duration in milliseconds (approximate, from WAV header). */
durationMs: number;
}
// ---------------------------------------------------------------------------
// TTS helpers
// ---------------------------------------------------------------------------
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
/** Check if espeak-ng is available. */
function isEspeakAvailable(): boolean {
try {
execSync('which espeak-ng', { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/** Generate WAV via espeak-ng. */
function generateViaEspeak(wavPath: string, text: string): boolean {
try {
execSync(
`espeak-ng -v en-us -s 150 -w "${wavPath}" "${text}"`,
{ timeout: 10000, stdio: 'pipe' },
);
return true;
} catch {
return false;
}
}
/** Generate WAV via Kokoro TTS (runs inside proxy-engine). */
async function generateViaKokoro(wavPath: string, text: string, voice: string): Promise<boolean> {
const modelPath = path.join(TTS_DIR, 'kokoro-v1.0.onnx');
const voicesPath = path.join(TTS_DIR, 'voices.bin');
if (!fs.existsSync(modelPath) || !fs.existsSync(voicesPath)) return false;
if (!isProxyReady()) return false;
try {
await sendProxyCommand('generate_tts', {
model: modelPath,
voices: voicesPath,
voice,
text,
output: wavPath,
});
return true;
} catch {
return false;
}
}
/** Read a WAV file's duration from its header. */
function getWavDurationMs(wavPath: string): number {
try {
const wav = fs.readFileSync(wavPath);
if (wav.length < 44) return 0;
if (wav.toString('ascii', 0, 4) !== 'RIFF') return 0;
let sampleRate = 16000;
let dataSize = 0;
let bitsPerSample = 16;
let channels = 1;
let offset = 12;
while (offset < wav.length - 8) {
const chunkId = wav.toString('ascii', offset, offset + 4);
const chunkSize = wav.readUInt32LE(offset + 4);
if (chunkId === 'fmt ') {
channels = wav.readUInt16LE(offset + 10);
sampleRate = wav.readUInt32LE(offset + 12);
bitsPerSample = wav.readUInt16LE(offset + 22);
}
if (chunkId === 'data') {
dataSize = chunkSize;
}
offset += 8 + chunkSize;
if (offset % 2 !== 0) offset++;
}
const bytesPerSample = (bitsPerSample / 8) * channels;
const totalSamples = bytesPerSample > 0 ? dataSize / bytesPerSample : 0;
return sampleRate > 0 ? Math.round((totalSamples / sampleRate) * 1000) : 0;
} catch {
return 0;
}
}
// ---------------------------------------------------------------------------
// PromptCache
// ---------------------------------------------------------------------------
export class PromptCache {
private prompts = new Map<string, ICachedPrompt>();
private log: (msg: string) => void;
private espeakAvailable: boolean | null = null;
constructor(log: (msg: string) => void) {
this.log = log;
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/** Get a cached prompt by ID. */
get(id: string): ICachedPrompt | null {
return this.prompts.get(id) ?? null;
}
/** Check if a prompt is cached. */
has(id: string): boolean {
return this.prompts.has(id);
}
/** List all cached prompt IDs. */
listIds(): string[] {
return [...this.prompts.keys()];
}
/**
* Generate a TTS prompt WAV and cache its path.
* Uses espeak-ng (primary) or Kokoro (fallback).
*/
async generatePrompt(id: string, text: string, voice = 'af_bella'): Promise<ICachedPrompt | null> {
fs.mkdirSync(TTS_DIR, { recursive: true });
const wavPath = path.join(TTS_DIR, `prompt-${id}.wav`);
// Check espeak availability once.
if (this.espeakAvailable === null) {
this.espeakAvailable = isEspeakAvailable();
}
// Generate WAV if not already on disk.
if (!fs.existsSync(wavPath)) {
let generated = false;
if (this.espeakAvailable) {
generated = generateViaEspeak(wavPath, text);
}
if (!generated) {
generated = await generateViaKokoro(wavPath, text, voice);
}
if (!generated) {
this.log(`[prompt-cache] failed to generate TTS for "${id}"`);
return null;
}
this.log(`[prompt-cache] generated WAV for "${id}"`);
}
return this.registerWav(id, wavPath);
}
/**
* Load a pre-existing WAV file as a prompt.
*/
async loadWavPrompt(id: string, wavPath: string): Promise<ICachedPrompt | null> {
if (!fs.existsSync(wavPath)) {
this.log(`[prompt-cache] WAV not found: ${wavPath}`);
return null;
}
return this.registerWav(id, wavPath);
}
/**
* Generate a beep tone WAV and cache it.
*/
async generateBeep(
id: string,
freqHz = 1000,
durationMs = 500,
amplitude = 8000,
): Promise<ICachedPrompt | null> {
fs.mkdirSync(TTS_DIR, { recursive: true });
const wavPath = path.join(TTS_DIR, `prompt-${id}.wav`);
if (!fs.existsSync(wavPath)) {
// Generate 16kHz 16-bit mono sine wave WAV.
const sampleRate = 16000;
const totalSamples = Math.floor((sampleRate * durationMs) / 1000);
const pcm = Buffer.alloc(totalSamples * 2);
for (let i = 0; i < totalSamples; i++) {
const t = i / sampleRate;
const fadeLen = Math.floor(sampleRate * 0.01); // 10ms fade
let envelope = 1.0;
if (i < fadeLen) envelope = i / fadeLen;
else if (i > totalSamples - fadeLen) envelope = (totalSamples - i) / fadeLen;
const sample = Math.round(Math.sin(2 * Math.PI * freqHz * t) * amplitude * envelope);
pcm.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i * 2);
}
// Write WAV file.
const headerSize = 44;
const dataSize = pcm.length;
const wav = Buffer.alloc(headerSize + dataSize);
// RIFF header
wav.write('RIFF', 0);
wav.writeUInt32LE(36 + dataSize, 4);
wav.write('WAVE', 8);
// fmt chunk
wav.write('fmt ', 12);
wav.writeUInt32LE(16, 16); // chunk size
wav.writeUInt16LE(1, 20); // PCM format
wav.writeUInt16LE(1, 22); // mono
wav.writeUInt32LE(sampleRate, 24);
wav.writeUInt32LE(sampleRate * 2, 28); // byte rate
wav.writeUInt16LE(2, 32); // block align
wav.writeUInt16LE(16, 34); // bits per sample
// data chunk
wav.write('data', 36);
wav.writeUInt32LE(dataSize, 40);
pcm.copy(wav, 44);
fs.writeFileSync(wavPath, wav);
this.log(`[prompt-cache] beep WAV generated for "${id}"`);
}
return this.registerWav(id, wavPath);
}
/** Remove a prompt from the cache. */
remove(id: string): void {
this.prompts.delete(id);
}
/** Clear all cached prompts. */
clear(): void {
this.prompts.clear();
}
// -------------------------------------------------------------------------
// Internal
// -------------------------------------------------------------------------
private registerWav(id: string, wavPath: string): ICachedPrompt {
const durationMs = getWavDurationMs(wavPath);
const prompt: ICachedPrompt = { id, wavPath, durationMs };
this.prompts.set(id, prompt);
this.log(`[prompt-cache] cached "${id}": ${wavPath} (${(durationMs / 1000).toFixed(1)}s)`);
return prompt;
}
}

View File

@@ -14,12 +14,36 @@ import { WebSocketServer, WebSocket } from 'ws';
import { handleWebRtcSignaling } from './webrtcbridge.ts';
import type { VoiceboxManager } from './voicebox.ts';
// CallManager was previously used for WebRTC call handling. Now replaced by Rust proxy-engine.
// Kept as `any` type for backward compat with the function signature until full WebRTC port.
type CallManager = any;
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
interface IHandleRequestContext {
getStatus: () => unknown;
log: (msg: string) => void;
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null;
onHangupCall: (callId: string) => boolean;
onConfigSaved?: () => void | Promise<void>;
voiceboxManager?: VoiceboxManager;
}
interface IWebUiOptions extends IHandleRequestContext {
port: number;
onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise<void>;
onWebRtcIce?: (sessionId: string, candidate: unknown) => Promise<void>;
onWebRtcClose?: (sessionId: string) => Promise<void>;
onWebRtcAccept?: (callId: string, sessionId: string) => void;
}
interface IWebRtcSocketMessage {
type?: string;
sessionId?: string;
callId?: string;
sdp?: string;
candidate?: unknown;
userAgent?: string;
_remoteIp?: string | null;
[key: string]: unknown;
}
// ---------------------------------------------------------------------------
// WebSocket broadcast
// ---------------------------------------------------------------------------
@@ -82,14 +106,9 @@ function loadStaticFiles(): void {
async function handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
getStatus: () => unknown,
log: (msg: string) => void,
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null,
onHangupCall: (callId: string) => boolean,
onConfigSaved?: () => void,
callManager?: CallManager,
voiceboxManager?: VoiceboxManager,
context: IHandleRequestContext,
): Promise<void> {
const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager } = context;
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const method = req.method || 'GET';
@@ -258,7 +277,7 @@ async function handleRequest(
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
log('[config] updated config.json');
onConfigSaved?.();
await onConfigSaved?.();
return sendJson(res, { ok: true });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
@@ -339,21 +358,21 @@ async function handleRequest(
// ---------------------------------------------------------------------------
export function initWebUi(
getStatus: () => unknown,
log: (msg: string) => void,
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null,
onHangupCall: (callId: string) => boolean,
onConfigSaved?: () => void,
callManager?: CallManager,
voiceboxManager?: VoiceboxManager,
/** WebRTC signaling handlers — forwarded to Rust proxy-engine. */
onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise<void>,
onWebRtcIce?: (sessionId: string, candidate: any) => Promise<void>,
onWebRtcClose?: (sessionId: string) => Promise<void>,
/** Called when browser sends webrtc-accept (callId + sessionId linking). */
onWebRtcAccept?: (callId: string, sessionId: string) => void,
options: IWebUiOptions,
): void {
const WEB_PORT = 3060;
const {
port,
getStatus,
log,
onStartCall,
onHangupCall,
onConfigSaved,
voiceboxManager,
onWebRtcOffer,
onWebRtcIce,
onWebRtcClose,
onWebRtcAccept,
} = options;
loadStaticFiles();
@@ -367,12 +386,12 @@ export function initWebUi(
const cert = fs.readFileSync(certPath, 'utf8');
const key = fs.readFileSync(keyPath, 'utf8');
server = https.createServer({ cert, key }, (req, res) =>
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }),
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
);
useTls = true;
} catch {
server = http.createServer((req, res) =>
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }),
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
);
}
@@ -386,12 +405,12 @@ export function initWebUi(
socket.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString());
const msg = JSON.parse(raw.toString()) as IWebRtcSocketMessage;
if (msg.type === 'webrtc-offer' && msg.sessionId) {
// Forward to Rust proxy-engine for WebRTC handling.
if (onWebRtcOffer) {
if (onWebRtcOffer && typeof msg.sdp === 'string') {
log(`[webrtc-ws] offer msg keys: ${Object.keys(msg).join(',')}, sdp type: ${typeof msg.sdp}, sdp len: ${msg.sdp?.length || 0}`);
onWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) =>
onWebRtcOffer(msg.sessionId, msg.sdp, socket).catch((e: any) =>
log(`[webrtc] offer error: ${e.message}`));
}
} else if (msg.type === 'webrtc-ice' && msg.sessionId) {
@@ -409,7 +428,7 @@ export function initWebUi(
}
} else if (msg.type?.startsWith('webrtc-')) {
msg._remoteIp = remoteIp;
handleWebRtcSignaling(socket as any, msg);
handleWebRtcSignaling(socket, msg);
}
} catch { /* ignore */ }
});
@@ -418,8 +437,8 @@ export function initWebUi(
socket.on('error', () => wsClients.delete(socket));
});
server.listen(WEB_PORT, '0.0.0.0', () => {
log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${WEB_PORT}`);
server.listen(port, '0.0.0.0', () => {
log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${port}`);
});
setInterval(() => broadcastWs('status', getStatus()), 1000);

View File

@@ -4,13 +4,36 @@
* The proxy-engine handles ALL SIP protocol mechanics. TypeScript only:
* - Sends configuration
* - Receives high-level events (incoming_call, call_ended, etc.)
* - Sends high-level commands (hangup, make_call, play_audio)
* - Sends high-level commands (hangup, make_call, add_leg, webrtc_offer)
*
* No raw SIP ever touches TypeScript.
*/
import path from 'node:path';
import { RustBridge } from '@push.rocks/smartrust';
import type { TProxyEventMap } from './shared/proxy-events.ts';
export type {
ICallAnsweredEvent,
ICallEndedEvent,
ICallRingingEvent,
IDeviceRegisteredEvent,
IIncomingCallEvent,
ILegAddedEvent,
ILegRemovedEvent,
ILegStateChangedEvent,
IOutboundCallEvent,
IOutboundCallStartedEvent,
IProviderRegisteredEvent,
IRecordingDoneEvent,
ISipUnhandledEvent,
IVoicemailErrorEvent,
IVoicemailStartedEvent,
IWebRtcAudioRxEvent,
IWebRtcIceCandidateEvent,
IWebRtcStateEvent,
IWebRtcTrackEvent,
TProxyEventMap,
} from './shared/proxy-events.ts';
// ---------------------------------------------------------------------------
// Command type map for smartrust
@@ -29,18 +52,6 @@ type TProxyCommands = {
params: { number: string; device_id?: string; provider_id?: string };
result: { call_id: string };
};
play_audio: {
params: { call_id: string; leg_id?: string; file_path: string; codec?: number };
result: Record<string, never>;
};
start_recording: {
params: { call_id: string; file_path: string; max_duration_ms?: number };
result: Record<string, never>;
};
stop_recording: {
params: { call_id: string };
result: { file_path: string; duration_ms: number };
};
add_leg: {
params: { call_id: string; number: string; provider_id?: string };
result: { leg_id: string };
@@ -88,7 +99,7 @@ type TProxyCommands = {
result: Record<string, never>;
};
generate_tts: {
params: { model: string; voices: string; voice: string; text: string; output: string };
params: { model: string; voices: string; voice: string; text: string; output: string; cacheable?: boolean };
result: { output: string };
};
// WebRTC signaling — bridged from the browser via the TS control plane.
@@ -121,50 +132,6 @@ type TProxyCommands = {
};
};
// ---------------------------------------------------------------------------
// Event types from Rust
// ---------------------------------------------------------------------------
export interface IIncomingCallEvent {
call_id: string;
from_uri: string;
to_number: string;
provider_id: string;
/** Whether registered browsers should see a `webrtc-incoming` toast for
* this call. Set by the Rust engine from the matched inbound route's
* `ringBrowsers` flag (defaults to `true` when no route matches, so
* deployments without explicit routes preserve pre-routing behavior). */
ring_browsers?: boolean;
}
export interface IOutboundCallEvent {
call_id: string;
from_device: string | null;
to_number: string;
}
export interface ICallEndedEvent {
call_id: string;
reason: string;
duration: number;
from_side?: string;
}
export interface IProviderRegisteredEvent {
provider_id: string;
registered: boolean;
public_ip: string | null;
}
export interface IDeviceRegisteredEvent {
device_id: string;
display_name: string;
address: string;
port: number;
aor: string;
expires: number;
}
// ---------------------------------------------------------------------------
// Bridge singleton
// ---------------------------------------------------------------------------
@@ -173,6 +140,16 @@ let bridge: RustBridge<TProxyCommands> | null = null;
let initialized = false;
let logFn: ((msg: string) => void) | undefined;
type TWebRtcIceCandidate = {
candidate?: string;
sdpMid?: string;
sdpMLineIndex?: number;
} | string;
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function buildLocalPaths(): string[] {
const root = process.cwd();
// Map Node's process.arch to tsrust's friendly target name.
@@ -231,8 +208,8 @@ export async function initProxyEngine(log?: (msg: string) => void): Promise<bool
initialized = true;
log?.('[proxy-engine] spawned and ready');
return true;
} catch (e: any) {
log?.(`[proxy-engine] init error: ${e.message}`);
} catch (error: unknown) {
log?.(`[proxy-engine] init error: ${errorMessage(error)}`);
bridge = null;
return false;
}
@@ -242,14 +219,14 @@ export async function initProxyEngine(log?: (msg: string) => void): Promise<bool
* Send the full app config to the proxy engine.
* This binds the SIP socket, starts provider registrations, etc.
*/
export async function configureProxyEngine(config: Record<string, unknown>): Promise<boolean> {
export async function configureProxyEngine(config: TProxyCommands['configure']['params']): Promise<boolean> {
if (!bridge || !initialized) return false;
try {
const result = await bridge.sendCommand('configure', config as any);
logFn?.(`[proxy-engine] configured, SIP bound on ${(result as any)?.bound || '?'}`);
const result = await sendProxyCommand('configure', config);
logFn?.(`[proxy-engine] configured, SIP bound on ${result.bound || '?'}`);
return true;
} catch (e: any) {
logFn?.(`[proxy-engine] configure error: ${e.message}`);
} catch (error: unknown) {
logFn?.(`[proxy-engine] configure error: ${errorMessage(error)}`);
return false;
}
}
@@ -260,14 +237,14 @@ export async function configureProxyEngine(config: Record<string, unknown>): Pro
export async function makeCall(number: string, deviceId?: string, providerId?: string): Promise<string | null> {
if (!bridge || !initialized) return null;
try {
const result = await bridge.sendCommand('make_call', {
const result = await sendProxyCommand('make_call', {
number,
device_id: deviceId,
provider_id: providerId,
} as any);
return (result as any)?.call_id || null;
} catch (e: any) {
logFn?.(`[proxy-engine] make_call error: ${e?.message || e}`);
});
return result.call_id || null;
} catch (error: unknown) {
logFn?.(`[proxy-engine] make_call error: ${errorMessage(error)}`);
return null;
}
}
@@ -278,7 +255,7 @@ export async function makeCall(number: string, deviceId?: string, providerId?: s
export async function hangupCall(callId: string): Promise<boolean> {
if (!bridge || !initialized) return false;
try {
await bridge.sendCommand('hangup', { call_id: callId } as any);
await sendProxyCommand('hangup', { call_id: callId });
return true;
} catch {
return false;
@@ -291,10 +268,9 @@ export async function hangupCall(callId: string): Promise<boolean> {
export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp: string } | null> {
if (!bridge || !initialized) return null;
try {
const result = await bridge.sendCommand('webrtc_offer', { session_id: sessionId, sdp } as any);
return result as any;
} catch (e: any) {
logFn?.(`[proxy-engine] webrtc_offer error: ${e?.message || e}`);
return await sendProxyCommand('webrtc_offer', { session_id: sessionId, sdp });
} catch (error: unknown) {
logFn?.(`[proxy-engine] webrtc_offer error: ${errorMessage(error)}`);
return null;
}
}
@@ -302,15 +278,15 @@ export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp
/**
* Forward an ICE candidate to the proxy engine.
*/
export async function webrtcIce(sessionId: string, candidate: any): Promise<void> {
export async function webrtcIce(sessionId: string, candidate: TWebRtcIceCandidate): Promise<void> {
if (!bridge || !initialized) return;
try {
await bridge.sendCommand('webrtc_ice', {
await sendProxyCommand('webrtc_ice', {
session_id: sessionId,
candidate: candidate?.candidate || candidate,
sdp_mid: candidate?.sdpMid,
sdp_mline_index: candidate?.sdpMLineIndex,
} as any);
candidate: typeof candidate === 'string' ? candidate : candidate.candidate || '',
sdp_mid: typeof candidate === 'string' ? undefined : candidate.sdpMid,
sdp_mline_index: typeof candidate === 'string' ? undefined : candidate.sdpMLineIndex,
});
} catch { /* ignore */ }
}
@@ -321,16 +297,16 @@ export async function webrtcIce(sessionId: string, candidate: any): Promise<void
export async function webrtcLink(sessionId: string, callId: string, providerMediaAddr: string, providerMediaPort: number, sipPt: number = 9): Promise<boolean> {
if (!bridge || !initialized) return false;
try {
await bridge.sendCommand('webrtc_link', {
await sendProxyCommand('webrtc_link', {
session_id: sessionId,
call_id: callId,
provider_media_addr: providerMediaAddr,
provider_media_port: providerMediaPort,
sip_pt: sipPt,
} as any);
});
return true;
} catch (e: any) {
logFn?.(`[proxy-engine] webrtc_link error: ${e?.message || e}`);
} catch (error: unknown) {
logFn?.(`[proxy-engine] webrtc_link error: ${errorMessage(error)}`);
return false;
}
}
@@ -341,14 +317,14 @@ export async function webrtcLink(sessionId: string, callId: string, providerMedi
export async function addLeg(callId: string, number: string, providerId?: string): Promise<string | null> {
if (!bridge || !initialized) return null;
try {
const result = await bridge.sendCommand('add_leg', {
const result = await sendProxyCommand('add_leg', {
call_id: callId,
number,
provider_id: providerId,
} as any);
return (result as any)?.leg_id || null;
} catch (e: any) {
logFn?.(`[proxy-engine] add_leg error: ${e?.message || e}`);
});
return result.leg_id || null;
} catch (error: unknown) {
logFn?.(`[proxy-engine] add_leg error: ${errorMessage(error)}`);
return null;
}
}
@@ -359,10 +335,10 @@ export async function addLeg(callId: string, number: string, providerId?: string
export async function removeLeg(callId: string, legId: string): Promise<boolean> {
if (!bridge || !initialized) return false;
try {
await bridge.sendCommand('remove_leg', { call_id: callId, leg_id: legId } as any);
await sendProxyCommand('remove_leg', { call_id: callId, leg_id: legId });
return true;
} catch (e: any) {
logFn?.(`[proxy-engine] remove_leg error: ${e?.message || e}`);
} catch (error: unknown) {
logFn?.(`[proxy-engine] remove_leg error: ${errorMessage(error)}`);
return false;
}
}
@@ -373,7 +349,7 @@ export async function removeLeg(callId: string, legId: string): Promise<boolean>
export async function webrtcClose(sessionId: string): Promise<void> {
if (!bridge || !initialized) return;
try {
await bridge.sendCommand('webrtc_close', { session_id: sessionId } as any);
await sendProxyCommand('webrtc_close', { session_id: sessionId });
} catch { /* ignore */ }
}
@@ -387,13 +363,13 @@ export async function webrtcClose(sessionId: string): Promise<void> {
export async function addDeviceLeg(callId: string, deviceId: string): Promise<string | null> {
if (!bridge || !initialized) return null;
try {
const result = await bridge.sendCommand('add_device_leg', {
const result = await sendProxyCommand('add_device_leg', {
call_id: callId,
device_id: deviceId,
} as any);
return (result as any)?.leg_id || null;
} catch (e: any) {
logFn?.(`[proxy-engine] add_device_leg error: ${e?.message || e}`);
});
return result.leg_id || null;
} catch (error: unknown) {
logFn?.(`[proxy-engine] add_device_leg error: ${errorMessage(error)}`);
return null;
}
}
@@ -408,14 +384,14 @@ export async function transferLeg(
): Promise<boolean> {
if (!bridge || !initialized) return false;
try {
await bridge.sendCommand('transfer_leg', {
await sendProxyCommand('transfer_leg', {
source_call_id: sourceCallId,
leg_id: legId,
target_call_id: targetCallId,
} as any);
});
return true;
} catch (e: any) {
logFn?.(`[proxy-engine] transfer_leg error: ${e?.message || e}`);
} catch (error: unknown) {
logFn?.(`[proxy-engine] transfer_leg error: ${errorMessage(error)}`);
return false;
}
}
@@ -431,15 +407,15 @@ export async function replaceLeg(
): Promise<string | null> {
if (!bridge || !initialized) return null;
try {
const result = await bridge.sendCommand('replace_leg', {
const result = await sendProxyCommand('replace_leg', {
call_id: callId,
old_leg_id: oldLegId,
number,
provider_id: providerId,
} as any);
return (result as any)?.new_leg_id || null;
} catch (e: any) {
logFn?.(`[proxy-engine] replace_leg error: ${e?.message || e}`);
});
return result.new_leg_id || null;
} catch (error: unknown) {
logFn?.(`[proxy-engine] replace_leg error: ${errorMessage(error)}`);
return null;
}
}
@@ -457,16 +433,15 @@ export async function startInteraction(
): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> {
if (!bridge || !initialized) return null;
try {
const result = await bridge.sendCommand('start_interaction', {
return await sendProxyCommand('start_interaction', {
call_id: callId,
leg_id: legId,
prompt_wav: promptWav,
expected_digits: expectedDigits,
timeout_ms: timeoutMs,
} as any);
return result as any;
} catch (e: any) {
logFn?.(`[proxy-engine] start_interaction error: ${e?.message || e}`);
});
} catch (error: unknown) {
logFn?.(`[proxy-engine] start_interaction error: ${errorMessage(error)}`);
return null;
}
}
@@ -482,14 +457,14 @@ export async function addToolLeg(
): Promise<string | null> {
if (!bridge || !initialized) return null;
try {
const result = await bridge.sendCommand('add_tool_leg', {
const result = await sendProxyCommand('add_tool_leg', {
call_id: callId,
tool_type: toolType,
config,
} as any);
return (result as any)?.tool_leg_id || null;
} catch (e: any) {
logFn?.(`[proxy-engine] add_tool_leg error: ${e?.message || e}`);
});
return result.tool_leg_id || null;
} catch (error: unknown) {
logFn?.(`[proxy-engine] add_tool_leg error: ${errorMessage(error)}`);
return null;
}
}
@@ -500,13 +475,13 @@ export async function addToolLeg(
export async function removeToolLeg(callId: string, toolLegId: string): Promise<boolean> {
if (!bridge || !initialized) return false;
try {
await bridge.sendCommand('remove_tool_leg', {
await sendProxyCommand('remove_tool_leg', {
call_id: callId,
tool_leg_id: toolLegId,
} as any);
});
return true;
} catch (e: any) {
logFn?.(`[proxy-engine] remove_tool_leg error: ${e?.message || e}`);
} catch (error: unknown) {
logFn?.(`[proxy-engine] remove_tool_leg error: ${errorMessage(error)}`);
return false;
}
}
@@ -522,15 +497,15 @@ export async function setLegMetadata(
): Promise<boolean> {
if (!bridge || !initialized) return false;
try {
await bridge.sendCommand('set_leg_metadata', {
await sendProxyCommand('set_leg_metadata', {
call_id: callId,
leg_id: legId,
key,
value,
} as any);
});
return true;
} catch (e: any) {
logFn?.(`[proxy-engine] set_leg_metadata error: ${e?.message || e}`);
} catch (error: unknown) {
logFn?.(`[proxy-engine] set_leg_metadata error: ${errorMessage(error)}`);
return false;
}
}
@@ -542,7 +517,7 @@ export async function setLegMetadata(
* dtmf_digit, recording_done, tool_recording_done, tool_transcription_done,
* leg_added, leg_removed, sip_unhandled
*/
export function onProxyEvent(event: string, handler: (data: any) => void): void {
export function onProxyEvent<K extends keyof TProxyEventMap>(event: K, handler: (data: TProxyEventMap[K]) => void): void {
if (!bridge) throw new Error('proxy engine not initialized');
bridge.on(`management:${event}`, handler);
}

187
ts/runtime/proxy-events.ts Normal file
View File

@@ -0,0 +1,187 @@
import { onProxyEvent } from '../proxybridge.ts';
import type { VoiceboxManager } from '../voicebox.ts';
import type { StatusStore } from './status-store.ts';
import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts';
export interface IRegisterProxyEventHandlersOptions {
log: (msg: string) => void;
statusStore: StatusStore;
voiceboxManager: VoiceboxManager;
webRtcLinks: WebRtcLinkManager;
getBrowserDeviceIds: () => string[];
sendToBrowserDevice: (deviceId: string, data: unknown) => boolean;
broadcast: (type: string, data: unknown) => void;
onLinkWebRtcSession: (callId: string, sessionId: string, media: IProviderMediaInfo) => void;
onCloseWebRtcSession: (sessionId: string) => void;
}
export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersOptions): void {
const {
log,
statusStore,
voiceboxManager,
webRtcLinks,
getBrowserDeviceIds,
sendToBrowserDevice,
broadcast,
onLinkWebRtcSession,
onCloseWebRtcSession,
} = options;
onProxyEvent('provider_registered', (data) => {
const previous = statusStore.noteProviderRegistered(data);
if (previous) {
if (data.registered && !previous.wasRegistered) {
log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`);
} else if (!data.registered && previous.wasRegistered) {
log(`[provider:${data.provider_id}] registration lost`);
}
}
broadcast('registration', { providerId: data.provider_id, registered: data.registered });
});
onProxyEvent('device_registered', (data) => {
if (statusStore.noteDeviceRegistered(data)) {
log(`[registrar] ${data.display_name} registered from ${data.address}:${data.port}`);
}
});
onProxyEvent('incoming_call', (data) => {
log(`[call] incoming: ${data.from_uri} -> ${data.to_number} via ${data.provider_id} (${data.call_id})`);
statusStore.noteIncomingCall(data);
if (data.ring_browsers === false) {
return;
}
for (const deviceId of getBrowserDeviceIds()) {
sendToBrowserDevice(deviceId, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.from_uri,
deviceId,
});
}
});
onProxyEvent('outbound_device_call', (data) => {
log(`[call] outbound: device ${data.from_device} -> ${data.to_number} (${data.call_id})`);
statusStore.noteOutboundDeviceCall(data);
});
onProxyEvent('outbound_call_started', (data) => {
log(`[call] outbound started: ${data.call_id} -> ${data.number} via ${data.provider_id}`);
statusStore.noteOutboundCallStarted(data);
for (const deviceId of getBrowserDeviceIds()) {
sendToBrowserDevice(deviceId, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.number,
deviceId,
});
}
});
onProxyEvent('call_ringing', (data) => {
statusStore.noteCallRinging(data);
});
onProxyEvent('call_answered', (data) => {
if (statusStore.noteCallAnswered(data)) {
log(`[call] ${data.call_id} connected`);
}
if (!data.provider_media_addr || !data.provider_media_port) {
return;
}
const target = webRtcLinks.noteCallAnswered(data.call_id, {
addr: data.provider_media_addr,
port: data.provider_media_port,
sipPt: data.sip_pt ?? 9,
});
if (!target) {
log(`[webrtc] media info cached for call=${data.call_id}, waiting for session accept`);
return;
}
onLinkWebRtcSession(data.call_id, target.sessionId, target.media);
});
onProxyEvent('call_ended', (data) => {
if (statusStore.noteCallEnded(data)) {
log(`[call] ${data.call_id} ended: ${data.reason} (${data.duration}s)`);
}
broadcast('webrtc-call-ended', { callId: data.call_id });
const sessionId = webRtcLinks.cleanupCall(data.call_id);
if (sessionId) {
onCloseWebRtcSession(sessionId);
}
});
onProxyEvent('sip_unhandled', (data) => {
log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`);
});
onProxyEvent('leg_added', (data) => {
log(`[leg] added: call=${data.call_id} leg=${data.leg_id} kind=${data.kind} state=${data.state}`);
statusStore.noteLegAdded(data);
});
onProxyEvent('leg_removed', (data) => {
log(`[leg] removed: call=${data.call_id} leg=${data.leg_id}`);
statusStore.noteLegRemoved(data);
});
onProxyEvent('leg_state_changed', (data) => {
log(`[leg] state: call=${data.call_id} leg=${data.leg_id} -> ${data.state}`);
statusStore.noteLegStateChanged(data);
});
onProxyEvent('webrtc_ice_candidate', (data) => {
broadcast('webrtc-ice', {
sessionId: data.session_id,
candidate: {
candidate: data.candidate,
sdpMid: data.sdp_mid,
sdpMLineIndex: data.sdp_mline_index,
},
});
});
onProxyEvent('webrtc_state', (data) => {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} state=${data.state}`);
});
onProxyEvent('webrtc_track', (data) => {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} track=${data.kind} codec=${data.codec}`);
});
onProxyEvent('webrtc_audio_rx', (data) => {
if (data.packet_count === 1 || data.packet_count === 50) {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} browser audio rx #${data.packet_count}`);
}
});
onProxyEvent('voicemail_started', (data) => {
log(`[voicemail] started for call ${data.call_id} caller=${data.caller_number}`);
});
onProxyEvent('recording_done', (data) => {
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) caller=${data.caller_number}`);
voiceboxManager.addMessage('default', {
callerNumber: data.caller_number || 'Unknown',
callerName: null,
fileName: data.file_path,
durationMs: data.duration_ms,
});
});
onProxyEvent('voicemail_error', (data) => {
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
});
}

313
ts/runtime/status-store.ts Normal file
View File

@@ -0,0 +1,313 @@
import type { IAppConfig } from '../config.ts';
import type {
ICallAnsweredEvent,
ICallEndedEvent,
ICallRingingEvent,
IDeviceRegisteredEvent,
IIncomingCallEvent,
ILegAddedEvent,
ILegRemovedEvent,
ILegStateChangedEvent,
IOutboundCallEvent,
IOutboundCallStartedEvent,
IProviderRegisteredEvent,
} from '../shared/proxy-events.ts';
import type {
IActiveCall,
ICallHistoryEntry,
IDeviceStatus,
IProviderStatus,
IStatusSnapshot,
TLegType,
} from '../shared/status.ts';
const MAX_HISTORY = 100;
const CODEC_NAMES: Record<number, string> = {
0: 'PCMU',
8: 'PCMA',
9: 'G.722',
111: 'Opus',
};
export class StatusStore {
private appConfig: IAppConfig;
private providerStatuses = new Map<string, IProviderStatus>();
private deviceStatuses = new Map<string, IDeviceStatus>();
private activeCalls = new Map<string, IActiveCall>();
private callHistory: ICallHistoryEntry[] = [];
constructor(appConfig: IAppConfig) {
this.appConfig = appConfig;
this.rebuildConfigState();
}
updateConfig(appConfig: IAppConfig): void {
this.appConfig = appConfig;
this.rebuildConfigState();
}
buildStatusSnapshot(
instanceId: string,
startTime: number,
browserDeviceIds: string[],
voicemailCounts: Record<string, number>,
): IStatusSnapshot {
const devices = [...this.deviceStatuses.values()];
for (const deviceId of browserDeviceIds) {
devices.push({
id: deviceId,
displayName: 'Browser',
address: null,
port: 0,
aor: null,
connected: true,
isBrowser: true,
});
}
return {
instanceId,
uptime: Math.floor((Date.now() - startTime) / 1000),
lanIp: this.appConfig.proxy.lanIp,
providers: [...this.providerStatuses.values()],
devices,
calls: [...this.activeCalls.values()].map((call) => ({
...call,
duration: Math.floor((Date.now() - call.startedAt) / 1000),
legs: [...call.legs.values()].map((leg) => ({
...leg,
pktSent: 0,
pktReceived: 0,
transcoding: false,
})),
})),
callHistory: this.callHistory,
contacts: this.appConfig.contacts || [],
voicemailCounts,
};
}
noteDashboardCallStarted(callId: string, number: string, providerId?: string): void {
this.activeCalls.set(callId, {
id: callId,
direction: 'outbound',
callerNumber: null,
calleeNumber: number,
providerUsed: providerId || null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
}
noteProviderRegistered(data: IProviderRegisteredEvent): { wasRegistered: boolean } | null {
const provider = this.providerStatuses.get(data.provider_id);
if (!provider) {
return null;
}
const wasRegistered = provider.registered;
provider.registered = data.registered;
provider.publicIp = data.public_ip;
return { wasRegistered };
}
noteDeviceRegistered(data: IDeviceRegisteredEvent): boolean {
const device = this.deviceStatuses.get(data.device_id);
if (!device) {
return false;
}
device.address = data.address;
device.port = data.port;
device.aor = data.aor;
device.connected = true;
return true;
}
noteIncomingCall(data: IIncomingCallEvent): void {
this.activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'inbound',
callerNumber: data.from_uri,
calleeNumber: data.to_number,
providerUsed: data.provider_id,
state: 'ringing',
startedAt: Date.now(),
legs: new Map(),
});
}
noteOutboundDeviceCall(data: IOutboundCallEvent): void {
this.activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: data.from_device,
calleeNumber: data.to_number,
providerUsed: null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
}
noteOutboundCallStarted(data: IOutboundCallStartedEvent): void {
this.activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: null,
calleeNumber: data.number,
providerUsed: data.provider_id,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
}
noteCallRinging(data: ICallRingingEvent): void {
const call = this.activeCalls.get(data.call_id);
if (call) {
call.state = 'ringing';
}
}
noteCallAnswered(data: ICallAnsweredEvent): boolean {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return false;
}
call.state = 'connected';
if (data.provider_media_addr && data.provider_media_port) {
for (const leg of call.legs.values()) {
if (leg.type !== 'sip-provider') {
continue;
}
leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`;
if (data.sip_pt !== undefined) {
leg.codec = CODEC_NAMES[data.sip_pt] || `PT${data.sip_pt}`;
}
break;
}
}
return true;
}
noteCallEnded(data: ICallEndedEvent): boolean {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return false;
}
this.callHistory.unshift({
id: call.id,
direction: call.direction,
callerNumber: call.callerNumber,
calleeNumber: call.calleeNumber,
providerUsed: call.providerUsed,
startedAt: call.startedAt,
duration: data.duration,
legs: [...call.legs.values()].map((leg) => ({
id: leg.id,
type: leg.type,
metadata: leg.metadata || {},
})),
});
if (this.callHistory.length > MAX_HISTORY) {
this.callHistory.pop();
}
this.activeCalls.delete(data.call_id);
return true;
}
noteLegAdded(data: ILegAddedEvent): void {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return;
}
call.legs.set(data.leg_id, {
id: data.leg_id,
type: data.kind,
state: data.state,
codec: data.codec ?? null,
rtpPort: data.rtpPort ?? null,
remoteMedia: data.remoteMedia ?? null,
metadata: data.metadata || {},
});
}
noteLegRemoved(data: ILegRemovedEvent): void {
this.activeCalls.get(data.call_id)?.legs.delete(data.leg_id);
}
noteLegStateChanged(data: ILegStateChangedEvent): void {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return;
}
const existingLeg = call.legs.get(data.leg_id);
if (existingLeg) {
existingLeg.state = data.state;
if (data.metadata) {
existingLeg.metadata = data.metadata;
}
return;
}
call.legs.set(data.leg_id, {
id: data.leg_id,
type: this.inferLegType(data.leg_id),
state: data.state,
codec: null,
rtpPort: null,
remoteMedia: null,
metadata: data.metadata || {},
});
}
private rebuildConfigState(): void {
const nextProviderStatuses = new Map<string, IProviderStatus>();
for (const provider of this.appConfig.providers) {
const previous = this.providerStatuses.get(provider.id);
nextProviderStatuses.set(provider.id, {
id: provider.id,
displayName: provider.displayName,
registered: previous?.registered ?? false,
publicIp: previous?.publicIp ?? null,
});
}
this.providerStatuses = nextProviderStatuses;
const nextDeviceStatuses = new Map<string, IDeviceStatus>();
for (const device of this.appConfig.devices) {
const previous = this.deviceStatuses.get(device.id);
nextDeviceStatuses.set(device.id, {
id: device.id,
displayName: device.displayName,
address: previous?.address ?? null,
port: previous?.port ?? 0,
aor: previous?.aor ?? null,
connected: previous?.connected ?? false,
isBrowser: false,
});
}
this.deviceStatuses = nextDeviceStatuses;
}
private inferLegType(legId: string): TLegType {
if (legId.includes('-prov')) {
return 'sip-provider';
}
if (legId.includes('-dev')) {
return 'sip-device';
}
return 'webrtc';
}
}

View File

@@ -0,0 +1,66 @@
export interface IProviderMediaInfo {
addr: string;
port: number;
sipPt: number;
}
export interface IWebRtcLinkTarget {
sessionId: string;
media: IProviderMediaInfo;
}
export class WebRtcLinkManager {
private sessionToCall = new Map<string, string>();
private callToSession = new Map<string, string>();
private pendingCallMedia = new Map<string, IProviderMediaInfo>();
acceptCall(callId: string, sessionId: string): IProviderMediaInfo | null {
const previousCallId = this.sessionToCall.get(sessionId);
if (previousCallId && previousCallId !== callId) {
this.callToSession.delete(previousCallId);
}
const previousSessionId = this.callToSession.get(callId);
if (previousSessionId && previousSessionId !== sessionId) {
this.sessionToCall.delete(previousSessionId);
}
this.sessionToCall.set(sessionId, callId);
this.callToSession.set(callId, sessionId);
const pendingMedia = this.pendingCallMedia.get(callId) ?? null;
if (pendingMedia) {
this.pendingCallMedia.delete(callId);
}
return pendingMedia;
}
noteCallAnswered(callId: string, media: IProviderMediaInfo): IWebRtcLinkTarget | null {
const sessionId = this.callToSession.get(callId);
if (!sessionId) {
this.pendingCallMedia.set(callId, media);
return null;
}
return { sessionId, media };
}
removeSession(sessionId: string): string | null {
const callId = this.sessionToCall.get(sessionId) ?? null;
this.sessionToCall.delete(sessionId);
if (callId) {
this.callToSession.delete(callId);
}
return callId;
}
cleanupCall(callId: string): string | null {
const sessionId = this.callToSession.get(callId) ?? null;
this.callToSession.delete(callId);
this.pendingCallMedia.delete(callId);
if (sessionId) {
this.sessionToCall.delete(sessionId);
}
return sessionId;
}
}

145
ts/shared/proxy-events.ts Normal file
View File

@@ -0,0 +1,145 @@
import type { TLegType } from './status.ts';
export interface IIncomingCallEvent {
call_id: string;
from_uri: string;
to_number: string;
provider_id: string;
ring_browsers?: boolean;
}
export interface IOutboundCallEvent {
call_id: string;
from_device: string | null;
to_number: string;
}
export interface IOutboundCallStartedEvent {
call_id: string;
number: string;
provider_id: string;
}
export interface ICallRingingEvent {
call_id: string;
}
export interface ICallAnsweredEvent {
call_id: string;
provider_media_addr?: string;
provider_media_port?: number;
sip_pt?: number;
}
export interface ICallEndedEvent {
call_id: string;
reason: string;
duration: number;
from_side?: string;
}
export interface IProviderRegisteredEvent {
provider_id: string;
registered: boolean;
public_ip: string | null;
}
export interface IDeviceRegisteredEvent {
device_id: string;
display_name: string;
address: string;
port: number;
aor: string;
expires: number;
}
export interface ISipUnhandledEvent {
method_or_status: string;
call_id?: string;
from_addr: string;
from_port: number;
}
export interface ILegAddedEvent {
call_id: string;
leg_id: string;
kind: TLegType;
state: string;
codec?: string | null;
rtpPort?: number | null;
remoteMedia?: string | null;
metadata?: Record<string, unknown>;
}
export interface ILegRemovedEvent {
call_id: string;
leg_id: string;
}
export interface ILegStateChangedEvent {
call_id: string;
leg_id: string;
state: string;
metadata?: Record<string, unknown>;
}
export interface IWebRtcIceCandidateEvent {
session_id: string;
candidate: string;
sdp_mid?: string;
sdp_mline_index?: number;
}
export interface IWebRtcStateEvent {
session_id?: string;
state: string;
}
export interface IWebRtcTrackEvent {
session_id?: string;
kind: string;
codec: string;
}
export interface IWebRtcAudioRxEvent {
session_id?: string;
packet_count: number;
}
export interface IVoicemailStartedEvent {
call_id: string;
caller_number?: string;
}
export interface IRecordingDoneEvent {
file_path: string;
duration_ms: number;
caller_number?: string;
}
export interface IVoicemailErrorEvent {
call_id: string;
error: string;
}
export type TProxyEventMap = {
provider_registered: IProviderRegisteredEvent;
device_registered: IDeviceRegisteredEvent;
incoming_call: IIncomingCallEvent;
outbound_device_call: IOutboundCallEvent;
outbound_call_started: IOutboundCallStartedEvent;
call_ringing: ICallRingingEvent;
call_answered: ICallAnsweredEvent;
call_ended: ICallEndedEvent;
sip_unhandled: ISipUnhandledEvent;
leg_added: ILegAddedEvent;
leg_removed: ILegRemovedEvent;
leg_state_changed: ILegStateChangedEvent;
webrtc_ice_candidate: IWebRtcIceCandidateEvent;
webrtc_state: IWebRtcStateEvent;
webrtc_track: IWebRtcTrackEvent;
webrtc_audio_rx: IWebRtcAudioRxEvent;
voicemail_started: IVoicemailStartedEvent;
recording_done: IRecordingDoneEvent;
voicemail_error: IVoicemailErrorEvent;
};

89
ts/shared/status.ts Normal file
View File

@@ -0,0 +1,89 @@
import type { IContact } from '../config.ts';
export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
export type TCallDirection = 'inbound' | 'outbound';
export interface IProviderStatus {
id: string;
displayName: string;
registered: boolean;
publicIp: string | null;
}
export interface IDeviceStatus {
id: string;
displayName: string;
address: string | null;
port: number;
aor: string | null;
connected: boolean;
isBrowser: boolean;
}
export interface IActiveLeg {
id: string;
type: TLegType;
state: string;
codec: string | null;
rtpPort: number | null;
remoteMedia: string | null;
metadata: Record<string, unknown>;
}
export interface IActiveCall {
id: string;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
state: string;
startedAt: number;
legs: Map<string, IActiveLeg>;
}
export interface IHistoryLeg {
id: string;
type: string;
metadata: Record<string, unknown>;
}
export interface ICallHistoryEntry {
id: string;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
startedAt: number;
duration: number;
legs: IHistoryLeg[];
}
export interface ILegStatus extends IActiveLeg {
pktSent: number;
pktReceived: number;
transcoding: boolean;
}
export interface ICallStatus {
id: string;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
state: string;
startedAt: number;
duration: number;
legs: ILegStatus[];
}
export interface IStatusSnapshot {
instanceId: string;
uptime: number;
lanIp: string;
providers: IProviderStatus[];
devices: IDeviceStatus[];
calls: ICallStatus[];
callHistory: ICallHistoryEntry[];
contacts: IContact[];
voicemailCounts: Record<string, number>;
}

View File

@@ -1,36 +1,20 @@
/**
* SIP proxy — entry point.
* SIP proxy bootstrap.
*
* Spawns the Rust proxy-engine which handles ALL SIP protocol mechanics.
* TypeScript is the control plane:
* - Loads config and pushes it to Rust
* - Receives high-level events (incoming calls, registration, etc.)
* - Drives the web dashboard
* - Manages IVR, voicemail, announcements
* - Handles WebRTC browser signaling (forwarded to Rust in Phase 2)
*
* No raw SIP ever touches TypeScript.
* Spawns the Rust proxy-engine, wires runtime state/event handling,
* and starts the web dashboard plus browser signaling layer.
*/
import fs from 'node:fs';
import path from 'node:path';
import { loadConfig } from './config.ts';
import type { IAppConfig } from './config.ts';
import { loadConfig, type IAppConfig } from './config.ts';
import { broadcastWs, initWebUi } from './frontend.ts';
import {
initWebRtcSignaling,
sendToBrowserDevice,
getAllBrowserDeviceIds,
getBrowserDeviceWs,
} from './webrtcbridge.ts';
import { initAnnouncement } from './announcement.ts';
import { PromptCache } from './call/prompt-cache.ts';
import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts';
import { VoiceboxManager } from './voicebox.ts';
import {
initProxyEngine,
configureProxyEngine,
onProxyEvent,
hangupCall,
makeCall,
shutdownProxyEngine,
@@ -38,648 +22,200 @@ import {
webrtcIce,
webrtcLink,
webrtcClose,
addLeg,
removeLeg,
} from './proxybridge.ts';
import type {
IIncomingCallEvent,
IOutboundCallEvent,
ICallEndedEvent,
IProviderRegisteredEvent,
IDeviceRegisteredEvent,
} from './proxybridge.ts';
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
import { registerProxyEventHandlers } from './runtime/proxy-events.ts';
import { StatusStore } from './runtime/status-store.ts';
import { WebRtcLinkManager, type IProviderMediaInfo } from './runtime/webrtc-linking.ts';
let appConfig: IAppConfig = loadConfig();
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
const startTime = Date.now();
const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const statusStore = new StatusStore(appConfig);
const webRtcLinks = new WebRtcLinkManager();
const voiceboxManager = new VoiceboxManager(log);
voiceboxManager.init(appConfig.voiceboxes ?? []);
initWebRtcSignaling({ log });
function now(): string {
return new Date().toISOString().replace('T', ' ').slice(0, 19);
}
function log(msg: string): void {
const line = `${now()} ${msg}\n`;
function log(message: string): void {
const line = `${now()} ${message}\n`;
fs.appendFileSync(LOG_PATH, line);
process.stdout.write(line);
broadcastWs('log', { message: msg });
broadcastWs('log', { message });
}
// ---------------------------------------------------------------------------
// Shadow state — maintained from Rust events for the dashboard
// ---------------------------------------------------------------------------
interface IProviderStatus {
id: string;
displayName: string;
registered: boolean;
publicIp: string | null;
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
interface IDeviceStatus {
id: string;
displayName: string;
address: string | null;
port: number;
connected: boolean;
isBrowser: boolean;
}
interface IActiveLeg {
id: string;
type: 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
state: string;
codec: string | null;
rtpPort: number | null;
remoteMedia: string | null;
metadata: Record<string, unknown>;
}
interface IActiveCall {
id: string;
direction: string;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
state: string;
startedAt: number;
legs: Map<string, IActiveLeg>;
}
interface IHistoryLeg {
id: string;
type: string;
metadata: Record<string, unknown>;
}
interface ICallHistoryEntry {
id: string;
direction: string;
callerNumber: string | null;
calleeNumber: string | null;
startedAt: number;
duration: number;
legs: IHistoryLeg[];
}
const providerStatuses = new Map<string, IProviderStatus>();
const deviceStatuses = new Map<string, IDeviceStatus>();
const activeCalls = new Map<string, IActiveCall>();
const callHistory: ICallHistoryEntry[] = [];
const MAX_HISTORY = 100;
// WebRTC session ↔ call linking state.
// Both pieces (session accept + call media info) can arrive in any order.
const webrtcSessionToCall = new Map<string, string>(); // sessionId → callId
const webrtcCallToSession = new Map<string, string>(); // callId → sessionId
const pendingCallMedia = new Map<string, { addr: string; port: number; sipPt: number }>(); // callId → provider media info
// Initialize provider statuses from config (all start as unregistered).
for (const p of appConfig.providers) {
providerStatuses.set(p.id, {
id: p.id,
displayName: p.displayName,
registered: false,
publicIp: null,
});
}
// Initialize device statuses from config.
for (const d of appConfig.devices) {
deviceStatuses.set(d.id, {
id: d.id,
displayName: d.displayName,
address: null,
port: 0,
connected: false,
isBrowser: false,
});
}
// ---------------------------------------------------------------------------
// Initialize subsystems
// ---------------------------------------------------------------------------
const promptCache = new PromptCache(log);
const voiceboxManager = new VoiceboxManager(log);
voiceboxManager.init(appConfig.voiceboxes ?? []);
// WebRTC signaling (browser device registration).
initWebRtcSignaling({ log });
// ---------------------------------------------------------------------------
// Status snapshot (fed to web dashboard)
// ---------------------------------------------------------------------------
function getStatus() {
// Merge SIP devices (from Rust) + browser devices (from TS WebSocket).
const devices = [...deviceStatuses.values()];
for (const bid of getAllBrowserDeviceIds()) {
devices.push({
id: bid,
displayName: 'Browser',
address: null,
port: 0,
connected: true,
isBrowser: true,
});
}
function buildProxyConfig(config: IAppConfig): Record<string, unknown> {
return {
instanceId,
uptime: Math.floor((Date.now() - startTime) / 1000),
lanIp: appConfig.proxy.lanIp,
providers: [...providerStatuses.values()],
devices,
calls: [...activeCalls.values()].map((c) => ({
...c,
duration: Math.floor((Date.now() - c.startedAt) / 1000),
legs: [...c.legs.values()].map((l) => ({
id: l.id,
type: l.type,
state: l.state,
codec: l.codec,
rtpPort: l.rtpPort,
remoteMedia: l.remoteMedia,
metadata: l.metadata || {},
pktSent: 0,
pktReceived: 0,
transcoding: false,
})),
})),
callHistory,
contacts: appConfig.contacts || [],
voicemailCounts: voiceboxManager.getAllUnheardCounts(),
proxy: config.proxy,
providers: config.providers,
devices: config.devices,
routing: config.routing,
voiceboxes: config.voiceboxes ?? [],
ivr: config.ivr,
};
}
// ---------------------------------------------------------------------------
// Start Rust proxy engine
// ---------------------------------------------------------------------------
function getStatus() {
return statusStore.buildStatusSnapshot(
instanceId,
startTime,
getAllBrowserDeviceIds(),
voiceboxManager.getAllUnheardCounts(),
);
}
function requestWebRtcLink(callId: string, sessionId: string, media: IProviderMediaInfo): void {
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${callId} media=${media.addr}:${media.port} pt=${media.sipPt}`);
void webrtcLink(sessionId, callId, media.addr, media.port, media.sipPt).then((ok) => {
log(`[webrtc] link result: ${ok}`);
});
}
async function configureRuntime(config: IAppConfig): Promise<boolean> {
return configureProxyEngine(buildProxyConfig(config));
}
async function reloadConfig(): Promise<void> {
try {
const previousConfig = appConfig;
const nextConfig = loadConfig();
appConfig = nextConfig;
statusStore.updateConfig(nextConfig);
voiceboxManager.init(nextConfig.voiceboxes ?? []);
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
log('[config] proxy.lanPort changed; restart required for SIP socket rebinding');
}
if (nextConfig.proxy.webUiPort !== previousConfig.proxy.webUiPort) {
log('[config] proxy.webUiPort changed; restart required for web UI rebinding');
}
const configured = await configureRuntime(nextConfig);
if (configured) {
log('[config] reloaded - proxy engine reconfigured');
} else {
log('[config] reload failed - proxy engine rejected config');
}
} catch (error: unknown) {
log(`[config] reload failed: ${errorMessage(error)}`);
}
}
async function startProxyEngine(): Promise<void> {
const ok = await initProxyEngine(log);
if (!ok) {
const started = await initProxyEngine(log);
if (!started) {
log('[FATAL] failed to start proxy engine');
process.exit(1);
}
// Subscribe to events from Rust BEFORE sending configure.
onProxyEvent('provider_registered', (data: IProviderRegisteredEvent) => {
const ps = providerStatuses.get(data.provider_id);
if (ps) {
const wasRegistered = ps.registered;
ps.registered = data.registered;
ps.publicIp = data.public_ip;
if (data.registered && !wasRegistered) {
log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`);
} else if (!data.registered && wasRegistered) {
log(`[provider:${data.provider_id}] registration lost`);
}
broadcastWs('registration', { providerId: data.provider_id, registered: data.registered });
}
});
onProxyEvent('device_registered', (data: IDeviceRegisteredEvent) => {
const ds = deviceStatuses.get(data.device_id);
if (ds) {
ds.address = data.address;
ds.port = data.port;
ds.connected = true;
log(`[registrar] ${data.display_name} registered from ${data.address}:${data.port}`);
}
});
onProxyEvent('incoming_call', (data: IIncomingCallEvent) => {
log(`[call] incoming: ${data.from_uri}${data.to_number} via ${data.provider_id} (${data.call_id})`);
activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'inbound',
callerNumber: data.from_uri,
calleeNumber: data.to_number,
providerUsed: data.provider_id,
state: 'ringing',
startedAt: Date.now(),
legs: new Map(),
});
// Notify browsers of the incoming call, but only if the matched inbound
// route asked for it. `ring_browsers !== false` preserves today's
// ring-by-default behavior for any Rust release that predates this
// field or for the fallback "no route matched" case (where Rust still
// sends `true`). Note: this is an informational toast — browsers do
// NOT race the SIP device to answer. First-to-answer-wins requires
// a multi-leg fork which is not yet implemented.
if (data.ring_browsers !== false) {
const browserIds = getAllBrowserDeviceIds();
for (const bid of browserIds) {
sendToBrowserDevice(bid, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.from_uri,
deviceId: bid,
});
}
}
});
onProxyEvent('outbound_device_call', (data: IOutboundCallEvent) => {
log(`[call] outbound: device ${data.from_device}${data.to_number} (${data.call_id})`);
activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: data.from_device,
calleeNumber: data.to_number,
providerUsed: null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
});
onProxyEvent('outbound_call_started', (data: any) => {
log(`[call] outbound started: ${data.call_id}${data.number} via ${data.provider_id}`);
activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: null,
calleeNumber: data.number,
providerUsed: data.provider_id,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
// Notify all browser devices — they can connect via WebRTC to listen/talk.
const browserIds = getAllBrowserDeviceIds();
for (const bid of browserIds) {
sendToBrowserDevice(bid, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.number,
deviceId: bid,
});
}
});
onProxyEvent('call_ringing', (data: { call_id: string }) => {
const call = activeCalls.get(data.call_id);
if (call) call.state = 'ringing';
});
onProxyEvent('call_answered', (data: { call_id: string; provider_media_addr?: string; provider_media_port?: number; sip_pt?: number }) => {
const call = activeCalls.get(data.call_id);
if (call) {
call.state = 'connected';
log(`[call] ${data.call_id} connected`);
// Enrich provider leg with media info from the answered event.
if (data.provider_media_addr && data.provider_media_port) {
for (const leg of call.legs.values()) {
if (leg.type === 'sip-provider') {
leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`;
if (data.sip_pt !== undefined) {
const codecNames: Record<number, string> = { 0: 'PCMU', 8: 'PCMA', 9: 'G.722', 111: 'Opus' };
leg.codec = codecNames[data.sip_pt] || `PT${data.sip_pt}`;
}
break;
}
}
}
}
// Try to link WebRTC session to this call for audio bridging.
if (data.provider_media_addr && data.provider_media_port) {
const sessionId = webrtcCallToSession.get(data.call_id);
if (sessionId) {
// Both session and media info available — link now.
const sipPt = data.sip_pt ?? 9;
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${data.call_id} media=${data.provider_media_addr}:${data.provider_media_port} pt=${sipPt}`);
webrtcLink(sessionId, data.call_id, data.provider_media_addr, data.provider_media_port, sipPt).then((ok) => {
log(`[webrtc] link result: ${ok}`);
});
} else {
// Session not yet accepted — store media info for when it arrives.
pendingCallMedia.set(data.call_id, {
addr: data.provider_media_addr,
port: data.provider_media_port,
sipPt: data.sip_pt ?? 9,
});
log(`[webrtc] media info cached for call=${data.call_id}, waiting for session accept`);
}
}
});
onProxyEvent('call_ended', (data: ICallEndedEvent) => {
const call = activeCalls.get(data.call_id);
if (call) {
log(`[call] ${data.call_id} ended: ${data.reason} (${data.duration}s)`);
// Snapshot legs with metadata for history.
const historyLegs: IHistoryLeg[] = [];
for (const [, leg] of call.legs) {
historyLegs.push({
id: leg.id,
type: leg.type,
metadata: leg.metadata || {},
});
}
// Move to history.
callHistory.unshift({
id: call.id,
direction: call.direction,
callerNumber: call.callerNumber,
calleeNumber: call.calleeNumber,
startedAt: call.startedAt,
duration: data.duration,
legs: historyLegs,
});
if (callHistory.length > MAX_HISTORY) callHistory.pop();
activeCalls.delete(data.call_id);
// Notify browser(s) that the call ended.
broadcastWs('webrtc-call-ended', { callId: data.call_id });
// Clean up WebRTC session mappings.
const sessionId = webrtcCallToSession.get(data.call_id);
if (sessionId) {
webrtcCallToSession.delete(data.call_id);
webrtcSessionToCall.delete(sessionId);
webrtcClose(sessionId).catch(() => {});
}
pendingCallMedia.delete(data.call_id);
}
});
onProxyEvent('sip_unhandled', (data: any) => {
log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`);
});
// Leg events (multiparty) — update shadow state so the dashboard shows legs.
onProxyEvent('leg_added', (data: any) => {
log(`[leg] added: call=${data.call_id} leg=${data.leg_id} kind=${data.kind} state=${data.state}`);
const call = activeCalls.get(data.call_id);
if (call) {
call.legs.set(data.leg_id, {
id: data.leg_id,
type: data.kind,
state: data.state,
codec: data.codec ?? null,
rtpPort: data.rtpPort ?? null,
remoteMedia: data.remoteMedia ?? null,
metadata: data.metadata || {},
});
}
});
onProxyEvent('leg_removed', (data: any) => {
log(`[leg] removed: call=${data.call_id} leg=${data.leg_id}`);
activeCalls.get(data.call_id)?.legs.delete(data.leg_id);
});
onProxyEvent('leg_state_changed', (data: any) => {
log(`[leg] state: call=${data.call_id} leg=${data.leg_id}${data.state}`);
const call = activeCalls.get(data.call_id);
if (!call) return;
const leg = call.legs.get(data.leg_id);
if (leg) {
leg.state = data.state;
if (data.metadata) leg.metadata = data.metadata;
} else {
// Initial legs (provider/device) don't emit leg_added — create on first state change.
const legId: string = data.leg_id;
const type = legId.includes('-prov') ? 'sip-provider' : legId.includes('-dev') ? 'sip-device' : 'webrtc';
call.legs.set(data.leg_id, {
id: data.leg_id,
type,
state: data.state,
codec: null,
rtpPort: null,
remoteMedia: null,
metadata: data.metadata || {},
});
}
});
// WebRTC events from Rust — forward ICE candidates to browser via WebSocket.
onProxyEvent('webrtc_ice_candidate', (data: any) => {
// Find the browser's WebSocket by session ID and send the ICE candidate.
broadcastWs('webrtc-ice', {
sessionId: data.session_id,
candidate: { candidate: data.candidate, sdpMid: data.sdp_mid, sdpMLineIndex: data.sdp_mline_index },
});
});
onProxyEvent('webrtc_state', (data: any) => {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} state=${data.state}`);
});
onProxyEvent('webrtc_track', (data: any) => {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} track=${data.kind} codec=${data.codec}`);
});
onProxyEvent('webrtc_audio_rx', (data: any) => {
if (data.packet_count === 1 || data.packet_count === 50) {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} browser audio rx #${data.packet_count}`);
}
});
// Voicemail events.
onProxyEvent('voicemail_started', (data: any) => {
log(`[voicemail] started for call ${data.call_id} caller=${data.caller_number}`);
});
onProxyEvent('recording_done', (data: any) => {
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) caller=${data.caller_number}`);
// Save voicemail metadata via VoiceboxManager.
voiceboxManager.addMessage('default', {
callerNumber: data.caller_number || 'Unknown',
callerName: null,
fileName: data.file_path,
durationMs: data.duration_ms,
});
});
onProxyEvent('voicemail_error', (data: any) => {
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
});
// Send full config to Rust — this binds the SIP socket and starts registrations.
const configured = await configureProxyEngine({
proxy: appConfig.proxy,
providers: appConfig.providers,
devices: appConfig.devices,
routing: appConfig.routing,
registerProxyEventHandlers({
log,
statusStore,
voiceboxManager,
webRtcLinks,
getBrowserDeviceIds: getAllBrowserDeviceIds,
sendToBrowserDevice,
broadcast: broadcastWs,
onLinkWebRtcSession: requestWebRtcLink,
onCloseWebRtcSession: (sessionId) => {
void webrtcClose(sessionId);
},
});
const configured = await configureRuntime(appConfig);
if (!configured) {
log('[FATAL] failed to configure proxy engine');
process.exit(1);
}
const providerList = appConfig.providers.map((p) => p.displayName).join(', ');
const deviceList = appConfig.devices.map((d) => d.displayName).join(', ');
const providerList = appConfig.providers.map((provider) => provider.displayName).join(', ');
const deviceList = appConfig.devices.map((device) => device.displayName).join(', ');
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`);
// Generate TTS audio (WAV files on disk, played by Rust audio_player).
try {
await initAnnouncement(log);
// Pre-generate prompts.
await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000);
for (const vb of appConfig.voiceboxes ?? []) {
if (!vb.enabled) continue;
const promptId = `voicemail-greeting-${vb.id}`;
if (vb.greetingWavPath) {
await promptCache.loadWavPrompt(promptId, vb.greetingWavPath);
} else {
const text = vb.greetingText || 'The person you are trying to reach is not available. Please leave a message after the tone.';
await promptCache.generatePrompt(promptId, text, vb.greetingVoice || 'af_bella');
}
}
if (appConfig.ivr?.enabled) {
for (const menu of appConfig.ivr.menus) {
await promptCache.generatePrompt(`ivr-menu-${menu.id}`, menu.promptText, menu.promptVoice || 'af_bella');
}
}
log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`);
} catch (e) {
log(`[tts] init failed: ${e}`);
}
}
// ---------------------------------------------------------------------------
// Web UI
// ---------------------------------------------------------------------------
initWebUi(
initWebUi({
port: appConfig.proxy.webUiPort,
getStatus,
log,
(number, deviceId, providerId) => {
// Outbound calls from dashboard — send make_call command to Rust.
onStartCall: (number, deviceId, providerId) => {
log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`);
// Fire-and-forget — the async result comes via events.
makeCall(number, deviceId, providerId).then((callId) => {
void makeCall(number, deviceId, providerId).then((callId) => {
if (callId) {
log(`[dashboard] call started: ${callId}`);
activeCalls.set(callId, {
id: callId,
direction: 'outbound',
callerNumber: null,
calleeNumber: number,
providerUsed: providerId || null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
statusStore.noteDashboardCallStarted(callId, number, providerId);
} else {
log(`[dashboard] call failed for ${number}`);
}
});
// Return a temporary ID so the frontend doesn't show "failed" immediately.
return { id: `pending-${Date.now()}` };
},
(callId) => {
hangupCall(callId);
onHangupCall: (callId) => {
void hangupCall(callId);
return true;
},
() => {
// Config saved — reconfigure Rust engine.
try {
const fresh = loadConfig();
Object.assign(appConfig, fresh);
// Update shadow state.
for (const p of fresh.providers) {
if (!providerStatuses.has(p.id)) {
providerStatuses.set(p.id, {
id: p.id, displayName: p.displayName, registered: false, publicIp: null,
});
}
}
for (const d of fresh.devices) {
if (!deviceStatuses.has(d.id)) {
deviceStatuses.set(d.id, {
id: d.id, displayName: d.displayName, address: null, port: 0, connected: false, isBrowser: false,
});
}
}
// Re-send config to Rust.
configureProxyEngine({
proxy: fresh.proxy,
providers: fresh.providers,
devices: fresh.devices,
routing: fresh.routing,
}).then((ok) => {
if (ok) log('[config] reloaded — proxy engine reconfigured');
else log('[config] reload failed — proxy engine rejected config');
});
} catch (e: any) {
log(`[config] reload failed: ${e.message}`);
}
},
undefined, // callManager — legacy, replaced by Rust proxy-engine
voiceboxManager, // voiceboxManager
// WebRTC signaling → forwarded to Rust proxy-engine.
async (sessionId, sdp, ws) => {
onConfigSaved: reloadConfig,
voiceboxManager,
onWebRtcOffer: async (sessionId, sdp, ws) => {
log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`);
if (!sdp || typeof sdp !== 'string' || sdp.length < 10) {
log(`[webrtc] WARNING: invalid SDP (type=${typeof sdp}), skipping offer`);
return;
}
log(`[webrtc] sending offer to Rust (${sdp.length}b)...`);
const result = await webrtcOffer(sessionId, sdp);
log(`[webrtc] Rust result: ${JSON.stringify(result)?.slice(0, 200)}`);
if (result?.sdp) {
ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp }));
log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`);
} else {
log(`[webrtc] ERROR: no answer SDP from Rust`);
return;
}
log('[webrtc] ERROR: no answer SDP from Rust');
},
async (sessionId, candidate) => {
onWebRtcIce: async (sessionId, candidate) => {
await webrtcIce(sessionId, candidate);
},
async (sessionId) => {
onWebRtcClose: async (sessionId) => {
webRtcLinks.removeSession(sessionId);
await webrtcClose(sessionId);
},
// onWebRtcAccept — browser has accepted a call, linking session to call.
(callId: string, sessionId: string) => {
onWebRtcAccept: (callId, sessionId) => {
log(`[webrtc] accept: callId=${callId} sessionId=${sessionId.slice(0, 8)}`);
// Store bidirectional mapping.
webrtcSessionToCall.set(sessionId, callId);
webrtcCallToSession.set(callId, sessionId);
// Check if we already have media info for this call (provider answered first).
const media = pendingCallMedia.get(callId);
if (media) {
pendingCallMedia.delete(callId);
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${callId} media=${media.addr}:${media.port} pt=${media.sipPt}`);
webrtcLink(sessionId, callId, media.addr, media.port, media.sipPt).then((ok) => {
log(`[webrtc] link result: ${ok}`);
});
} else {
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
const pendingMedia = webRtcLinks.acceptCall(callId, sessionId);
if (pendingMedia) {
requestWebRtcLink(callId, sessionId, pendingMedia);
return;
}
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
},
);
});
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
void startProxyEngine();
startProxyEngine();
process.on('SIGINT', () => {
log('SIGINT, exiting');
shutdownProxyEngine();
process.exit(0);
});
process.on('SIGINT', () => { log('SIGINT, exiting'); shutdownProxyEngine(); process.exit(0); });
process.on('SIGTERM', () => { log('SIGTERM, exiting'); shutdownProxyEngine(); process.exit(0); });
process.on('SIGTERM', () => {
log('SIGTERM, exiting');
shutdownProxyEngine();
process.exit(0);
});

View File

@@ -5,8 +5,8 @@
* - Browser device registration/unregistration via WebSocket
* - WS → deviceId mapping
*
* All WebRTC media logic (PeerConnection, RTP, transcoding) lives in
* ts/call/webrtc-leg.ts and is managed by the CallManager.
* All WebRTC media logic (PeerConnection, RTP, transcoding, mixer wiring)
* lives in the Rust proxy-engine. This module only tracks browser sessions.
*/
import { WebSocket } from 'ws';
@@ -39,7 +39,7 @@ export function initWebRtcSignaling(cfg: IWebRtcSignalingConfig): void {
/**
* Handle a WebRTC signaling message from a browser client.
* Only handles registration; offer/ice/hangup are routed through CallManager.
* Only handles registration; offer/ice/hangup are routed through frontend.ts.
*/
export function handleWebRtcSignaling(
ws: WebSocket,
@@ -51,7 +51,7 @@ export function handleWebRtcSignaling(
handleRegister(ws, message.sessionId!, message.userAgent, message._remoteIp);
}
// Other webrtc-* types (offer, ice, hangup, accept) are handled
// by the CallManager via frontend.ts WebSocket handler.
// by the frontend.ts WebSocket handler and forwarded to Rust.
}
/**
@@ -64,13 +64,6 @@ export function sendToBrowserDevice(deviceId: string, data: unknown): boolean {
return true;
}
/**
* Get the WebSocket for a browser device (used by CallManager to create WebRtcLegs).
*/
export function getBrowserDeviceWs(deviceId: string): WebSocket | null {
return deviceIdToWs.get(deviceId) ?? null;
}
/**
* Get all registered browser device IDs.
*/

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: 'siprouter',
version: '1.20.3',
version: '1.23.0',
description: 'undefined'
}

View File

@@ -41,11 +41,10 @@ export class SipproxyDevices extends DeesElement {
},
},
{
key: 'contact',
key: 'address',
header: 'Contact',
renderer: (_val: any, row: any) => {
const c = row.contact;
const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--';
const text = row.address ? (row.port ? `${row.address}:${row.port}` : row.address) : '--';
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`;
},
},

View File

@@ -186,11 +186,10 @@ export class SipproxyViewOverview extends DeesElement {
},
},
{
key: 'contact',
key: 'address',
header: 'Contact',
renderer: (_val: any, row: any) => {
const c = row.contact;
const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--';
const text = row.address ? (row.port ? `${row.address}:${row.port}` : row.address) : '--';
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`;
},
},

View File

@@ -164,173 +164,346 @@ export class SipproxyViewProviders extends DeesElement {
iconName: 'lucide:plus',
type: ['header'] as any,
actionFunc: async () => {
await this.openAddModal();
},
},
{
name: 'Add Sipgate',
iconName: 'lucide:phone',
type: ['header'] as any,
actionFunc: async () => {
await this.openAddModal(PROVIDER_TEMPLATES.sipgate, 'Sipgate');
},
},
{
name: 'Add O2/Alice',
iconName: 'lucide:phone',
type: ['header'] as any,
actionFunc: async () => {
await this.openAddModal(PROVIDER_TEMPLATES.o2, 'O2/Alice');
await this.openAddStepper();
},
},
];
}
// ---- add provider modal --------------------------------------------------
// ---- add provider stepper ------------------------------------------------
private async openAddModal(
template?: typeof PROVIDER_TEMPLATES.sipgate,
templateName?: string,
) {
const { DeesModal } = await import('@design.estate/dees-catalog');
private async openAddStepper() {
const { DeesStepper } = await import('@design.estate/dees-catalog');
type TDeesStepper = InstanceType<typeof DeesStepper>;
// IStep / menuOptions types: we keep content typing loose (`any[]`) to
// avoid having to import tsclass IMenuItem just for one parameter annotation.
const formData = {
displayName: templateName || '',
domain: template?.domain || '',
outboundProxyAddress: template?.outboundProxy?.address || '',
outboundProxyPort: String(template?.outboundProxy?.port ?? 5060),
type TProviderType = 'Custom' | 'Sipgate' | 'O2/Alice';
interface IAccumulator {
providerType: TProviderType;
displayName: string;
domain: string;
outboundProxyAddress: string;
outboundProxyPort: string;
username: string;
password: string;
// Advanced — exposed in step 4
registerIntervalSec: string;
codecs: string;
earlyMediaSilence: boolean;
}
const accumulator: IAccumulator = {
providerType: 'Custom',
displayName: '',
domain: '',
outboundProxyAddress: '',
outboundProxyPort: '5060',
username: '',
password: '',
registerIntervalSec: String(template?.registerIntervalSec ?? 300),
codecs: template?.codecs ? template.codecs.join(', ') : '9, 0, 8, 101',
earlyMediaSilence: template?.quirks?.earlyMediaSilence ?? false,
registerIntervalSec: '300',
codecs: '9, 0, 8, 101',
earlyMediaSilence: false,
};
const heading = template
? `Add ${templateName} Provider`
: 'Add Provider';
// Snapshot the currently-selected step's form (if any) into accumulator.
const snapshotActiveForm = async (stepper: TDeesStepper) => {
const form = stepper.activeForm;
if (!form) return;
const data: Record<string, any> = await form.collectFormData();
Object.assign(accumulator, data);
};
await DeesModal.createAndShow({
heading,
width: 'small',
showCloseButton: true,
// Overwrite template-owned fields. Keep user-owned fields (username,
// password) untouched. displayName is replaced only when empty or still
// holds a branded auto-fill.
const applyTemplate = (type: TProviderType) => {
const tpl =
type === 'Sipgate' ? PROVIDER_TEMPLATES.sipgate
: type === 'O2/Alice' ? PROVIDER_TEMPLATES.o2
: null;
if (!tpl) return;
accumulator.domain = tpl.domain;
accumulator.outboundProxyAddress = tpl.outboundProxy.address;
accumulator.outboundProxyPort = String(tpl.outboundProxy.port);
accumulator.registerIntervalSec = String(tpl.registerIntervalSec);
accumulator.codecs = tpl.codecs.join(', ');
accumulator.earlyMediaSilence = tpl.quirks.earlyMediaSilence;
if (
!accumulator.displayName ||
accumulator.displayName === 'Sipgate' ||
accumulator.displayName === 'O2/Alice'
) {
accumulator.displayName = type;
}
};
// --- Step builders (called after step 1 so accumulator is populated) ---
const buildConnectionStep = (): any => ({
title: 'Connection',
content: html`
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
<dees-form>
<dees-input-text
.key=${'displayName'}
.label=${'Display Name'}
.value=${formData.displayName}
@input=${(e: Event) => { formData.displayName = (e.target as any).value; }}
.value=${accumulator.displayName}
.required=${true}
></dees-input-text>
<dees-input-text
.key=${'domain'}
.label=${'Domain'}
.value=${formData.domain}
@input=${(e: Event) => { formData.domain = (e.target as any).value; }}
.value=${accumulator.domain}
.required=${true}
></dees-input-text>
<dees-input-text
.key=${'outboundProxyAddress'}
.label=${'Outbound Proxy Address'}
.value=${formData.outboundProxyAddress}
@input=${(e: Event) => { formData.outboundProxyAddress = (e.target as any).value; }}
.value=${accumulator.outboundProxyAddress}
></dees-input-text>
<dees-input-text
.key=${'outboundProxyPort'}
.label=${'Outbound Proxy Port'}
.value=${formData.outboundProxyPort}
@input=${(e: Event) => { formData.outboundProxyPort = (e.target as any).value; }}
.value=${accumulator.outboundProxyPort}
></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Continue',
iconName: 'lucide:arrow-right',
action: async (stepper: TDeesStepper) => {
await snapshotActiveForm(stepper);
stepper.goNext();
},
},
],
});
const buildCredentialsStep = (): any => ({
title: 'Credentials',
content: html`
<dees-form>
<dees-input-text
.key=${'username'}
.label=${'Username / Auth ID'}
.value=${formData.username}
@input=${(e: Event) => { formData.username = (e.target as any).value; }}
.value=${accumulator.username}
.required=${true}
></dees-input-text>
<dees-input-text
.key=${'password'}
.label=${'Password'}
.isPasswordBool=${true}
.value=${formData.password}
@input=${(e: Event) => { formData.password = (e.target as any).value; }}
.value=${accumulator.password}
.required=${true}
></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Continue',
iconName: 'lucide:arrow-right',
action: async (stepper: TDeesStepper) => {
await snapshotActiveForm(stepper);
stepper.goNext();
},
},
],
});
const buildAdvancedStep = (): any => ({
title: 'Advanced',
content: html`
<dees-form>
<dees-input-text
.key=${'registerIntervalSec'}
.label=${'Register Interval (sec)'}
.value=${formData.registerIntervalSec}
@input=${(e: Event) => { formData.registerIntervalSec = (e.target as any).value; }}
.value=${accumulator.registerIntervalSec}
></dees-input-text>
<dees-input-text
.key=${'codecs'}
.label=${'Codecs (comma-separated payload types)'}
.value=${formData.codecs}
@input=${(e: Event) => { formData.codecs = (e.target as any).value; }}
.value=${accumulator.codecs}
></dees-input-text>
<dees-input-checkbox
.key=${'earlyMediaSilence'}
.label=${'Early Media Silence (quirk)'}
.value=${formData.earlyMediaSilence}
@newValue=${(e: CustomEvent) => { formData.earlyMediaSilence = e.detail; }}
.value=${accumulator.earlyMediaSilence}
></dees-input-checkbox>
</div>
</dees-form>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalRef: any) => {
modalRef.destroy();
name: 'Continue',
iconName: 'lucide:arrow-right',
action: async (stepper: TDeesStepper) => {
await snapshotActiveForm(stepper);
// Rebuild the review step so its rendering reflects the latest
// accumulator values (the review step captures values at build time).
stepper.steps = [...stepper.steps.slice(0, 4), buildReviewStep()];
await (stepper as any).updateComplete;
stepper.goNext();
},
},
{
name: 'Create',
iconName: 'lucide:check',
action: async (modalRef: any) => {
if (!formData.displayName.trim() || !formData.domain.trim()) {
deesCatalog.DeesToast.error('Display name and domain are required');
return;
}
try {
const providerId = slugify(formData.displayName);
const codecs = formData.codecs
],
});
const buildReviewStep = (): any => {
const resolvedId = slugify(accumulator.displayName);
return {
title: 'Review & Create',
content: html`
<dees-panel>
<div
style="display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:.85rem;padding:8px 4px;"
>
<div style="color:#94a3b8;">Type</div>
<div>${accumulator.providerType}</div>
<div style="color:#94a3b8;">Display Name</div>
<div>${accumulator.displayName}</div>
<div style="color:#94a3b8;">ID</div>
<div style="font-family:'JetBrains Mono',monospace;">${resolvedId}</div>
<div style="color:#94a3b8;">Domain</div>
<div>${accumulator.domain}</div>
<div style="color:#94a3b8;">Outbound Proxy</div>
<div>
${accumulator.outboundProxyAddress || accumulator.domain}:${accumulator.outboundProxyPort}
</div>
<div style="color:#94a3b8;">Username</div>
<div>${accumulator.username}</div>
<div style="color:#94a3b8;">Password</div>
<div>${'*'.repeat(Math.min(accumulator.password.length, 12))}</div>
<div style="color:#94a3b8;">Register Interval</div>
<div>${accumulator.registerIntervalSec}s</div>
<div style="color:#94a3b8;">Codecs</div>
<div>${accumulator.codecs}</div>
<div style="color:#94a3b8;">Early-Media Silence</div>
<div>${accumulator.earlyMediaSilence ? 'yes' : 'no'}</div>
</div>
</dees-panel>
`,
menuOptions: [
{
name: 'Create Provider',
iconName: 'lucide:check',
action: async (stepper: TDeesStepper) => {
// Collision-resolve id against current state.
const existing = (this.appData.providers || []).map((p) => p.id);
let uniqueId = resolvedId;
let suffix = 2;
while (existing.includes(uniqueId)) {
uniqueId = `${resolvedId}-${suffix++}`;
}
const parsedCodecs = accumulator.codecs
.split(',')
.map((s: string) => parseInt(s.trim(), 10))
.filter((n: number) => !isNaN(n));
const newProvider: any = {
id: providerId,
displayName: formData.displayName.trim(),
domain: formData.domain.trim(),
id: uniqueId,
displayName: accumulator.displayName.trim(),
domain: accumulator.domain.trim(),
outboundProxy: {
address: formData.outboundProxyAddress.trim() || formData.domain.trim(),
port: parseInt(formData.outboundProxyPort, 10) || 5060,
address:
accumulator.outboundProxyAddress.trim() || accumulator.domain.trim(),
port: parseInt(accumulator.outboundProxyPort, 10) || 5060,
},
username: formData.username.trim(),
password: formData.password,
registerIntervalSec: parseInt(formData.registerIntervalSec, 10) || 300,
codecs,
username: accumulator.username.trim(),
password: accumulator.password,
registerIntervalSec: parseInt(accumulator.registerIntervalSec, 10) || 300,
codecs: parsedCodecs.length ? parsedCodecs : [9, 0, 8, 101],
quirks: {
earlyMediaSilence: formData.earlyMediaSilence,
earlyMediaSilence: accumulator.earlyMediaSilence,
},
};
const result = await appState.apiSaveConfig({
addProvider: newProvider,
});
if (result.ok) {
modalRef.destroy();
deesCatalog.DeesToast.success(`Provider "${formData.displayName}" created`);
} else {
deesCatalog.DeesToast.error('Failed to save provider');
try {
const result = await appState.apiSaveConfig({
addProvider: newProvider,
});
if (result.ok) {
await stepper.destroy();
deesCatalog.DeesToast.success(
`Provider "${newProvider.displayName}" created`,
);
} else {
deesCatalog.DeesToast.error('Failed to save provider');
}
} catch (err: any) {
console.error('Failed to create provider:', err);
deesCatalog.DeesToast.error('Failed to create provider');
}
},
},
],
};
};
// --- Step 1: Provider Type ------------------------------------------------
//
// Note: `DeesStepper.createAndShow()` dismisses on backdrop click; a user
// mid-form could lose work. Acceptable for v1 — revisit if users complain.
const typeOptions: { option: string; key: TProviderType }[] = [
{ option: 'Custom', key: 'Custom' },
{ option: 'Sipgate', key: 'Sipgate' },
{ option: 'O2 / Alice', key: 'O2/Alice' },
];
const currentTypeOption =
typeOptions.find((o) => o.key === accumulator.providerType) || null;
const typeStep: any = {
title: 'Choose provider type',
content: html`
<dees-form>
<dees-input-dropdown
.key=${'providerType'}
.label=${'Provider Type'}
.options=${typeOptions}
.selectedOption=${currentTypeOption}
.enableSearch=${false}
.required=${true}
></dees-input-dropdown>
</dees-form>
`,
menuOptions: [
{
name: 'Continue',
iconName: 'lucide:arrow-right',
action: async (stepper: TDeesStepper) => {
// `dees-input-dropdown.value` is an object `{option, key, payload?}`,
// not a plain string — extract the `key` directly instead of using
// the generic `snapshotActiveForm` helper (which would clobber
// `accumulator.providerType`'s string type via Object.assign).
const form = stepper.activeForm;
if (form) {
const data: Record<string, any> = await form.collectFormData();
const selected = data.providerType;
if (selected && typeof selected === 'object' && selected.key) {
accumulator.providerType = selected.key as TProviderType;
}
} catch (err: any) {
console.error('Failed to create provider:', err);
deesCatalog.DeesToast.error('Failed to create provider');
}
if (!accumulator.providerType) {
accumulator.providerType = 'Custom';
}
applyTemplate(accumulator.providerType);
// (Re)build steps 2-5 with current accumulator values.
stepper.steps = [
typeStep,
buildConnectionStep(),
buildCredentialsStep(),
buildAdvancedStep(),
buildReviewStep(),
];
await (stepper as any).updateComplete;
stepper.goNext();
},
},
],
});
};
await DeesStepper.createAndShow({ steps: [typeStep] });
}
// ---- edit provider modal -------------------------------------------------

View File

@@ -2,72 +2,12 @@
* Application state — receives live updates from the proxy via WebSocket.
*/
export interface IProviderStatus {
id: string;
displayName: string;
registered: boolean;
publicIp: string | null;
}
import type { IContact } from '../../ts/config.ts';
import type { ICallHistoryEntry, ICallStatus, IDeviceStatus, IProviderStatus } from '../../ts/shared/status.ts';
export interface IDeviceStatus {
id: string;
displayName: string;
contact: { address: string; port: number } | null;
aor: string;
connected: boolean;
isBrowser: boolean;
}
export interface ILegStatus {
id: string;
type: 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
state: string;
remoteMedia: { address: string; port: number } | null;
rtpPort: number | null;
pktSent: number;
pktReceived: number;
codec: string | null;
transcoding: boolean;
metadata?: Record<string, unknown>;
}
export interface ICallStatus {
id: string;
state: string;
direction: 'inbound' | 'outbound' | 'internal';
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
createdAt: number;
duration: number;
legs: ILegStatus[];
}
export interface IHistoryLeg {
id: string;
type: string;
metadata: Record<string, unknown>;
}
export interface ICallHistoryEntry {
id: string;
direction: 'inbound' | 'outbound' | 'internal';
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
startedAt: number;
duration: number;
legs?: IHistoryLeg[];
}
export interface IContact {
id: string;
name: string;
number: string;
company?: string;
notes?: string;
starred?: boolean;
}
export type { IContact };
export type { ICallHistoryEntry, ICallStatus, IDeviceStatus, IProviderStatus };
export type { ILegStatus } from '../../ts/shared/status.ts';
export interface IAppState {
connected: boolean;