Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89ae12318e | |||
| feb3514de4 | |||
| adfc4726fd | |||
| 06c86d7e81 | |||
| cff70ab179 | |||
| 51f7560730 | |||
| 5a280c5c41 | |||
| 59d8c2557c | |||
| cfadd7a2b6 | |||
| 80f710f6d8 | |||
| 9ea57cd659 | |||
| c40c726dc3 | |||
| 37ba7501fa | |||
| 24924a1aea |
53
changelog.md
53
changelog.md
@@ -1,5 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-14 - 1.25.0 - feat(proxy-engine)
|
||||
add live TTS streaming interactions and incoming number range support
|
||||
|
||||
- add a new start_tts_interaction command and bridge API to begin IVR or leg interactions before full TTS rendering completes
|
||||
- stream synthesized TTS chunks into the mixer with cancellation handling so prompts can stop cleanly on digit match, leg removal, or shutdown
|
||||
- extract PCM-to-mixer frame conversion for reusable live prompt processing
|
||||
- extend routing pattern matching to support numeric number ranges like start..end, including + prefixed values
|
||||
- add incomingNumbers config typing and frontend config update support for single, range, and regex number modes
|
||||
|
||||
## 2026-04-14 - 1.24.0 - feat(routing)
|
||||
require explicit inbound DID routes and normalize SIP identities for provider-based number matching
|
||||
|
||||
- Inbound route resolution now returns no match unless a configured inbound route explicitly matches the provider and called number.
|
||||
- Normalized routing identities were added for SIP/TEL URIs so inbound DIDs and outbound dialed numbers match consistently across provider-specific formats.
|
||||
- Call handling and incoming call events now use normalized numbers, improving routing accuracy for shared trunk providers.
|
||||
- Route configuration docs and the web route editor were updated to support explicit inbound DID ownership, voicemail fallback, and IVR selection.
|
||||
- Mixer RTP handling was enhanced to better support variable packet durations, timestamp-based gap fill, and non-blocking output drop reporting.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "siprouter",
|
||||
"version": "1.20.3",
|
||||
"version": "1.25.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
36
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
146
readme.md
146
readme.md
@@ -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 (60–120ms), 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
|
||||
|
||||
@@ -131,24 +148,41 @@ Create `.nogit/config.json`:
|
||||
"routing": {
|
||||
"routes": [
|
||||
{
|
||||
"id": "inbound-default",
|
||||
"name": "Ring all devices",
|
||||
"priority": 100,
|
||||
"direction": "inbound",
|
||||
"match": {},
|
||||
"id": "inbound-main-did",
|
||||
"name": "Main DID",
|
||||
"priority": 200,
|
||||
"enabled": true,
|
||||
"match": {
|
||||
"direction": "inbound",
|
||||
"sourceProvider": "my-trunk",
|
||||
"numberPattern": "+49421219694"
|
||||
},
|
||||
"action": {
|
||||
"targets": ["desk-phone"],
|
||||
"ringBrowsers": true,
|
||||
"voicemailBox": "main",
|
||||
"noAnswerTimeout": 25
|
||||
"voicemailBox": "main"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "inbound-support-did",
|
||||
"name": "Support DID",
|
||||
"priority": 190,
|
||||
"enabled": true,
|
||||
"match": {
|
||||
"direction": "inbound",
|
||||
"sourceProvider": "my-trunk",
|
||||
"numberPattern": "+49421219695"
|
||||
},
|
||||
"action": {
|
||||
"ivrMenuId": "support-menu"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "outbound-default",
|
||||
"name": "Route via trunk",
|
||||
"priority": 100,
|
||||
"direction": "outbound",
|
||||
"match": {},
|
||||
"enabled": true,
|
||||
"match": { "direction": "outbound" },
|
||||
"action": { "provider": "my-trunk" }
|
||||
}
|
||||
]
|
||||
@@ -170,9 +204,11 @@ Create `.nogit/config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Inbound number ownership is explicit: add one inbound route per DID (or DID prefix) and scope it with `sourceProvider` when a provider delivers multiple external numbers.
|
||||
|
||||
### 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 +218,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 +245,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 +281,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 2–6 frames based on observed network jitter. Handles hold/resume by detecting large forward sequence jumps and resetting cleanly.
|
||||
@@ -262,13 +305,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
10
rust/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -115,9 +115,8 @@ pub struct TranscodeState {
|
||||
impl TranscodeState {
|
||||
/// Create a new transcoding session with fresh codec state.
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let mut opus_enc =
|
||||
OpusEncoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
||||
.map_err(|e| format!("opus encoder: {e}"))?;
|
||||
let mut opus_enc = OpusEncoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
||||
.map_err(|e| format!("opus encoder: {e}"))?;
|
||||
opus_enc
|
||||
.set_complexity(5)
|
||||
.map_err(|e| format!("opus set_complexity: {e}"))?;
|
||||
@@ -160,14 +159,9 @@ impl TranscodeState {
|
||||
let key = (from_rate, to_rate, canonical_chunk);
|
||||
|
||||
if !self.resamplers.contains_key(&key) {
|
||||
let r = FftFixedIn::<f64>::new(
|
||||
from_rate as usize,
|
||||
to_rate as usize,
|
||||
canonical_chunk,
|
||||
1,
|
||||
1,
|
||||
)
|
||||
.map_err(|e| format!("resampler {from_rate}->{to_rate}: {e}"))?;
|
||||
let r =
|
||||
FftFixedIn::<f64>::new(from_rate as usize, to_rate as usize, canonical_chunk, 1, 1)
|
||||
.map_err(|e| format!("resampler {from_rate}->{to_rate}: {e}"))?;
|
||||
self.resamplers.insert(key, r);
|
||||
}
|
||||
let resampler = self.resamplers.get_mut(&key).unwrap();
|
||||
@@ -284,8 +278,7 @@ impl TranscodeState {
|
||||
match pt {
|
||||
PT_OPUS => {
|
||||
let mut pcm = vec![0i16; 5760]; // up to 120ms at 48kHz
|
||||
let packet =
|
||||
OpusPacket::try_from(data).map_err(|e| format!("opus packet: {e}"))?;
|
||||
let packet = OpusPacket::try_from(data).map_err(|e| format!("opus packet: {e}"))?;
|
||||
let out =
|
||||
MutSignals::try_from(&mut pcm[..]).map_err(|e| format!("opus signals: {e}"))?;
|
||||
let n: usize = self
|
||||
@@ -343,8 +336,7 @@ impl TranscodeState {
|
||||
match pt {
|
||||
PT_OPUS => {
|
||||
let mut pcm = vec![0.0f32; 5760]; // up to 120ms at 48kHz
|
||||
let packet =
|
||||
OpusPacket::try_from(data).map_err(|e| format!("opus packet: {e}"))?;
|
||||
let packet = OpusPacket::try_from(data).map_err(|e| format!("opus packet: {e}"))?;
|
||||
let out =
|
||||
MutSignals::try_from(&mut pcm[..]).map_err(|e| format!("opus signals: {e}"))?;
|
||||
let n: usize = self
|
||||
@@ -368,8 +360,8 @@ impl TranscodeState {
|
||||
/// Returns f32 PCM at 48kHz. `frame_size` should be 960 for 20ms.
|
||||
pub fn opus_plc(&mut self, frame_size: usize) -> Result<Vec<f32>, String> {
|
||||
let mut pcm = vec![0.0f32; frame_size];
|
||||
let out = MutSignals::try_from(&mut pcm[..])
|
||||
.map_err(|e| format!("opus plc signals: {e}"))?;
|
||||
let out =
|
||||
MutSignals::try_from(&mut pcm[..]).map_err(|e| format!("opus plc signals: {e}"))?;
|
||||
let n: usize = self
|
||||
.opus_dec
|
||||
.decode_float(None::<OpusPacket<'_>>, out, false)
|
||||
@@ -425,14 +417,9 @@ impl TranscodeState {
|
||||
let key = (from_rate, to_rate, canonical_chunk);
|
||||
|
||||
if !self.resamplers_f32.contains_key(&key) {
|
||||
let r = FftFixedIn::<f32>::new(
|
||||
from_rate as usize,
|
||||
to_rate as usize,
|
||||
canonical_chunk,
|
||||
1,
|
||||
1,
|
||||
)
|
||||
.map_err(|e| format!("resampler f32 {from_rate}->{to_rate}: {e}"))?;
|
||||
let r =
|
||||
FftFixedIn::<f32>::new(from_rate as usize, to_rate as usize, canonical_chunk, 1, 1)
|
||||
.map_err(|e| format!("resampler f32 {from_rate}->{to_rate}: {e}"))?;
|
||||
self.resamplers_f32.insert(key, r);
|
||||
}
|
||||
let resampler = self.resamplers_f32.get_mut(&key).unwrap();
|
||||
@@ -508,8 +495,10 @@ mod tests {
|
||||
let encoded = mulaw_encode(sample);
|
||||
let decoded = mulaw_decode(encoded);
|
||||
// µ-law is lossy; verify the decoded value is close.
|
||||
assert!((sample as i32 - decoded as i32).abs() < 1000,
|
||||
"µ-law roundtrip failed for {sample}: got {decoded}");
|
||||
assert!(
|
||||
(sample as i32 - decoded as i32).abs() < 1000,
|
||||
"µ-law roundtrip failed for {sample}: got {decoded}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,8 +507,10 @@ mod tests {
|
||||
for sample in [-32768i16, -1000, -1, 0, 1, 1000, 32767] {
|
||||
let encoded = alaw_encode(sample);
|
||||
let decoded = alaw_decode(encoded);
|
||||
assert!((sample as i32 - decoded as i32).abs() < 1000,
|
||||
"A-law roundtrip failed for {sample}: got {decoded}");
|
||||
assert!(
|
||||
(sample as i32 - decoded as i32).abs() < 1000,
|
||||
"A-law roundtrip failed for {sample}: got {decoded}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,7 +534,9 @@ mod tests {
|
||||
fn pcmu_to_pcma_roundtrip() {
|
||||
let mut st = TranscodeState::new().unwrap();
|
||||
// 160 bytes = 20ms of PCMU at 8kHz
|
||||
let pcmu_data: Vec<u8> = (0..160).map(|i| mulaw_encode((i as i16 * 200) - 16000)).collect();
|
||||
let pcmu_data: Vec<u8> = (0..160)
|
||||
.map(|i| mulaw_encode((i as i16 * 200) - 16000))
|
||||
.collect();
|
||||
let pcma = st.transcode(&pcmu_data, PT_PCMU, PT_PCMA, None).unwrap();
|
||||
assert_eq!(pcma.len(), 160); // Same frame size
|
||||
let back = st.transcode(&pcma, PT_PCMA, PT_PCMU, None).unwrap();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -36,10 +36,7 @@ pub async fn play_wav_file(
|
||||
|
||||
// Read all samples as i16.
|
||||
let samples: Vec<i16> = if spec.bits_per_sample == 16 {
|
||||
reader
|
||||
.samples::<i16>()
|
||||
.filter_map(|s| s.ok())
|
||||
.collect()
|
||||
reader.samples::<i16>().filter_map(|s| s.ok()).collect()
|
||||
} else if spec.bits_per_sample == 32 && spec.sample_format == hound::SampleFormat::Float {
|
||||
reader
|
||||
.samples::<f32>()
|
||||
@@ -199,10 +196,7 @@ pub fn load_prompt_pcm_frames(wav_path: &str) -> Result<Vec<Vec<f32>>, String> {
|
||||
.map(|s| s as f32 / 32768.0)
|
||||
.collect()
|
||||
} else if spec.bits_per_sample == 32 && spec.sample_format == hound::SampleFormat::Float {
|
||||
reader
|
||||
.samples::<f32>()
|
||||
.filter_map(|s| s.ok())
|
||||
.collect()
|
||||
reader.samples::<f32>().filter_map(|s| s.ok()).collect()
|
||||
} else {
|
||||
return Err(format!(
|
||||
"unsupported WAV format: {}bit {:?}",
|
||||
@@ -214,14 +208,23 @@ pub fn load_prompt_pcm_frames(wav_path: &str) -> Result<Vec<Vec<f32>>, String> {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
pcm_to_mix_frames(&samples, wav_rate)
|
||||
}
|
||||
|
||||
/// Convert PCM samples at an arbitrary rate into 48kHz 20ms mixer frames.
|
||||
pub fn pcm_to_mix_frames(samples: &[f32], sample_rate: u32) -> Result<Vec<Vec<f32>>, String> {
|
||||
if samples.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Resample to MIX_RATE (48kHz) if needed.
|
||||
let resampled = if wav_rate != MIX_RATE {
|
||||
let resampled = if sample_rate != MIX_RATE {
|
||||
let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?;
|
||||
transcoder
|
||||
.resample_f32(&samples, wav_rate, MIX_RATE)
|
||||
.resample_f32(samples, sample_rate, MIX_RATE)
|
||||
.map_err(|e| format!("resample: {e}"))?
|
||||
} else {
|
||||
samples
|
||||
samples.to_vec()
|
||||
};
|
||||
|
||||
// Split into MIX_FRAME_SIZE (960) sample frames.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,29 @@
|
||||
//! The mixer provides mix-minus audio to all participants.
|
||||
|
||||
use crate::call::{Call, CallDirection, CallState, LegId, LegInfo, LegKind, LegState};
|
||||
use crate::config::{AppConfig, ProviderConfig};
|
||||
use crate::config::{normalize_routing_identity, AppConfig, ProviderConfig};
|
||||
use crate::ipc::{emit_event, OutTx};
|
||||
use crate::leg_io::{create_leg_channels, spawn_sip_inbound, spawn_sip_outbound};
|
||||
use crate::mixer::spawn_mixer;
|
||||
use crate::registrar::Registrar;
|
||||
use crate::rtp::RtpPortPool;
|
||||
use crate::sip_leg::{SipLeg, SipLegAction, SipLegConfig};
|
||||
use sip_proto::helpers::{build_sdp, generate_call_id, generate_tag, parse_sdp_endpoint, SdpOptions};
|
||||
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
|
||||
/// route's `ring_browsers` flag, or the fallback default).
|
||||
/// route's `ring_browsers` flag).
|
||||
pub struct InboundCallCreated {
|
||||
pub call_id: String,
|
||||
pub ring_browsers: bool,
|
||||
@@ -211,15 +216,27 @@ impl CallManager {
|
||||
let device_leg = call.legs.values().find(|l| l.kind == LegKind::SipDevice);
|
||||
if let Some(dev) = device_leg {
|
||||
if let Some(dev_addr) = dev.signaling_addr {
|
||||
let ringing = SipMessage::create_response(180, "Ringing", device_invite, None);
|
||||
let ringing = SipMessage::create_response(
|
||||
180,
|
||||
"Ringing",
|
||||
device_invite,
|
||||
None,
|
||||
);
|
||||
let _ = socket.send_to(&ringing.serialize(), dev_addr).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
emit_event(&self.out_tx, "call_ringing", serde_json::json!({ "call_id": call_id }));
|
||||
emit_event(&self.out_tx, "leg_state_changed",
|
||||
serde_json::json!({ "call_id": call_id, "leg_id": leg_id, "state": "ringing" }));
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"call_ringing",
|
||||
serde_json::json!({ "call_id": call_id }),
|
||||
);
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"leg_state_changed",
|
||||
serde_json::json!({ "call_id": call_id, "leg_id": leg_id, "state": "ringing" }),
|
||||
);
|
||||
}
|
||||
SipLegAction::ConnectedWithAck(ack_buf) => {
|
||||
let _ = socket.send_to(&ack_buf, target).await;
|
||||
@@ -242,16 +259,30 @@ impl CallManager {
|
||||
spawn_sip_inbound(rtp_socket.clone(), channels.inbound_tx);
|
||||
spawn_sip_outbound(rtp_socket, remote_addr, channels.outbound_rx);
|
||||
if let Some(call) = self.calls.get(call_id) {
|
||||
call.add_leg_to_mixer(leg_id, sip_pt, channels.inbound_rx, channels.outbound_tx)
|
||||
.await;
|
||||
call.add_leg_to_mixer(
|
||||
leg_id,
|
||||
sip_pt,
|
||||
channels.inbound_rx,
|
||||
channels.outbound_tx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// For device-originated calls: send 200 OK to device and wire device leg.
|
||||
if let Some(call) = self.calls.get(call_id) {
|
||||
if let Some(device_invite) = call.device_invite.clone() {
|
||||
let device_leg_info: Option<(SocketAddr, u16, Arc<UdpSocket>, Option<SocketAddr>, String)> =
|
||||
call.legs.values().find(|l| l.kind == LegKind::SipDevice).and_then(|dev| {
|
||||
let device_leg_info: Option<(
|
||||
SocketAddr,
|
||||
u16,
|
||||
Arc<UdpSocket>,
|
||||
Option<SocketAddr>,
|
||||
String,
|
||||
)> = call
|
||||
.legs
|
||||
.values()
|
||||
.find(|l| l.kind == LegKind::SipDevice)
|
||||
.and_then(|dev| {
|
||||
Some((
|
||||
dev.signaling_addr?,
|
||||
dev.rtp_port,
|
||||
@@ -261,11 +292,21 @@ impl CallManager {
|
||||
))
|
||||
});
|
||||
|
||||
if let Some((dev_addr, dev_rtp_port, dev_rtp_socket, dev_remote, dev_leg_id)) = device_leg_info {
|
||||
if let Some((
|
||||
dev_addr,
|
||||
dev_rtp_port,
|
||||
dev_rtp_socket,
|
||||
dev_remote,
|
||||
dev_leg_id,
|
||||
)) = device_leg_info
|
||||
{
|
||||
// Build SDP pointing device to our device_rtp port.
|
||||
// Use LAN IP for the device (it's on the local network).
|
||||
let call_ref = self.calls.get(call_id).unwrap();
|
||||
let prov_leg = call_ref.legs.values().find(|l| l.kind == LegKind::SipProvider);
|
||||
let prov_leg = call_ref
|
||||
.legs
|
||||
.values()
|
||||
.find(|l| l.kind == LegKind::SipProvider);
|
||||
let lan_ip_str = prov_leg
|
||||
.and_then(|l| l.sip_leg.as_ref())
|
||||
.map(|sl| sl.config.lan_ip.clone())
|
||||
@@ -277,13 +318,18 @@ impl CallManager {
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let ok = SipMessage::create_response(200, "OK", &device_invite, Some(ResponseOptions {
|
||||
to_tag: Some(generate_tag()),
|
||||
contact: Some(format!("<sip:{}:{}>", lan_ip_str, 5060)),
|
||||
body: Some(sdp),
|
||||
content_type: Some("application/sdp".to_string()),
|
||||
extra_headers: None,
|
||||
}));
|
||||
let ok = SipMessage::create_response(
|
||||
200,
|
||||
"OK",
|
||||
&device_invite,
|
||||
Some(ResponseOptions {
|
||||
to_tag: Some(generate_tag()),
|
||||
contact: Some(format!("<sip:{}:{}>", lan_ip_str, 5060)),
|
||||
body: Some(sdp),
|
||||
content_type: Some("application/sdp".to_string()),
|
||||
extra_headers: None,
|
||||
}),
|
||||
);
|
||||
let _ = socket.send_to(&ok.serialize(), dev_addr).await;
|
||||
|
||||
// Update device leg state.
|
||||
@@ -310,27 +356,47 @@ impl CallManager {
|
||||
if let Some(dev_remote_addr) = dev_remote {
|
||||
let dev_channels = create_leg_channels();
|
||||
spawn_sip_inbound(dev_rtp_socket.clone(), dev_channels.inbound_tx);
|
||||
spawn_sip_outbound(dev_rtp_socket, dev_remote_addr, dev_channels.outbound_rx);
|
||||
spawn_sip_outbound(
|
||||
dev_rtp_socket,
|
||||
dev_remote_addr,
|
||||
dev_channels.outbound_rx,
|
||||
);
|
||||
if let Some(call) = self.calls.get(call_id) {
|
||||
call.add_leg_to_mixer(&dev_leg_id, dev_pt, dev_channels.inbound_rx, dev_channels.outbound_tx)
|
||||
.await;
|
||||
call.add_leg_to_mixer(
|
||||
&dev_leg_id,
|
||||
dev_pt,
|
||||
dev_channels.inbound_rx,
|
||||
dev_channels.outbound_tx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit_event(&self.out_tx, "call_answered", serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"provider_media_addr": remote.map(|a| a.ip().to_string()),
|
||||
"provider_media_port": remote.map(|a| a.port()),
|
||||
"sip_pt": sip_pt,
|
||||
}));
|
||||
emit_event(&self.out_tx, "leg_state_changed",
|
||||
serde_json::json!({ "call_id": call_id, "leg_id": leg_id, "state": "connected" }));
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"call_answered",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"provider_media_addr": remote.map(|a| a.ip().to_string()),
|
||||
"provider_media_port": remote.map(|a| a.port()),
|
||||
"sip_pt": sip_pt,
|
||||
}),
|
||||
);
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"leg_state_changed",
|
||||
serde_json::json!({ "call_id": call_id, "leg_id": leg_id, "state": "connected" }),
|
||||
);
|
||||
}
|
||||
SipLegAction::Terminated(reason) => {
|
||||
let duration = self.calls.get(call_id).map(|c| c.duration_secs()).unwrap_or(0);
|
||||
let duration = self
|
||||
.calls
|
||||
.get(call_id)
|
||||
.map(|c| c.duration_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Notify device if this is a device-originated outbound call.
|
||||
if let Some(call) = self.calls.get(call_id) {
|
||||
@@ -340,7 +406,8 @@ impl CallManager {
|
||||
if let Some(dev_addr) = dev.signaling_addr {
|
||||
// Map reason to SIP response code.
|
||||
let code: u16 = if reason.starts_with("rejected_") {
|
||||
reason.strip_prefix("rejected_")
|
||||
reason
|
||||
.strip_prefix("rejected_")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(503)
|
||||
} else if reason == "bye" {
|
||||
@@ -351,7 +418,12 @@ impl CallManager {
|
||||
503
|
||||
};
|
||||
if code > 0 && dev.state != LegState::Connected {
|
||||
let resp = SipMessage::create_response(code, "Service Unavailable", device_invite, None);
|
||||
let resp = SipMessage::create_response(
|
||||
code,
|
||||
"Service Unavailable",
|
||||
device_invite,
|
||||
None,
|
||||
);
|
||||
let _ = socket.send_to(&resp.serialize(), dev_addr).await;
|
||||
}
|
||||
}
|
||||
@@ -364,22 +436,38 @@ impl CallManager {
|
||||
leg.state = LegState::Terminated;
|
||||
}
|
||||
}
|
||||
emit_event(&self.out_tx, "leg_state_changed",
|
||||
serde_json::json!({ "call_id": call_id, "leg_id": leg_id, "state": "terminated" }));
|
||||
emit_event(&self.out_tx, "call_ended",
|
||||
serde_json::json!({ "call_id": call_id, "reason": reason, "duration": duration }));
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"leg_state_changed",
|
||||
serde_json::json!({ "call_id": call_id, "leg_id": leg_id, "state": "terminated" }),
|
||||
);
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"call_ended",
|
||||
serde_json::json!({ "call_id": call_id, "reason": reason, "duration": duration }),
|
||||
);
|
||||
self.terminate_call(call_id).await;
|
||||
return true;
|
||||
}
|
||||
SipLegAction::SendAndTerminate(buf, reason) => {
|
||||
let _ = socket.send_to(&buf, from_addr).await;
|
||||
let duration = self.calls.get(call_id).map(|c| c.duration_secs()).unwrap_or(0);
|
||||
emit_event(&self.out_tx, "call_ended",
|
||||
serde_json::json!({ "call_id": call_id, "reason": reason, "duration": duration }));
|
||||
let duration = self
|
||||
.calls
|
||||
.get(call_id)
|
||||
.map(|c| c.duration_secs())
|
||||
.unwrap_or(0);
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"call_ended",
|
||||
serde_json::json!({ "call_id": call_id, "reason": reason, "duration": duration }),
|
||||
);
|
||||
self.terminate_call(call_id).await;
|
||||
return true;
|
||||
}
|
||||
SipLegAction::AuthRetry { ack_407, invite_with_auth } => {
|
||||
SipLegAction::AuthRetry {
|
||||
ack_407,
|
||||
invite_with_auth,
|
||||
} => {
|
||||
if let Some(ack) = ack_407 {
|
||||
let _ = socket.send_to(&ack, target).await;
|
||||
}
|
||||
@@ -413,11 +501,21 @@ impl CallManager {
|
||||
let this_kind = this_leg.map(|l| l.kind).unwrap_or(LegKind::SipProvider);
|
||||
|
||||
// Find the counterpart leg.
|
||||
let other_leg = call.legs.values().find(|l| l.id != this_leg_id && l.state != LegState::Terminated);
|
||||
let (other_addr, other_rtp_port, other_leg_id, other_kind, other_public_ip) = match other_leg {
|
||||
Some(l) => (l.signaling_addr, l.rtp_port, l.id.clone(), l.kind, l.public_ip.clone()),
|
||||
None => return false,
|
||||
};
|
||||
let other_leg = call
|
||||
.legs
|
||||
.values()
|
||||
.find(|l| l.id != this_leg_id && l.state != LegState::Terminated);
|
||||
let (other_addr, other_rtp_port, other_leg_id, other_kind, other_public_ip) =
|
||||
match other_leg {
|
||||
Some(l) => (
|
||||
l.signaling_addr,
|
||||
l.rtp_port,
|
||||
l.id.clone(),
|
||||
l.kind,
|
||||
l.public_ip.clone(),
|
||||
),
|
||||
None => return false,
|
||||
};
|
||||
let forward_to = match other_addr {
|
||||
Some(a) => a,
|
||||
None => return false,
|
||||
@@ -436,7 +534,9 @@ impl CallManager {
|
||||
};
|
||||
|
||||
// Check if the other leg is a B2BUA leg (has SipLeg for proper dialog mgmt).
|
||||
let other_has_sip_leg = call.legs.get(&other_leg_id)
|
||||
let other_has_sip_leg = call
|
||||
.legs
|
||||
.get(&other_leg_id)
|
||||
.map(|l| l.sip_leg.is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -470,7 +570,8 @@ impl CallManager {
|
||||
if let Some(other) = call.legs.get_mut(&other_leg_id) {
|
||||
if let Some(sip_leg) = &mut other.sip_leg {
|
||||
if let Some(hangup_buf) = sip_leg.build_hangup() {
|
||||
let _ = socket.send_to(&hangup_buf, sip_leg.config.sip_target).await;
|
||||
let _ =
|
||||
socket.send_to(&hangup_buf, sip_leg.config.sip_target).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,7 +602,8 @@ impl CallManager {
|
||||
if let Some(other) = call.legs.get_mut(&other_leg_id) {
|
||||
if let Some(sip_leg) = &mut other.sip_leg {
|
||||
if let Some(hangup_buf) = sip_leg.build_hangup() {
|
||||
let _ = socket.send_to(&hangup_buf, sip_leg.config.sip_target).await;
|
||||
let _ =
|
||||
socket.send_to(&hangup_buf, sip_leg.config.sip_target).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -538,13 +640,17 @@ impl CallManager {
|
||||
if this_kind == LegKind::SipProvider {
|
||||
// From provider → forward to device: rewrite request URI.
|
||||
if let Some(ruri) = fwd.request_uri().map(|s| s.to_string()) {
|
||||
let new_ruri = rewrite_sip_uri(&ruri, &forward_to.ip().to_string(), forward_to.port());
|
||||
let new_ruri =
|
||||
rewrite_sip_uri(&ruri, &forward_to.ip().to_string(), forward_to.port());
|
||||
fwd.set_request_uri(&new_ruri);
|
||||
}
|
||||
}
|
||||
if fwd.is_dialog_establishing() {
|
||||
// Record-Route must also be routable from the destination leg.
|
||||
fwd.prepend_header("Record-Route", &format!("<sip:{advertise_ip}:{lan_port};lr>"));
|
||||
fwd.prepend_header(
|
||||
"Record-Route",
|
||||
&format!("<sip:{advertise_ip}:{lan_port};lr>"),
|
||||
);
|
||||
}
|
||||
let _ = socket.send_to(&fwd.serialize(), forward_to).await;
|
||||
return true;
|
||||
@@ -569,13 +675,20 @@ impl CallManager {
|
||||
if code == 180 || code == 183 {
|
||||
if call.state == CallState::SettingUp {
|
||||
call.state = CallState::Ringing;
|
||||
emit_event(&self.out_tx, "call_ringing", serde_json::json!({ "call_id": call_id }));
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"call_ringing",
|
||||
serde_json::json!({ "call_id": call_id }),
|
||||
);
|
||||
}
|
||||
if let Some(leg) = call.legs.get_mut(this_leg_id) {
|
||||
leg.state = LegState::Ringing;
|
||||
}
|
||||
emit_event(&self.out_tx, "leg_state_changed",
|
||||
serde_json::json!({ "call_id": call_id, "leg_id": this_leg_id, "state": "ringing" }));
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"leg_state_changed",
|
||||
serde_json::json!({ "call_id": call_id, "leg_id": this_leg_id, "state": "ringing" }),
|
||||
);
|
||||
} else if code >= 200 && code < 300 {
|
||||
let mut needs_wiring = false;
|
||||
if let Some(leg) = call.legs.get_mut(this_leg_id) {
|
||||
@@ -595,12 +708,19 @@ impl CallManager {
|
||||
needs_wiring = true;
|
||||
}
|
||||
|
||||
emit_event(&self.out_tx, "leg_state_changed",
|
||||
serde_json::json!({ "call_id": call_id, "leg_id": this_leg_id, "state": "connected" }));
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"leg_state_changed",
|
||||
serde_json::json!({ "call_id": call_id, "leg_id": this_leg_id, "state": "connected" }),
|
||||
);
|
||||
|
||||
if call.state != CallState::Connected {
|
||||
call.state = CallState::Connected;
|
||||
emit_event(&self.out_tx, "call_answered", serde_json::json!({ "call_id": call_id }));
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"call_answered",
|
||||
serde_json::json!({ "call_id": call_id }),
|
||||
);
|
||||
}
|
||||
|
||||
// Forward the response before wiring (drop call borrow).
|
||||
@@ -681,6 +801,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;
|
||||
@@ -689,31 +810,59 @@ impl CallManager {
|
||||
|
||||
// Extract caller/callee info.
|
||||
let from_header = invite.get_header("From").unwrap_or("");
|
||||
let caller_number = SipMessage::extract_uri(from_header)
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let called_number = invite
|
||||
.request_uri()
|
||||
.and_then(|uri| SipMessage::extract_uri(uri))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let caller_number = normalize_routing_identity(from_header);
|
||||
let called_number = normalize_routing_identity(invite.request_uri().unwrap_or(""));
|
||||
|
||||
// Resolve via the configured inbound routing table. This honors
|
||||
// user-defined routes from the UI (numberPattern, callerPattern,
|
||||
// sourceProvider, targets, ringBrowsers). If no route matches, the
|
||||
// fallback returns an empty `device_ids` and `ring_browsers: true`,
|
||||
// which preserves pre-routing behavior via the `resolve_first_device`
|
||||
// fallback below.
|
||||
// Resolve via the configured inbound routing table. The matched route
|
||||
// is the source of truth for which external numbers this provider is
|
||||
// allowed to deliver to us.
|
||||
//
|
||||
// TODO: Multi-target inbound fork is not yet implemented.
|
||||
// - `route.device_ids` beyond the first registered target are ignored.
|
||||
// - `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 route = match config.resolve_inbound_route(provider_id, &called_number, &caller_number)
|
||||
{
|
||||
Some(route) => route,
|
||||
None => {
|
||||
let resp = SipMessage::create_response(404, "Not Found", invite, None);
|
||||
let _ = socket.send_to(&resp.serialize(), from_addr).await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
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,13 +875,28 @@ 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,
|
||||
&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 });
|
||||
return Some(InboundCallCreated {
|
||||
call_id,
|
||||
ring_browsers,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -827,8 +991,10 @@ impl CallManager {
|
||||
// Register SIP Call-ID → both legs (provider leg handles provider messages).
|
||||
// For passthrough, both legs share the same SIP Call-ID.
|
||||
// We route based on source address in route_passthrough_message.
|
||||
self.sip_index
|
||||
.insert(sip_call_id.clone(), (call_id.clone(), provider_leg_id.clone()));
|
||||
self.sip_index.insert(
|
||||
sip_call_id.clone(),
|
||||
(call_id.clone(), provider_leg_id.clone()),
|
||||
);
|
||||
|
||||
// Rewrite and forward INVITE to device.
|
||||
let mut fwd_invite = invite.clone();
|
||||
@@ -861,7 +1027,10 @@ impl CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
Some(InboundCallCreated { call_id, ring_browsers })
|
||||
Some(InboundCallCreated {
|
||||
call_id,
|
||||
ring_browsers,
|
||||
})
|
||||
}
|
||||
|
||||
/// Initiate an outbound B2BUA call from the dashboard.
|
||||
@@ -910,7 +1079,9 @@ impl CallManager {
|
||||
|
||||
// Send INVITE.
|
||||
let to_uri = format!("sip:{number}@{}", provider_config.domain);
|
||||
sip_leg.send_invite(registered_aor, &to_uri, &sip_call_id, socket).await;
|
||||
sip_leg
|
||||
.send_invite(registered_aor, &to_uri, &sip_call_id, socket)
|
||||
.await;
|
||||
|
||||
// Create call with mixer.
|
||||
let (mixer_cmd_tx, mixer_task) = spawn_mixer(call_id.clone(), self.out_tx.clone());
|
||||
@@ -1081,7 +1252,9 @@ impl CallManager {
|
||||
|
||||
// Build proper To URI and send INVITE.
|
||||
let to_uri = format!("sip:{}@{}", dialed_number, provider_config.domain);
|
||||
sip_leg.send_invite(registered_aor, &to_uri, &provider_sip_call_id, socket).await;
|
||||
sip_leg
|
||||
.send_invite(registered_aor, &to_uri, &provider_sip_call_id, socket)
|
||||
.await;
|
||||
|
||||
call.legs.insert(
|
||||
provider_leg_id.clone(),
|
||||
@@ -1157,7 +1330,9 @@ impl CallManager {
|
||||
|
||||
let mut sip_leg = SipLeg::new(leg_id.clone(), leg_config);
|
||||
let to_uri = format!("sip:{number}@{}", provider_config.domain);
|
||||
sip_leg.send_invite(registered_aor, &to_uri, &sip_call_id, socket).await;
|
||||
sip_leg
|
||||
.send_invite(registered_aor, &to_uri, &sip_call_id, socket)
|
||||
.await;
|
||||
|
||||
let codec_pt = provider_config.codecs.first().copied().unwrap_or(9);
|
||||
|
||||
@@ -1228,9 +1403,16 @@ impl CallManager {
|
||||
};
|
||||
|
||||
let mut sip_leg = SipLeg::new(leg_id.clone(), leg_config);
|
||||
let to_uri = format!("sip:{}@{}:{}", device_id, device_addr.ip(), device_addr.port());
|
||||
let to_uri = format!(
|
||||
"sip:{}@{}:{}",
|
||||
device_id,
|
||||
device_addr.ip(),
|
||||
device_addr.port()
|
||||
);
|
||||
let from_uri = format!("sip:sipproxy@{lan_ip}:{lan_port}");
|
||||
sip_leg.send_invite(&from_uri, &to_uri, &sip_call_id, socket).await;
|
||||
sip_leg
|
||||
.send_invite(&from_uri, &to_uri, &sip_call_id, socket)
|
||||
.await;
|
||||
|
||||
let leg_info = LegInfo {
|
||||
id: leg_id.clone(),
|
||||
@@ -1264,12 +1446,7 @@ impl CallManager {
|
||||
}
|
||||
|
||||
/// Remove a leg from a call.
|
||||
pub async fn remove_leg(
|
||||
&mut self,
|
||||
call_id: &str,
|
||||
leg_id: &str,
|
||||
socket: &UdpSocket,
|
||||
) -> bool {
|
||||
pub async fn remove_leg(&mut self, call_id: &str, leg_id: &str, socket: &UdpSocket) -> bool {
|
||||
let call = match self.calls.get_mut(call_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
@@ -1282,7 +1459,9 @@ impl CallManager {
|
||||
if let Some(leg) = call.legs.get_mut(leg_id) {
|
||||
if let Some(sip_leg) = &mut leg.sip_leg {
|
||||
if let Some(hangup_bytes) = sip_leg.build_hangup() {
|
||||
let _ = socket.send_to(&hangup_bytes, sip_leg.config.sip_target).await;
|
||||
let _ = socket
|
||||
.send_to(&hangup_bytes, sip_leg.config.sip_target)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
leg.state = LegState::Terminated;
|
||||
@@ -1328,9 +1507,7 @@ impl CallManager {
|
||||
target_call_id: &str,
|
||||
) -> bool {
|
||||
// Validate both calls exist and the leg is in the source call.
|
||||
if !self.calls.contains_key(source_call_id)
|
||||
|| !self.calls.contains_key(target_call_id)
|
||||
{
|
||||
if !self.calls.contains_key(source_call_id) || !self.calls.contains_key(target_call_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1475,7 +1652,9 @@ impl CallManager {
|
||||
}
|
||||
if let Some(sip_leg) = &mut leg.sip_leg {
|
||||
if let Some(hangup_bytes) = sip_leg.build_hangup() {
|
||||
let _ = socket.send_to(&hangup_bytes, sip_leg.config.sip_target).await;
|
||||
let _ = socket
|
||||
.send_to(&hangup_bytes, sip_leg.config.sip_target)
|
||||
.await;
|
||||
}
|
||||
} else if let Some(addr) = leg.signaling_addr {
|
||||
// Passthrough leg — send a simple BYE.
|
||||
@@ -1536,6 +1715,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());
|
||||
@@ -1559,7 +1739,9 @@ impl CallManager {
|
||||
});
|
||||
|
||||
let response = SipMessage::create_response(
|
||||
200, "OK", invite,
|
||||
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)),
|
||||
@@ -1630,17 +1812,21 @@ 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();
|
||||
let rtp_socket = rtp_alloc.socket;
|
||||
tokio::spawn(async move {
|
||||
crate::voicemail::run_voicemail_session(
|
||||
rtp_socket, provider_media, codec_pt,
|
||||
greeting_wav, recording_path, 120_000,
|
||||
call_id_owned, caller_owned, out_tx,
|
||||
rtp_socket,
|
||||
provider_media,
|
||||
codec_pt,
|
||||
greeting_wav,
|
||||
recording_path,
|
||||
120_000,
|
||||
call_id_owned,
|
||||
caller_owned,
|
||||
out_tx,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -1648,11 +1834,218 @@ 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 the IVR prompt as a live chunked TTS stream so playback can
|
||||
// start after the first chunk instead of waiting for a full WAV render.
|
||||
let voice = menu.prompt_voice.as_deref().unwrap_or("af_bella");
|
||||
let live_prompt = {
|
||||
let mut tts = tts_engine.lock().await;
|
||||
match tts
|
||||
.start_live_prompt(crate::tts::TtsPromptRequest {
|
||||
model_path: crate::tts::DEFAULT_MODEL_PATH.to_string(),
|
||||
voices_path: crate::tts::DEFAULT_VOICES_PATH.to_string(),
|
||||
voice_name: voice.to_string(),
|
||||
text: menu.prompt_text.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(prompt) => Some(prompt),
|
||||
Err(e) => {
|
||||
eprintln!("[ivr] live TTS setup 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 {
|
||||
if let Some(prompt) = live_prompt {
|
||||
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: prompt.initial_frames,
|
||||
prompt_stream_rx: Some(prompt.stream_rx),
|
||||
prompt_cancel_tx: Some(prompt.cancel_tx),
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn resolve_first_device(&self, config: &AppConfig, registrar: &Registrar) -> Option<SocketAddr> {
|
||||
fn resolve_first_device(
|
||||
&self,
|
||||
config: &AppConfig,
|
||||
registrar: &Registrar,
|
||||
) -> Option<SocketAddr> {
|
||||
for device in &config.devices {
|
||||
if let Some(addr) = registrar.get_device_contact(&device.id) {
|
||||
return Some(addr);
|
||||
@@ -1662,13 +2055,55 @@ 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(¶ms).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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! proxy engine via the `configure` command. These types mirror the TS interfaces.
|
||||
|
||||
use serde::Deserialize;
|
||||
use sip_proto::message::SipMessage;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// Network endpoint.
|
||||
@@ -159,6 +160,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,12 +171,158 @@ 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract the URI user part and normalize phone-like identities for routing.
|
||||
///
|
||||
/// This keeps inbound route matching stable across provider-specific URI shapes,
|
||||
/// e.g. `sip:+49 421 219694@trunk.example` and `sip:0049421219694@trunk.example`
|
||||
/// both normalize to `+49421219694`.
|
||||
pub fn normalize_routing_identity(value: &str) -> String {
|
||||
let extracted = SipMessage::extract_uri_user(value).unwrap_or(value).trim();
|
||||
if extracted.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut digits = String::new();
|
||||
let mut saw_plus = false;
|
||||
|
||||
for (idx, ch) in extracted.chars().enumerate() {
|
||||
if ch.is_ascii_digit() {
|
||||
digits.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '+' && idx == 0 {
|
||||
saw_plus = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(ch, ' ' | '\t' | '-' | '.' | '/' | '(' | ')') {
|
||||
continue;
|
||||
}
|
||||
|
||||
return extracted.to_string();
|
||||
}
|
||||
|
||||
if digits.is_empty() {
|
||||
return extracted.to_string();
|
||||
}
|
||||
if saw_plus {
|
||||
return format!("+{digits}");
|
||||
}
|
||||
if digits.starts_with("00") && digits.len() > 2 {
|
||||
return format!("+{}", &digits[2..]);
|
||||
}
|
||||
|
||||
digits
|
||||
}
|
||||
|
||||
fn parse_numeric_range_value(value: &str) -> Option<(bool, &str)> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (has_plus, digits) = if let Some(rest) = trimmed.strip_prefix('+') {
|
||||
(true, rest)
|
||||
} else {
|
||||
(false, trimmed)
|
||||
};
|
||||
|
||||
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((has_plus, digits))
|
||||
}
|
||||
|
||||
fn matches_numeric_range_pattern(pattern: &str, value: &str) -> bool {
|
||||
let Some((start, end)) = pattern.split_once("..") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some((start_plus, start_digits)) = parse_numeric_range_value(start) else {
|
||||
return false;
|
||||
};
|
||||
let Some((end_plus, end_digits)) = parse_numeric_range_value(end) else {
|
||||
return false;
|
||||
};
|
||||
let Some((value_plus, value_digits)) = parse_numeric_range_value(value) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if start_plus != end_plus || value_plus != start_plus {
|
||||
return false;
|
||||
}
|
||||
if start_digits.len() != end_digits.len() || value_digits.len() != start_digits.len() {
|
||||
return false;
|
||||
}
|
||||
if start_digits > end_digits {
|
||||
return false;
|
||||
}
|
||||
|
||||
value_digits >= start_digits && value_digits <= end_digits
|
||||
}
|
||||
|
||||
/// Test a value against a pattern string.
|
||||
/// - None/empty: matches everything (wildcard)
|
||||
/// - `start..end`: numeric range match
|
||||
/// - Trailing '*': prefix match
|
||||
/// - Starts with '/': regex match
|
||||
/// - Otherwise: exact match
|
||||
@@ -187,6 +338,10 @@ pub fn matches_pattern(pattern: Option<&str>, value: &str) -> bool {
|
||||
return value.starts_with(&pattern[..pattern.len() - 1]);
|
||||
}
|
||||
|
||||
if matches_numeric_range_pattern(pattern, value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Regex match: "/^\\+49/" or "/pattern/i"
|
||||
if pattern.starts_with('/') {
|
||||
if let Some(last_slash) = pattern[1..].rfind('/') {
|
||||
@@ -306,7 +461,7 @@ impl AppConfig {
|
||||
provider_id: &str,
|
||||
called_number: &str,
|
||||
caller_number: &str,
|
||||
) -> InboundRouteResult {
|
||||
) -> Option<InboundRouteResult> {
|
||||
let mut routes: Vec<&Route> = self
|
||||
.routing
|
||||
.routes
|
||||
@@ -330,22 +485,170 @@ impl AppConfig {
|
||||
continue;
|
||||
}
|
||||
|
||||
return InboundRouteResult {
|
||||
return Some(InboundRouteResult {
|
||||
device_ids: route.action.targets.clone().unwrap_or_default(),
|
||||
ring_browsers: route.action.ring_browsers.unwrap_or(false),
|
||||
voicemail_box: route.action.voicemail_box.clone(),
|
||||
ivr_menu_id: route.action.ivr_menu_id.clone(),
|
||||
no_answer_timeout: route.action.no_answer_timeout,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: ring all devices + browsers.
|
||||
InboundRouteResult {
|
||||
device_ids: vec![],
|
||||
ring_browsers: true,
|
||||
voicemail_box: None,
|
||||
ivr_menu_id: None,
|
||||
no_answer_timeout: None,
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_app_config(routes: Vec<Route>) -> AppConfig {
|
||||
AppConfig {
|
||||
proxy: ProxyConfig {
|
||||
lan_ip: "127.0.0.1".to_string(),
|
||||
lan_port: 5070,
|
||||
public_ip_seed: None,
|
||||
rtp_port_range: RtpPortRange {
|
||||
min: 20_000,
|
||||
max: 20_100,
|
||||
},
|
||||
},
|
||||
providers: vec![ProviderConfig {
|
||||
id: "provider-a".to_string(),
|
||||
display_name: "Provider A".to_string(),
|
||||
domain: "example.com".to_string(),
|
||||
outbound_proxy: Endpoint {
|
||||
address: "example.com".to_string(),
|
||||
port: 5060,
|
||||
},
|
||||
username: "user".to_string(),
|
||||
password: "pass".to_string(),
|
||||
register_interval_sec: 300,
|
||||
codecs: vec![9],
|
||||
quirks: Quirks {
|
||||
early_media_silence: false,
|
||||
silence_payload_type: None,
|
||||
silence_max_packets: None,
|
||||
},
|
||||
}],
|
||||
devices: vec![DeviceConfig {
|
||||
id: "desk".to_string(),
|
||||
display_name: "Desk".to_string(),
|
||||
expected_address: "127.0.0.1".to_string(),
|
||||
extension: "100".to_string(),
|
||||
}],
|
||||
routing: RoutingConfig { routes },
|
||||
voiceboxes: vec![],
|
||||
ivr: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_routing_identity_extracts_uri_user_and_phone_number() {
|
||||
assert_eq!(
|
||||
normalize_routing_identity("sip:0049 421 219694@voip.easybell.de"),
|
||||
"+49421219694"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_routing_identity("<tel:+49 (421) 219694>"),
|
||||
"+49421219694"
|
||||
);
|
||||
assert_eq!(normalize_routing_identity("sip:100@pbx.local"), "100");
|
||||
assert_eq!(normalize_routing_identity("sip:alice@pbx.local"), "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_inbound_route_requires_explicit_match() {
|
||||
let cfg = test_app_config(vec![]);
|
||||
assert!(cfg
|
||||
.resolve_inbound_route("provider-a", "+49421219694", "+491701234567")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_inbound_route_matches_per_number_on_shared_provider() {
|
||||
let cfg = test_app_config(vec![
|
||||
Route {
|
||||
id: "main".to_string(),
|
||||
name: "Main DID".to_string(),
|
||||
priority: 200,
|
||||
enabled: true,
|
||||
match_criteria: RouteMatch {
|
||||
direction: "inbound".to_string(),
|
||||
number_pattern: Some("+49421219694".to_string()),
|
||||
caller_pattern: None,
|
||||
source_provider: Some("provider-a".to_string()),
|
||||
source_device: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
targets: Some(vec!["desk".to_string()]),
|
||||
ring_browsers: Some(true),
|
||||
voicemail_box: None,
|
||||
ivr_menu_id: None,
|
||||
no_answer_timeout: None,
|
||||
provider: None,
|
||||
failover_providers: None,
|
||||
strip_prefix: None,
|
||||
prepend_prefix: None,
|
||||
},
|
||||
},
|
||||
Route {
|
||||
id: "support".to_string(),
|
||||
name: "Support DID".to_string(),
|
||||
priority: 100,
|
||||
enabled: true,
|
||||
match_criteria: RouteMatch {
|
||||
direction: "inbound".to_string(),
|
||||
number_pattern: Some("+49421219695".to_string()),
|
||||
caller_pattern: None,
|
||||
source_provider: Some("provider-a".to_string()),
|
||||
source_device: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
targets: None,
|
||||
ring_browsers: Some(false),
|
||||
voicemail_box: Some("support-box".to_string()),
|
||||
ivr_menu_id: None,
|
||||
no_answer_timeout: Some(20),
|
||||
provider: None,
|
||||
failover_providers: None,
|
||||
strip_prefix: None,
|
||||
prepend_prefix: None,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
let main = cfg
|
||||
.resolve_inbound_route("provider-a", "+49421219694", "+491701234567")
|
||||
.expect("main DID should match");
|
||||
assert_eq!(main.device_ids, vec!["desk".to_string()]);
|
||||
assert!(main.ring_browsers);
|
||||
|
||||
let support = cfg
|
||||
.resolve_inbound_route("provider-a", "+49421219695", "+491701234567")
|
||||
.expect("support DID should match");
|
||||
assert_eq!(support.voicemail_box.as_deref(), Some("support-box"));
|
||||
assert_eq!(support.no_answer_timeout, Some(20));
|
||||
assert!(!support.ring_browsers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_pattern_supports_numeric_ranges() {
|
||||
assert!(matches_pattern(
|
||||
Some("042116767546..042116767548"),
|
||||
"042116767547"
|
||||
));
|
||||
assert!(!matches_pattern(
|
||||
Some("042116767546..042116767548"),
|
||||
"042116767549"
|
||||
));
|
||||
assert!(matches_pattern(
|
||||
Some("+4942116767546..+4942116767548"),
|
||||
"+4942116767547"
|
||||
));
|
||||
assert!(!matches_pattern(
|
||||
Some("+4942116767546..+4942116767548"),
|
||||
"042116767547"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,13 @@ pub struct Command {
|
||||
}
|
||||
|
||||
/// Send a response to a command.
|
||||
pub fn respond(tx: &OutTx, id: &str, success: bool, result: Option<serde_json::Value>, error: Option<&str>) {
|
||||
pub fn respond(
|
||||
tx: &OutTx,
|
||||
id: &str,
|
||||
success: bool,
|
||||
result: Option<serde_json::Value>,
|
||||
error: Option<&str>,
|
||||
) {
|
||||
let mut resp = serde_json::json!({ "id": id, "success": success });
|
||||
if let Some(r) = result {
|
||||
resp["result"] = r;
|
||||
|
||||
@@ -63,7 +63,8 @@ pub fn spawn_sip_inbound(
|
||||
if offset + 4 > n {
|
||||
continue; // Malformed: extension header truncated.
|
||||
}
|
||||
let ext_len = u16::from_be_bytes([buf[offset + 2], buf[offset + 3]]) as usize;
|
||||
let ext_len =
|
||||
u16::from_be_bytes([buf[offset + 2], buf[offset + 3]]) as usize;
|
||||
offset += 4 + ext_len * 4;
|
||||
}
|
||||
if offset >= n {
|
||||
@@ -74,7 +75,17 @@ pub fn spawn_sip_inbound(
|
||||
if payload.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if inbound_tx.send(RtpPacket { payload, payload_type: pt, marker, seq, timestamp }).await.is_err() {
|
||||
if inbound_tx
|
||||
.send(RtpPacket {
|
||||
payload,
|
||||
payload_type: pt,
|
||||
marker,
|
||||
seq,
|
||||
timestamp,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break; // Channel closed — leg removed.
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
||||
//! All encoding/decoding happens at leg boundaries. Per-leg inbound denoising at 48kHz.
|
||||
//!
|
||||
//! The mixer runs a 20ms tick loop:
|
||||
//! 1. Drain inbound channels, decode to f32, resample to 48kHz, denoise per-leg
|
||||
//! 1. Drain inbound channels, reorder RTP, decode variable-duration packets to 48kHz,
|
||||
//! and queue them in per-leg PCM buffers
|
||||
//! 2. Compute total mix (sum of all **participant** legs' f32 PCM as f64)
|
||||
//! 3. For each participant leg: mix-minus = total - own, resample to leg codec rate, encode, send
|
||||
//! 4. For each isolated leg: play prompt frame or silence, check DTMF
|
||||
@@ -16,11 +17,12 @@
|
||||
|
||||
use crate::ipc::{emit_event, OutTx};
|
||||
use crate::jitter_buffer::{JitterBuffer, JitterResult};
|
||||
use crate::rtp::{build_rtp_header, rtp_clock_increment};
|
||||
use crate::rtp::{build_rtp_header, rtp_clock_increment, rtp_clock_rate};
|
||||
use crate::tts::TtsStreamMessage;
|
||||
use codec_lib::{codec_sample_rate, new_denoiser, TranscodeState};
|
||||
use nnnoiseless::DenoiseState;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{self, Duration, MissedTickBehavior};
|
||||
|
||||
@@ -29,6 +31,12 @@ use tokio::time::{self, Duration, MissedTickBehavior};
|
||||
const MIX_RATE: u32 = 48000;
|
||||
/// Samples per 20ms frame at the mixing rate.
|
||||
const MIX_FRAME_SIZE: usize = 960; // 48000 * 0.020
|
||||
/// Safety cap for how much timestamp-derived gap fill we synthesize at once.
|
||||
const MAX_GAP_FILL_SAMPLES: usize = MIX_FRAME_SIZE * 6; // 120ms
|
||||
/// Bound how many decode / concealment steps a leg can consume in one tick.
|
||||
const MAX_PACKET_STEPS_PER_TICK: usize = 24;
|
||||
/// Report the first output drop immediately, then every N drops.
|
||||
const DROP_REPORT_INTERVAL: u64 = 50;
|
||||
|
||||
/// A raw RTP payload received from a leg (no RTP header).
|
||||
pub struct RtpPacket {
|
||||
@@ -39,10 +47,6 @@ pub struct RtpPacket {
|
||||
/// RTP sequence number for reordering.
|
||||
pub seq: u16,
|
||||
/// RTP timestamp from the original packet header.
|
||||
///
|
||||
/// Set on inbound RTP but not yet consumed downstream — reserved for
|
||||
/// future jitter/sync work in the mixer.
|
||||
#[allow(dead_code)]
|
||||
pub timestamp: u32,
|
||||
}
|
||||
|
||||
@@ -61,6 +65,12 @@ enum LegRole {
|
||||
struct IsolationState {
|
||||
/// PCM frames at MIX_RATE (960 samples each, 48kHz f32) queued for playback.
|
||||
prompt_frames: VecDeque<Vec<f32>>,
|
||||
/// Live TTS frames arrive here while playback is already in progress.
|
||||
prompt_stream_rx: Option<mpsc::Receiver<TtsStreamMessage>>,
|
||||
/// Cancels the background TTS producer when the interaction ends early.
|
||||
prompt_cancel_tx: Option<watch::Sender<bool>>,
|
||||
/// Whether the live prompt stream has ended.
|
||||
prompt_stream_finished: bool,
|
||||
/// Digits that complete the interaction (e.g., ['1', '2']).
|
||||
expected_digits: Vec<char>,
|
||||
/// Ticks remaining before timeout (decremented each tick after prompt ends).
|
||||
@@ -109,6 +119,7 @@ struct ToolLegSlot {
|
||||
#[allow(dead_code)]
|
||||
tool_type: ToolType,
|
||||
audio_tx: mpsc::Sender<ToolAudioBatch>,
|
||||
dropped_batches: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -136,6 +147,10 @@ pub enum MixerCommand {
|
||||
leg_id: String,
|
||||
/// PCM frames at MIX_RATE (48kHz f32), each 960 samples.
|
||||
prompt_pcm_frames: Vec<Vec<f32>>,
|
||||
/// Optional live prompt stream. Frames are appended as they are synthesized.
|
||||
prompt_stream_rx: Option<mpsc::Receiver<TtsStreamMessage>>,
|
||||
/// Optional cancellation handle for the live prompt stream.
|
||||
prompt_cancel_tx: Option<watch::Sender<bool>>,
|
||||
expected_digits: Vec<char>,
|
||||
timeout_ms: u32,
|
||||
result_tx: oneshot::Sender<InteractionResult>,
|
||||
@@ -163,8 +178,15 @@ struct MixerLegSlot {
|
||||
denoiser: Box<DenoiseState<'static>>,
|
||||
inbound_rx: mpsc::Receiver<RtpPacket>,
|
||||
outbound_tx: mpsc::Sender<Vec<u8>>,
|
||||
/// Decoded PCM waiting for playout. Variable-duration RTP packets are
|
||||
/// decoded into this FIFO; the mixer consumes exactly one 20ms frame per tick.
|
||||
pcm_buffer: VecDeque<f32>,
|
||||
/// Last decoded+denoised PCM frame at MIX_RATE (960 samples, 48kHz f32).
|
||||
last_pcm_frame: Vec<f32>,
|
||||
/// Next RTP timestamp expected from the inbound stream.
|
||||
expected_rtp_timestamp: Option<u32>,
|
||||
/// Best-effort estimate of packet duration in RTP clock units.
|
||||
estimated_packet_ts: u32,
|
||||
/// Number of consecutive ticks with no inbound packet.
|
||||
silent_ticks: u32,
|
||||
/// Per-leg jitter buffer for packet reordering and timing.
|
||||
@@ -173,15 +195,302 @@ struct MixerLegSlot {
|
||||
rtp_seq: u16,
|
||||
rtp_ts: u32,
|
||||
rtp_ssrc: u32,
|
||||
/// Dropped outbound frames for this leg (queue full / closed).
|
||||
outbound_drops: u64,
|
||||
/// Current role of this leg in the mixer.
|
||||
role: LegRole,
|
||||
}
|
||||
|
||||
fn mix_samples_to_rtp_ts(codec_pt: u8, mix_samples: usize) -> u32 {
|
||||
let clock_rate = rtp_clock_rate(codec_pt).max(1) as u64;
|
||||
(((mix_samples as u64 * clock_rate) + (MIX_RATE as u64 / 2)) / MIX_RATE as u64) as u32
|
||||
}
|
||||
|
||||
fn rtp_ts_to_mix_samples(codec_pt: u8, rtp_ts: u32) -> usize {
|
||||
let clock_rate = rtp_clock_rate(codec_pt).max(1) as u64;
|
||||
(((rtp_ts as u64 * MIX_RATE as u64) + (clock_rate / 2)) / clock_rate) as usize
|
||||
}
|
||||
|
||||
fn is_forward_rtp_delta(delta: u32) -> bool {
|
||||
delta > 0 && delta < 0x8000_0000
|
||||
}
|
||||
|
||||
fn should_emit_drop_event(total_drops: u64) -> bool {
|
||||
total_drops == 1 || total_drops % DROP_REPORT_INTERVAL == 0
|
||||
}
|
||||
|
||||
fn emit_output_drop_event(
|
||||
out_tx: &OutTx,
|
||||
call_id: &str,
|
||||
leg_id: Option<&str>,
|
||||
tool_leg_id: Option<&str>,
|
||||
stream: &str,
|
||||
reason: &str,
|
||||
total_drops: u64,
|
||||
) {
|
||||
if !should_emit_drop_event(total_drops) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit_event(
|
||||
out_tx,
|
||||
"mixer_output_drop",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"leg_id": leg_id,
|
||||
"tool_leg_id": tool_leg_id,
|
||||
"stream": stream,
|
||||
"reason": reason,
|
||||
"total_drops": total_drops,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn fade_concealment_from_last_frame(slot: &mut MixerLegSlot, samples: usize, decay: f32) {
|
||||
let mut template = if slot.last_pcm_frame.is_empty() {
|
||||
vec![0.0f32; MIX_FRAME_SIZE]
|
||||
} else {
|
||||
slot.last_pcm_frame.clone()
|
||||
};
|
||||
|
||||
let mut remaining = samples;
|
||||
while remaining > 0 {
|
||||
for sample in &mut template {
|
||||
*sample *= decay;
|
||||
}
|
||||
let take = remaining.min(template.len());
|
||||
slot.pcm_buffer.extend(template.iter().take(take).copied());
|
||||
remaining -= take;
|
||||
}
|
||||
}
|
||||
|
||||
fn append_packet_loss_concealment(slot: &mut MixerLegSlot, samples: usize) {
|
||||
let mut remaining = samples.max(1);
|
||||
while remaining > 0 {
|
||||
let chunk = remaining.min(MIX_FRAME_SIZE);
|
||||
if slot.codec_pt == codec_lib::PT_OPUS {
|
||||
match slot.transcoder.opus_plc(chunk) {
|
||||
Ok(mut pcm) => {
|
||||
pcm.resize(chunk, 0.0);
|
||||
slot.pcm_buffer.extend(pcm);
|
||||
}
|
||||
Err(_) => fade_concealment_from_last_frame(slot, chunk, 0.8),
|
||||
}
|
||||
} else {
|
||||
fade_concealment_from_last_frame(slot, chunk, 0.85);
|
||||
}
|
||||
remaining -= chunk;
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_packet_to_mix_pcm(slot: &mut MixerLegSlot, pkt: &RtpPacket) -> Option<Vec<f32>> {
|
||||
let (pcm, rate) = slot
|
||||
.transcoder
|
||||
.decode_to_f32(&pkt.payload, pkt.payload_type)
|
||||
.ok()?;
|
||||
|
||||
let pcm_48k = if rate == MIX_RATE {
|
||||
pcm
|
||||
} else {
|
||||
slot.transcoder
|
||||
.resample_f32(&pcm, rate, MIX_RATE)
|
||||
.unwrap_or_else(|_| vec![0.0f32; MIX_FRAME_SIZE])
|
||||
};
|
||||
|
||||
let processed = if slot.codec_pt != codec_lib::PT_OPUS {
|
||||
TranscodeState::denoise_f32(&mut slot.denoiser, &pcm_48k)
|
||||
} else {
|
||||
pcm_48k
|
||||
};
|
||||
|
||||
Some(processed)
|
||||
}
|
||||
|
||||
fn queue_inbound_packet(slot: &mut MixerLegSlot, pkt: RtpPacket) {
|
||||
if let Some(pcm_48k) = decode_packet_to_mix_pcm(slot, &pkt) {
|
||||
if pcm_48k.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(expected_ts) = slot.expected_rtp_timestamp {
|
||||
let gap_ts = pkt.timestamp.wrapping_sub(expected_ts);
|
||||
if is_forward_rtp_delta(gap_ts) {
|
||||
let gap_samples = rtp_ts_to_mix_samples(slot.codec_pt, gap_ts);
|
||||
if gap_samples <= MAX_GAP_FILL_SAMPLES {
|
||||
append_packet_loss_concealment(slot, gap_samples);
|
||||
} else {
|
||||
slot.pcm_buffer.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let packet_ts = mix_samples_to_rtp_ts(slot.codec_pt, pcm_48k.len());
|
||||
if packet_ts > 0 {
|
||||
slot.estimated_packet_ts = packet_ts;
|
||||
slot.expected_rtp_timestamp = Some(pkt.timestamp.wrapping_add(packet_ts));
|
||||
}
|
||||
slot.pcm_buffer.extend(pcm_48k);
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_leg_playout_buffer(slot: &mut MixerLegSlot) {
|
||||
let mut steps = 0usize;
|
||||
while slot.pcm_buffer.len() < MIX_FRAME_SIZE && steps < MAX_PACKET_STEPS_PER_TICK {
|
||||
steps += 1;
|
||||
match slot.jitter.consume() {
|
||||
JitterResult::Packet(pkt) => queue_inbound_packet(slot, pkt),
|
||||
JitterResult::Missing => {
|
||||
let conceal_ts = slot
|
||||
.estimated_packet_ts
|
||||
.max(rtp_clock_increment(slot.codec_pt));
|
||||
let conceal_samples =
|
||||
rtp_ts_to_mix_samples(slot.codec_pt, conceal_ts).clamp(1, MAX_GAP_FILL_SAMPLES);
|
||||
append_packet_loss_concealment(slot, conceal_samples);
|
||||
if let Some(expected_ts) = slot.expected_rtp_timestamp {
|
||||
slot.expected_rtp_timestamp = Some(expected_ts.wrapping_add(conceal_ts));
|
||||
}
|
||||
}
|
||||
JitterResult::Filling => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take_mix_frame(slot: &mut MixerLegSlot) -> Vec<f32> {
|
||||
let mut frame = Vec::with_capacity(MIX_FRAME_SIZE);
|
||||
while frame.len() < MIX_FRAME_SIZE {
|
||||
if let Some(sample) = slot.pcm_buffer.pop_front() {
|
||||
frame.push(sample);
|
||||
} else {
|
||||
frame.push(0.0);
|
||||
}
|
||||
}
|
||||
frame
|
||||
}
|
||||
|
||||
fn soft_limit_sample(sample: f32) -> f32 {
|
||||
const KNEE: f32 = 0.85;
|
||||
|
||||
let abs = sample.abs();
|
||||
if abs <= KNEE {
|
||||
sample
|
||||
} else {
|
||||
let excess = abs - KNEE;
|
||||
let compressed = KNEE + (excess / (1.0 + (excess / (1.0 - KNEE))));
|
||||
sample.signum() * compressed.min(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn try_send_leg_output(
|
||||
out_tx: &OutTx,
|
||||
call_id: &str,
|
||||
leg_id: &str,
|
||||
slot: &mut MixerLegSlot,
|
||||
rtp: Vec<u8>,
|
||||
stream: &str,
|
||||
) {
|
||||
let reason = match slot.outbound_tx.try_send(rtp) {
|
||||
Ok(()) => return,
|
||||
Err(mpsc::error::TrySendError::Full(_)) => "full",
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => "closed",
|
||||
};
|
||||
|
||||
slot.outbound_drops += 1;
|
||||
emit_output_drop_event(
|
||||
out_tx,
|
||||
call_id,
|
||||
Some(leg_id),
|
||||
None,
|
||||
stream,
|
||||
reason,
|
||||
slot.outbound_drops,
|
||||
);
|
||||
}
|
||||
|
||||
fn try_send_tool_output(
|
||||
out_tx: &OutTx,
|
||||
call_id: &str,
|
||||
tool_leg_id: &str,
|
||||
tool: &mut ToolLegSlot,
|
||||
batch: ToolAudioBatch,
|
||||
) {
|
||||
let reason = match tool.audio_tx.try_send(batch) {
|
||||
Ok(()) => return,
|
||||
Err(mpsc::error::TrySendError::Full(_)) => "full",
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => "closed",
|
||||
};
|
||||
|
||||
tool.dropped_batches += 1;
|
||||
emit_output_drop_event(
|
||||
out_tx,
|
||||
call_id,
|
||||
None,
|
||||
Some(tool_leg_id),
|
||||
"tool-batch",
|
||||
reason,
|
||||
tool.dropped_batches,
|
||||
);
|
||||
}
|
||||
|
||||
fn cancel_prompt_producer(state: &mut IsolationState) {
|
||||
if let Some(cancel_tx) = state.prompt_cancel_tx.take() {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_isolated_interaction(state: &mut IsolationState) {
|
||||
cancel_prompt_producer(state);
|
||||
if let Some(tx) = state.result_tx.take() {
|
||||
let _ = tx.send(InteractionResult::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_prompt_stream(
|
||||
out_tx: &OutTx,
|
||||
call_id: &str,
|
||||
leg_id: &str,
|
||||
state: &mut IsolationState,
|
||||
) {
|
||||
loop {
|
||||
let Some(mut stream_rx) = state.prompt_stream_rx.take() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match stream_rx.try_recv() {
|
||||
Ok(TtsStreamMessage::Frames(frames)) => {
|
||||
state.prompt_frames.extend(frames);
|
||||
state.prompt_stream_rx = Some(stream_rx);
|
||||
}
|
||||
Ok(TtsStreamMessage::Finished) => {
|
||||
state.prompt_stream_finished = true;
|
||||
return;
|
||||
}
|
||||
Ok(TtsStreamMessage::Failed(error)) => {
|
||||
emit_event(
|
||||
out_tx,
|
||||
"mixer_error",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"leg_id": leg_id,
|
||||
"error": format!("tts stream failed: {error}"),
|
||||
}),
|
||||
);
|
||||
state.prompt_stream_finished = true;
|
||||
return;
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => {
|
||||
state.prompt_stream_rx = Some(stream_rx);
|
||||
return;
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
state.prompt_stream_finished = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the mixer task for a call. Returns the command sender and task handle.
|
||||
pub fn spawn_mixer(
|
||||
call_id: String,
|
||||
out_tx: OutTx,
|
||||
) -> (mpsc::Sender<MixerCommand>, JoinHandle<()>) {
|
||||
pub fn spawn_mixer(call_id: String, out_tx: OutTx) -> (mpsc::Sender<MixerCommand>, JoinHandle<()>) {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<MixerCommand>(32);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
@@ -192,11 +501,7 @@ pub fn spawn_mixer(
|
||||
}
|
||||
|
||||
/// The 20ms mixing loop.
|
||||
async fn mixer_loop(
|
||||
call_id: String,
|
||||
mut cmd_rx: mpsc::Receiver<MixerCommand>,
|
||||
out_tx: OutTx,
|
||||
) {
|
||||
async fn mixer_loop(call_id: String, mut cmd_rx: mpsc::Receiver<MixerCommand>, out_tx: OutTx) {
|
||||
let mut legs: HashMap<String, MixerLegSlot> = HashMap::new();
|
||||
let mut tool_legs: HashMap<String, ToolLegSlot> = HashMap::new();
|
||||
let mut interval = time::interval(Duration::from_millis(20));
|
||||
@@ -237,11 +542,15 @@ async fn mixer_loop(
|
||||
denoiser: new_denoiser(),
|
||||
inbound_rx,
|
||||
outbound_tx,
|
||||
pcm_buffer: VecDeque::new(),
|
||||
last_pcm_frame: vec![0.0f32; MIX_FRAME_SIZE],
|
||||
expected_rtp_timestamp: None,
|
||||
estimated_packet_ts: rtp_clock_increment(codec_pt),
|
||||
silent_ticks: 0,
|
||||
rtp_seq: 0,
|
||||
rtp_ts: 0,
|
||||
rtp_ssrc: rand::random(),
|
||||
outbound_drops: 0,
|
||||
role: LegRole::Participant,
|
||||
jitter: JitterBuffer::new(),
|
||||
},
|
||||
@@ -251,9 +560,7 @@ async fn mixer_loop(
|
||||
// If the leg is isolated, send Cancelled before dropping.
|
||||
if let Some(slot) = legs.get_mut(&leg_id) {
|
||||
if let LegRole::Isolated(ref mut state) = slot.role {
|
||||
if let Some(tx) = state.result_tx.take() {
|
||||
let _ = tx.send(InteractionResult::Cancelled);
|
||||
}
|
||||
cancel_isolated_interaction(state);
|
||||
}
|
||||
}
|
||||
legs.remove(&leg_id);
|
||||
@@ -263,9 +570,7 @@ async fn mixer_loop(
|
||||
// Cancel all outstanding interactions before shutting down.
|
||||
for slot in legs.values_mut() {
|
||||
if let LegRole::Isolated(ref mut state) = slot.role {
|
||||
if let Some(tx) = state.result_tx.take() {
|
||||
let _ = tx.send(InteractionResult::Cancelled);
|
||||
}
|
||||
cancel_isolated_interaction(state);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -273,6 +578,8 @@ async fn mixer_loop(
|
||||
Ok(MixerCommand::StartInteraction {
|
||||
leg_id,
|
||||
prompt_pcm_frames,
|
||||
prompt_stream_rx,
|
||||
prompt_cancel_tx,
|
||||
expected_digits,
|
||||
timeout_ms,
|
||||
result_tx,
|
||||
@@ -280,13 +587,14 @@ async fn mixer_loop(
|
||||
if let Some(slot) = legs.get_mut(&leg_id) {
|
||||
// Cancel any existing interaction first.
|
||||
if let LegRole::Isolated(ref mut old_state) = slot.role {
|
||||
if let Some(tx) = old_state.result_tx.take() {
|
||||
let _ = tx.send(InteractionResult::Cancelled);
|
||||
}
|
||||
cancel_isolated_interaction(old_state);
|
||||
}
|
||||
let timeout_ticks = timeout_ms / 20;
|
||||
slot.role = LegRole::Isolated(IsolationState {
|
||||
prompt_frames: VecDeque::from(prompt_pcm_frames),
|
||||
prompt_stream_rx,
|
||||
prompt_cancel_tx,
|
||||
prompt_stream_finished: false,
|
||||
expected_digits,
|
||||
timeout_ticks_remaining: timeout_ticks,
|
||||
prompt_done: false,
|
||||
@@ -294,6 +602,9 @@ async fn mixer_loop(
|
||||
});
|
||||
} else {
|
||||
// Leg not found — immediately cancel.
|
||||
if let Some(cancel_tx) = prompt_cancel_tx {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
let _ = result_tx.send(InteractionResult::Cancelled);
|
||||
}
|
||||
}
|
||||
@@ -302,7 +613,14 @@ async fn mixer_loop(
|
||||
tool_type,
|
||||
audio_tx,
|
||||
}) => {
|
||||
tool_legs.insert(leg_id, ToolLegSlot { tool_type, audio_tx });
|
||||
tool_legs.insert(
|
||||
leg_id,
|
||||
ToolLegSlot {
|
||||
tool_type,
|
||||
audio_tx,
|
||||
dropped_batches: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(MixerCommand::RemoveToolLeg { leg_id }) => {
|
||||
tool_legs.remove(&leg_id);
|
||||
@@ -343,54 +661,11 @@ async fn mixer_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2b: Consume exactly one frame from the jitter buffer.
|
||||
match slot.jitter.consume() {
|
||||
JitterResult::Packet(pkt) => {
|
||||
match slot.transcoder.decode_to_f32(&pkt.payload, pkt.payload_type) {
|
||||
Ok((pcm, rate)) => {
|
||||
let pcm_48k = if rate == MIX_RATE {
|
||||
pcm
|
||||
} else {
|
||||
slot.transcoder
|
||||
.resample_f32(&pcm, rate, MIX_RATE)
|
||||
.unwrap_or_else(|_| vec![0.0f32; MIX_FRAME_SIZE])
|
||||
};
|
||||
let processed = if slot.codec_pt != codec_lib::PT_OPUS {
|
||||
TranscodeState::denoise_f32(&mut slot.denoiser, &pcm_48k)
|
||||
} else {
|
||||
pcm_48k
|
||||
};
|
||||
let mut frame = processed;
|
||||
frame.resize(MIX_FRAME_SIZE, 0.0);
|
||||
slot.last_pcm_frame = frame;
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
JitterResult::Missing => {
|
||||
// Invoke Opus PLC or fade for non-Opus codecs.
|
||||
if slot.codec_pt == codec_lib::PT_OPUS {
|
||||
match slot.transcoder.opus_plc(MIX_FRAME_SIZE) {
|
||||
Ok(pcm) => {
|
||||
slot.last_pcm_frame = pcm;
|
||||
}
|
||||
Err(_) => {
|
||||
for s in slot.last_pcm_frame.iter_mut() {
|
||||
*s *= 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-Opus: fade last frame toward silence.
|
||||
for s in slot.last_pcm_frame.iter_mut() {
|
||||
*s *= 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
JitterResult::Filling => {
|
||||
slot.last_pcm_frame = vec![0.0f32; MIX_FRAME_SIZE];
|
||||
}
|
||||
}
|
||||
// Step 2b: Decode enough RTP to cover one 20ms playout frame.
|
||||
// Variable-duration packets (10ms, 20ms, 60ms, ...) accumulate in
|
||||
// the per-leg PCM FIFO; we pop exactly one 20ms frame below.
|
||||
fill_leg_playout_buffer(slot);
|
||||
slot.last_pcm_frame = take_mix_frame(slot);
|
||||
|
||||
// Run jitter adaptation + prune stale packets.
|
||||
slot.jitter.adapt();
|
||||
@@ -404,6 +679,9 @@ async fn mixer_loop(
|
||||
}
|
||||
if slot.silent_ticks > 150 {
|
||||
slot.last_pcm_frame = vec![0.0f32; MIX_FRAME_SIZE];
|
||||
slot.pcm_buffer.clear();
|
||||
slot.expected_rtp_timestamp = None;
|
||||
slot.estimated_packet_ts = rtp_clock_increment(slot.codec_pt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,12 +704,12 @@ async fn mixer_loop(
|
||||
for (lid, slot) in legs.iter_mut() {
|
||||
match &mut slot.role {
|
||||
LegRole::Participant => {
|
||||
// Mix-minus: total minus this leg's own contribution, clamped to [-1.0, 1.0].
|
||||
// Mix-minus: total minus this leg's own contribution.
|
||||
// Apply a light soft limiter instead of hard clipping the sum.
|
||||
let mut mix_minus = Vec::with_capacity(MIX_FRAME_SIZE);
|
||||
for i in 0..MIX_FRAME_SIZE {
|
||||
let sample =
|
||||
(total_mix[i] - slot.last_pcm_frame[i] as f64) as f32;
|
||||
mix_minus.push(sample.clamp(-1.0, 1.0));
|
||||
let sample = (total_mix[i] - slot.last_pcm_frame[i] as f64) as f32;
|
||||
mix_minus.push(soft_limit_sample(sample));
|
||||
}
|
||||
|
||||
// Resample from 48kHz to the leg's codec native rate.
|
||||
@@ -445,11 +723,10 @@ async fn mixer_loop(
|
||||
};
|
||||
|
||||
// Encode to the leg's codec (f32 → i16 → codec inside encode_from_f32).
|
||||
let encoded =
|
||||
match slot.transcoder.encode_from_f32(&resampled, slot.codec_pt) {
|
||||
Ok(e) if !e.is_empty() => e,
|
||||
_ => continue,
|
||||
};
|
||||
let encoded = match slot.transcoder.encode_from_f32(&resampled, slot.codec_pt) {
|
||||
Ok(e) if !e.is_empty() => e,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Build RTP packet with header.
|
||||
let header =
|
||||
@@ -460,10 +737,11 @@ async fn mixer_loop(
|
||||
slot.rtp_seq = slot.rtp_seq.wrapping_add(1);
|
||||
slot.rtp_ts = slot.rtp_ts.wrapping_add(rtp_clock_increment(slot.codec_pt));
|
||||
|
||||
// Non-blocking send — drop frame if channel is full.
|
||||
let _ = slot.outbound_tx.try_send(rtp);
|
||||
try_send_leg_output(&out_tx, &call_id, lid, slot, rtp, "participant-audio");
|
||||
}
|
||||
LegRole::Isolated(state) => {
|
||||
drain_prompt_stream(&out_tx, &call_id, lid, state);
|
||||
|
||||
// Check for DTMF digit from this leg.
|
||||
let mut matched_digit: Option<char> = None;
|
||||
for (src_lid, dtmf_pkt) in &dtmf_forward {
|
||||
@@ -487,12 +765,14 @@ async fn mixer_loop(
|
||||
|
||||
if let Some(digit) = matched_digit {
|
||||
// Interaction complete — digit matched.
|
||||
completed_interactions
|
||||
.push((lid.clone(), InteractionResult::Digit(digit)));
|
||||
completed_interactions.push((lid.clone(), InteractionResult::Digit(digit)));
|
||||
} else {
|
||||
// Play prompt frame or silence.
|
||||
// Play prompt frame, wait for live TTS, or move to timeout once the
|
||||
// prompt stream has fully drained.
|
||||
let pcm_frame = if let Some(frame) = state.prompt_frames.pop_front() {
|
||||
frame
|
||||
} else if !state.prompt_stream_finished {
|
||||
vec![0.0f32; MIX_FRAME_SIZE]
|
||||
} else {
|
||||
state.prompt_done = true;
|
||||
vec![0.0f32; MIX_FRAME_SIZE]
|
||||
@@ -508,6 +788,7 @@ async fn mixer_loop(
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut prompt_rtp: Option<Vec<u8>> = None;
|
||||
if let Ok(encoded) =
|
||||
slot.transcoder.encode_from_f32(&resampled, slot.codec_pt)
|
||||
{
|
||||
@@ -521,10 +802,9 @@ async fn mixer_loop(
|
||||
let mut rtp = header.to_vec();
|
||||
rtp.extend_from_slice(&encoded);
|
||||
slot.rtp_seq = slot.rtp_seq.wrapping_add(1);
|
||||
slot.rtp_ts = slot
|
||||
.rtp_ts
|
||||
.wrapping_add(rtp_clock_increment(slot.codec_pt));
|
||||
let _ = slot.outbound_tx.try_send(rtp);
|
||||
slot.rtp_ts =
|
||||
slot.rtp_ts.wrapping_add(rtp_clock_increment(slot.codec_pt));
|
||||
prompt_rtp = Some(rtp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +817,17 @@ async fn mixer_loop(
|
||||
state.timeout_ticks_remaining -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rtp) = prompt_rtp {
|
||||
try_send_leg_output(
|
||||
&out_tx,
|
||||
&call_id,
|
||||
lid,
|
||||
slot,
|
||||
rtp,
|
||||
"isolated-prompt",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -546,6 +837,7 @@ async fn mixer_loop(
|
||||
for (lid, result) in completed_interactions {
|
||||
if let Some(slot) = legs.get_mut(&lid) {
|
||||
if let LegRole::Isolated(ref mut state) = slot.role {
|
||||
cancel_prompt_producer(state);
|
||||
if let Some(tx) = state.result_tx.take() {
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
@@ -566,7 +858,7 @@ async fn mixer_loop(
|
||||
})
|
||||
.collect();
|
||||
|
||||
for tool in tool_legs.values() {
|
||||
for (tool_leg_id, tool) in tool_legs.iter_mut() {
|
||||
let batch = ToolAudioBatch {
|
||||
sources: sources
|
||||
.iter()
|
||||
@@ -576,8 +868,7 @@ async fn mixer_loop(
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
// Non-blocking send — drop batch if tool can't keep up.
|
||||
let _ = tool.audio_tx.try_send(batch);
|
||||
try_send_tool_output(&out_tx, &call_id, tool_leg_id, tool, batch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,7 +901,7 @@ async fn mixer_loop(
|
||||
rtp_out.extend_from_slice(&dtmf_pkt.payload);
|
||||
target_slot.rtp_seq = target_slot.rtp_seq.wrapping_add(1);
|
||||
// Don't increment rtp_ts for DTMF — it shares timestamp context with audio.
|
||||
let _ = target_slot.outbound_tx.try_send(rtp_out);
|
||||
try_send_leg_output(&out_tx, &call_id, target_lid, target_slot, rtp_out, "dtmf");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,11 +267,7 @@ impl ProviderManager {
|
||||
|
||||
/// Try to handle a SIP response as a provider registration response.
|
||||
/// Returns true if consumed.
|
||||
pub async fn handle_response(
|
||||
&self,
|
||||
msg: &SipMessage,
|
||||
socket: &UdpSocket,
|
||||
) -> bool {
|
||||
pub async fn handle_response(&self, msg: &SipMessage, socket: &UdpSocket) -> bool {
|
||||
for ps_arc in &self.providers {
|
||||
let mut ps = ps_arc.lock().await;
|
||||
let was_registered = ps.is_registered;
|
||||
@@ -322,7 +318,10 @@ impl ProviderManager {
|
||||
}
|
||||
|
||||
/// Find a provider by its config ID (e.g. "easybell").
|
||||
pub async fn find_by_provider_id(&self, provider_id: &str) -> Option<Arc<Mutex<ProviderState>>> {
|
||||
pub async fn find_by_provider_id(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> Option<Arc<Mutex<ProviderState>>> {
|
||||
for ps_arc in &self.providers {
|
||||
let ps = ps_arc.lock().await;
|
||||
if ps.config.id == provider_id {
|
||||
|
||||
@@ -25,8 +25,7 @@ impl Recorder {
|
||||
) -> Result<Self, String> {
|
||||
// Ensure parent directory exists.
|
||||
if let Some(parent) = Path::new(file_path).parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("create dir: {e}"))?;
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?;
|
||||
}
|
||||
|
||||
let sample_rate = 8000u32; // Record at 8kHz (standard telephony)
|
||||
@@ -57,10 +56,13 @@ impl Recorder {
|
||||
|
||||
/// Create a recorder that writes raw PCM at a given sample rate.
|
||||
/// Used by tool legs that already have decoded PCM (no RTP processing needed).
|
||||
pub fn new_pcm(file_path: &str, sample_rate: u32, max_duration_ms: Option<u64>) -> Result<Self, String> {
|
||||
pub fn new_pcm(
|
||||
file_path: &str,
|
||||
sample_rate: u32,
|
||||
max_duration_ms: Option<u64>,
|
||||
) -> Result<Self, String> {
|
||||
if let Some(parent) = Path::new(file_path).parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("create dir: {e}"))?;
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?;
|
||||
}
|
||||
|
||||
let spec = hound::WavSpec {
|
||||
|
||||
@@ -60,18 +60,17 @@ impl Registrar {
|
||||
|
||||
/// Try to handle a SIP REGISTER from a device.
|
||||
/// Returns Some(response_bytes) if handled, None if not a known device.
|
||||
pub fn handle_register(
|
||||
&mut self,
|
||||
msg: &SipMessage,
|
||||
from_addr: SocketAddr,
|
||||
) -> Option<Vec<u8>> {
|
||||
pub fn handle_register(&mut self, msg: &SipMessage, from_addr: SocketAddr) -> Option<Vec<u8>> {
|
||||
if msg.method() != Some("REGISTER") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the device by matching the source IP against expectedAddress.
|
||||
let from_ip = from_addr.ip().to_string();
|
||||
let device = self.devices.iter().find(|d| d.expected_address == from_ip)?;
|
||||
let device = self
|
||||
.devices
|
||||
.iter()
|
||||
.find(|d| d.expected_address == from_ip)?;
|
||||
|
||||
let from_header = msg.get_header("From").unwrap_or("");
|
||||
let aor = SipMessage::extract_uri(from_header)
|
||||
@@ -79,9 +78,7 @@ impl Registrar {
|
||||
.unwrap_or_else(|| format!("sip:{}@{}", device.extension, from_ip));
|
||||
|
||||
let expires_header = msg.get_header("Expires");
|
||||
let requested: u32 = expires_header
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(3600);
|
||||
let requested: u32 = expires_header.and_then(|s| s.parse().ok()).unwrap_or(3600);
|
||||
let expires = requested.min(MAX_EXPIRES);
|
||||
|
||||
let entry = RegisteredDevice {
|
||||
@@ -122,10 +119,7 @@ impl Registrar {
|
||||
Some(ResponseOptions {
|
||||
to_tag: Some(generate_tag()),
|
||||
contact: Some(contact),
|
||||
extra_headers: Some(vec![(
|
||||
"Expires".to_string(),
|
||||
expires.to_string(),
|
||||
)]),
|
||||
extra_headers: Some(vec![("Expires".to_string(), expires.to_string())]),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
@@ -145,8 +139,8 @@ impl Registrar {
|
||||
/// Find a registered device by its source IP address.
|
||||
pub fn find_by_address(&self, addr: &SocketAddr) -> Option<&RegisteredDevice> {
|
||||
let ip = addr.ip().to_string();
|
||||
self.registered.values().find(|e| {
|
||||
e.contact_addr.ip().to_string() == ip && Instant::now() <= e.expires_at
|
||||
})
|
||||
self.registered
|
||||
.values()
|
||||
.find(|e| e.contact_addr.ip().to_string() == ip && Instant::now() <= e.expires_at)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +82,15 @@ pub fn build_rtp_header(pt: u8, seq: u16, timestamp: u32, ssrc: u32) -> [u8; 12]
|
||||
|
||||
/// Get the RTP clock increment per 20ms frame for a payload type.
|
||||
pub fn rtp_clock_increment(pt: u8) -> u32 {
|
||||
rtp_clock_rate(pt) / 50
|
||||
}
|
||||
|
||||
/// Get the RTP clock rate for a payload type.
|
||||
pub fn rtp_clock_rate(pt: u8) -> u32 {
|
||||
match pt {
|
||||
9 => 160, // G.722: 8000 Hz clock rate (despite 16kHz audio) × 0.02s
|
||||
0 | 8 => 160, // PCMU/PCMA: 8000 × 0.02
|
||||
111 => 960, // Opus: 48000 × 0.02
|
||||
_ => 160,
|
||||
9 => 8000, // G.722 uses an 8kHz RTP clock despite 16kHz audio.
|
||||
0 | 8 => 8000, // PCMU/PCMA
|
||||
111 => 48000, // Opus
|
||||
_ => 8000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,17 +128,24 @@ impl SipLeg {
|
||||
max_forwards: Some(70),
|
||||
body: Some(sdp),
|
||||
content_type: Some("application/sdp".to_string()),
|
||||
extra_headers: Some(vec![
|
||||
("User-Agent".to_string(), "SipRouter/1.0".to_string()),
|
||||
]),
|
||||
extra_headers: Some(vec![(
|
||||
"User-Agent".to_string(),
|
||||
"SipRouter/1.0".to_string(),
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
self.dialog = Some(SipDialog::from_uac_invite(&invite, ip, self.config.lan_port));
|
||||
self.dialog = Some(SipDialog::from_uac_invite(
|
||||
&invite,
|
||||
ip,
|
||||
self.config.lan_port,
|
||||
));
|
||||
self.invite = Some(invite.clone());
|
||||
self.state = LegState::Inviting;
|
||||
|
||||
let _ = socket.send_to(&invite.serialize(), self.config.sip_target).await;
|
||||
let _ = socket
|
||||
.send_to(&invite.serialize(), self.config.sip_target)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Handle an incoming SIP message routed to this leg.
|
||||
@@ -443,10 +450,7 @@ pub enum SipLegAction {
|
||||
/// Build an ACK for a non-2xx response (same transaction as the INVITE).
|
||||
fn build_non_2xx_ack(original_invite: &SipMessage, response: &SipMessage) -> SipMessage {
|
||||
let via = original_invite.get_header("Via").unwrap_or("").to_string();
|
||||
let from = original_invite
|
||||
.get_header("From")
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let from = original_invite.get_header("From").unwrap_or("").to_string();
|
||||
let to = response.get_header("To").unwrap_or("").to_string();
|
||||
let call_id = original_invite.call_id().to_string();
|
||||
let cseq_num: u32 = original_invite
|
||||
|
||||
@@ -28,10 +28,8 @@ impl SipTransport {
|
||||
}
|
||||
|
||||
/// Spawn the UDP receive loop. Calls the handler for every received packet.
|
||||
pub fn spawn_receiver<F>(
|
||||
&self,
|
||||
handler: F,
|
||||
) where
|
||||
pub fn spawn_receiver<F>(&self, handler: F)
|
||||
where
|
||||
F: Fn(&[u8], SocketAddr) + Send + 'static,
|
||||
{
|
||||
let socket = self.socket.clone();
|
||||
|
||||
@@ -51,7 +51,8 @@ pub fn spawn_recording_tool(
|
||||
});
|
||||
|
||||
// Convert f32 [-1.0, 1.0] to i16 for WAV writing.
|
||||
let pcm_i16: Vec<i16> = source.pcm_48k
|
||||
let pcm_i16: Vec<i16> = source
|
||||
.pcm_48k
|
||||
.iter()
|
||||
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
|
||||
.collect();
|
||||
|
||||
@@ -1,15 +1,49 @@
|
||||
//! 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 crate::audio_player::pcm_to_mix_frames;
|
||||
use kokoro_tts::{KokoroTts, Voice};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
pub const DEFAULT_MODEL_PATH: &str = ".nogit/tts/kokoro-v1.0.onnx";
|
||||
pub const DEFAULT_VOICES_PATH: &str = ".nogit/tts/voices.bin";
|
||||
const TTS_OUTPUT_RATE: u32 = 24000;
|
||||
const MAX_CHUNK_CHARS: usize = 220;
|
||||
const MIN_CHUNK_CHARS: usize = 80;
|
||||
|
||||
pub enum TtsStreamMessage {
|
||||
Frames(Vec<Vec<f32>>),
|
||||
Finished,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub struct TtsLivePrompt {
|
||||
pub initial_frames: Vec<Vec<f32>>,
|
||||
pub stream_rx: mpsc::Receiver<TtsStreamMessage>,
|
||||
pub cancel_tx: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TtsPromptRequest {
|
||||
pub model_path: String,
|
||||
pub voices_path: String,
|
||||
pub voice_name: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Wraps the Kokoro TTS engine with lazy model loading.
|
||||
pub struct TtsEngine {
|
||||
tts: Option<KokoroTts>,
|
||||
tts: Option<Arc<KokoroTts>>,
|
||||
/// Path that was used to load the current model (for cache invalidation).
|
||||
loaded_model_path: String,
|
||||
loaded_voices_path: String,
|
||||
@@ -24,6 +58,69 @@ impl TtsEngine {
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_loaded(
|
||||
&mut self,
|
||||
model_path: &str,
|
||||
voices_path: &str,
|
||||
) -> Result<Arc<KokoroTts>, String> {
|
||||
if !Path::new(model_path).exists() {
|
||||
return Err(format!("model not found: {model_path}"));
|
||||
}
|
||||
if !Path::new(voices_path).exists() {
|
||||
return Err(format!("voices not found: {voices_path}"));
|
||||
}
|
||||
|
||||
if self.tts.is_none()
|
||||
|| self.loaded_model_path != model_path
|
||||
|| self.loaded_voices_path != voices_path
|
||||
{
|
||||
eprintln!("[tts] loading model: {model_path}");
|
||||
let tts = Arc::new(
|
||||
KokoroTts::new(model_path, voices_path)
|
||||
.await
|
||||
.map_err(|e| format!("model load failed: {e:?}"))?,
|
||||
);
|
||||
self.tts = Some(tts);
|
||||
self.loaded_model_path = model_path.to_string();
|
||||
self.loaded_voices_path = voices_path.to_string();
|
||||
}
|
||||
|
||||
Ok(self.tts.as_ref().unwrap().clone())
|
||||
}
|
||||
|
||||
pub async fn start_live_prompt(
|
||||
&mut self,
|
||||
request: TtsPromptRequest,
|
||||
) -> Result<TtsLivePrompt, String> {
|
||||
if request.text.trim().is_empty() {
|
||||
return Err("empty text".into());
|
||||
}
|
||||
|
||||
let tts = self
|
||||
.ensure_loaded(&request.model_path, &request.voices_path)
|
||||
.await?;
|
||||
let voice = select_voice(&request.voice_name);
|
||||
let chunks = chunk_text(&request.text);
|
||||
if chunks.is_empty() {
|
||||
return Err("empty text".into());
|
||||
}
|
||||
|
||||
let initial_frames = synth_text_to_mix_frames(&tts, chunks[0].as_str(), voice).await?;
|
||||
let remaining_chunks: Vec<String> = chunks.into_iter().skip(1).collect();
|
||||
let (stream_tx, stream_rx) = mpsc::channel(8);
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
|
||||
tokio::spawn(async move {
|
||||
stream_live_prompt_chunks(tts, voice, remaining_chunks, stream_tx, cancel_rx).await;
|
||||
});
|
||||
|
||||
Ok(TtsLivePrompt {
|
||||
initial_frames,
|
||||
stream_rx,
|
||||
cancel_tx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate a WAV file from text.
|
||||
///
|
||||
/// Params (from IPC JSON):
|
||||
@@ -32,52 +129,64 @@ impl TtsEngine {
|
||||
/// - `voice`: voice name (e.g. "af_bella")
|
||||
/// - `text`: text to synthesize
|
||||
/// - `output`: output WAV file path
|
||||
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())
|
||||
/// - `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")?;
|
||||
let voices_path = params.get("voices").and_then(|v| v.as_str())
|
||||
let voices_path = params
|
||||
.get("voices")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing 'voices' param")?;
|
||||
let voice_name = params.get("voice").and_then(|v| v.as_str())
|
||||
let voice_name = params
|
||||
.get("voice")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("af_bella");
|
||||
let text = params.get("text").and_then(|v| v.as_str())
|
||||
let text = params
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing 'text' param")?;
|
||||
let output_path = params.get("output").and_then(|v| v.as_str())
|
||||
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());
|
||||
}
|
||||
|
||||
// Check that model/voices files exist.
|
||||
if !Path::new(model_path).exists() {
|
||||
return Err(format!("model not found: {model_path}"));
|
||||
}
|
||||
if !Path::new(voices_path).exists() {
|
||||
return Err(format!("voices not found: {voices_path}"));
|
||||
// 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 }));
|
||||
}
|
||||
|
||||
// Lazy-load or reload if paths changed.
|
||||
if self.tts.is_none()
|
||||
|| self.loaded_model_path != model_path
|
||||
|| self.loaded_voices_path != voices_path
|
||||
{
|
||||
eprintln!("[tts] loading model: {model_path}");
|
||||
let tts = KokoroTts::new(model_path, voices_path)
|
||||
.await
|
||||
.map_err(|e| format!("model load failed: {e:?}"))?;
|
||||
self.tts = Some(tts);
|
||||
self.loaded_model_path = model_path.to_string();
|
||||
self.loaded_voices_path = voices_path.to_string();
|
||||
// Ensure parent directory exists.
|
||||
if let Some(parent) = Path::new(output_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
let tts = self.tts.as_ref().unwrap();
|
||||
let tts = self.ensure_loaded(model_path, voices_path).await?;
|
||||
let voice = select_voice(voice_name);
|
||||
|
||||
eprintln!("[tts] synthesizing voice '{voice_name}': \"{text}\"");
|
||||
let (samples, duration) = tts.synth(text, voice)
|
||||
eprintln!("[tts] synthesizing WAV voice '{voice_name}' to {output_path}");
|
||||
let (samples, duration) = tts
|
||||
.synth(text, voice)
|
||||
.await
|
||||
.map_err(|e| format!("synthesis failed: {e:?}"))?;
|
||||
eprintln!("[tts] synthesized {} samples in {duration:?}", samples.len());
|
||||
eprintln!(
|
||||
"[tts] synthesized {} samples in {duration:?}",
|
||||
samples.len()
|
||||
);
|
||||
|
||||
// Write 24kHz 16-bit mono WAV.
|
||||
let spec = hound::WavSpec {
|
||||
@@ -91,13 +200,149 @@ impl TtsEngine {
|
||||
.map_err(|e| format!("WAV create failed: {e}"))?;
|
||||
for &sample in &samples {
|
||||
let s16 = (sample * 32767.0).round().clamp(-32768.0, 32767.0) as i16;
|
||||
writer.write_sample(s16).map_err(|e| format!("WAV write: {e}"))?;
|
||||
writer
|
||||
.write_sample(s16)
|
||||
.map_err(|e| format!("WAV write: {e}"))?;
|
||||
}
|
||||
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);
|
||||
}
|
||||
writer.finalize().map_err(|e| format!("WAV finalize: {e}"))?;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
async fn synth_text_to_mix_frames(
|
||||
tts: &Arc<KokoroTts>,
|
||||
text: &str,
|
||||
voice: Voice,
|
||||
) -> Result<Vec<Vec<f32>>, String> {
|
||||
let (samples, duration) = tts
|
||||
.synth(text, voice)
|
||||
.await
|
||||
.map_err(|e| format!("synthesis failed: {e:?}"))?;
|
||||
eprintln!(
|
||||
"[tts] synthesized chunk ({} chars, {} samples) in {duration:?}",
|
||||
text.chars().count(),
|
||||
samples.len()
|
||||
);
|
||||
pcm_to_mix_frames(&samples, TTS_OUTPUT_RATE)
|
||||
}
|
||||
|
||||
async fn stream_live_prompt_chunks(
|
||||
tts: Arc<KokoroTts>,
|
||||
voice: Voice,
|
||||
chunks: Vec<String>,
|
||||
stream_tx: mpsc::Sender<TtsStreamMessage>,
|
||||
mut cancel_rx: watch::Receiver<bool>,
|
||||
) {
|
||||
for chunk in chunks {
|
||||
if *cancel_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
|
||||
match synth_text_to_mix_frames(&tts, &chunk, voice).await {
|
||||
Ok(frames) => {
|
||||
if *cancel_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
if stream_tx.send(TtsStreamMessage::Frames(frames)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = stream_tx.send(TtsStreamMessage::Failed(error)).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if cancel_rx.has_changed().unwrap_or(false) && *cancel_rx.borrow_and_update() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = stream_tx.send(TtsStreamMessage::Finished).await;
|
||||
}
|
||||
|
||||
fn chunk_text(text: &str) -> Vec<String> {
|
||||
let mut chunks = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for ch in text.chars() {
|
||||
current.push(ch);
|
||||
|
||||
let len = current.chars().count();
|
||||
let hard_split = len >= MAX_CHUNK_CHARS && (ch.is_whitespace() || is_soft_boundary(ch));
|
||||
let natural_split = len >= MIN_CHUNK_CHARS && is_sentence_boundary(ch);
|
||||
|
||||
if natural_split || hard_split {
|
||||
push_chunk(&mut chunks, &mut current);
|
||||
}
|
||||
}
|
||||
|
||||
push_chunk(&mut chunks, &mut current);
|
||||
|
||||
if chunks.len() >= 2 {
|
||||
let last_len = chunks.last().unwrap().chars().count();
|
||||
if last_len < (MIN_CHUNK_CHARS / 2) {
|
||||
let tail = chunks.pop().unwrap();
|
||||
if let Some(prev) = chunks.last_mut() {
|
||||
prev.push(' ');
|
||||
prev.push_str(tail.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
fn push_chunk(chunks: &mut Vec<String>, current: &mut String) {
|
||||
let trimmed = current.trim();
|
||||
if !trimmed.is_empty() {
|
||||
chunks.push(trimmed.to_string());
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
|
||||
fn is_sentence_boundary(ch: char) -> bool {
|
||||
matches!(ch, '.' | '!' | '?' | '\n' | ';' | ':')
|
||||
}
|
||||
|
||||
fn is_soft_boundary(ch: char) -> bool {
|
||||
matches!(ch, ',' | ';' | ':' | ')' | ']' | '\n')
|
||||
}
|
||||
|
||||
/// Map voice name string to Kokoro Voice enum variant.
|
||||
|
||||
@@ -128,8 +128,8 @@ async fn record_from_socket(
|
||||
break; // Max duration reached.
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => break, // Socket error (closed).
|
||||
Err(_) => break, // Timeout (max duration + grace).
|
||||
Ok(Err(_)) => break, // Socket error (closed).
|
||||
Err(_) => break, // Timeout (max duration + grace).
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,9 +58,7 @@ impl WebRtcEngine {
|
||||
.register_default_codecs()
|
||||
.map_err(|e| format!("register codecs: {e}"))?;
|
||||
|
||||
let api = APIBuilder::new()
|
||||
.with_media_engine(media_engine)
|
||||
.build();
|
||||
let api = APIBuilder::new().with_media_engine(media_engine).build();
|
||||
|
||||
let config = RTCConfiguration {
|
||||
ice_servers: vec![],
|
||||
@@ -91,8 +89,7 @@ impl WebRtcEngine {
|
||||
.map_err(|e| format!("add track: {e}"))?;
|
||||
|
||||
// Shared mixer channel sender (populated when linked to a call).
|
||||
let mixer_tx: Arc<Mutex<Option<mpsc::Sender<RtpPacket>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
let mixer_tx: Arc<Mutex<Option<mpsc::Sender<RtpPacket>>>> = Arc::new(Mutex::new(None));
|
||||
|
||||
// ICE candidate handler.
|
||||
let out_tx_ice = self.out_tx.clone();
|
||||
@@ -256,7 +253,11 @@ impl WebRtcEngine {
|
||||
|
||||
pub async fn close_session(&mut self, session_id: &str) -> Result<(), String> {
|
||||
if let Some(session) = self.sessions.remove(session_id) {
|
||||
session.pc.close().await.map_err(|e| format!("close: {e}"))?;
|
||||
session
|
||||
.pc
|
||||
.close()
|
||||
.await
|
||||
.map_err(|e| format!("close: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -51,9 +51,7 @@ impl SipDialog {
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(generate_tag),
|
||||
remote_tag: None,
|
||||
local_uri: SipMessage::extract_uri(from)
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
local_uri: SipMessage::extract_uri(from).unwrap_or("").to_string(),
|
||||
remote_uri: SipMessage::extract_uri(to).unwrap_or("").to_string(),
|
||||
local_cseq,
|
||||
remote_cseq: 0,
|
||||
@@ -181,10 +179,7 @@ impl SipDialog {
|
||||
format!("<{}>{remote_tag_str}", self.remote_uri),
|
||||
),
|
||||
("Call-ID".to_string(), self.call_id.clone()),
|
||||
(
|
||||
"CSeq".to_string(),
|
||||
format!("{} {method}", self.local_cseq),
|
||||
),
|
||||
("CSeq".to_string(), format!("{} {method}", self.local_cseq)),
|
||||
("Max-Forwards".to_string(), "70".to_string()),
|
||||
];
|
||||
|
||||
@@ -243,10 +238,7 @@ impl SipDialog {
|
||||
format!("<{}>{remote_tag_str}", self.remote_uri),
|
||||
),
|
||||
("Call-ID".to_string(), self.call_id.clone()),
|
||||
(
|
||||
"CSeq".to_string(),
|
||||
format!("{} ACK", self.local_cseq),
|
||||
),
|
||||
("CSeq".to_string(), format!("{} ACK", self.local_cseq)),
|
||||
("Max-Forwards".to_string(), "70".to_string()),
|
||||
];
|
||||
|
||||
@@ -271,10 +263,7 @@ impl SipDialog {
|
||||
("From".to_string(), from),
|
||||
("To".to_string(), to),
|
||||
("Call-ID".to_string(), self.call_id.clone()),
|
||||
(
|
||||
"CSeq".to_string(),
|
||||
format!("{} CANCEL", self.local_cseq),
|
||||
),
|
||||
("CSeq".to_string(), format!("{} CANCEL", self.local_cseq)),
|
||||
("Max-Forwards".to_string(), "70".to_string()),
|
||||
("Content-Length".to_string(), "0".to_string()),
|
||||
];
|
||||
@@ -284,11 +273,7 @@ impl SipDialog {
|
||||
.unwrap_or(&self.remote_target)
|
||||
.to_string();
|
||||
|
||||
SipMessage::new(
|
||||
format!("CANCEL {ruri} SIP/2.0"),
|
||||
headers,
|
||||
String::new(),
|
||||
)
|
||||
SipMessage::new(format!("CANCEL {ruri} SIP/2.0"), headers, String::new())
|
||||
}
|
||||
|
||||
/// Transition the dialog to terminated state.
|
||||
|
||||
@@ -27,7 +27,9 @@ pub fn generate_branch() -> String {
|
||||
|
||||
fn random_hex(bytes: usize) -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..bytes).map(|_| format!("{:02x}", rng.gen::<u8>())).collect()
|
||||
(0..bytes)
|
||||
.map(|_| format!("{:02x}", rng.gen::<u8>()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---- Codec registry --------------------------------------------------------
|
||||
@@ -142,7 +144,9 @@ pub fn parse_digest_challenge(header: &str) -> Option<DigestChallenge> {
|
||||
return Some(after[1..1 + end].to_string());
|
||||
}
|
||||
// Unquoted value.
|
||||
let end = after.find(|c: char| c == ',' || c.is_whitespace()).unwrap_or(after.len());
|
||||
let end = after
|
||||
.find(|c: char| c == ',' || c.is_whitespace())
|
||||
.unwrap_or(after.len());
|
||||
return Some(after[..end].to_string());
|
||||
}
|
||||
None
|
||||
@@ -241,11 +245,7 @@ pub struct MwiResult {
|
||||
pub extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub fn build_mwi_body(
|
||||
new_messages: u32,
|
||||
old_messages: u32,
|
||||
account_uri: &str,
|
||||
) -> MwiResult {
|
||||
pub fn build_mwi_body(new_messages: u32, old_messages: u32, account_uri: &str) -> MwiResult {
|
||||
let waiting = if new_messages > 0 { "yes" } else { "no" };
|
||||
let body = format!(
|
||||
"Messages-Waiting: {waiting}\r\n\
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! SDP handling, Digest authentication, and URI rewriting.
|
||||
//! Ported from the TypeScript `ts/sip/` library.
|
||||
|
||||
pub mod message;
|
||||
pub mod dialog;
|
||||
pub mod helpers;
|
||||
pub mod message;
|
||||
pub mod rewrite;
|
||||
|
||||
/// Network endpoint (address + port + optional negotiated codec).
|
||||
|
||||
@@ -14,7 +14,11 @@ pub struct SipMessage {
|
||||
|
||||
impl SipMessage {
|
||||
pub fn new(start_line: String, headers: Vec<(String, String)>, body: String) -> Self {
|
||||
Self { start_line, headers, body }
|
||||
Self {
|
||||
start_line,
|
||||
headers,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Parsing -----------------------------------------------------------
|
||||
@@ -175,7 +179,8 @@ impl SipMessage {
|
||||
|
||||
/// Inserts a header at the top of the header list.
|
||||
pub fn prepend_header(&mut self, name: &str, value: &str) -> &mut Self {
|
||||
self.headers.insert(0, (name.to_string(), value.to_string()));
|
||||
self.headers
|
||||
.insert(0, (name.to_string(), value.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -233,10 +238,7 @@ impl SipMessage {
|
||||
.to_display_name
|
||||
.map(|d| format!("\"{d}\" "))
|
||||
.unwrap_or_default();
|
||||
let to_tag_str = opts
|
||||
.to_tag
|
||||
.map(|t| format!(";tag={t}"))
|
||||
.unwrap_or_default();
|
||||
let to_tag_str = opts.to_tag.map(|t| format!(";tag={t}")).unwrap_or_default();
|
||||
|
||||
let mut headers = vec![
|
||||
(
|
||||
@@ -364,7 +366,43 @@ impl SipMessage {
|
||||
.find(|c: char| c == ';' || c == '>')
|
||||
.unwrap_or(trimmed.len());
|
||||
let result = &trimmed[..end];
|
||||
if result.is_empty() { None } else { Some(result) }
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the user part from a SIP/TEL URI or header value.
|
||||
pub fn extract_uri_user(uri_or_header_value: &str) -> Option<&str> {
|
||||
let raw = Self::extract_uri(uri_or_header_value).unwrap_or(uri_or_header_value);
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let user_part = if raw
|
||||
.get(..5)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("sips:"))
|
||||
{
|
||||
&raw[5..]
|
||||
} else if raw.get(..4).is_some_and(|prefix| {
|
||||
prefix.eq_ignore_ascii_case("sip:") || prefix.eq_ignore_ascii_case("tel:")
|
||||
}) {
|
||||
&raw[4..]
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
|
||||
let end = user_part
|
||||
.find(|c: char| matches!(c, '@' | ';' | '?' | '>'))
|
||||
.unwrap_or(user_part.len());
|
||||
let result = &user_part[..end];
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -506,6 +544,19 @@ mod tests {
|
||||
SipMessage::extract_uri("\"Name\" <sip:user@host>;tag=abc"),
|
||||
Some("sip:user@host")
|
||||
);
|
||||
assert_eq!(
|
||||
SipMessage::extract_uri_user("\"Name\" <sip:+49 421 219694@host>;tag=abc"),
|
||||
Some("+49 421 219694")
|
||||
);
|
||||
assert_eq!(
|
||||
SipMessage::extract_uri_user("sip:0049421219694@voip.easybell.de"),
|
||||
Some("0049421219694")
|
||||
);
|
||||
assert_eq!(
|
||||
SipMessage::extract_uri_user("tel:+49421219694;phone-context=example.com"),
|
||||
Some("+49421219694")
|
||||
);
|
||||
assert_eq!(SipMessage::extract_uri_user("SIP:user@host"), Some("user"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -535,7 +586,10 @@ mod tests {
|
||||
);
|
||||
assert_eq!(invite.method(), Some("INVITE"));
|
||||
assert_eq!(invite.call_id(), "test-123");
|
||||
assert!(invite.get_header("Via").unwrap().contains("192.168.1.1:5070"));
|
||||
assert!(invite
|
||||
.get_header("Via")
|
||||
.unwrap()
|
||||
.contains("192.168.1.1:5070"));
|
||||
|
||||
let response = SipMessage::create_response(
|
||||
200,
|
||||
|
||||
@@ -92,7 +92,11 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
|
||||
.collect();
|
||||
|
||||
let original = match (orig_addr, orig_port) {
|
||||
(Some(a), Some(p)) => Some(Endpoint { address: a, port: p, codec_pt: None }),
|
||||
(Some(a), Some(p)) => Some(Endpoint {
|
||||
address: a,
|
||||
port: p,
|
||||
codec_pt: None,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.20.3',
|
||||
version: '1.25.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
38
ts/config.ts
38
ts/config.ts
@@ -48,6 +48,24 @@ export interface IDeviceConfig {
|
||||
extension: string;
|
||||
}
|
||||
|
||||
export type TIncomingNumberMode = 'single' | 'range' | 'regex';
|
||||
|
||||
export interface IIncomingNumberConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
providerId?: string;
|
||||
mode: TIncomingNumberMode;
|
||||
countryCode?: string;
|
||||
areaCode?: string;
|
||||
localNumber?: string;
|
||||
rangeEnd?: string;
|
||||
pattern?: string;
|
||||
|
||||
// Legacy persisted fields kept for migration compatibility.
|
||||
number?: string;
|
||||
rangeStart?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Match/Action routing model
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -62,8 +80,11 @@ export interface ISipRouteMatch {
|
||||
direction: 'inbound' | 'outbound';
|
||||
|
||||
/**
|
||||
* Match the dialed/called number (To/Request-URI for inbound DID, dialed digits for outbound).
|
||||
* Supports: exact string, prefix with trailing '*' (e.g. "+4930*"), or regex ("/^\\+49/").
|
||||
* Match the normalized called number.
|
||||
*
|
||||
* Inbound: matches the provider-delivered DID / Request-URI user part.
|
||||
* Outbound: matches the normalized dialed digits.
|
||||
* Supports: exact string, numeric range `start..end`, prefix with trailing '*' (e.g. "+4930*"), or regex ("/^\\+49/").
|
||||
*/
|
||||
numberPattern?: string;
|
||||
|
||||
@@ -89,13 +110,13 @@ export interface ISipRouteAction {
|
||||
|
||||
// --- Inbound actions (IVR / voicemail) ---
|
||||
|
||||
/** Route directly to a voicemail box (skip ringing devices). */
|
||||
/** Voicemail fallback for matched inbound routes. */
|
||||
voicemailBox?: string;
|
||||
|
||||
/** Route to an IVR menu by menu ID (skip ringing devices). */
|
||||
ivrMenuId?: string;
|
||||
|
||||
/** Override no-answer timeout (seconds) before routing to voicemail. */
|
||||
/** Reserved for future no-answer handling. */
|
||||
noAnswerTimeout?: number;
|
||||
|
||||
// --- Outbound actions (provider selection) ---
|
||||
@@ -231,6 +252,7 @@ export interface IAppConfig {
|
||||
proxy: IProxyConfig;
|
||||
providers: IProviderConfig[];
|
||||
devices: IDeviceConfig[];
|
||||
incomingNumbers?: IIncomingNumberConfig[];
|
||||
routing: IRoutingConfig;
|
||||
contacts: IContact[];
|
||||
voiceboxes?: IVoiceboxConfig[];
|
||||
@@ -285,6 +307,14 @@ export function loadConfig(): IAppConfig {
|
||||
d.extension ??= '100';
|
||||
}
|
||||
|
||||
cfg.incomingNumbers ??= [];
|
||||
for (const incoming of cfg.incomingNumbers) {
|
||||
if (!incoming.id) incoming.id = `incoming-${Date.now()}`;
|
||||
incoming.label ??= incoming.id;
|
||||
incoming.mode ??= incoming.pattern ? 'regex' : incoming.rangeStart || incoming.rangeEnd ? 'range' : 'single';
|
||||
incoming.countryCode ??= incoming.mode === 'regex' ? undefined : '+49';
|
||||
}
|
||||
|
||||
cfg.routing ??= { routes: [] };
|
||||
cfg.routing.routes ??= [];
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -247,6 +266,7 @@ async function handleRequest(
|
||||
if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName;
|
||||
}
|
||||
}
|
||||
if (updates.incomingNumbers !== undefined) cfg.incomingNumbers = updates.incomingNumbers;
|
||||
if (updates.routing) {
|
||||
if (updates.routing.routes) {
|
||||
cfg.routing.routes = updates.routing.routes;
|
||||
@@ -258,7 +278,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 +359,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 +387,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 +406,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 +429,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 +438,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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -71,6 +82,19 @@ type TProxyCommands = {
|
||||
};
|
||||
result: { result: 'digit' | 'timeout' | 'cancelled'; digit?: string };
|
||||
};
|
||||
start_tts_interaction: {
|
||||
params: {
|
||||
call_id: string;
|
||||
leg_id: string;
|
||||
text: string;
|
||||
voice?: string;
|
||||
model?: string;
|
||||
voices?: string;
|
||||
expected_digits: string;
|
||||
timeout_ms: number;
|
||||
};
|
||||
result: { result: 'digit' | 'timeout' | 'cancelled'; digit?: string };
|
||||
};
|
||||
add_tool_leg: {
|
||||
params: {
|
||||
call_id: string;
|
||||
@@ -88,7 +112,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 +145,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 +153,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 +221,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 +232,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 +250,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 +268,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 +281,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 +291,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 +310,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 +330,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 +348,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 +362,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 +376,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 +397,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 +420,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 +446,49 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a live TTS interaction on a specific leg. The first chunk is rendered
|
||||
* up front and the rest streams into the mixer while playback is already live.
|
||||
*/
|
||||
export async function startTtsInteraction(
|
||||
callId: string,
|
||||
legId: string,
|
||||
text: string,
|
||||
expectedDigits: string,
|
||||
timeoutMs: number,
|
||||
options?: {
|
||||
voice?: string;
|
||||
model?: string;
|
||||
voices?: string;
|
||||
},
|
||||
): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
return await sendProxyCommand('start_tts_interaction', {
|
||||
call_id: callId,
|
||||
leg_id: legId,
|
||||
text,
|
||||
expected_digits: expectedDigits,
|
||||
timeout_ms: timeoutMs,
|
||||
voice: options?.voice,
|
||||
model: options?.model,
|
||||
voices: options?.voices,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] start_tts_interaction error: ${errorMessage(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -482,14 +504,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 +522,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 +544,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 +564,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
187
ts/runtime/proxy-events.ts
Normal 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
313
ts/runtime/status-store.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
66
ts/runtime/webrtc-linking.ts
Normal file
66
ts/runtime/webrtc-linking.ts
Normal 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
145
ts/shared/proxy-events.ts
Normal 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
89
ts/shared/status.ts
Normal 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>;
|
||||
}
|
||||
718
ts/sipproxy.ts
718
ts/sipproxy.ts
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.20.3',
|
||||
version: '1.25.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 -------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user