initial commit — SIP B2BUA + WebRTC bridge with Rust codec engine
Full-featured SIP router with multi-provider trunking, browser softphone via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML noise suppression, Kokoro neural TTS announcements, and a Lit-based web dashboard with live call monitoring and REST API.
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# SIP traces contain real phone numbers, auth nonces and dialog state.
|
||||||
|
sip_trace.log
|
||||||
|
sip_trace_*.log
|
||||||
|
|
||||||
|
# Runtime logs
|
||||||
|
proxy.out
|
||||||
|
proxy_v2.out
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Compiled binaries
|
||||||
|
sipproxy
|
||||||
|
siprouter
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist_ts_web/
|
||||||
|
dist_rust/
|
||||||
|
|
||||||
|
# Rust build artifacts
|
||||||
|
rust/target/
|
||||||
|
|
||||||
|
# Playwright artifacts
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# Secrets / local env
|
||||||
|
.nogit/
|
||||||
12
.smartconfig.json
Normal file
12
.smartconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"@git.zone/tsbundle": {
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"from": "./ts_web/index.ts",
|
||||||
|
"to": "./dist_ts_web/bundle.js",
|
||||||
|
"bundler": "esbuild",
|
||||||
|
"production": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
41
CLAUDE.md
Normal file
41
CLAUDE.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Project Notes
|
||||||
|
|
||||||
|
## Architecture: Hub Model (Call as Centerpiece)
|
||||||
|
|
||||||
|
All call logic lives in `ts/call/`. The Call is the central entity with N legs.
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
- `ts/call/call-manager.ts` — singleton registry, factory methods, SIP routing
|
||||||
|
- `ts/call/call.ts` — the hub: owns legs, media forwarding
|
||||||
|
- `ts/call/sip-leg.ts` — SIP device/provider connection (wraps SipDialog)
|
||||||
|
- `ts/call/webrtc-leg.ts` — browser WebRTC connection (wraps werift PeerConnection)
|
||||||
|
- `ts/call/rtp-port-pool.ts` — unified RTP port pool
|
||||||
|
- `ts/sipproxy.ts` — thin bootstrap wiring everything together
|
||||||
|
- `ts/webrtcbridge.ts` — browser device registration (signaling only)
|
||||||
|
|
||||||
|
### WebRTC Browser Call Flow (Critical)
|
||||||
|
|
||||||
|
The browser call flow has a specific signaling order that MUST be followed:
|
||||||
|
|
||||||
|
1. `POST /api/call` with browser deviceId → CallManager creates Call, saves pending state, notifies browser via `webrtc-incoming`
|
||||||
|
2. Browser sends `webrtc-offer` (with its own `sessionId`) → CallManager creates a **standalone** WebRtcLeg (NOT attached to any call yet)
|
||||||
|
3. Browser sends `webrtc-accept` (with `callId` + `sessionId`) → CallManager links the standalone WebRtcLeg to the Call, then starts the SIP provider leg
|
||||||
|
|
||||||
|
**The WebRtcLeg CANNOT be created at call creation time** because the browser's session ID is unknown until the `webrtc-offer` arrives.
|
||||||
|
|
||||||
|
### WebRTC Audio Return Channel (Critical)
|
||||||
|
|
||||||
|
The SIP→browser audio path works through the Call hub:
|
||||||
|
|
||||||
|
1. Provider sends RTP to SipLeg's socket
|
||||||
|
2. SipLeg's `onRtpReceived` fires → Call hub's `forwardRtp`
|
||||||
|
3. Call hub calls `webrtcLeg.sendRtp(data)` → which calls `forwardToBrowser()`
|
||||||
|
4. `forwardToBrowser` transcodes (G.722→Opus) and sends via `sender.sendRtp()` (WebRTC PeerConnection)
|
||||||
|
|
||||||
|
**`WebRtcLeg.sendRtp()` MUST feed into `forwardToBrowser()`** (the WebRTC PeerConnection path), NOT send to a UDP address. This was a bug that caused one-way audio.
|
||||||
|
|
||||||
|
The browser→SIP direction works independently: `ontrack.onReceiveRtp` → `forwardToSip()` → transcodes → sends directly to provider's media endpoint via UDP.
|
||||||
|
|
||||||
|
### SIP Protocol Library
|
||||||
|
|
||||||
|
`ts/sip/` is a zero-dependency SIP protocol library. Do not add transport or timer logic there — it's purely data-level (parse/build/mutate/serialize).
|
||||||
116
changelog.md
Normal file
116
changelog.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-09 - 1.8.0 - feat(providerstate)
|
||||||
|
sync provider registrations when config is reloaded after save
|
||||||
|
|
||||||
|
- Adds provider state synchronization to create registrations for new providers, remove deleted providers, and re-register providers whose configuration changed.
|
||||||
|
- Preserves detected public IP when rebuilding provider state for updated provider configs.
|
||||||
|
- Triggers registration status broadcasts after config reload so runtime state stays aligned with saved provider settings.
|
||||||
|
|
||||||
|
## 2026-04-09 - 1.7.0 - feat(audio)
|
||||||
|
add directional RNNoise suppression to transcoding and preserve RTP continuity during announcement handoff
|
||||||
|
|
||||||
|
- adds per-direction RNNoise denoising in the Rust opus transcoder with 48kHz frame processing
|
||||||
|
- passes transcoder direction through the TypeScript bridge so browser-bound and SIP-bound audio use separate suppression state
|
||||||
|
- shares RTP sequence and timestamp counters between announcements and live provider audio to avoid browser jitter buffer discontinuities
|
||||||
|
- updates the background restart script to rebuild the Rust codec before bundling
|
||||||
|
|
||||||
|
## 2026-04-09 - 1.6.0 - feat(codec,call,web-ui)
|
||||||
|
add isolated codec sessions for concurrent call transcoding and fix form input event handling
|
||||||
|
|
||||||
|
- adds per-session Rust codec state with create_session and destroy_session support to prevent concurrent calls from corrupting Opus and G.722 state
|
||||||
|
- updates WebRTC call setup to await transcoder initialization and clean up codec sessions on teardown
|
||||||
|
- improves codec robustness with safer PCM handling, larger Opus decode buffers, auto-reinit after bridge exit, and telephony tuning for Opus encoding
|
||||||
|
- switches multiple web UI forms from changeValue to input/newValue events so text fields and checkboxes update reliably
|
||||||
|
|
||||||
|
## 2026-04-09 - 1.5.1 - fix(call,opus-codec)
|
||||||
|
improve SIP/WebRTC RTP routing and use cached FFT resampling for transcoding
|
||||||
|
|
||||||
|
- route browser-to-provider RTP through the SIP leg socket to avoid symmetric RTP double-path issues
|
||||||
|
- preserve active calls when a WebRTC leg disconnects by removing only the terminated leg and updating call state correctly
|
||||||
|
- set dynamic SIP From URIs and display names for mediated and conference invites
|
||||||
|
- replace the custom resampler with rubato FFT resampling and cache resamplers by rate pair and chunk size for continuous audio streams
|
||||||
|
|
||||||
|
## 2026-04-09 - 1.5.0 - feat(contacts,phone)
|
||||||
|
add starred contacts quick dial flow and improve call/media handling
|
||||||
|
|
||||||
|
- Add starred contacts to config and app state, with sorting and contact stats support in the contacts view.
|
||||||
|
- Let contact actions open the phone view with a selected contact and show starred contacts as quick-dial entries.
|
||||||
|
- Update the phone and contacts UI to use dees input components and show all devices with online/offline status.
|
||||||
|
- Prevent raw codec passthrough on transcode failure and forward RTP using the parsed payload instead of slicing serialized packets.
|
||||||
|
- Adjust the 3x Opus downsampling lowpass filter coefficients for improved resampling behavior.
|
||||||
|
|
||||||
|
## 2026-04-09 - 1.4.0 - feat(calling)
|
||||||
|
add cached TTS announcements, external participant dialing, and call history support
|
||||||
|
|
||||||
|
- pre-generate Piper-based announcements and cache encoded G.722 and Opus RTP frames for SIP and WebRTC playback
|
||||||
|
- add encode_pcm support in the Rust codec bridge with anti-aliased PCM resampling for direct PCM-to-codec encoding
|
||||||
|
- add API and UI support for dialing external participants into an existing call
|
||||||
|
- record completed calls in bounded call history and expose them in the web UI
|
||||||
|
- improve RTP handling with stable SSRC usage, codec-specific silence payloads, and safer async transcoding sequencing
|
||||||
|
|
||||||
|
## 2026-04-09 - 1.3.1 - fix(router)
|
||||||
|
prevent duplicate app navigation callbacks when syncing tab selection with URL updates
|
||||||
|
|
||||||
|
- add an optional skipCallback flag to router navigation so URL changes do not re-trigger view loading
|
||||||
|
- update sipproxy app tab selection handling to push browser history without causing navigation loops
|
||||||
|
- limit router-driven appdash view loading to browser back/forward navigation
|
||||||
|
|
||||||
|
## 2026-04-09 - 1.3.0 - feat(webrtc-ui)
|
||||||
|
add routed dashboard views with contacts and provider management, and fix browser WebRTC call linking and audio forwarding
|
||||||
|
|
||||||
|
- Defers browser WebRTC leg creation until the offer arrives, links the standalone session on accept, and routes SIP-to-browser audio through the WebRTC peer connection to prevent one-way audio
|
||||||
|
- Adds URL-routed app views for overview, calls, phone, contacts, providers, and log, replacing the previous dashboard-only layout
|
||||||
|
- Introduces contacts support in config and app state, exposes contacts in status payloads, and adds UI to create, edit, delete, and call contacts
|
||||||
|
- Expands provider configuration management to support editing full provider settings plus adding and removing providers
|
||||||
|
- Makes outbound provider selection tolerant of deployments with no configured providers to avoid forwarding errors
|
||||||
|
|
||||||
|
## 2026-04-09 - 1.2.0 - feat(call)
|
||||||
|
introduce a hub-based call manager with SIP and WebRTC legs, unified RTP port pooling, and expanded call control APIs
|
||||||
|
|
||||||
|
- Adds a new call hub model with Call, Leg, SipLeg, WebRtcLeg, shared types, and centralized RTP forwarding/transcoding.
|
||||||
|
- Replaces the legacy call originator flow with CallManager-driven call handling in the SIP proxy bootstrap.
|
||||||
|
- Extends the web API and dashboard to support provider selection, richer call status, adding/removing legs, transfers, and WebRTC call control.
|
||||||
|
- Refactors WebRTC bridge responsibilities to signaling only while moving media handling into the new call layer.
|
||||||
|
|
||||||
|
## 2026-04-09 - 1.1.1 - fix(calloriginator)
|
||||||
|
handle 200 OK retransmits without rebuilding the bridge and always start RTP keepalive silence when remote media is available
|
||||||
|
|
||||||
|
- Resend ACK for repeated 200 OK responses and return early once the call is already connected.
|
||||||
|
- Prevent duplicate bridge setup and repeated state transitions on SIP response retransmits.
|
||||||
|
- Start the silence stream for all calls with remote media to keep the provider media path alive and help open the RTP NAT path.
|
||||||
|
|
||||||
|
## 2026-04-09 - 1.1.0 - feat(webrtc)
|
||||||
|
add browser audio device controls and improve WebRTC audio bridging
|
||||||
|
|
||||||
|
- add microphone and speaker selection to the browser softphone
|
||||||
|
- show local and remote audio level meters during active WebRTC calls
|
||||||
|
- ensure the WebRTC answer negotiates sendrecv audio so the server can send RTP to the browser
|
||||||
|
- extract and use sender SSRC for browser-bound RTP and add diagnostics for SIP-to-WebRTC media flow
|
||||||
|
- avoid starting the SIP silence stream for browser calls to prevent media path issues with easybell
|
||||||
|
|
||||||
|
## 2026-04-09 - 1.0.0 - platform
|
||||||
|
Major 1.0.0 release delivering a configurable multi-provider SIP router with web UI, browser softphone support, and Rust-based media transcoding.
|
||||||
|
|
||||||
|
- Introduced a generic multi-provider SIP proxy/router architecture with local registrar, per-provider upstream registration, digest auth handling, configurable routing, device status, quick-dials, and provider-specific call quirks
|
||||||
|
- Added a full web dashboard and later migrated it to a component-based frontend using `@design.estate/dees-element`, with provider/device/call/log views, status APIs, WebSocket updates, hot reload behavior, and improved app shell/modals
|
||||||
|
- Added configuration APIs and settings UI for editing providers, devices, inbound routing, quick-dials, and browser ringing behavior, persisted to `.nogit/config.json`
|
||||||
|
- Renamed the project from `grandstream-sip-proxy` to `SipRouter`, including binary naming and updated user-agent branding
|
||||||
|
- Added WebRTC softphone support for browsers, including browser device registration, incoming-call notifications, accept/reject handling, targeted WebSocket messaging, and device-aware call routing
|
||||||
|
- Improved browser device UX with automatic registration, browser-specific naming, `(this browser)` labeling, duplicate/stale registration cleanup, IP display, and always-visible configured devices with connection state
|
||||||
|
- Added HTTPS support for browser audio flows, including inbound browser ringing, single-port HTTP/HTTPS serving, and re-enabled `getUserMedia` once TLS was available
|
||||||
|
- Refined call routing so calls go to the selected device instead of a hardcoded endpoint, including explicit device selection requirements and defaulting outbound origin to the first SIP device instead of the browser
|
||||||
|
- Improved call stability by starting a silence stream when leg B connects to prevent provider teardown before media is flowing
|
||||||
|
- Migrated runtime/server infrastructure from Deno to Node.js/tsx, replacing Deno-specific APIs with Node.js HTTP/HTTPS and WebSocket implementations
|
||||||
|
- Completed media handling in Rust, replacing buggy TypeScript G.722 processing with a single IPC transcoding path covering Opus, G.722, PCMU, PCMA, resampling, and verified browser-to-mobile audio bridging
|
||||||
|
- Included prior build/runtime work such as TypeScript migration, Deno support with single-binary compilation, and related setup as part of the path to the final 1.0.0 architecture
|
||||||
|
|
||||||
|
## 2026-04-08 - unknown - initial
|
||||||
|
Initial SIP-aware proxy for Grandstream HT801 ↔ easybell connectivity.
|
||||||
|
|
||||||
|
- Added SIP message parsing with binary passthrough to avoid corrupting STUN keep-alives and RTP
|
||||||
|
- Implemented Contact and Request-URI rewriting between LAN and public addresses
|
||||||
|
- Added SDP rewriting and per-call RTP relay sockets
|
||||||
|
- Added NAT priming and G.722 silence streaming after `200 OK` so easybell detects inbound media promptly
|
||||||
|
- Inserted `Record-Route` so in-dialog ACK/BYE/re-INVITE continue through the proxy
|
||||||
|
- Included captured device setting snapshots and setup documentation for diagnosing registration issues
|
||||||
17
html/index.html
Normal file
17
html/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<title>SipRouter</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; background: #0f172a; color: #e2e8f0; font-family: system-ui, -apple-system, sans-serif; }
|
||||||
|
sipproxy-app { display: block; position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<sipproxy-app></sipproxy-app>
|
||||||
|
<script type="module" src="/bundle.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
license
Normal file
21
license
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Task Venture Capital GmbH
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "siprouter",
|
||||||
|
"version": "1.8.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"bundle": "node node_modules/.pnpm/esbuild@0.27.7/node_modules/esbuild/bin/esbuild ts_web/index.ts --bundle --format=esm --outfile=dist_ts_web/bundle.js --platform=browser --target=es2022 --minify",
|
||||||
|
"buildRust": "tsrust",
|
||||||
|
"start": "tsx ts/sipproxy.ts",
|
||||||
|
"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-element": "^2.2.4",
|
||||||
|
"@push.rocks/smartrust": "^1.3.2",
|
||||||
|
"@push.rocks/smartstate": "^2.3.0",
|
||||||
|
"werift": "^0.22.9",
|
||||||
|
"ws": "^8.20.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@git.zone/tsbundle": "^2.10.0",
|
||||||
|
"@git.zone/tsrust": "^1.3.2",
|
||||||
|
"@git.zone/tswatch": "^3.3.2",
|
||||||
|
"@types/ws": "^8.18.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
5677
pnpm-lock.yaml
generated
Normal file
5677
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
readme.ideas.md
Normal file
12
readme.ideas.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Ideas / Future Improvements
|
||||||
|
|
||||||
|
## nnnoiseless (RNNoise) Denoiser Improvements
|
||||||
|
|
||||||
|
### VAD-gated passthrough
|
||||||
|
`process_frame()` returns an `f32` VAD probability (0.0-1.0). Currently ignored. Use it to skip denoising when VAD is low — prevents the model from suppressing non-speech audio (hold music, DTMF tones, IVR prompts).
|
||||||
|
|
||||||
|
### Pre-warm denoiser on session creation
|
||||||
|
The first `process_frame()` call on a fresh `DenoiseState` produces fade-in artifacts (documented behavior). Feed a silent 480-sample frame during `TranscodeState::new()` so the first real audio frame gets a warmed-up RNN state.
|
||||||
|
|
||||||
|
### Custom telephony-trained RNNoise model
|
||||||
|
nnnoiseless supports loading custom `.rnn` model files via `RnnModel::from_bytes()` / `RnnModel::from_static_bytes()`. The default model is trained on general audio. A model trained specifically on telephony noise profiles (codec artifacts, line noise, echo residual) would perform better. Models from https://github.com/GregorR/rnnoise-models can be converted with `train/convert_rnnoise.py`.
|
||||||
316
readme.md
Normal file
316
readme.md
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
# @serve.zone/siprouter
|
||||||
|
|
||||||
|
A production-grade **SIP B2BUA + WebRTC bridge** built with TypeScript and Rust. Routes calls between SIP providers, SIP hardware devices, and browser softphones — with real-time codec transcoding, ML noise suppression, neural TTS announcements, and a slick web dashboard.
|
||||||
|
|
||||||
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔥 What It Does
|
||||||
|
|
||||||
|
siprouter sits between your SIP trunk providers and your endpoints — hardware phones, ATAs, browser softphones — and handles **everything** in between:
|
||||||
|
|
||||||
|
- 📞 **SIP B2BUA** — Terminates and re-originates calls with full RFC 3261 dialog state management
|
||||||
|
- 🌐 **WebRTC Bridge** — Browser-based softphone with bidirectional audio to the SIP network
|
||||||
|
- 🎛️ **Multi-Provider Trunking** — Register with multiple SIP providers simultaneously (sipgate, easybell, o2, etc.)
|
||||||
|
- 🔊 **Rust Codec Engine** — Real-time Opus ↔ G.722 ↔ PCMU ↔ PCMA transcoding in native Rust
|
||||||
|
- 🤖 **ML Noise Suppression** — RNNoise denoiser with per-direction state (to SIP / to browser)
|
||||||
|
- 🗣️ **Neural TTS** — Kokoro-powered "connecting your call" announcements, pre-encoded for instant playback
|
||||||
|
- 🔀 **Hub Model Calls** — N-leg calls with dynamic add/remove, transfer, and RTP fan-out
|
||||||
|
- 🖥️ **Web Dashboard** — Real-time SPA with live call monitoring, browser phone, contact management, provider config
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Browser Softphone │
|
||||||
|
│ (WebRTC via WebSocket signaling) │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│ Opus/WebRTC
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ siprouter │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Call Hub │ │ Rust Transcoder │ │
|
||||||
|
│ │ N legs │──│ Opus/G.722/PCM │ │
|
||||||
|
│ │ fan-out │ │ + RNNoise │ │
|
||||||
|
│ └────┬─────┘ └──────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────┴─────┐ ┌──────────────────┐ │
|
||||||
|
│ │ SIP Stack│ │ Kokoro TTS │ │
|
||||||
|
│ │ Dialog SM│ │ (ONNX Runtime) │ │
|
||||||
|
│ └────┬─────┘ └──────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────┴──────────────────────────┐ │
|
||||||
|
│ │ Local Registrar + Provider │ │
|
||||||
|
│ │ Registration Engine │ │
|
||||||
|
│ └───────────────────────────────┘ │
|
||||||
|
└──────────┬──────────────┬────────────┘
|
||||||
|
│ │
|
||||||
|
┌──────┴──────┐ ┌─────┴──────┐
|
||||||
|
│ SIP Devices │ │ SIP Trunk │
|
||||||
|
│ (HT801, etc)│ │ Providers │
|
||||||
|
└─────────────┘ └────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Hub Model
|
||||||
|
|
||||||
|
Every call is a **hub** with N legs. Each leg is either a `SipLeg` (hardware device or provider) or a `WebRtcLeg` (browser). RTP flows through the hub — each leg's received audio is forwarded to all other legs, with codec transcoding handled transparently by the Rust engine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** ≥ 20 with `tsx` globally available
|
||||||
|
- **pnpm** for package management
|
||||||
|
- **Rust** toolchain (for building the codec engine and TTS)
|
||||||
|
|
||||||
|
### Install & Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and install
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Build the Rust binaries (opus-codec + tts-engine)
|
||||||
|
pnpm run buildRust
|
||||||
|
|
||||||
|
# Bundle the web frontend
|
||||||
|
pnpm run bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Create `.nogit/config.json` with your setup:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"proxy": {
|
||||||
|
"lanIp": "192.168.1.100", // Your server's LAN IP
|
||||||
|
"lanPort": 5070, // SIP signaling port
|
||||||
|
"rtpPortRange": [20000, 20200],// RTP relay port pool (even ports)
|
||||||
|
"webUiPort": 3060 // Dashboard port
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"id": "my-trunk",
|
||||||
|
"name": "My SIP Provider",
|
||||||
|
"host": "sip.provider.com",
|
||||||
|
"port": 5060,
|
||||||
|
"username": "user",
|
||||||
|
"password": "pass",
|
||||||
|
"codecs": ["G.722", "PCMA", "PCMU"],
|
||||||
|
"registerExpiry": 3600
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"id": "desk-phone",
|
||||||
|
"name": "Desk Phone",
|
||||||
|
"type": "sip"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routing": {
|
||||||
|
"inbound": {
|
||||||
|
"default": { "target": "all-devices", "ringBrowser": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TTS Setup (Optional)
|
||||||
|
|
||||||
|
For neural "connecting your call" announcements, download the Kokoro TTS model:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .nogit/tts
|
||||||
|
# Download the full-quality model (310MB) + voices (27MB)
|
||||||
|
curl -L -o .nogit/tts/kokoro-v1.0.onnx \
|
||||||
|
https://github.com/mzdk100/kokoro/releases/download/V1.0/kokoro-v1.0.onnx
|
||||||
|
curl -L -o .nogit/tts/voices.bin \
|
||||||
|
https://github.com/mzdk100/kokoro/releases/download/V1.0/voices.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
If the model files aren't present, the announcement feature is simply disabled — everything else works fine.
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The SIP proxy starts on the configured port and the web dashboard is available at `http://<your-ip>:3060`.
|
||||||
|
|
||||||
|
### HTTPS (Optional)
|
||||||
|
|
||||||
|
Place `cert.pem` and `key.pem` in `.nogit/` for TLS on the dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
siprouter/
|
||||||
|
├── ts/ # TypeScript source
|
||||||
|
│ ├── sipproxy.ts # Main entry — bootstraps everything
|
||||||
|
│ ├── config.ts # Config loader & validation
|
||||||
|
│ ├── registrar.ts # Local SIP registrar for devices
|
||||||
|
│ ├── providerstate.ts # Per-provider upstream registration engine
|
||||||
|
│ ├── frontend.ts # Web dashboard HTTP/WS server + REST API
|
||||||
|
│ ├── webrtcbridge.ts # WebRTC signaling layer
|
||||||
|
│ ├── opusbridge.ts # Rust IPC bridge (smartrust)
|
||||||
|
│ ├── codec.ts # High-level RTP transcoding interface
|
||||||
|
│ ├── announcement.ts # Neural TTS announcement generator
|
||||||
|
│ ├── sip/ # Zero-dependency SIP protocol library
|
||||||
|
│ │ ├── message.ts # SIP message parser/builder/mutator
|
||||||
|
│ │ ├── dialog.ts # RFC 3261 dialog state machine
|
||||||
|
│ │ ├── helpers.ts # SDP builder, digest auth, codec registry
|
||||||
|
│ │ └── rewrite.ts # SIP URI + SDP body rewriting
|
||||||
|
│ └── call/ # Hub-model call management
|
||||||
|
│ ├── call-manager.ts # Central registry, factory, routing
|
||||||
|
│ ├── call.ts # Call hub — owns N legs, media fan-out
|
||||||
|
│ ├── sip-leg.ts # SIP device/provider connection
|
||||||
|
│ ├── webrtc-leg.ts # Browser WebRTC connection
|
||||||
|
│ └── rtp-port-pool.ts # UDP port allocation
|
||||||
|
├── ts_web/ # Web frontend (Lit-based SPA)
|
||||||
|
│ ├── elements/ # Web components (dashboard, phone, etc.)
|
||||||
|
│ └── state/ # App state, WebRTC client, notifications
|
||||||
|
├── rust/ # Rust workspace
|
||||||
|
│ └── crates/
|
||||||
|
│ ├── opus-codec/ # Real-time audio transcoder (Opus/G.722/PCM)
|
||||||
|
│ └── tts-engine/ # Kokoro neural TTS CLI
|
||||||
|
├── html/ # Static HTML shell
|
||||||
|
├── .nogit/ # Secrets, config, models (gitignored)
|
||||||
|
└── dist_rust/ # Compiled Rust binaries (gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎧 Codec Engine (Rust)
|
||||||
|
|
||||||
|
The `opus-codec` binary handles all real-time audio processing via a JSON-over-stdio IPC protocol:
|
||||||
|
|
||||||
|
| Codec | Payload Type | Sample Rate | Use Case |
|
||||||
|
|-------|-------------|-------------|----------|
|
||||||
|
| **Opus** | 111 | 48 kHz | WebRTC browsers |
|
||||||
|
| **G.722** | 9 | 16 kHz | HD SIP devices |
|
||||||
|
| **PCMU** (G.711 µ-law) | 0 | 8 kHz | Legacy SIP |
|
||||||
|
| **PCMA** (G.711 A-law) | 8 | 8 kHz | Legacy SIP |
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Per-call isolated codec sessions (no cross-call state corruption)
|
||||||
|
- FFT-based sample rate conversion via `rubato`
|
||||||
|
- **RNNoise ML noise suppression** with per-direction state — denoises audio flowing to SIP separately from audio flowing to the browser
|
||||||
|
- Raw PCM encoding for TTS frame processing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗣️ Neural TTS (Rust)
|
||||||
|
|
||||||
|
The `tts-engine` binary uses [Kokoro TTS](https://github.com/mzdk100/kokoro) (82M parameter neural model) to synthesize announcements at startup:
|
||||||
|
|
||||||
|
- **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 announcement
|
||||||
|
- Pre-encoded to G.722 + Opus for zero-latency RTP playback during call setup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Web Dashboard & REST API
|
||||||
|
|
||||||
|
### Dashboard Views
|
||||||
|
|
||||||
|
| View | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| **Overview** | Stats tiles — uptime, providers, devices, active calls |
|
||||||
|
| **Calls** | Active calls with leg details, codec info, packet counters. Add/remove legs, transfer, hangup |
|
||||||
|
| **Phone** | Browser softphone — mic/speaker selection, audio meters, dial pad, incoming call popup |
|
||||||
|
| **Contacts** | Contact management with click-to-call |
|
||||||
|
| **Providers** | SIP trunk config with registration status |
|
||||||
|
| **Log** | Live streaming log viewer |
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/status` | GET | Full system status (providers, devices, calls) |
|
||||||
|
| `/api/call` | POST | Originate a call |
|
||||||
|
| `/api/hangup` | POST | Hang up a call |
|
||||||
|
| `/api/call/:id/addleg` | POST | Add a leg to an active call |
|
||||||
|
| `/api/call/:id/addexternal` | POST | Add an external participant |
|
||||||
|
| `/api/call/:id/removeleg` | POST | Remove a leg from a call |
|
||||||
|
| `/api/transfer` | POST | Transfer a call |
|
||||||
|
| `/api/config` | GET/POST | Read or update configuration (hot-reload) |
|
||||||
|
|
||||||
|
### WebSocket Events
|
||||||
|
|
||||||
|
Connect to `/ws` for real-time push:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "type": "status", "data": { ... } } // Full status snapshot (1s interval)
|
||||||
|
{ "type": "log", "data": { "message": "..." } } // Log lines in real-time
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Ports
|
||||||
|
|
||||||
|
| Port | Protocol | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| 5070 (configurable) | UDP | SIP signaling |
|
||||||
|
| 20000–20200 (configurable) | UDP | RTP relay (even ports, per-call allocation) |
|
||||||
|
| 3060 (configurable) | TCP | Web dashboard + WebSocket + REST API |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start in dev mode
|
||||||
|
pnpm start
|
||||||
|
|
||||||
|
# Build Rust crates
|
||||||
|
pnpm run buildRust
|
||||||
|
|
||||||
|
# Bundle web frontend
|
||||||
|
pnpm run bundle
|
||||||
|
|
||||||
|
# Restart background server (build + bundle + restart)
|
||||||
|
pnpm run restartBackground
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
- **Hub Model** — Calls are N-leg hubs, not point-to-point. This enables multi-party, dynamic leg manipulation, and transfer without tearing down the call.
|
||||||
|
- **Zero-dependency SIP library** — `ts/sip/` is a pure data-level SIP stack (parse/build/mutate/serialize). No transport or timer logic — those live in the application layer.
|
||||||
|
- **Rust for the hot path** — Codec transcoding and noise suppression run in native Rust for real-time performance. TypeScript handles signaling and orchestration.
|
||||||
|
- **Per-session codec isolation** — Each call gets its own Opus/G.722 encoder/decoder state in the Rust process, preventing stateful codec prediction from leaking between concurrent calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
1971
rust/Cargo.lock
generated
Normal file
1971
rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
rust/Cargo.toml
Normal file
7
rust/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["crates/opus-codec", "crates/tts-engine"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
17
rust/crates/opus-codec/Cargo.toml
Normal file
17
rust/crates/opus-codec/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "opus-codec"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "opus-codec"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
audiopus = "0.3.0-rc.0"
|
||||||
|
ezk-g722 = "0.1"
|
||||||
|
rubato = "0.14"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
nnnoiseless = { version = "0.5", default-features = false }
|
||||||
464
rust/crates/opus-codec/src/main.rs
Normal file
464
rust/crates/opus-codec/src/main.rs
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
/// Audio transcoding bridge for smartrust.
|
||||||
|
///
|
||||||
|
/// Handles Opus ↔ G.722 ↔ PCMU transcoding for the SIP router.
|
||||||
|
/// Uses audiopus (libopus) for Opus and ezk-g722 (SpanDSP port) for G.722.
|
||||||
|
///
|
||||||
|
/// Supports per-session codec state so concurrent calls don't corrupt each
|
||||||
|
/// other's stateful codecs (Opus, G.722 ADPCM).
|
||||||
|
///
|
||||||
|
/// Protocol:
|
||||||
|
/// -> {"id":"1","method":"init","params":{}}
|
||||||
|
/// <- {"id":"1","success":true,"result":{}}
|
||||||
|
/// -> {"id":"2","method":"create_session","params":{"session_id":"call-abc"}}
|
||||||
|
/// <- {"id":"2","success":true,"result":{}}
|
||||||
|
/// -> {"id":"3","method":"transcode","params":{"session_id":"call-abc","data_b64":"...","from_pt":111,"to_pt":9}}
|
||||||
|
/// <- {"id":"3","success":true,"result":{"data_b64":"..."}}
|
||||||
|
/// -> {"id":"4","method":"destroy_session","params":{"session_id":"call-abc"}}
|
||||||
|
/// <- {"id":"4","success":true,"result":{}}
|
||||||
|
|
||||||
|
use audiopus::coder::{Decoder as OpusDecoder, Encoder as OpusEncoder};
|
||||||
|
use audiopus::packet::Packet as OpusPacket;
|
||||||
|
use audiopus::{Application, Bitrate as OpusBitrate, Channels, MutSignals, SampleRate};
|
||||||
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
|
use base64::Engine as _;
|
||||||
|
use ezk_g722::libg722::{self, Bitrate};
|
||||||
|
use nnnoiseless::DenoiseState;
|
||||||
|
use rubato::{FftFixedIn, Resampler};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{self, BufRead, Write};
|
||||||
|
|
||||||
|
// Payload type constants.
|
||||||
|
const PT_PCMU: u8 = 0;
|
||||||
|
const PT_PCMA: u8 = 8;
|
||||||
|
const PT_G722: u8 = 9;
|
||||||
|
const PT_OPUS: u8 = 111;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Request {
|
||||||
|
id: String,
|
||||||
|
method: String,
|
||||||
|
#[serde(default)]
|
||||||
|
params: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn respond(out: &mut impl Write, 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; }
|
||||||
|
if let Some(e) = error { resp["error"] = serde_json::Value::String(e.to_string()); }
|
||||||
|
let _ = writeln!(out, "{}", resp);
|
||||||
|
let _ = out.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Codec state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct TranscodeState {
|
||||||
|
opus_enc: OpusEncoder,
|
||||||
|
opus_dec: OpusDecoder,
|
||||||
|
g722_enc: libg722::encoder::Encoder,
|
||||||
|
g722_dec: libg722::decoder::Decoder,
|
||||||
|
// Cached FFT resamplers keyed by (from_rate, to_rate, chunk_size).
|
||||||
|
resamplers: HashMap<(u32, u32, usize), FftFixedIn<f64>>,
|
||||||
|
// Per-direction ML noise suppression (RNNoise). Separate state per direction
|
||||||
|
// prevents the RNN hidden state from being corrupted by interleaved audio streams.
|
||||||
|
denoiser_to_sip: Box<DenoiseState<'static>>,
|
||||||
|
denoiser_to_browser: Box<DenoiseState<'static>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranscodeState {
|
||||||
|
fn new() -> Result<Self, String> {
|
||||||
|
let mut opus_enc = OpusEncoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
||||||
|
.map_err(|e| format!("opus encoder: {e}"))?;
|
||||||
|
// Telephony-grade tuning: complexity 5 is sufficient for voice bridged to G.722.
|
||||||
|
opus_enc.set_complexity(5).map_err(|e| format!("opus set_complexity: {e}"))?;
|
||||||
|
opus_enc.set_bitrate(OpusBitrate::BitsPerSecond(24000)).map_err(|e| format!("opus set_bitrate: {e}"))?;
|
||||||
|
let opus_dec = OpusDecoder::new(SampleRate::Hz48000, Channels::Mono)
|
||||||
|
.map_err(|e| format!("opus decoder: {e}"))?;
|
||||||
|
let g722_enc = libg722::encoder::Encoder::new(Bitrate::Mode1_64000, false, false);
|
||||||
|
let g722_dec = libg722::decoder::Decoder::new(Bitrate::Mode1_64000, false, false);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
opus_enc, opus_dec, g722_enc, g722_dec,
|
||||||
|
resamplers: HashMap::new(),
|
||||||
|
denoiser_to_sip: DenoiseState::new(),
|
||||||
|
denoiser_to_browser: DenoiseState::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-quality sample rate conversion using rubato FFT resampler.
|
||||||
|
/// Resamplers are cached by (from_rate, to_rate, chunk_size) and reused,
|
||||||
|
/// maintaining proper inter-frame state for continuous audio streams.
|
||||||
|
fn resample(&mut self, pcm: &[i16], from_rate: u32, to_rate: u32) -> Result<Vec<i16>, String> {
|
||||||
|
if from_rate == to_rate || pcm.is_empty() {
|
||||||
|
return Ok(pcm.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk = pcm.len();
|
||||||
|
let key = (from_rate, to_rate, chunk);
|
||||||
|
|
||||||
|
// Get or create cached resampler for this rate pair + chunk size.
|
||||||
|
if !self.resamplers.contains_key(&key) {
|
||||||
|
let r = FftFixedIn::<f64>::new(from_rate as usize, to_rate as usize, 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();
|
||||||
|
|
||||||
|
// i16 → f64 normalized to [-1.0, 1.0]
|
||||||
|
let float_in: Vec<f64> = pcm.iter().map(|&s| s as f64 / 32768.0).collect();
|
||||||
|
let input = vec![float_in];
|
||||||
|
|
||||||
|
let result = resampler.process(&input, None)
|
||||||
|
.map_err(|e| format!("resample {from_rate}->{to_rate}: {e}"))?;
|
||||||
|
|
||||||
|
// f64 → i16
|
||||||
|
Ok(result[0].iter()
|
||||||
|
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply RNNoise ML noise suppression to 48kHz PCM audio.
|
||||||
|
/// Processes in 480-sample (10ms) frames. State persists across calls.
|
||||||
|
fn denoise(denoiser: &mut DenoiseState, pcm: &[i16]) -> Vec<i16> {
|
||||||
|
let frame_size = DenoiseState::FRAME_SIZE; // 480
|
||||||
|
let total = pcm.len();
|
||||||
|
// Round down to whole frames — don't process partial frames to avoid
|
||||||
|
// injecting artificial silence into the RNN state.
|
||||||
|
let whole = (total / frame_size) * frame_size;
|
||||||
|
let mut output = Vec::with_capacity(total);
|
||||||
|
let mut out_buf = [0.0f32; 480];
|
||||||
|
|
||||||
|
for offset in (0..whole).step_by(frame_size) {
|
||||||
|
let input: Vec<f32> = pcm[offset..offset + frame_size]
|
||||||
|
.iter().map(|&s| s as f32).collect();
|
||||||
|
denoiser.process_frame(&mut out_buf, &input);
|
||||||
|
output.extend(out_buf.iter()
|
||||||
|
.map(|&s| s.round().clamp(-32768.0, 32767.0) as i16));
|
||||||
|
}
|
||||||
|
// Pass through any trailing partial-frame samples unmodified.
|
||||||
|
if whole < total {
|
||||||
|
output.extend_from_slice(&pcm[whole..]);
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transcode audio payload from one codec to another.
|
||||||
|
/// `direction`: "to_sip" or "to_browser" — selects the per-direction denoiser.
|
||||||
|
/// If None, denoising is skipped (backward compat).
|
||||||
|
fn transcode(&mut self, data: &[u8], from_pt: u8, to_pt: u8, direction: Option<&str>) -> Result<Vec<u8>, String> {
|
||||||
|
if from_pt == to_pt {
|
||||||
|
return Ok(data.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode to PCM (at source sample rate).
|
||||||
|
let (pcm, rate) = self.decode_to_pcm(data, from_pt)?;
|
||||||
|
|
||||||
|
// Apply noise suppression if direction is specified.
|
||||||
|
let processed = if let Some(dir) = direction {
|
||||||
|
// Resample to 48kHz for denoising (no-op when already 48kHz).
|
||||||
|
let pcm_48k = self.resample(&pcm, rate, 48000)?;
|
||||||
|
let denoiser = match dir {
|
||||||
|
"to_sip" => &mut self.denoiser_to_sip,
|
||||||
|
_ => &mut self.denoiser_to_browser,
|
||||||
|
};
|
||||||
|
let denoised = Self::denoise(denoiser, &pcm_48k);
|
||||||
|
// Resample to target rate (no-op when target is 48kHz).
|
||||||
|
let target_rate = codec_sample_rate(to_pt);
|
||||||
|
self.resample(&denoised, 48000, target_rate)?
|
||||||
|
} else {
|
||||||
|
// No denoising — direct resample.
|
||||||
|
let target_rate = codec_sample_rate(to_pt);
|
||||||
|
if rate == target_rate { pcm } else { self.resample(&pcm, rate, target_rate)? }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encode from PCM.
|
||||||
|
self.encode_from_pcm(&processed, to_pt)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_to_pcm(&mut self, data: &[u8], pt: u8) -> Result<(Vec<i16>, u32), String> {
|
||||||
|
match pt {
|
||||||
|
PT_OPUS => {
|
||||||
|
let mut pcm = vec![0i16; 5760]; // up to 120ms at 48kHz (RFC 6716 max)
|
||||||
|
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.opus_dec.decode(Some(packet), out, false)
|
||||||
|
.map_err(|e| format!("opus decode: {e}"))?.into();
|
||||||
|
pcm.truncate(n);
|
||||||
|
Ok((pcm, 48000))
|
||||||
|
}
|
||||||
|
PT_G722 => {
|
||||||
|
let pcm = self.g722_dec.decode(data);
|
||||||
|
Ok((pcm, 16000))
|
||||||
|
}
|
||||||
|
PT_PCMU => {
|
||||||
|
let pcm: Vec<i16> = data.iter().map(|&b| mulaw_decode(b)).collect();
|
||||||
|
Ok((pcm, 8000))
|
||||||
|
}
|
||||||
|
PT_PCMA => {
|
||||||
|
let pcm: Vec<i16> = data.iter().map(|&b| alaw_decode(b)).collect();
|
||||||
|
Ok((pcm, 8000))
|
||||||
|
}
|
||||||
|
_ => Err(format!("unsupported source PT {pt}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_from_pcm(&mut self, pcm: &[i16], pt: u8) -> Result<Vec<u8>, String> {
|
||||||
|
match pt {
|
||||||
|
PT_OPUS => {
|
||||||
|
let mut buf = vec![0u8; 4000];
|
||||||
|
let n: usize = self.opus_enc.encode(pcm, &mut buf)
|
||||||
|
.map_err(|e| format!("opus encode: {e}"))?.into();
|
||||||
|
buf.truncate(n);
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
PT_G722 => {
|
||||||
|
Ok(self.g722_enc.encode(pcm))
|
||||||
|
}
|
||||||
|
PT_PCMU => {
|
||||||
|
Ok(pcm.iter().map(|&s| mulaw_encode(s)).collect())
|
||||||
|
}
|
||||||
|
PT_PCMA => {
|
||||||
|
Ok(pcm.iter().map(|&s| alaw_encode(s)).collect())
|
||||||
|
}
|
||||||
|
_ => Err(format!("unsupported target PT {pt}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_sample_rate(pt: u8) -> u32 {
|
||||||
|
match pt {
|
||||||
|
PT_OPUS => 48000,
|
||||||
|
PT_G722 => 16000,
|
||||||
|
_ => 8000, // PCMU, PCMA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// G.711 µ-law (PCMU)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn mulaw_encode(sample: i16) -> u8 {
|
||||||
|
const BIAS: i16 = 0x84;
|
||||||
|
const CLIP: i16 = 32635;
|
||||||
|
let sign = if sample < 0 { 0x80u8 } else { 0 };
|
||||||
|
// Use i32 to avoid overflow when sample == i16::MIN (-32768).
|
||||||
|
let mut s = (sample as i32).unsigned_abs().min(CLIP as u32) as i16;
|
||||||
|
s += BIAS;
|
||||||
|
let mut exp = 7u8;
|
||||||
|
let mut mask = 0x4000i16;
|
||||||
|
while exp > 0 && (s & mask) == 0 { exp -= 1; mask >>= 1; }
|
||||||
|
let mantissa = ((s >> (exp + 3)) & 0x0f) as u8;
|
||||||
|
!(sign | (exp << 4) | mantissa)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mulaw_decode(mulaw: u8) -> i16 {
|
||||||
|
let v = !mulaw;
|
||||||
|
let sign = v & 0x80;
|
||||||
|
let exp = (v >> 4) & 0x07;
|
||||||
|
let mantissa = v & 0x0f;
|
||||||
|
let mut sample = (((mantissa as i16) << 4) + 0x84) << exp;
|
||||||
|
sample -= 0x84;
|
||||||
|
if sign != 0 { -sample } else { sample }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// G.711 A-law (PCMA)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn alaw_encode(sample: i16) -> u8 {
|
||||||
|
let sign = if sample >= 0 { 0x80u8 } else { 0 };
|
||||||
|
// Use i32 to avoid overflow when sample == i16::MIN (-32768).
|
||||||
|
let s = (sample as i32).unsigned_abs().min(32767) as i16;
|
||||||
|
let mut exp = 7u8;
|
||||||
|
let mut mask = 0x4000i16;
|
||||||
|
while exp > 0 && (s & mask) == 0 { exp -= 1; mask >>= 1; }
|
||||||
|
let mantissa = if exp > 0 { ((s >> (exp + 3)) & 0x0f) as u8 } else { ((s >> 4) & 0x0f) as u8 };
|
||||||
|
(sign | (exp << 4) | mantissa) ^ 0x55
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alaw_decode(alaw: u8) -> i16 {
|
||||||
|
let v = alaw ^ 0x55;
|
||||||
|
let sign = v & 0x80;
|
||||||
|
let exp = (v >> 4) & 0x07;
|
||||||
|
let mantissa = v & 0x0f;
|
||||||
|
let sample = if exp == 0 {
|
||||||
|
((mantissa as i16) << 4) + 8
|
||||||
|
} else {
|
||||||
|
(((mantissa as i16) << 4) + 0x108) << (exp - 1)
|
||||||
|
};
|
||||||
|
if sign != 0 { sample } else { -sample }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main loop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Resolve a session: if session_id is provided, look it up in the sessions map;
|
||||||
|
/// otherwise fall back to the default state (backward compat with `init`).
|
||||||
|
fn get_session<'a>(
|
||||||
|
sessions: &'a mut HashMap<String, TranscodeState>,
|
||||||
|
default: &'a mut Option<TranscodeState>,
|
||||||
|
params: &serde_json::Value,
|
||||||
|
) -> Option<&'a mut TranscodeState> {
|
||||||
|
if let Some(sid) = params.get("session_id").and_then(|v| v.as_str()) {
|
||||||
|
sessions.get_mut(sid)
|
||||||
|
} else {
|
||||||
|
default.as_mut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let stdin = io::stdin();
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let mut out = io::BufWriter::new(stdout.lock());
|
||||||
|
|
||||||
|
let _ = writeln!(out, r#"{{"event":"ready","data":{{}}}}"#);
|
||||||
|
let _ = out.flush();
|
||||||
|
|
||||||
|
// Default state for backward-compat `init` (no session_id).
|
||||||
|
let mut default_state: Option<TranscodeState> = None;
|
||||||
|
// Per-session codec state for concurrent call isolation.
|
||||||
|
let mut sessions: HashMap<String, TranscodeState> = HashMap::new();
|
||||||
|
|
||||||
|
for line in stdin.lock().lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(l) if !l.trim().is_empty() => l,
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
let req: Request = match serde_json::from_str(&line) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
respond(&mut out, "", false, None, Some(&format!("parse: {e}")));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match req.method.as_str() {
|
||||||
|
// Backward-compat: init the default (shared) session.
|
||||||
|
"init" => {
|
||||||
|
match TranscodeState::new() {
|
||||||
|
Ok(s) => {
|
||||||
|
default_state = Some(s);
|
||||||
|
respond(&mut out, &req.id, true, Some(serde_json::json!({})), None);
|
||||||
|
}
|
||||||
|
Err(e) => respond(&mut out, &req.id, false, None, Some(&e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an isolated session with its own codec state.
|
||||||
|
"create_session" => {
|
||||||
|
let session_id = match req.params.get("session_id").and_then(|v| v.as_str()) {
|
||||||
|
Some(s) => s.to_string(),
|
||||||
|
None => { respond(&mut out, &req.id, false, None, Some("missing session_id")); continue; }
|
||||||
|
};
|
||||||
|
if sessions.contains_key(&session_id) {
|
||||||
|
respond(&mut out, &req.id, true, Some(serde_json::json!({})), None);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match TranscodeState::new() {
|
||||||
|
Ok(s) => {
|
||||||
|
sessions.insert(session_id, s);
|
||||||
|
respond(&mut out, &req.id, true, Some(serde_json::json!({})), None);
|
||||||
|
}
|
||||||
|
Err(e) => respond(&mut out, &req.id, false, None, Some(&e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy a session, freeing its codec state.
|
||||||
|
"destroy_session" => {
|
||||||
|
let session_id = match req.params.get("session_id").and_then(|v| v.as_str()) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => { respond(&mut out, &req.id, false, None, Some("missing session_id")); continue; }
|
||||||
|
};
|
||||||
|
sessions.remove(session_id);
|
||||||
|
respond(&mut out, &req.id, true, Some(serde_json::json!({})), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcode: uses session_id if provided, else default state.
|
||||||
|
"transcode" => {
|
||||||
|
let st = match get_session(&mut sessions, &mut default_state, &req.params) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => { respond(&mut out, &req.id, false, None, Some("not initialized (no session or default state)")); continue; }
|
||||||
|
};
|
||||||
|
let data_b64 = match req.params.get("data_b64").and_then(|v| v.as_str()) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => { respond(&mut out, &req.id, false, None, Some("missing data_b64")); continue; }
|
||||||
|
};
|
||||||
|
let from_pt = req.params.get("from_pt").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
|
||||||
|
let to_pt = req.params.get("to_pt").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
|
||||||
|
let direction = req.params.get("direction").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
let data = match B64.decode(data_b64) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => { respond(&mut out, &req.id, false, None, Some(&format!("b64: {e}"))); continue; }
|
||||||
|
};
|
||||||
|
|
||||||
|
match st.transcode(&data, from_pt, to_pt, direction) {
|
||||||
|
Ok(result) => {
|
||||||
|
respond(&mut out, &req.id, true, Some(serde_json::json!({ "data_b64": B64.encode(&result) })), None);
|
||||||
|
}
|
||||||
|
Err(e) => respond(&mut out, &req.id, false, None, Some(&e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode raw 16-bit PCM to a target codec.
|
||||||
|
// Params: data_b64 (raw PCM bytes, 16-bit LE), sample_rate (input Hz), to_pt
|
||||||
|
// Optional: session_id for isolated codec state.
|
||||||
|
"encode_pcm" => {
|
||||||
|
let st = match get_session(&mut sessions, &mut default_state, &req.params) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => { respond(&mut out, &req.id, false, None, Some("not initialized (no session or default state)")); continue; }
|
||||||
|
};
|
||||||
|
let data_b64 = match req.params.get("data_b64").and_then(|v| v.as_str()) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => { respond(&mut out, &req.id, false, None, Some("missing data_b64")); continue; }
|
||||||
|
};
|
||||||
|
let sample_rate = req.params.get("sample_rate").and_then(|v| v.as_u64()).unwrap_or(22050) as u32;
|
||||||
|
let to_pt = req.params.get("to_pt").and_then(|v| v.as_u64()).unwrap_or(9) as u8;
|
||||||
|
|
||||||
|
let data = match B64.decode(data_b64) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => { respond(&mut out, &req.id, false, None, Some(&format!("b64: {e}"))); continue; }
|
||||||
|
};
|
||||||
|
|
||||||
|
if data.len() % 2 != 0 {
|
||||||
|
respond(&mut out, &req.id, false, None, Some("PCM data has odd byte count (expected 16-bit LE samples)"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert raw bytes to i16 samples.
|
||||||
|
let pcm: Vec<i16> = data.chunks_exact(2)
|
||||||
|
.map(|c| i16::from_le_bytes([c[0], c[1]]))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Resample to target codec's sample rate.
|
||||||
|
let target_rate = codec_sample_rate(to_pt);
|
||||||
|
let resampled = match st.resample(&pcm, sample_rate, target_rate) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => { respond(&mut out, &req.id, false, None, Some(&e)); continue; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encode to target codec (reuse encode_from_pcm).
|
||||||
|
match st.encode_from_pcm(&resampled, to_pt) {
|
||||||
|
Ok(encoded) => {
|
||||||
|
respond(&mut out, &req.id, true, Some(serde_json::json!({ "data_b64": B64.encode(&encoded) })), None);
|
||||||
|
}
|
||||||
|
Err(e) => { respond(&mut out, &req.id, false, None, Some(&e)); continue; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy commands (kept for backward compat).
|
||||||
|
"encode" | "decode" => {
|
||||||
|
respond(&mut out, &req.id, false, None, Some("use 'transcode' command instead"));
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => respond(&mut out, &req.id, false, None, Some(&format!("unknown: {}", req.method))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
rust/crates/tts-engine/Cargo.toml
Normal file
18
rust/crates/tts-engine/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "tts-engine"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "tts-engine"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
kokoro-tts = { version = "0.3", default-features = false }
|
||||||
|
# Pin to rc.11 matching kokoro-tts's expectation; enable vendored TLS to avoid system libssl-dev.
|
||||||
|
ort = { version = "=2.0.0-rc.11", default-features = false, features = [
|
||||||
|
"std", "download-binaries", "copy-dylibs", "ndarray",
|
||||||
|
"tls-native-vendored"
|
||||||
|
] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
|
hound = "3.5"
|
||||||
149
rust/crates/tts-engine/src/main.rs
Normal file
149
rust/crates/tts-engine/src/main.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/// TTS engine CLI — synthesizes text to a WAV file using Kokoro neural TTS.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// echo "Hello world" | tts-engine --model kokoro-v1.0.onnx --voices voices.bin --output out.wav
|
||||||
|
/// tts-engine --model kokoro-v1.0.onnx --voices voices.bin --output out.wav --text "Hello world"
|
||||||
|
///
|
||||||
|
/// Outputs 24kHz 16-bit mono WAV.
|
||||||
|
|
||||||
|
use kokoro_tts::{KokoroTts, Voice};
|
||||||
|
use std::io::{self, Read};
|
||||||
|
|
||||||
|
fn parse_args() -> Result<(String, String, String, String, Option<String>), String> {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let mut model = String::new();
|
||||||
|
let mut voices = String::new();
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut text: Option<String> = None;
|
||||||
|
let mut voice_name: Option<String> = None;
|
||||||
|
|
||||||
|
let mut i = 1;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--model" => { i += 1; model = args.get(i).cloned().unwrap_or_default(); }
|
||||||
|
"--voices" => { i += 1; voices = args.get(i).cloned().unwrap_or_default(); }
|
||||||
|
"--output" | "--output_file" => { i += 1; output = args.get(i).cloned().unwrap_or_default(); }
|
||||||
|
"--text" => { i += 1; text = args.get(i).cloned(); }
|
||||||
|
"--voice" => { i += 1; voice_name = args.get(i).cloned(); }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.is_empty() { return Err("--model required".into()); }
|
||||||
|
if voices.is_empty() { return Err("--voices required".into()); }
|
||||||
|
if output.is_empty() { return Err("--output required".into()); }
|
||||||
|
|
||||||
|
let voice_str = voice_name.unwrap_or_else(|| "af_bella".into());
|
||||||
|
|
||||||
|
Ok((model, voices, output, voice_str, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_voice(name: &str) -> Voice {
|
||||||
|
match name {
|
||||||
|
"af_bella" => Voice::AfBella(1.0),
|
||||||
|
"af_heart" => Voice::AfHeart(1.0),
|
||||||
|
"af_jessica" => Voice::AfJessica(1.0),
|
||||||
|
"af_nicole" => Voice::AfNicole(1.0),
|
||||||
|
"af_nova" => Voice::AfNova(1.0),
|
||||||
|
"af_sarah" => Voice::AfSarah(1.0),
|
||||||
|
"af_sky" => Voice::AfSky(1.0),
|
||||||
|
"af_river" => Voice::AfRiver(1.0),
|
||||||
|
"af_alloy" => Voice::AfAlloy(1.0),
|
||||||
|
"af_aoede" => Voice::AfAoede(1.0),
|
||||||
|
"af_kore" => Voice::AfKore(1.0),
|
||||||
|
"am_adam" => Voice::AmAdam(1.0),
|
||||||
|
"am_echo" => Voice::AmEcho(1.0),
|
||||||
|
"am_eric" => Voice::AmEric(1.0),
|
||||||
|
"am_fenrir" => Voice::AmFenrir(1.0),
|
||||||
|
"am_liam" => Voice::AmLiam(1.0),
|
||||||
|
"am_michael" => Voice::AmMichael(1.0),
|
||||||
|
"am_onyx" => Voice::AmOnyx(1.0),
|
||||||
|
"am_puck" => Voice::AmPuck(1.0),
|
||||||
|
"bf_alice" => Voice::BfAlice(1.0),
|
||||||
|
"bf_emma" => Voice::BfEmma(1.0),
|
||||||
|
"bf_isabella" => Voice::BfIsabella(1.0),
|
||||||
|
"bf_lily" => Voice::BfLily(1.0),
|
||||||
|
"bm_daniel" => Voice::BmDaniel(1.0),
|
||||||
|
"bm_fable" => Voice::BmFable(1.0),
|
||||||
|
"bm_george" => Voice::BmGeorge(1.0),
|
||||||
|
"bm_lewis" => Voice::BmLewis(1.0),
|
||||||
|
_ => {
|
||||||
|
eprintln!("[tts-engine] unknown voice '{}', falling back to af_bella", name);
|
||||||
|
Voice::AfBella(1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let (model_path, voices_path, output_path, voice_name, text_arg) = match parse_args() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error: {}", e);
|
||||||
|
eprintln!("Usage: tts-engine --model <model.onnx> --voices <voices.bin> --output <output.wav> [--text <text>] [--voice <voice_name>]");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get text from --text arg or stdin.
|
||||||
|
let text = match text_arg {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin().read_to_string(&mut buf).expect("failed to read stdin");
|
||||||
|
buf.trim().to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if text.is_empty() {
|
||||||
|
eprintln!("[tts-engine] no text provided");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("[tts-engine] loading model: {}", model_path);
|
||||||
|
let tts = match KokoroTts::new(&model_path, &voices_path).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[tts-engine] failed to load model: {:?}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let voice = select_voice(&voice_name);
|
||||||
|
eprintln!("[tts-engine] synthesizing with voice '{}': \"{}\"", voice_name, text);
|
||||||
|
|
||||||
|
let (samples, duration) = match tts.synth(&text, voice).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[tts-engine] synthesis failed: {:?}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eprintln!("[tts-engine] synthesized {} samples in {:?}", samples.len(), duration);
|
||||||
|
|
||||||
|
// Write WAV: 24kHz, 16-bit, mono (same format announcement.ts expects).
|
||||||
|
let spec = hound::WavSpec {
|
||||||
|
channels: 1,
|
||||||
|
sample_rate: 24000,
|
||||||
|
bits_per_sample: 16,
|
||||||
|
sample_format: hound::SampleFormat::Int,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut writer = match hound::WavWriter::create(&output_path, spec) {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[tts-engine] failed to create WAV: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for &sample in &samples {
|
||||||
|
let s16 = (sample * 32767.0).round().clamp(-32768.0, 32767.0) as i16;
|
||||||
|
writer.write_sample(s16).unwrap();
|
||||||
|
}
|
||||||
|
writer.finalize().unwrap();
|
||||||
|
|
||||||
|
eprintln!("[tts-engine] wrote {}", output_path);
|
||||||
|
}
|
||||||
8
ts/00_commitinfo_data.ts
Normal file
8
ts/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
|
*/
|
||||||
|
export const commitinfo = {
|
||||||
|
name: 'siprouter',
|
||||||
|
version: '1.8.0',
|
||||||
|
description: 'undefined'
|
||||||
|
}
|
||||||
261
ts/announcement.ts
Normal file
261
ts/announcement.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* TTS announcement module — pre-generates audio announcements using Kokoro TTS
|
||||||
|
* and caches them as encoded RTP packets for playback during call setup.
|
||||||
|
*
|
||||||
|
* On startup, generates the announcement WAV via the Rust tts-engine binary
|
||||||
|
* (Kokoro neural TTS), encodes each 20ms frame to G.722 (for SIP) and Opus
|
||||||
|
* (for WebRTC) via the Rust transcoder, and caches the packets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { buildRtpHeader, rtpClockIncrement } from './call/leg.ts';
|
||||||
|
import { encodePcm, isCodecReady } from './opusbridge.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A pre-encoded announcement ready for RTP playback. */
|
||||||
|
export interface IAnnouncementCache {
|
||||||
|
/** G.722 encoded frames (each is a 20ms frame payload, no RTP header). */
|
||||||
|
g722Frames: Buffer[];
|
||||||
|
/** Opus encoded frames for WebRTC playback. */
|
||||||
|
opusFrames: Buffer[];
|
||||||
|
/** Total duration in milliseconds. */
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let cachedAnnouncement: IAnnouncementCache | null = null;
|
||||||
|
|
||||||
|
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
|
||||||
|
const KOKORO_MODEL = 'kokoro-v1.0.onnx';
|
||||||
|
const KOKORO_VOICES = 'voices.bin';
|
||||||
|
const KOKORO_VOICE = 'af_bella'; // American female, clear and natural
|
||||||
|
const ANNOUNCEMENT_TEXT = "Hello. I'm connecting your call now.";
|
||||||
|
const CACHE_WAV = path.join(TTS_DIR, 'announcement.wav');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Initialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-generate the announcement audio and encode to G.722 frames.
|
||||||
|
* Must be called after the codec bridge is initialized.
|
||||||
|
*/
|
||||||
|
export async function initAnnouncement(log: (msg: string) => void): Promise<boolean> {
|
||||||
|
const modelPath = path.join(TTS_DIR, KOKORO_MODEL);
|
||||||
|
const voicesPath = path.join(TTS_DIR, KOKORO_VOICES);
|
||||||
|
|
||||||
|
// Check if Kokoro model files exist.
|
||||||
|
if (!fs.existsSync(modelPath)) {
|
||||||
|
log('[tts] Kokoro model not found at ' + modelPath + ' — announcements disabled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(voicesPath)) {
|
||||||
|
log('[tts] Kokoro voices not found at ' + voicesPath + ' — announcements disabled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find tts-engine binary.
|
||||||
|
const root = process.cwd();
|
||||||
|
const ttsBinPaths = [
|
||||||
|
path.join(root, 'dist_rust', 'tts-engine'),
|
||||||
|
path.join(root, 'rust', 'target', 'release', 'tts-engine'),
|
||||||
|
path.join(root, 'rust', 'target', 'debug', 'tts-engine'),
|
||||||
|
];
|
||||||
|
const ttsBin = ttsBinPaths.find((p) => fs.existsSync(p));
|
||||||
|
if (!ttsBin) {
|
||||||
|
log('[tts] tts-engine binary not found — announcements disabled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate WAV if not cached.
|
||||||
|
if (!fs.existsSync(CACHE_WAV)) {
|
||||||
|
log('[tts] generating announcement audio via Kokoro TTS...');
|
||||||
|
execSync(
|
||||||
|
`"${ttsBin}" --model "${modelPath}" --voices "${voicesPath}" --voice "${KOKORO_VOICE}" --output "${CACHE_WAV}" --text "${ANNOUNCEMENT_TEXT}"`,
|
||||||
|
{ timeout: 120000, stdio: 'pipe' },
|
||||||
|
);
|
||||||
|
log('[tts] announcement WAV generated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read WAV and extract raw PCM.
|
||||||
|
const wav = fs.readFileSync(CACHE_WAV);
|
||||||
|
const pcm = extractPcmFromWav(wav);
|
||||||
|
if (!pcm) {
|
||||||
|
log('[tts] failed to parse WAV file');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for codec bridge to be ready.
|
||||||
|
if (!isCodecReady()) {
|
||||||
|
log('[tts] codec bridge not ready — will retry');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kokoro outputs 24000 Hz, 16-bit mono.
|
||||||
|
// We encode in chunks: 20ms at 24000 Hz = 480 samples = 960 bytes of PCM.
|
||||||
|
// The Rust encoder will resample to 16kHz internally for G.722.
|
||||||
|
const SAMPLE_RATE = 24000;
|
||||||
|
const FRAME_SAMPLES = Math.floor(SAMPLE_RATE * 0.02); // 480 samples per 20ms
|
||||||
|
const FRAME_BYTES = FRAME_SAMPLES * 2; // 16-bit = 2 bytes per sample
|
||||||
|
const totalFrames = Math.floor(pcm.length / FRAME_BYTES);
|
||||||
|
|
||||||
|
const g722Frames: Buffer[] = [];
|
||||||
|
const opusFrames: Buffer[] = [];
|
||||||
|
|
||||||
|
log(`[tts] encoding ${totalFrames} frames (${FRAME_SAMPLES} samples/frame @ ${SAMPLE_RATE}Hz)...`);
|
||||||
|
for (let i = 0; i < totalFrames; i++) {
|
||||||
|
const framePcm = pcm.subarray(i * FRAME_BYTES, (i + 1) * FRAME_BYTES);
|
||||||
|
const pcmBuf = Buffer.from(framePcm);
|
||||||
|
const [g722, opus] = await Promise.all([
|
||||||
|
encodePcm(pcmBuf, SAMPLE_RATE, 9), // G.722 for SIP devices
|
||||||
|
encodePcm(pcmBuf, SAMPLE_RATE, 111), // Opus for WebRTC browsers
|
||||||
|
]);
|
||||||
|
if (g722) g722Frames.push(g722);
|
||||||
|
if (opus) opusFrames.push(opus);
|
||||||
|
if (!g722 && !opus && i < 3) log(`[tts] frame ${i} encode failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedAnnouncement = {
|
||||||
|
g722Frames,
|
||||||
|
opusFrames,
|
||||||
|
durationMs: totalFrames * 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
log(`[tts] announcement cached: ${g722Frames.length} frames (${(totalFrames * 20 / 1000).toFixed(1)}s)`);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
log(`[tts] init error: ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Playback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play the pre-cached announcement to an RTP endpoint.
|
||||||
|
*
|
||||||
|
* @param sendPacket - function to send a raw RTP packet
|
||||||
|
* @param ssrc - SSRC to use in RTP headers
|
||||||
|
* @param onDone - called when the announcement finishes
|
||||||
|
* @returns a cancel function, or null if no announcement is cached
|
||||||
|
*/
|
||||||
|
export function playAnnouncement(
|
||||||
|
sendPacket: (pkt: Buffer) => void,
|
||||||
|
ssrc: number,
|
||||||
|
onDone?: () => void,
|
||||||
|
): (() => void) | null {
|
||||||
|
if (!cachedAnnouncement || cachedAnnouncement.g722Frames.length === 0) {
|
||||||
|
onDone?.();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frames = cachedAnnouncement.g722Frames;
|
||||||
|
const PT = 9; // G.722
|
||||||
|
let frameIdx = 0;
|
||||||
|
let seq = Math.floor(Math.random() * 0xffff);
|
||||||
|
let rtpTs = Math.floor(Math.random() * 0xffffffff);
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (frameIdx >= frames.length) {
|
||||||
|
clearInterval(timer);
|
||||||
|
onDone?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = frames[frameIdx];
|
||||||
|
const hdr = buildRtpHeader(PT, seq & 0xffff, rtpTs >>> 0, ssrc >>> 0, frameIdx === 0);
|
||||||
|
const pkt = Buffer.concat([hdr, payload]);
|
||||||
|
sendPacket(pkt);
|
||||||
|
|
||||||
|
seq++;
|
||||||
|
rtpTs += rtpClockIncrement(PT);
|
||||||
|
frameIdx++;
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
// Return cancel function.
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play pre-cached Opus announcement to a WebRTC PeerConnection sender.
|
||||||
|
*
|
||||||
|
* @param sendRtpPacket - function to send a raw RTP packet via sender.sendRtp()
|
||||||
|
* @param ssrc - SSRC to use in RTP headers
|
||||||
|
* @param onDone - called when announcement finishes
|
||||||
|
* @returns cancel function, or null if no announcement cached
|
||||||
|
*/
|
||||||
|
export function playAnnouncementToWebRtc(
|
||||||
|
sendRtpPacket: (pkt: Buffer) => void,
|
||||||
|
ssrc: number,
|
||||||
|
counters: { seq: number; ts: number },
|
||||||
|
onDone?: () => void,
|
||||||
|
): (() => void) | null {
|
||||||
|
if (!cachedAnnouncement || cachedAnnouncement.opusFrames.length === 0) {
|
||||||
|
onDone?.();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frames = cachedAnnouncement.opusFrames;
|
||||||
|
const PT = 111; // Opus
|
||||||
|
let frameIdx = 0;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (frameIdx >= frames.length) {
|
||||||
|
clearInterval(timer);
|
||||||
|
onDone?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = frames[frameIdx];
|
||||||
|
const hdr = buildRtpHeader(PT, counters.seq & 0xffff, counters.ts >>> 0, ssrc >>> 0, frameIdx === 0);
|
||||||
|
const pkt = Buffer.concat([hdr, payload]);
|
||||||
|
sendRtpPacket(pkt);
|
||||||
|
|
||||||
|
counters.seq++;
|
||||||
|
counters.ts += 960; // Opus at 48kHz: 960 samples per 20ms
|
||||||
|
frameIdx++;
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if an announcement is cached and ready. */
|
||||||
|
export function isAnnouncementReady(): boolean {
|
||||||
|
return cachedAnnouncement !== null && cachedAnnouncement.g722Frames.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WAV parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function extractPcmFromWav(wav: Buffer): Buffer | null {
|
||||||
|
// Minimal WAV parser — find the "data" chunk.
|
||||||
|
if (wav.length < 44) return null;
|
||||||
|
if (wav.toString('ascii', 0, 4) !== 'RIFF') return null;
|
||||||
|
if (wav.toString('ascii', 8, 12) !== 'WAVE') return null;
|
||||||
|
|
||||||
|
let offset = 12;
|
||||||
|
while (offset < wav.length - 8) {
|
||||||
|
const chunkId = wav.toString('ascii', offset, offset + 4);
|
||||||
|
const chunkSize = wav.readUInt32LE(offset + 4);
|
||||||
|
if (chunkId === 'data') {
|
||||||
|
return wav.subarray(offset + 8, offset + 8 + chunkSize);
|
||||||
|
}
|
||||||
|
offset += 8 + chunkSize;
|
||||||
|
// Word-align.
|
||||||
|
if (offset % 2 !== 0) offset++;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
1139
ts/call/call-manager.ts
Normal file
1139
ts/call/call-manager.ts
Normal file
File diff suppressed because it is too large
Load Diff
255
ts/call/call.ts
Normal file
255
ts/call/call.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Call — the hub entity in the hub model.
|
||||||
|
*
|
||||||
|
* A Call owns N legs and bridges their media. For 2-party calls, RTP packets
|
||||||
|
* from leg A are forwarded to leg B and vice versa. For N>2 party calls,
|
||||||
|
* packets from each leg are forwarded to all other legs (fan-out).
|
||||||
|
*
|
||||||
|
* Transcoding is applied per-leg when codecs differ.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import type { ILeg } from './leg.ts';
|
||||||
|
import type { TCallState, TCallDirection, ICallStatus } from './types.ts';
|
||||||
|
import { RtpPortPool } from './rtp-port-pool.ts';
|
||||||
|
import type { SipLeg } from './sip-leg.ts';
|
||||||
|
|
||||||
|
export class Call {
|
||||||
|
readonly id: string;
|
||||||
|
state: TCallState = 'setting-up';
|
||||||
|
direction: TCallDirection;
|
||||||
|
readonly createdAt: number;
|
||||||
|
|
||||||
|
callerNumber: string | null = null;
|
||||||
|
calleeNumber: string | null = null;
|
||||||
|
providerUsed: string | null = null;
|
||||||
|
|
||||||
|
/** All legs in this call. */
|
||||||
|
private legs = new Map<string, ILeg>();
|
||||||
|
|
||||||
|
/** Codec payload type for the "native" audio in the call (usually the first SIP leg's codec). */
|
||||||
|
private nativeCodec: number | null = null;
|
||||||
|
|
||||||
|
/** Port pool reference for cleanup. */
|
||||||
|
private portPool: RtpPortPool;
|
||||||
|
private log: (msg: string) => void;
|
||||||
|
private onChange: ((call: Call) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(options: {
|
||||||
|
id: string;
|
||||||
|
direction: TCallDirection;
|
||||||
|
portPool: RtpPortPool;
|
||||||
|
log: (msg: string) => void;
|
||||||
|
onChange?: (call: Call) => void;
|
||||||
|
}) {
|
||||||
|
this.id = options.id;
|
||||||
|
this.direction = options.direction;
|
||||||
|
this.createdAt = Date.now();
|
||||||
|
this.portPool = options.portPool;
|
||||||
|
this.log = options.log;
|
||||||
|
this.onChange = options.onChange ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Leg management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Add a leg to this call and wire up media forwarding. */
|
||||||
|
addLeg(leg: ILeg): void {
|
||||||
|
this.legs.set(leg.id, leg);
|
||||||
|
|
||||||
|
// Wire up RTP forwarding: when this leg receives a packet, forward to all other legs.
|
||||||
|
leg.onRtpReceived = (data: Buffer) => {
|
||||||
|
this.forwardRtp(leg.id, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.log(`[call:${this.id}] added leg ${leg.id} (${leg.type}), total=${this.legs.size}`);
|
||||||
|
this.updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a leg from this call, tear it down, and release its port. */
|
||||||
|
removeLeg(legId: string): void {
|
||||||
|
const leg = this.legs.get(legId);
|
||||||
|
if (!leg) return;
|
||||||
|
|
||||||
|
leg.onRtpReceived = null;
|
||||||
|
leg.teardown();
|
||||||
|
if (leg.rtpPort) {
|
||||||
|
this.portPool.release(leg.rtpPort);
|
||||||
|
}
|
||||||
|
this.legs.delete(legId);
|
||||||
|
|
||||||
|
this.log(`[call:${this.id}] removed leg ${legId}, total=${this.legs.size}`);
|
||||||
|
this.updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeg(legId: string): ILeg | null {
|
||||||
|
return this.legs.get(legId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLegs(): ILeg[] {
|
||||||
|
return [...this.legs.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
getLegByType(type: string): ILeg | null {
|
||||||
|
for (const leg of this.legs.values()) {
|
||||||
|
if (leg.type === type) return leg;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLegBySipCallId(sipCallId: string): ILeg | null {
|
||||||
|
for (const leg of this.legs.values()) {
|
||||||
|
if (leg.sipCallId === sipCallId) return leg;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get legCount(): number {
|
||||||
|
return this.legs.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Media forwarding (the hub)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private forwardRtp(fromLegId: string, data: Buffer): void {
|
||||||
|
for (const [id, leg] of this.legs) {
|
||||||
|
if (id === fromLegId) continue;
|
||||||
|
if (leg.state !== 'connected') continue;
|
||||||
|
|
||||||
|
// For WebRTC legs, sendRtp calls forwardToBrowser which handles transcoding internally.
|
||||||
|
// For SIP legs, forward the raw packet (same codec path) or let the leg handle it.
|
||||||
|
// The Call hub does NOT transcode — that's the leg's responsibility.
|
||||||
|
leg.sendRtp(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// State management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private updateState(): void {
|
||||||
|
if (this.state === 'terminated' || this.state === 'terminating') return;
|
||||||
|
|
||||||
|
const legs = [...this.legs.values()];
|
||||||
|
if (legs.length === 0) {
|
||||||
|
this.state = 'terminated';
|
||||||
|
} else if (legs.every((l) => l.state === 'terminated')) {
|
||||||
|
this.state = 'terminated';
|
||||||
|
} else if (legs.some((l) => l.state === 'connected') && legs.filter((l) => l.state !== 'terminated').length >= 2) {
|
||||||
|
this.state = 'connected';
|
||||||
|
} else if (legs.some((l) => l.state === 'ringing')) {
|
||||||
|
this.state = 'ringing';
|
||||||
|
} else {
|
||||||
|
this.state = 'setting-up';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onChange?.(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notify the call that a leg's state has changed. */
|
||||||
|
notifyLegStateChange(_leg: ILeg): void {
|
||||||
|
this.updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hangup
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tear down all legs and terminate the call. */
|
||||||
|
hangup(): void {
|
||||||
|
if (this.state === 'terminated' || this.state === 'terminating') return;
|
||||||
|
this.state = 'terminating';
|
||||||
|
this.log(`[call:${this.id}] hanging up (${this.legs.size} legs)`);
|
||||||
|
|
||||||
|
for (const [id, leg] of this.legs) {
|
||||||
|
// Send BYE/CANCEL for SIP legs.
|
||||||
|
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
|
||||||
|
(leg as SipLeg).sendHangup();
|
||||||
|
}
|
||||||
|
leg.teardown();
|
||||||
|
if (leg.rtpPort) {
|
||||||
|
this.portPool.release(leg.rtpPort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.legs.clear();
|
||||||
|
|
||||||
|
this.state = 'terminated';
|
||||||
|
this.onChange?.(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a BYE from one leg — tear down the other legs.
|
||||||
|
* Called by CallManager when a SipLeg receives a BYE.
|
||||||
|
*/
|
||||||
|
handleLegTerminated(terminatedLegId: string): void {
|
||||||
|
const terminatedLeg = this.legs.get(terminatedLegId);
|
||||||
|
if (!terminatedLeg) return;
|
||||||
|
|
||||||
|
// Remove the terminated leg.
|
||||||
|
terminatedLeg.onRtpReceived = null;
|
||||||
|
if (terminatedLeg.rtpPort) {
|
||||||
|
this.portPool.release(terminatedLeg.rtpPort);
|
||||||
|
}
|
||||||
|
this.legs.delete(terminatedLegId);
|
||||||
|
|
||||||
|
// If this is a 2-party call, hang up the other leg too.
|
||||||
|
if (this.legs.size <= 1) {
|
||||||
|
for (const [id, leg] of this.legs) {
|
||||||
|
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
|
||||||
|
(leg as SipLeg).sendHangup();
|
||||||
|
}
|
||||||
|
leg.teardown();
|
||||||
|
if (leg.rtpPort) {
|
||||||
|
this.portPool.release(leg.rtpPort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.legs.clear();
|
||||||
|
this.state = 'terminated';
|
||||||
|
this.log(`[call:${this.id}] terminated`);
|
||||||
|
this.onChange?.(this);
|
||||||
|
} else {
|
||||||
|
this.log(`[call:${this.id}] leg ${terminatedLegId} removed, ${this.legs.size} remaining`);
|
||||||
|
this.updateState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Transfer
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach a leg from this call (without tearing it down).
|
||||||
|
* The leg can then be added to another call.
|
||||||
|
*/
|
||||||
|
detachLeg(legId: string): ILeg | null {
|
||||||
|
const leg = this.legs.get(legId);
|
||||||
|
if (!leg) return null;
|
||||||
|
|
||||||
|
leg.onRtpReceived = null;
|
||||||
|
this.legs.delete(legId);
|
||||||
|
|
||||||
|
this.log(`[call:${this.id}] detached leg ${legId}`);
|
||||||
|
this.updateState();
|
||||||
|
return leg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Status
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getStatus(): ICallStatus {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
state: this.state,
|
||||||
|
direction: this.direction,
|
||||||
|
callerNumber: this.callerNumber,
|
||||||
|
calleeNumber: this.calleeNumber,
|
||||||
|
providerUsed: this.providerUsed,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
duration: Math.floor((Date.now() - this.createdAt) / 1000),
|
||||||
|
legs: [...this.legs.values()].map((l) => l.getStatus()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
12
ts/call/index.ts
Normal file
12
ts/call/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export type { TCallState, TLegState, TLegType, TCallDirection, ICallStatus, ILegStatus, ICallHistoryEntry } from './types.ts';
|
||||||
|
export type { ILeg } from './leg.ts';
|
||||||
|
export { rtpClockIncrement, buildRtpHeader, codecDisplayName } from './leg.ts';
|
||||||
|
export { RtpPortPool } from './rtp-port-pool.ts';
|
||||||
|
export type { IRtpAllocation } from './rtp-port-pool.ts';
|
||||||
|
export { SipLeg } from './sip-leg.ts';
|
||||||
|
export type { ISipLegConfig } from './sip-leg.ts';
|
||||||
|
export { WebRtcLeg } from './webrtc-leg.ts';
|
||||||
|
export type { IWebRtcLegConfig } from './webrtc-leg.ts';
|
||||||
|
export { Call } from './call.ts';
|
||||||
|
export { CallManager } from './call-manager.ts';
|
||||||
|
export type { ICallManagerConfig } from './call-manager.ts';
|
||||||
104
ts/call/leg.ts
Normal file
104
ts/call/leg.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* ILeg interface — abstract connection from a Call hub to an endpoint.
|
||||||
|
*
|
||||||
|
* Concrete implementations: SipLeg (SIP devices + providers) and WebRtcLeg (browsers).
|
||||||
|
* Shared RTP utilities (header building, clock rates) are also defined here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import type dgram from 'node:dgram';
|
||||||
|
import type { IEndpoint } from '../sip/index.ts';
|
||||||
|
import type { TLegState, TLegType, ILegStatus } from './types.ts';
|
||||||
|
import type { IRtpTranscoder } from '../codec.ts';
|
||||||
|
import type { SipDialog } from '../sip/index.ts';
|
||||||
|
import type { SipMessage } from '../sip/index.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ILeg interface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ILeg {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: TLegType;
|
||||||
|
state: TLegState;
|
||||||
|
|
||||||
|
/** The SIP Call-ID used by this leg (for CallManager routing). */
|
||||||
|
readonly sipCallId: string;
|
||||||
|
|
||||||
|
/** Where this leg sends/receives RTP. */
|
||||||
|
readonly rtpPort: number | null;
|
||||||
|
readonly rtpSock: dgram.Socket | null;
|
||||||
|
remoteMedia: IEndpoint | null;
|
||||||
|
|
||||||
|
/** Negotiated codec payload type (e.g. 9 = G.722, 111 = Opus). */
|
||||||
|
codec: number | null;
|
||||||
|
|
||||||
|
/** Transcoder for converting to this leg's codec (set by Call when codecs differ). */
|
||||||
|
transcoder: IRtpTranscoder | null;
|
||||||
|
|
||||||
|
/** Packet counters. */
|
||||||
|
pktSent: number;
|
||||||
|
pktReceived: number;
|
||||||
|
|
||||||
|
/** SIP dialog (SipLegs only, null for WebRtcLegs). */
|
||||||
|
readonly dialog: SipDialog | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an RTP packet toward this leg's remote endpoint.
|
||||||
|
* If a transcoder is set, the Call should transcode before calling this.
|
||||||
|
*/
|
||||||
|
sendRtp(data: Buffer): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback set by the owning Call — invoked when this leg receives an RTP packet.
|
||||||
|
* The Call uses this to forward to other legs.
|
||||||
|
*/
|
||||||
|
onRtpReceived: ((data: Buffer) => void) | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming SIP message routed to this leg (SipLegs only).
|
||||||
|
* Returns a SipMessage response if one needs to be sent, or null.
|
||||||
|
*/
|
||||||
|
handleSipMessage(msg: SipMessage, rinfo: IEndpoint): void;
|
||||||
|
|
||||||
|
/** Release all resources (sockets, peer connections, etc.). */
|
||||||
|
teardown(): void;
|
||||||
|
|
||||||
|
/** Status snapshot for the dashboard. */
|
||||||
|
getStatus(): ILegStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared RTP utilities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** RTP clock increment per 20ms frame for each codec. */
|
||||||
|
export function rtpClockIncrement(pt: number): number {
|
||||||
|
if (pt === 111) return 960; // Opus: 48000 Hz x 0.02s
|
||||||
|
if (pt === 9) return 160; // G.722: 8000 Hz x 0.02s (SDP clock rate quirk)
|
||||||
|
return 160; // PCMU/PCMA: 8000 Hz x 0.02s
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a fresh RTP header with correct PT, timestamp, seq, SSRC. */
|
||||||
|
export function buildRtpHeader(pt: number, seq: number, ts: number, ssrc: number, marker: boolean): Buffer {
|
||||||
|
const hdr = Buffer.alloc(12);
|
||||||
|
hdr[0] = 0x80; // V=2
|
||||||
|
hdr[1] = (marker ? 0x80 : 0) | (pt & 0x7f);
|
||||||
|
hdr.writeUInt16BE(seq & 0xffff, 2);
|
||||||
|
hdr.writeUInt32BE(ts >>> 0, 4);
|
||||||
|
hdr.writeUInt32BE(ssrc >>> 0, 8);
|
||||||
|
return hdr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Codec name for status display. */
|
||||||
|
export function codecDisplayName(pt: number | null): string | null {
|
||||||
|
if (pt === null) return null;
|
||||||
|
switch (pt) {
|
||||||
|
case 0: return 'PCMU';
|
||||||
|
case 8: return 'PCMA';
|
||||||
|
case 9: return 'G.722';
|
||||||
|
case 111: return 'Opus';
|
||||||
|
case 101: return 'telephone-event';
|
||||||
|
default: return `PT${pt}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
ts/call/rtp-port-pool.ts
Normal file
71
ts/call/rtp-port-pool.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Unified RTP port pool — replaces the three separate allocators
|
||||||
|
* in sipproxy.ts, calloriginator.ts, and webrtcbridge.ts.
|
||||||
|
*
|
||||||
|
* Allocates even-numbered UDP ports from a configured range.
|
||||||
|
* Each allocation binds a dgram socket and returns it ready to use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dgram from 'node:dgram';
|
||||||
|
|
||||||
|
export interface IRtpAllocation {
|
||||||
|
port: number;
|
||||||
|
sock: dgram.Socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RtpPortPool {
|
||||||
|
private min: number;
|
||||||
|
private max: number;
|
||||||
|
private allocated = new Map<number, dgram.Socket>();
|
||||||
|
private log: (msg: string) => void;
|
||||||
|
|
||||||
|
constructor(min: number, max: number, log: (msg: string) => void) {
|
||||||
|
this.min = min % 2 === 0 ? min : min + 1; // ensure even start
|
||||||
|
this.max = max;
|
||||||
|
this.log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocate an even-numbered port and bind a UDP socket to it.
|
||||||
|
* Returns null if the pool is exhausted.
|
||||||
|
*/
|
||||||
|
allocate(): IRtpAllocation | null {
|
||||||
|
for (let port = this.min; port < this.max; port += 2) {
|
||||||
|
if (this.allocated.has(port)) continue;
|
||||||
|
|
||||||
|
const sock = dgram.createSocket('udp4');
|
||||||
|
try {
|
||||||
|
sock.bind(port, '0.0.0.0');
|
||||||
|
} catch {
|
||||||
|
try { sock.close(); } catch { /* ignore */ }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.allocated.set(port, sock);
|
||||||
|
this.log(`[rtp-pool] allocated port ${port} (${this.allocated.size} in use)`);
|
||||||
|
return { port, sock };
|
||||||
|
}
|
||||||
|
this.log('[rtp-pool] WARN: port pool exhausted');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a port back to the pool and close its socket.
|
||||||
|
*/
|
||||||
|
release(port: number): void {
|
||||||
|
const sock = this.allocated.get(port);
|
||||||
|
if (!sock) return;
|
||||||
|
try { sock.close(); } catch { /* ignore */ }
|
||||||
|
this.allocated.delete(port);
|
||||||
|
this.log(`[rtp-pool] released port ${port} (${this.allocated.size} in use)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of currently allocated ports. */
|
||||||
|
get size(): number {
|
||||||
|
return this.allocated.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total capacity (number of even ports in range). */
|
||||||
|
get capacity(): number {
|
||||||
|
return Math.floor((this.max - this.min) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
622
ts/call/sip-leg.ts
Normal file
622
ts/call/sip-leg.ts
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
/**
|
||||||
|
* SipLeg — a SIP connection from the Call hub to a device or provider.
|
||||||
|
*
|
||||||
|
* Wraps a SipDialog and an RTP socket. Handles:
|
||||||
|
* - INVITE/ACK/BYE/CANCEL lifecycle
|
||||||
|
* - SDP rewriting (LAN IP for devices, public IP for providers)
|
||||||
|
* - Digest auth for provider legs (407/401)
|
||||||
|
* - Early-media silence for providers with quirks
|
||||||
|
* - Record-Route insertion for dialog-establishing requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dgram from 'node:dgram';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import {
|
||||||
|
SipMessage,
|
||||||
|
SipDialog,
|
||||||
|
buildSdp,
|
||||||
|
parseSdpEndpoint,
|
||||||
|
rewriteSdp,
|
||||||
|
rewriteSipUri,
|
||||||
|
parseDigestChallenge,
|
||||||
|
computeDigestAuth,
|
||||||
|
generateTag,
|
||||||
|
} from '../sip/index.ts';
|
||||||
|
import type { IEndpoint } from '../sip/index.ts';
|
||||||
|
import type { IProviderConfig, IQuirks } from '../config.ts';
|
||||||
|
import type { TLegState, TLegType, ILegStatus } from './types.ts';
|
||||||
|
import type { ILeg } from './leg.ts';
|
||||||
|
import { codecDisplayName } from './leg.ts';
|
||||||
|
import type { IRtpTranscoder } from '../codec.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SipLeg config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ISipLegConfig {
|
||||||
|
/** Whether this leg faces a device (LAN) or a provider (WAN). */
|
||||||
|
role: 'device' | 'provider';
|
||||||
|
|
||||||
|
/** Proxy LAN IP (for SDP rewriting toward devices). */
|
||||||
|
lanIp: string;
|
||||||
|
/** Proxy LAN port (for Via, Contact, Record-Route). */
|
||||||
|
lanPort: number;
|
||||||
|
|
||||||
|
/** Public IP (for SDP rewriting toward providers). */
|
||||||
|
getPublicIp: () => string | null;
|
||||||
|
|
||||||
|
/** Send a SIP message via the main UDP socket. */
|
||||||
|
sendSip: (buf: Buffer, dest: IEndpoint) => void;
|
||||||
|
/** Logging function. */
|
||||||
|
log: (msg: string) => void;
|
||||||
|
|
||||||
|
/** Provider config (for provider legs: auth, codecs, quirks, outbound proxy). */
|
||||||
|
provider?: IProviderConfig;
|
||||||
|
|
||||||
|
/** The endpoint to send SIP messages to (device address or provider outbound proxy). */
|
||||||
|
sipTarget: IEndpoint;
|
||||||
|
|
||||||
|
/** RTP port and socket (pre-allocated from the pool). */
|
||||||
|
rtpPort: number;
|
||||||
|
rtpSock: dgram.Socket;
|
||||||
|
|
||||||
|
/** Payload types to offer in SDP. */
|
||||||
|
payloadTypes?: number[];
|
||||||
|
|
||||||
|
/** Registered AOR (for From header in provider leg). */
|
||||||
|
getRegisteredAor?: () => string | null;
|
||||||
|
/** SIP password (for digest auth). */
|
||||||
|
getSipPassword?: () => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SipLeg
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class SipLeg implements ILeg {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: TLegType;
|
||||||
|
state: TLegState = 'inviting';
|
||||||
|
readonly config: ISipLegConfig;
|
||||||
|
|
||||||
|
/** The SIP dialog for this leg. */
|
||||||
|
dialog: SipDialog | null = null;
|
||||||
|
|
||||||
|
/** Original INVITE (needed for CANCEL). */
|
||||||
|
invite: SipMessage | null = null;
|
||||||
|
|
||||||
|
/** Original unauthenticated INVITE (for re-ACKing retransmitted 407s). */
|
||||||
|
private origInvite: SipMessage | null = null;
|
||||||
|
|
||||||
|
/** Whether we've attempted digest auth on this leg. */
|
||||||
|
private authAttempted = false;
|
||||||
|
|
||||||
|
/** RTP socket and port. */
|
||||||
|
readonly rtpPort: number;
|
||||||
|
readonly rtpSock: dgram.Socket;
|
||||||
|
|
||||||
|
/** Remote media endpoint (learned from SDP). */
|
||||||
|
remoteMedia: IEndpoint | null = null;
|
||||||
|
|
||||||
|
/** Negotiated codec. */
|
||||||
|
codec: number | null = null;
|
||||||
|
|
||||||
|
/** Transcoder (set by Call when codecs differ between legs). */
|
||||||
|
transcoder: IRtpTranscoder | null = null;
|
||||||
|
|
||||||
|
/** Stable SSRC for this leg (used for silence + forwarded audio). */
|
||||||
|
readonly ssrc: number = (Math.random() * 0xffffffff) >>> 0;
|
||||||
|
|
||||||
|
/** Packet counters. */
|
||||||
|
pktSent = 0;
|
||||||
|
pktReceived = 0;
|
||||||
|
|
||||||
|
/** Callback set by Call to receive RTP. */
|
||||||
|
onRtpReceived: ((data: Buffer) => void) | null = null;
|
||||||
|
|
||||||
|
/** Silence stream timer (for provider quirks). */
|
||||||
|
private silenceTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** Callbacks for lifecycle events. */
|
||||||
|
onStateChange: ((leg: SipLeg) => void) | null = null;
|
||||||
|
onConnected: ((leg: SipLeg) => void) | null = null;
|
||||||
|
onTerminated: ((leg: SipLeg) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(id: string, config: ISipLegConfig) {
|
||||||
|
this.id = id;
|
||||||
|
this.type = config.role === 'device' ? 'sip-device' : 'sip-provider';
|
||||||
|
this.config = config;
|
||||||
|
this.rtpPort = config.rtpPort;
|
||||||
|
this.rtpSock = config.rtpSock;
|
||||||
|
|
||||||
|
// Set up RTP receive handler.
|
||||||
|
this.rtpSock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
|
||||||
|
this.pktReceived++;
|
||||||
|
|
||||||
|
// Learn remote media endpoint from first packet if not yet known.
|
||||||
|
if (!this.remoteMedia) {
|
||||||
|
this.remoteMedia = { address: rinfo.address, port: rinfo.port };
|
||||||
|
this.config.log(`[sip-leg:${this.id}] learned remote media: ${rinfo.address}:${rinfo.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to the Call hub.
|
||||||
|
if (this.onRtpReceived) {
|
||||||
|
this.onRtpReceived(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rtpSock.on('error', (e: Error) => {
|
||||||
|
this.config.log(`[sip-leg:${this.id}] rtp error: ${e.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get sipCallId(): string {
|
||||||
|
return this.dialog?.callId || 'no-dialog';
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Outbound INVITE (B2BUA mode — create a new dialog)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an INVITE to establish this leg.
|
||||||
|
* Creates a new SipDialog (UAC side).
|
||||||
|
*/
|
||||||
|
sendInvite(options: {
|
||||||
|
fromUri: string;
|
||||||
|
toUri: string;
|
||||||
|
callId: string;
|
||||||
|
fromTag?: string;
|
||||||
|
fromDisplayName?: string;
|
||||||
|
cseq?: number;
|
||||||
|
extraHeaders?: [string, string][];
|
||||||
|
}): void {
|
||||||
|
const ip = this.type === 'sip-provider'
|
||||||
|
? (this.config.getPublicIp() || this.config.lanIp)
|
||||||
|
: this.config.lanIp;
|
||||||
|
const pts = this.config.payloadTypes || [9, 0, 8, 101];
|
||||||
|
|
||||||
|
const sdp = buildSdp({ ip, port: this.rtpPort, payloadTypes: pts });
|
||||||
|
|
||||||
|
const invite = SipMessage.createRequest('INVITE', options.toUri, {
|
||||||
|
via: { host: ip, port: this.config.lanPort },
|
||||||
|
from: { uri: options.fromUri, displayName: options.fromDisplayName, tag: options.fromTag },
|
||||||
|
to: { uri: options.toUri },
|
||||||
|
callId: options.callId,
|
||||||
|
cseq: options.cseq,
|
||||||
|
contact: `<sip:${ip}:${this.config.lanPort}>`,
|
||||||
|
body: sdp,
|
||||||
|
contentType: 'application/sdp',
|
||||||
|
extraHeaders: options.extraHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.invite = invite;
|
||||||
|
this.dialog = SipDialog.fromUacInvite(invite, ip, this.config.lanPort);
|
||||||
|
this.state = 'inviting';
|
||||||
|
|
||||||
|
this.config.log(`[sip-leg:${this.id}] INVITE -> ${this.config.sipTarget.address}:${this.config.sipTarget.port}`);
|
||||||
|
this.config.sendSip(invite.serialize(), this.config.sipTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Passthrough mode — forward a SIP message with rewriting
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept an incoming INVITE as a UAS (for passthrough inbound calls).
|
||||||
|
* Creates a SipDialog on the UAS side.
|
||||||
|
*/
|
||||||
|
acceptIncoming(invite: SipMessage): void {
|
||||||
|
const localTag = generateTag();
|
||||||
|
this.dialog = SipDialog.fromUasInvite(invite, localTag, this.config.lanIp, this.config.lanPort);
|
||||||
|
this.invite = invite;
|
||||||
|
this.state = 'inviting';
|
||||||
|
|
||||||
|
// Learn remote media from SDP.
|
||||||
|
if (invite.hasSdpBody) {
|
||||||
|
const ep = parseSdpEndpoint(invite.body);
|
||||||
|
if (ep) {
|
||||||
|
this.remoteMedia = ep;
|
||||||
|
this.config.log(`[sip-leg:${this.id}] media from SDP: ${ep.address}:${ep.port}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward a SIP message through this leg with SDP rewriting.
|
||||||
|
* Used for passthrough calls where the proxy relays messages.
|
||||||
|
*/
|
||||||
|
forwardMessage(msg: SipMessage, dest: IEndpoint): void {
|
||||||
|
const rewriteIp = this.type === 'sip-provider'
|
||||||
|
? (this.config.getPublicIp() || this.config.lanIp)
|
||||||
|
: this.config.lanIp;
|
||||||
|
|
||||||
|
// Rewrite SDP if present.
|
||||||
|
if (msg.hasSdpBody) {
|
||||||
|
const { body, original } = rewriteSdp(msg.body, rewriteIp, this.rtpPort);
|
||||||
|
msg.body = body;
|
||||||
|
msg.updateContentLength();
|
||||||
|
if (original) {
|
||||||
|
this.remoteMedia = original;
|
||||||
|
this.config.log(`[sip-leg:${this.id}] media from SDP rewrite: ${original.address}:${original.port}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record-Route for dialog-establishing requests.
|
||||||
|
if (msg.isRequest && msg.isDialogEstablishing) {
|
||||||
|
msg.prependHeader('Record-Route', `<sip:${this.config.lanIp}:${this.config.lanPort};lr>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite Contact.
|
||||||
|
if (this.type === 'sip-provider') {
|
||||||
|
const contact = msg.getHeader('Contact');
|
||||||
|
if (contact) {
|
||||||
|
const nc = rewriteSipUri(contact, rewriteIp, this.config.lanPort);
|
||||||
|
if (nc !== contact) msg.setHeader('Contact', nc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite Request-URI for inbound messages going to device.
|
||||||
|
if (this.type === 'sip-device' && msg.isRequest) {
|
||||||
|
msg.setRequestUri(rewriteSipUri(msg.requestUri!, dest.address, dest.port));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config.sendSip(msg.serialize(), dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SIP message handling (routed by CallManager)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
handleSipMessage(msg: SipMessage, rinfo: IEndpoint): void {
|
||||||
|
if (msg.isResponse) {
|
||||||
|
this.handleResponse(msg, rinfo);
|
||||||
|
} else {
|
||||||
|
this.handleRequest(msg, rinfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleResponse(msg: SipMessage, _rinfo: IEndpoint): void {
|
||||||
|
const code = msg.statusCode ?? 0;
|
||||||
|
const method = msg.cseqMethod?.toUpperCase();
|
||||||
|
|
||||||
|
this.config.log(`[sip-leg:${this.id}] <- ${code} (${method})`);
|
||||||
|
|
||||||
|
if (method === 'INVITE') {
|
||||||
|
this.handleInviteResponse(msg, code);
|
||||||
|
}
|
||||||
|
// BYE/CANCEL responses don't need action beyond logging.
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInviteResponse(msg: SipMessage, code: number): void {
|
||||||
|
// Handle retransmitted 407 for the original unauthenticated INVITE.
|
||||||
|
if (this.authAttempted && this.dialog) {
|
||||||
|
const responseCSeqNum = parseInt((msg.getHeader('CSeq') || '').split(/\s+/)[0], 10);
|
||||||
|
if (responseCSeqNum < this.dialog.localCSeq && code >= 400) {
|
||||||
|
if (this.origInvite) {
|
||||||
|
const ack = buildNon2xxAck(this.origInvite, msg);
|
||||||
|
this.config.sendSip(ack.serialize(), this.config.sipTarget);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 407 Proxy Authentication Required.
|
||||||
|
if (code === 407 && this.type === 'sip-provider') {
|
||||||
|
this.handleAuthChallenge(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update dialog state.
|
||||||
|
if (this.dialog) {
|
||||||
|
this.dialog.processResponse(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 180 || code === 183) {
|
||||||
|
this.state = 'ringing';
|
||||||
|
this.onStateChange?.(this);
|
||||||
|
} else if (code >= 200 && code < 300) {
|
||||||
|
// ACK the 200 OK.
|
||||||
|
if (this.dialog) {
|
||||||
|
const ack = this.dialog.createAck();
|
||||||
|
this.config.sendSip(ack.serialize(), this.config.sipTarget);
|
||||||
|
this.config.log(`[sip-leg:${this.id}] ACK sent`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already connected (200 retransmit), just re-ACK.
|
||||||
|
if (this.state === 'connected') {
|
||||||
|
this.config.log(`[sip-leg:${this.id}] re-ACK (200 retransmit)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Learn media endpoint from SDP.
|
||||||
|
if (msg.hasSdpBody) {
|
||||||
|
const ep = parseSdpEndpoint(msg.body);
|
||||||
|
if (ep) {
|
||||||
|
this.remoteMedia = ep;
|
||||||
|
this.config.log(`[sip-leg:${this.id}] media = ${ep.address}:${ep.port}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'connected';
|
||||||
|
this.config.log(`[sip-leg:${this.id}] CONNECTED`);
|
||||||
|
|
||||||
|
// Start silence for provider legs with early media quirks.
|
||||||
|
if (this.type === 'sip-provider') {
|
||||||
|
this.startSilence();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prime the RTP path.
|
||||||
|
if (this.remoteMedia) {
|
||||||
|
this.primeRtp(this.remoteMedia);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onConnected?.(this);
|
||||||
|
this.onStateChange?.(this);
|
||||||
|
} else if (code >= 300) {
|
||||||
|
this.config.log(`[sip-leg:${this.id}] rejected ${code}`);
|
||||||
|
this.state = 'terminated';
|
||||||
|
if (this.dialog) this.dialog.terminate();
|
||||||
|
this.onTerminated?.(this);
|
||||||
|
this.onStateChange?.(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAuthChallenge(msg: SipMessage): void {
|
||||||
|
if (this.authAttempted) {
|
||||||
|
this.config.log(`[sip-leg:${this.id}] 407 after auth attempt — credentials rejected`);
|
||||||
|
this.state = 'terminated';
|
||||||
|
if (this.dialog) this.dialog.terminate();
|
||||||
|
this.onTerminated?.(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.authAttempted = true;
|
||||||
|
|
||||||
|
const challenge = msg.getHeader('Proxy-Authenticate');
|
||||||
|
if (!challenge) {
|
||||||
|
this.config.log(`[sip-leg:${this.id}] 407 but no Proxy-Authenticate`);
|
||||||
|
this.state = 'terminated';
|
||||||
|
if (this.dialog) this.dialog.terminate();
|
||||||
|
this.onTerminated?.(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseDigestChallenge(challenge);
|
||||||
|
if (!parsed) {
|
||||||
|
this.config.log(`[sip-leg:${this.id}] could not parse digest challenge`);
|
||||||
|
this.state = 'terminated';
|
||||||
|
if (this.dialog) this.dialog.terminate();
|
||||||
|
this.onTerminated?.(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = this.config.getSipPassword?.();
|
||||||
|
const aor = this.config.getRegisteredAor?.();
|
||||||
|
if (!password || !aor) {
|
||||||
|
this.config.log(`[sip-leg:${this.id}] 407 but no password or AOR`);
|
||||||
|
this.state = 'terminated';
|
||||||
|
if (this.dialog) this.dialog.terminate();
|
||||||
|
this.onTerminated?.(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = aor.replace(/^sips?:/, '').split('@')[0];
|
||||||
|
const destUri = this.invite?.requestUri || '';
|
||||||
|
|
||||||
|
const authValue = computeDigestAuth({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
realm: parsed.realm,
|
||||||
|
nonce: parsed.nonce,
|
||||||
|
method: 'INVITE',
|
||||||
|
uri: destUri,
|
||||||
|
algorithm: parsed.algorithm,
|
||||||
|
opaque: parsed.opaque,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ACK the 407.
|
||||||
|
if (this.invite) {
|
||||||
|
const ack407 = buildNon2xxAck(this.invite, msg);
|
||||||
|
this.config.sendSip(ack407.serialize(), this.config.sipTarget);
|
||||||
|
this.config.log(`[sip-leg:${this.id}] ACK-407 sent`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep original INVITE for re-ACKing retransmitted 407s.
|
||||||
|
this.origInvite = this.invite;
|
||||||
|
|
||||||
|
// Resend INVITE with auth, same From tag, incremented CSeq.
|
||||||
|
const ip = this.config.getPublicIp() || this.config.lanIp;
|
||||||
|
const fromTag = this.dialog!.localTag;
|
||||||
|
const pts = this.config.payloadTypes || [9, 0, 8, 101];
|
||||||
|
|
||||||
|
const sdp = buildSdp({ ip, port: this.rtpPort, payloadTypes: pts });
|
||||||
|
|
||||||
|
const inviteAuth = SipMessage.createRequest('INVITE', destUri, {
|
||||||
|
via: { host: ip, port: this.config.lanPort },
|
||||||
|
from: { uri: aor, tag: fromTag },
|
||||||
|
to: { uri: destUri },
|
||||||
|
callId: this.dialog!.callId,
|
||||||
|
cseq: 2,
|
||||||
|
contact: `<sip:${ip}:${this.config.lanPort}>`,
|
||||||
|
body: sdp,
|
||||||
|
contentType: 'application/sdp',
|
||||||
|
extraHeaders: [['Proxy-Authorization', authValue]],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.invite = inviteAuth;
|
||||||
|
this.dialog!.localCSeq = 2;
|
||||||
|
|
||||||
|
this.config.log(`[sip-leg:${this.id}] resending INVITE with auth`);
|
||||||
|
this.config.sendSip(inviteAuth.serialize(), this.config.sipTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRequest(msg: SipMessage, rinfo: IEndpoint): void {
|
||||||
|
const method = msg.method;
|
||||||
|
this.config.log(`[sip-leg:${this.id}] <- ${method} from ${rinfo.address}:${rinfo.port}`);
|
||||||
|
|
||||||
|
if (method === 'BYE') {
|
||||||
|
// Send 200 OK to the BYE.
|
||||||
|
const ok = SipMessage.createResponse(200, 'OK', msg);
|
||||||
|
this.config.sendSip(ok.serialize(), { address: rinfo.address, port: rinfo.port });
|
||||||
|
|
||||||
|
this.state = 'terminated';
|
||||||
|
if (this.dialog) this.dialog.terminate();
|
||||||
|
this.onTerminated?.(this);
|
||||||
|
this.onStateChange?.(this);
|
||||||
|
}
|
||||||
|
// Other in-dialog requests (re-INVITE, INFO, etc.) can be handled here in the future.
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Send BYE / CANCEL
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Send BYE (if confirmed) or CANCEL (if early) to tear down this leg. */
|
||||||
|
sendHangup(): void {
|
||||||
|
if (!this.dialog) return;
|
||||||
|
|
||||||
|
if (this.dialog.state === 'confirmed') {
|
||||||
|
const bye = this.dialog.createRequest('BYE');
|
||||||
|
this.config.sendSip(bye.serialize(), this.config.sipTarget);
|
||||||
|
this.config.log(`[sip-leg:${this.id}] BYE sent`);
|
||||||
|
} else if (this.dialog.state === 'early' && this.invite) {
|
||||||
|
const cancel = this.dialog.createCancel(this.invite);
|
||||||
|
this.config.sendSip(cancel.serialize(), this.config.sipTarget);
|
||||||
|
this.config.log(`[sip-leg:${this.id}] CANCEL sent`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'terminating';
|
||||||
|
this.dialog.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// RTP
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
sendRtp(data: Buffer): void {
|
||||||
|
if (!this.remoteMedia) return;
|
||||||
|
this.rtpSock.send(data, this.remoteMedia.port, this.remoteMedia.address);
|
||||||
|
this.pktSent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a 1-byte UDP packet to punch NAT hole. */
|
||||||
|
private primeRtp(peer: IEndpoint): void {
|
||||||
|
try {
|
||||||
|
this.rtpSock.send(Buffer.alloc(1), peer.port, peer.address);
|
||||||
|
this.config.log(`[sip-leg:${this.id}] RTP primed -> ${peer.address}:${peer.port}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.config.log(`[sip-leg:${this.id}] prime error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Silence stream (provider quirks)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private startSilence(): void {
|
||||||
|
if (this.silenceTimer) return;
|
||||||
|
const quirks = this.config.provider?.quirks;
|
||||||
|
if (!quirks?.earlyMediaSilence) return;
|
||||||
|
if (!this.remoteMedia) return;
|
||||||
|
|
||||||
|
const PT = quirks.silencePayloadType ?? 9;
|
||||||
|
const MAX = quirks.silenceMaxPackets ?? 250;
|
||||||
|
const PAYLOAD = 160;
|
||||||
|
let seq = Math.floor(Math.random() * 0xffff);
|
||||||
|
let rtpTs = Math.floor(Math.random() * 0xffffffff);
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
// Use proper silence byte for the codec (0x00 is NOT silence for most codecs).
|
||||||
|
const silenceByte = silenceByteForPT(PT);
|
||||||
|
|
||||||
|
this.silenceTimer = setInterval(() => {
|
||||||
|
if (this.pktReceived > 0 || count >= MAX) {
|
||||||
|
clearInterval(this.silenceTimer!);
|
||||||
|
this.silenceTimer = null;
|
||||||
|
this.config.log(`[sip-leg:${this.id}] silence stop after ${count} pkts`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pkt = Buffer.alloc(12 + PAYLOAD, silenceByte);
|
||||||
|
// RTP header (first 12 bytes).
|
||||||
|
pkt[0] = 0x80;
|
||||||
|
pkt[1] = PT;
|
||||||
|
pkt.writeUInt16BE(seq & 0xffff, 2);
|
||||||
|
pkt.writeUInt32BE(rtpTs >>> 0, 4);
|
||||||
|
pkt.writeUInt32BE(this.ssrc >>> 0, 8); // stable SSRC
|
||||||
|
this.rtpSock.send(pkt, this.remoteMedia!.port, this.remoteMedia!.address);
|
||||||
|
seq++;
|
||||||
|
rtpTs += PAYLOAD;
|
||||||
|
count++;
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
this.config.log(`[sip-leg:${this.id}] silence start -> ${this.remoteMedia.address}:${this.remoteMedia.port} (ssrc=${this.ssrc})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
teardown(): void {
|
||||||
|
if (this.silenceTimer) {
|
||||||
|
clearInterval(this.silenceTimer);
|
||||||
|
this.silenceTimer = null;
|
||||||
|
}
|
||||||
|
this.state = 'terminated';
|
||||||
|
if (this.dialog) this.dialog.terminate();
|
||||||
|
// Note: RTP socket is NOT closed here — the RtpPortPool manages that.
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): ILegStatus {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
type: this.type,
|
||||||
|
state: this.state,
|
||||||
|
remoteMedia: this.remoteMedia,
|
||||||
|
rtpPort: this.rtpPort,
|
||||||
|
pktSent: this.pktSent,
|
||||||
|
pktReceived: this.pktReceived,
|
||||||
|
codec: codecDisplayName(this.codec),
|
||||||
|
transcoding: this.transcoder !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: proper silence byte per codec
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Return the byte value representing digital silence for a given RTP payload type. */
|
||||||
|
function silenceByteForPT(pt: number): number {
|
||||||
|
switch (pt) {
|
||||||
|
case 0: return 0xFF; // PCMU: μ-law silence (zero amplitude)
|
||||||
|
case 8: return 0xD5; // PCMA: A-law silence (zero amplitude)
|
||||||
|
case 9: return 0xD5; // G.722: sub-band silence (zero amplitude)
|
||||||
|
default: return 0xFF; // safe default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: ACK for non-2xx (same transaction)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildNon2xxAck(originalInvite: SipMessage, response: SipMessage): SipMessage {
|
||||||
|
const via = originalInvite.getHeader('Via') || '';
|
||||||
|
const from = originalInvite.getHeader('From') || '';
|
||||||
|
const toFromResponse = response.getHeader('To') || '';
|
||||||
|
const callId = originalInvite.callId;
|
||||||
|
const cseqNum = parseInt((originalInvite.getHeader('CSeq') || '1').split(/\s+/)[0], 10);
|
||||||
|
|
||||||
|
return new SipMessage(
|
||||||
|
`ACK ${originalInvite.requestUri} SIP/2.0`,
|
||||||
|
[
|
||||||
|
['Via', via],
|
||||||
|
['From', from],
|
||||||
|
['To', toFromResponse],
|
||||||
|
['Call-ID', callId],
|
||||||
|
['CSeq', `${cseqNum} ACK`],
|
||||||
|
['Max-Forwards', '70'],
|
||||||
|
['Content-Length', '0'],
|
||||||
|
],
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
68
ts/call/types.ts
Normal file
68
ts/call/types.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Hub model type definitions — Call, Leg, and status types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IEndpoint } from '../sip/index.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type TCallState =
|
||||||
|
| 'setting-up'
|
||||||
|
| 'ringing'
|
||||||
|
| 'connected'
|
||||||
|
| 'on-hold'
|
||||||
|
| 'transferring'
|
||||||
|
| 'terminating'
|
||||||
|
| 'terminated';
|
||||||
|
|
||||||
|
export type TLegState =
|
||||||
|
| 'inviting'
|
||||||
|
| 'ringing'
|
||||||
|
| 'connected'
|
||||||
|
| 'on-hold'
|
||||||
|
| 'terminating'
|
||||||
|
| 'terminated';
|
||||||
|
|
||||||
|
export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc';
|
||||||
|
|
||||||
|
export type TCallDirection = 'inbound' | 'outbound' | 'internal';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status interfaces (for frontend dashboard)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ILegStatus {
|
||||||
|
id: string;
|
||||||
|
type: TLegType;
|
||||||
|
state: TLegState;
|
||||||
|
remoteMedia: IEndpoint | null;
|
||||||
|
rtpPort: number | null;
|
||||||
|
pktSent: number;
|
||||||
|
pktReceived: number;
|
||||||
|
codec: string | null;
|
||||||
|
transcoding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICallStatus {
|
||||||
|
id: string;
|
||||||
|
state: TCallState;
|
||||||
|
direction: TCallDirection;
|
||||||
|
callerNumber: string | null;
|
||||||
|
calleeNumber: string | null;
|
||||||
|
providerUsed: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
duration: number;
|
||||||
|
legs: ILegStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICallHistoryEntry {
|
||||||
|
id: string;
|
||||||
|
direction: TCallDirection;
|
||||||
|
callerNumber: string | null;
|
||||||
|
calleeNumber: string | null;
|
||||||
|
providerUsed: string | null;
|
||||||
|
startedAt: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
417
ts/call/webrtc-leg.ts
Normal file
417
ts/call/webrtc-leg.ts
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* WebRtcLeg — a WebRTC connection from the Call hub to a browser client.
|
||||||
|
*
|
||||||
|
* Wraps a werift RTCPeerConnection and handles:
|
||||||
|
* - WebRTC offer/answer/ICE negotiation
|
||||||
|
* - Opus <-> G.722/PCMU/PCMA transcoding via Rust IPC
|
||||||
|
* - RTP header rebuilding with correct PT, timestamp, SSRC
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dgram from 'node:dgram';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import type { IEndpoint } from '../sip/index.ts';
|
||||||
|
import type { TLegState, ILegStatus } from './types.ts';
|
||||||
|
import type { ILeg } from './leg.ts';
|
||||||
|
import { rtpClockIncrement, buildRtpHeader, codecDisplayName } from './leg.ts';
|
||||||
|
import { createTranscoder, OPUS_PT } from '../codec.ts';
|
||||||
|
import type { IRtpTranscoder } from '../codec.ts';
|
||||||
|
import { createSession, destroySession } from '../opusbridge.ts';
|
||||||
|
import type { SipDialog } from '../sip/index.ts';
|
||||||
|
import type { SipMessage } from '../sip/index.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebRtcLeg config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface IWebRtcLegConfig {
|
||||||
|
/** The browser's WebSocket connection. */
|
||||||
|
ws: WebSocket;
|
||||||
|
/** The browser's session ID. */
|
||||||
|
sessionId: string;
|
||||||
|
/** RTP port and socket (pre-allocated from the pool). */
|
||||||
|
rtpPort: number;
|
||||||
|
rtpSock: dgram.Socket;
|
||||||
|
/** Logging function. */
|
||||||
|
log: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebRtcLeg
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class WebRtcLeg implements ILeg {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type = 'webrtc' as const;
|
||||||
|
state: TLegState = 'inviting';
|
||||||
|
readonly sessionId: string;
|
||||||
|
|
||||||
|
/** The werift RTCPeerConnection instance. */
|
||||||
|
private pc: any = null;
|
||||||
|
|
||||||
|
/** RTP socket for bridging to SIP. */
|
||||||
|
readonly rtpSock: dgram.Socket;
|
||||||
|
readonly rtpPort: number;
|
||||||
|
|
||||||
|
/** Remote media endpoint (the other side of the bridge, set by Call). */
|
||||||
|
remoteMedia: IEndpoint | null = null;
|
||||||
|
|
||||||
|
/** Negotiated WebRTC codec payload type. */
|
||||||
|
codec: number | null = null;
|
||||||
|
|
||||||
|
/** Transcoders for WebRTC <-> SIP conversion. */
|
||||||
|
transcoder: IRtpTranscoder | null = null; // used by Call for fan-out
|
||||||
|
private toSipTranscoder: IRtpTranscoder | null = null;
|
||||||
|
private fromSipTranscoder: IRtpTranscoder | null = null;
|
||||||
|
|
||||||
|
/** RTP counters for outgoing (to SIP) direction. */
|
||||||
|
private toSipSeq = 0;
|
||||||
|
private toSipTs = 0;
|
||||||
|
private toSipSsrc = (Math.random() * 0xffffffff) >>> 0;
|
||||||
|
|
||||||
|
/** RTP counters for incoming (from SIP) direction.
|
||||||
|
* Initialized to random values so announcements and provider audio share
|
||||||
|
* a continuous sequence — prevents the browser jitter buffer from discarding
|
||||||
|
* packets after the announcement→provider transition. */
|
||||||
|
readonly fromSipCounters = {
|
||||||
|
seq: Math.floor(Math.random() * 0xffff),
|
||||||
|
ts: Math.floor(Math.random() * 0xffffffff),
|
||||||
|
};
|
||||||
|
fromSipSsrc = (Math.random() * 0xffffffff) >>> 0;
|
||||||
|
|
||||||
|
/** Packet counters. */
|
||||||
|
pktSent = 0;
|
||||||
|
pktReceived = 0;
|
||||||
|
|
||||||
|
/** Callback set by Call. */
|
||||||
|
onRtpReceived: ((data: Buffer) => void) | null = null;
|
||||||
|
|
||||||
|
/** Callback to send transcoded RTP to the provider via the SipLeg's socket.
|
||||||
|
* Set by CallManager when the bridge is established. If null, falls back to own rtpSock. */
|
||||||
|
onSendToProvider: ((data: Buffer, dest: IEndpoint) => void) | null = null;
|
||||||
|
|
||||||
|
/** Lifecycle callbacks. */
|
||||||
|
onConnected: ((leg: WebRtcLeg) => void) | null = null;
|
||||||
|
onTerminated: ((leg: WebRtcLeg) => void) | null = null;
|
||||||
|
|
||||||
|
/** Cancel handle for an in-progress announcement. */
|
||||||
|
announcementCancel: (() => void) | null = null;
|
||||||
|
|
||||||
|
private ws: WebSocket;
|
||||||
|
private config: IWebRtcLegConfig;
|
||||||
|
private pendingIceCandidates: any[] = [];
|
||||||
|
|
||||||
|
// SipDialog is not applicable for WebRTC legs.
|
||||||
|
readonly dialog: SipDialog | null = null;
|
||||||
|
readonly sipCallId: string;
|
||||||
|
|
||||||
|
constructor(id: string, config: IWebRtcLegConfig) {
|
||||||
|
this.id = id;
|
||||||
|
this.sessionId = config.sessionId;
|
||||||
|
this.ws = config.ws;
|
||||||
|
this.rtpSock = config.rtpSock;
|
||||||
|
this.rtpPort = config.rtpPort;
|
||||||
|
this.config = config;
|
||||||
|
this.sipCallId = `webrtc-${id}`;
|
||||||
|
|
||||||
|
// Log RTP arriving on this socket (symmetric RTP from provider).
|
||||||
|
// Audio forwarding is handled by the Call hub: SipLeg → forwardRtp → WebRtcLeg.sendRtp.
|
||||||
|
// We do NOT transcode here to avoid double-processing (the SipLeg also receives these packets).
|
||||||
|
let sipRxCount = 0;
|
||||||
|
this.rtpSock.on('message', (data: Buffer) => {
|
||||||
|
sipRxCount++;
|
||||||
|
if (sipRxCount === 1 || sipRxCount === 50 || sipRxCount % 500 === 0) {
|
||||||
|
this.config.log(`[webrtc-leg:${this.id}] SIP->browser rtp #${sipRxCount} (${data.length}b) [symmetric, ignored]`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// WebRTC offer/answer
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a WebRTC offer from the browser. Creates the PeerConnection,
|
||||||
|
* sets remote offer, creates answer, and sends it back.
|
||||||
|
*/
|
||||||
|
async handleOffer(offerSdp: string): Promise<void> {
|
||||||
|
this.config.log(`[webrtc-leg:${this.id}] received offer`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const werift = await import('werift');
|
||||||
|
|
||||||
|
this.pc = new werift.RTCPeerConnection({ iceServers: [] });
|
||||||
|
|
||||||
|
// Add sendrecv transceiver before setRemoteDescription.
|
||||||
|
this.pc.addTransceiver('audio', { direction: 'sendrecv' });
|
||||||
|
|
||||||
|
// Handle incoming audio from browser.
|
||||||
|
this.pc.ontrack = (event: any) => {
|
||||||
|
const track = event.track;
|
||||||
|
this.config.log(`[webrtc-leg:${this.id}] got track: ${track.kind}`);
|
||||||
|
|
||||||
|
let rxCount = 0;
|
||||||
|
track.onReceiveRtp.subscribe((rtp: any) => {
|
||||||
|
if (!this.remoteMedia) return;
|
||||||
|
rxCount++;
|
||||||
|
if (rxCount === 1 || rxCount === 50 || rxCount % 500 === 0) {
|
||||||
|
this.config.log(`[webrtc-leg:${this.id}] browser->SIP rtp #${rxCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.forwardToSip(rtp, rxCount);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ICE candidate handling.
|
||||||
|
this.pc.onicecandidate = (candidate: any) => {
|
||||||
|
if (candidate) {
|
||||||
|
const json = candidate.toJSON?.() || candidate;
|
||||||
|
this.wsSend({ type: 'webrtc-ice', sessionId: this.sessionId, candidate: json });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pc.onconnectionstatechange = () => {
|
||||||
|
this.config.log(`[webrtc-leg:${this.id}] connection state: ${this.pc.connectionState}`);
|
||||||
|
if (this.pc.connectionState === 'connected') {
|
||||||
|
this.state = 'connected';
|
||||||
|
this.onConnected?.(this);
|
||||||
|
} else if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') {
|
||||||
|
this.state = 'terminated';
|
||||||
|
this.onTerminated?.(this);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.pc.oniceconnectionstatechange !== undefined) {
|
||||||
|
this.pc.oniceconnectionstatechange = () => {
|
||||||
|
this.config.log(`[webrtc-leg:${this.id}] ICE state: ${this.pc.iceConnectionState}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set remote offer and create answer.
|
||||||
|
await this.pc.setRemoteDescription({ type: 'offer', sdp: offerSdp });
|
||||||
|
const answer = await this.pc.createAnswer();
|
||||||
|
await this.pc.setLocalDescription(answer);
|
||||||
|
|
||||||
|
const sdp: string = this.pc.localDescription!.sdp;
|
||||||
|
|
||||||
|
// Detect negotiated codec.
|
||||||
|
const mAudio = sdp.match(/m=audio\s+\d+\s+\S+\s+(\d+)/);
|
||||||
|
if (mAudio) {
|
||||||
|
this.codec = parseInt(mAudio[1], 10);
|
||||||
|
this.config.log(`[webrtc-leg:${this.id}] negotiated audio PT=${this.codec}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract sender SSRC from SDP.
|
||||||
|
const ssrcMatch = sdp.match(/a=ssrc:(\d+)\s/);
|
||||||
|
if (ssrcMatch) {
|
||||||
|
this.fromSipSsrc = parseInt(ssrcMatch[1], 10);
|
||||||
|
}
|
||||||
|
// Also try from sender object.
|
||||||
|
const senders = this.pc.getSenders();
|
||||||
|
if (senders[0]) {
|
||||||
|
const senderSsrc = (senders[0] as any).ssrc ?? (senders[0] as any)._ssrc;
|
||||||
|
if (senderSsrc) this.fromSipSsrc = senderSsrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send answer to browser.
|
||||||
|
this.wsSend({ type: 'webrtc-answer', sessionId: this.sessionId, sdp });
|
||||||
|
this.config.log(`[webrtc-leg:${this.id}] sent answer, rtp port=${this.rtpPort}`);
|
||||||
|
|
||||||
|
// Process buffered ICE candidates.
|
||||||
|
for (const c of this.pendingIceCandidates) {
|
||||||
|
try { await this.pc.addIceCandidate(c); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
this.pendingIceCandidates = [];
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
this.config.log(`[webrtc-leg:${this.id}] offer error: ${err.message}`);
|
||||||
|
this.wsSend({ type: 'webrtc-error', sessionId: this.sessionId, error: err.message });
|
||||||
|
this.state = 'terminated';
|
||||||
|
this.onTerminated?.(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add an ICE candidate from the browser. */
|
||||||
|
async addIceCandidate(candidate: any): Promise<void> {
|
||||||
|
if (!this.pc) {
|
||||||
|
this.pendingIceCandidates.push(candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (candidate) await this.pc.addIceCandidate(candidate);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.config.log(`[webrtc-leg:${this.id}] ICE error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Transcoding setup
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Codec session ID for isolated Rust codec state (unique per leg). */
|
||||||
|
private codecSessionId = `webrtc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up transcoders for bridging between WebRTC and SIP codecs.
|
||||||
|
* Called by the Call when the remote media endpoint is known.
|
||||||
|
* Creates an isolated Rust codec session so concurrent calls don't
|
||||||
|
* corrupt each other's stateful codec state (Opus/G.722 ADPCM).
|
||||||
|
*/
|
||||||
|
async setupTranscoders(sipPT: number): Promise<void> {
|
||||||
|
const webrtcPT = this.codec ?? OPUS_PT;
|
||||||
|
// Create isolated codec session for this leg.
|
||||||
|
await createSession(this.codecSessionId);
|
||||||
|
this.toSipTranscoder = createTranscoder(webrtcPT, sipPT, this.codecSessionId, 'to_sip');
|
||||||
|
this.fromSipTranscoder = createTranscoder(sipPT, webrtcPT, this.codecSessionId, 'to_browser');
|
||||||
|
const mode = this.toSipTranscoder ? `transcoding PT ${webrtcPT}<->${sipPT}` : `pass-through PT ${webrtcPT}`;
|
||||||
|
this.config.log(`[webrtc-leg:${this.id}] ${mode} (session: ${this.codecSessionId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// RTP forwarding
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Forward RTP from SIP side to browser via WebRTC. */
|
||||||
|
private forwardToBrowser(data: Buffer, count: number): void {
|
||||||
|
const sender = this.pc?.getSenders()[0];
|
||||||
|
if (!sender) return;
|
||||||
|
|
||||||
|
if (this.fromSipTranscoder && data.length > 12) {
|
||||||
|
const payload = Buffer.from(data.subarray(12));
|
||||||
|
// Stop announcement if still playing — provider audio takes over.
|
||||||
|
if (this.announcementCancel) {
|
||||||
|
this.announcementCancel();
|
||||||
|
this.announcementCancel = null;
|
||||||
|
}
|
||||||
|
// Capture seq/ts BEFORE async transcode to preserve ordering.
|
||||||
|
const toPT = this.fromSipTranscoder.toPT;
|
||||||
|
const seq = this.fromSipCounters.seq++;
|
||||||
|
const ts = this.fromSipCounters.ts;
|
||||||
|
this.fromSipCounters.ts += rtpClockIncrement(toPT);
|
||||||
|
const result = this.fromSipTranscoder.payload(payload);
|
||||||
|
const sendTranscoded = (transcoded: Buffer) => {
|
||||||
|
if (transcoded.length === 0) return; // transcoding failed
|
||||||
|
try {
|
||||||
|
const hdr = buildRtpHeader(toPT, seq, ts, this.fromSipSsrc, false);
|
||||||
|
const out = Buffer.concat([hdr, transcoded]);
|
||||||
|
const r = sender.sendRtp(out);
|
||||||
|
if (r instanceof Promise) r.catch(() => {});
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
if (result instanceof Promise) result.then(sendTranscoded).catch(() => {});
|
||||||
|
else sendTranscoded(result);
|
||||||
|
} else if (!this.fromSipTranscoder) {
|
||||||
|
// No transcoder — either same codec or not set up yet.
|
||||||
|
// Only forward if we don't expect transcoding.
|
||||||
|
if (this.codec === null) {
|
||||||
|
try { sender.sendRtp(data); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forward RTP from browser to SIP side. */
|
||||||
|
private forwardToSip(rtp: any, count: number): void {
|
||||||
|
if (!this.remoteMedia) return;
|
||||||
|
|
||||||
|
if (this.toSipTranscoder) {
|
||||||
|
const payload: Buffer = rtp.payload;
|
||||||
|
if (!payload || payload.length === 0) return;
|
||||||
|
// Capture seq/ts BEFORE async transcode to preserve ordering.
|
||||||
|
const toPT = this.toSipTranscoder.toPT;
|
||||||
|
const seq = this.toSipSeq++;
|
||||||
|
const ts = this.toSipTs;
|
||||||
|
this.toSipTs += rtpClockIncrement(toPT);
|
||||||
|
const result = this.toSipTranscoder.payload(payload);
|
||||||
|
const sendTranscoded = (transcoded: Buffer) => {
|
||||||
|
if (transcoded.length === 0) return; // transcoding failed
|
||||||
|
const hdr = buildRtpHeader(toPT, seq, ts, this.toSipSsrc, false);
|
||||||
|
const out = Buffer.concat([hdr, transcoded]);
|
||||||
|
if (this.onSendToProvider) {
|
||||||
|
this.onSendToProvider(out, this.remoteMedia!);
|
||||||
|
} else {
|
||||||
|
this.rtpSock.send(out, this.remoteMedia!.port, this.remoteMedia!.address);
|
||||||
|
}
|
||||||
|
this.pktSent++;
|
||||||
|
};
|
||||||
|
if (result instanceof Promise) result.then(sendTranscoded).catch(() => {});
|
||||||
|
else sendTranscoded(result);
|
||||||
|
} else if (this.codec === null) {
|
||||||
|
// Same codec (no transcoding needed) — pass through.
|
||||||
|
const raw = rtp.serialize();
|
||||||
|
if (this.onSendToProvider) {
|
||||||
|
this.onSendToProvider(raw, this.remoteMedia);
|
||||||
|
} else {
|
||||||
|
this.rtpSock.send(raw, this.remoteMedia.port, this.remoteMedia.address);
|
||||||
|
}
|
||||||
|
this.pktSent++;
|
||||||
|
}
|
||||||
|
// If codec is set but transcoder is null, drop the packet — transcoder not ready yet.
|
||||||
|
// This prevents raw Opus from being sent to a G.722 endpoint.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send RTP to the browser via WebRTC (used by Call hub for fan-out).
|
||||||
|
* This transcodes and sends through the PeerConnection, NOT to a UDP address.
|
||||||
|
*/
|
||||||
|
sendRtp(data: Buffer): void {
|
||||||
|
this.forwardToBrowser(data, this.pktSent);
|
||||||
|
this.pktSent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a pre-encoded RTP packet directly to the browser via PeerConnection.
|
||||||
|
* Used for announcements — the packet must already be in the correct codec (Opus).
|
||||||
|
*/
|
||||||
|
sendDirectToBrowser(pkt: Buffer): void {
|
||||||
|
const sender = this.pc?.getSenders()[0];
|
||||||
|
if (!sender) return;
|
||||||
|
try {
|
||||||
|
const r = sender.sendRtp(pkt);
|
||||||
|
if (r instanceof Promise) r.catch(() => {});
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No-op: WebRTC legs don't process SIP messages. */
|
||||||
|
handleSipMessage(_msg: SipMessage, _rinfo: IEndpoint): void {
|
||||||
|
// WebRTC legs don't handle SIP messages.
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
teardown(): void {
|
||||||
|
this.state = 'terminated';
|
||||||
|
try { this.pc?.close(); } catch { /* ignore */ }
|
||||||
|
this.pc = null;
|
||||||
|
// Destroy the isolated Rust codec session for this leg.
|
||||||
|
destroySession(this.codecSessionId).catch(() => {});
|
||||||
|
// Note: RTP socket is NOT closed here — the RtpPortPool manages that.
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): ILegStatus {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
type: this.type,
|
||||||
|
state: this.state,
|
||||||
|
remoteMedia: this.remoteMedia,
|
||||||
|
rtpPort: this.rtpPort,
|
||||||
|
pktSent: this.pktSent,
|
||||||
|
pktReceived: this.pktReceived,
|
||||||
|
codec: codecDisplayName(this.codec),
|
||||||
|
transcoding: this.toSipTranscoder !== null || this.fromSipTranscoder !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private wsSend(data: unknown): void {
|
||||||
|
try {
|
||||||
|
if (this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
40
ts/codec.ts
Normal file
40
ts/codec.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Audio codec translation layer for bridging between WebRTC and SIP.
|
||||||
|
*
|
||||||
|
* All actual codec work (Opus, G.722, PCMU, PCMA) is done in Rust via
|
||||||
|
* the smartrust bridge. This module provides the RTP-level transcoding
|
||||||
|
* interface used by the webrtcbridge.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { transcode, isCodecReady } from './opusbridge.ts';
|
||||||
|
|
||||||
|
/** Opus dynamic payload type (standard WebRTC assignment). */
|
||||||
|
export const OPUS_PT = 111;
|
||||||
|
|
||||||
|
export interface IRtpTranscoder {
|
||||||
|
/** Transcode an RTP payload. Always async (Rust IPC). */
|
||||||
|
payload(data: Buffer): Promise<Buffer>;
|
||||||
|
fromPT: number;
|
||||||
|
toPT: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a transcoder that converts RTP payloads between two codecs.
|
||||||
|
* Returns null if the codecs are the same or the Rust bridge isn't ready.
|
||||||
|
*
|
||||||
|
* @param sessionId - optional Rust codec session for isolated state per call
|
||||||
|
*/
|
||||||
|
export function createTranscoder(fromPT: number, toPT: number, sessionId?: string, direction?: string): IRtpTranscoder | null {
|
||||||
|
if (fromPT === toPT) return null;
|
||||||
|
if (!isCodecReady()) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fromPT,
|
||||||
|
toPT,
|
||||||
|
async payload(data: Buffer): Promise<Buffer> {
|
||||||
|
const result = await transcode(data, fromPT, toPT, sessionId, direction);
|
||||||
|
return result || Buffer.alloc(0); // return empty on failure — never pass raw codec bytes
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
153
ts/config.ts
Normal file
153
ts/config.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Application configuration — loaded from .nogit/config.json.
|
||||||
|
*
|
||||||
|
* All network addresses, credentials, provider settings, device definitions,
|
||||||
|
* and routing rules come from this single config file. No hardcoded values
|
||||||
|
* in source.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { IEndpoint } from './sip/index.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface IQuirks {
|
||||||
|
earlyMediaSilence: boolean;
|
||||||
|
silencePayloadType?: number;
|
||||||
|
silenceMaxPackets?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProviderConfig {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
domain: string;
|
||||||
|
outboundProxy: IEndpoint;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
registerIntervalSec: number;
|
||||||
|
codecs: number[];
|
||||||
|
quirks: IQuirks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeviceConfig {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
expectedAddress: string;
|
||||||
|
extension: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRoutingConfig {
|
||||||
|
outbound: { default: string };
|
||||||
|
inbound: Record<string, string[]>;
|
||||||
|
ringBrowsers?: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProxyConfig {
|
||||||
|
lanIp: string;
|
||||||
|
lanPort: number;
|
||||||
|
publicIpSeed: string | null;
|
||||||
|
rtpPortRange: { min: number; max: number };
|
||||||
|
webUiPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IContact {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
company?: string;
|
||||||
|
notes?: string;
|
||||||
|
starred?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAppConfig {
|
||||||
|
proxy: IProxyConfig;
|
||||||
|
providers: IProviderConfig[];
|
||||||
|
devices: IDeviceConfig[];
|
||||||
|
routing: IRoutingConfig;
|
||||||
|
contacts: IContact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Loader
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
|
||||||
|
|
||||||
|
export function loadConfig(): IAppConfig {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||||
|
} catch {
|
||||||
|
throw new Error(`config not found at ${CONFIG_PATH} — create .nogit/config.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = JSON.parse(raw) as IAppConfig;
|
||||||
|
|
||||||
|
// Basic validation.
|
||||||
|
if (!cfg.proxy) throw new Error('config: missing "proxy" section');
|
||||||
|
if (!cfg.proxy.lanIp) throw new Error('config: missing proxy.lanIp');
|
||||||
|
if (!cfg.proxy.lanPort) throw new Error('config: missing proxy.lanPort');
|
||||||
|
if (!cfg.proxy.rtpPortRange?.min || !cfg.proxy.rtpPortRange?.max) {
|
||||||
|
throw new Error('config: missing proxy.rtpPortRange.min/max');
|
||||||
|
}
|
||||||
|
cfg.proxy.webUiPort ??= 3060;
|
||||||
|
cfg.proxy.publicIpSeed ??= null;
|
||||||
|
|
||||||
|
cfg.providers ??= [];
|
||||||
|
for (const p of cfg.providers) {
|
||||||
|
if (!p.id || !p.domain || !p.outboundProxy || !p.username || !p.password) {
|
||||||
|
throw new Error(`config: provider "${p.id || '?'}" missing required fields`);
|
||||||
|
}
|
||||||
|
p.displayName ??= p.id;
|
||||||
|
p.registerIntervalSec ??= 300;
|
||||||
|
p.codecs ??= [9, 0, 8, 101];
|
||||||
|
p.quirks ??= { earlyMediaSilence: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(cfg.devices) || !cfg.devices.length) {
|
||||||
|
throw new Error('config: need at least one device');
|
||||||
|
}
|
||||||
|
for (const d of cfg.devices) {
|
||||||
|
if (!d.id || !d.expectedAddress) {
|
||||||
|
throw new Error(`config: device "${d.id || '?'}" missing required fields`);
|
||||||
|
}
|
||||||
|
d.displayName ??= d.id;
|
||||||
|
d.extension ??= '100';
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.routing ??= { outbound: { default: cfg.providers[0].id }, inbound: {} };
|
||||||
|
cfg.routing.outbound ??= { default: cfg.providers[0].id };
|
||||||
|
cfg.contacts ??= [];
|
||||||
|
for (const c of cfg.contacts) {
|
||||||
|
c.starred ??= false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lookup helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getProvider(cfg: IAppConfig, id: string): IProviderConfig | null {
|
||||||
|
return cfg.providers.find((p) => p.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDevice(cfg: IAppConfig, id: string): IDeviceConfig | null {
|
||||||
|
return cfg.devices.find((d) => d.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderForOutbound(cfg: IAppConfig): IProviderConfig | null {
|
||||||
|
const id = cfg.routing?.outbound?.default;
|
||||||
|
if (!id) return cfg.providers[0] || null;
|
||||||
|
return getProvider(cfg, id) || cfg.providers[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDevicesForInbound(cfg: IAppConfig, providerId: string): IDeviceConfig[] {
|
||||||
|
const ids = cfg.routing.inbound[providerId];
|
||||||
|
if (!ids?.length) return cfg.devices; // fallback: ring all devices
|
||||||
|
return ids.map((id) => getDevice(cfg, id)).filter(Boolean) as IDeviceConfig[];
|
||||||
|
}
|
||||||
372
ts/frontend.ts
Normal file
372
ts/frontend.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* Web dashboard server for the SIP proxy.
|
||||||
|
*
|
||||||
|
* Serves a bundled web component frontend (ts_web/) and pushes
|
||||||
|
* live updates over WebSocket. The frontend is built with
|
||||||
|
* @design.estate/dees-element web components and bundled with esbuild.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import type { CallManager } from './call/index.ts';
|
||||||
|
import { handleWebRtcSignaling } from './webrtcbridge.ts';
|
||||||
|
|
||||||
|
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebSocket broadcast
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const wsClients = new Set<WebSocket>();
|
||||||
|
|
||||||
|
function timestamp(): string {
|
||||||
|
return new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastWs(type: string, data: unknown): void {
|
||||||
|
if (!wsClients.size) return;
|
||||||
|
const msg = JSON.stringify({ type, data, ts: timestamp() });
|
||||||
|
for (const ws of wsClients) {
|
||||||
|
try {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
|
||||||
|
} catch {
|
||||||
|
wsClients.delete(ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static file cache (loaded at startup)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const staticFiles = new Map<string, { data: Buffer; contentType: string }>();
|
||||||
|
|
||||||
|
function loadStaticFiles(): void {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
// Load index.html
|
||||||
|
const htmlPath = path.join(cwd, 'html', 'index.html');
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(htmlPath);
|
||||||
|
staticFiles.set('/', { data, contentType: 'text/html; charset=utf-8' });
|
||||||
|
staticFiles.set('/index.html', { data, contentType: 'text/html; charset=utf-8' });
|
||||||
|
} catch {
|
||||||
|
const fallback = Buffer.from(`<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>SIP Router</title>
|
||||||
|
<style>body{margin:0;background:#0f172a;color:#e2e8f0;font-family:system-ui}sipproxy-app{display:block;position:fixed;inset:0;overflow:hidden}</style>
|
||||||
|
</head><body><sipproxy-app></sipproxy-app><script type="module" src="/bundle.js"></script></body></html>`);
|
||||||
|
staticFiles.set('/', { data: fallback, contentType: 'text/html; charset=utf-8' });
|
||||||
|
staticFiles.set('/index.html', { data: fallback, contentType: 'text/html; charset=utf-8' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load bundle.js
|
||||||
|
const bundlePath = path.join(cwd, 'dist_ts_web', 'bundle.js');
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(bundlePath);
|
||||||
|
staticFiles.set('/bundle.js', { data, contentType: 'application/javascript; charset=utf-8' });
|
||||||
|
} catch { /* Bundle not found */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP request handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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,
|
||||||
|
): Promise<void> {
|
||||||
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||||
|
const method = req.method || 'GET';
|
||||||
|
|
||||||
|
// API: status.
|
||||||
|
if (url.pathname === '/api/status') {
|
||||||
|
return sendJson(res, getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
// API: start call (with optional providerId).
|
||||||
|
if (url.pathname === '/api/call' && method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const number = body?.number;
|
||||||
|
if (!number || typeof number !== 'string') {
|
||||||
|
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
|
||||||
|
}
|
||||||
|
const call = onStartCall(number, body?.deviceId, body?.providerId);
|
||||||
|
if (call) return sendJson(res, { ok: true, callId: call.id });
|
||||||
|
return sendJson(res, { ok: false, error: 'call origination failed — provider not registered or no ports available' }, 503);
|
||||||
|
} catch (e: any) {
|
||||||
|
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API: hangup.
|
||||||
|
if (url.pathname === '/api/hangup' && method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const callId = body?.callId;
|
||||||
|
if (!callId || typeof callId !== 'string') {
|
||||||
|
return sendJson(res, { ok: false, error: 'missing "callId" field' }, 400);
|
||||||
|
}
|
||||||
|
return sendJson(res, { ok: onHangupCall(callId) });
|
||||||
|
} catch (e: any) {
|
||||||
|
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API: add leg to call.
|
||||||
|
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addleg') && method === 'POST') {
|
||||||
|
try {
|
||||||
|
const callId = url.pathname.split('/')[3];
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
if (!body?.deviceId) return sendJson(res, { ok: false, error: 'missing deviceId' }, 400);
|
||||||
|
const ok = callManager?.addDeviceToCall(callId, body.deviceId) ?? false;
|
||||||
|
return sendJson(res, { ok });
|
||||||
|
} catch (e: any) {
|
||||||
|
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API: add external participant (dial out) to existing call.
|
||||||
|
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addexternal') && method === 'POST') {
|
||||||
|
try {
|
||||||
|
const callId = url.pathname.split('/')[3];
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
if (!body?.number) return sendJson(res, { ok: false, error: 'missing number' }, 400);
|
||||||
|
const ok = callManager?.addExternalToCall(callId, body.number, body.providerId) ?? false;
|
||||||
|
return sendJson(res, { ok });
|
||||||
|
} catch (e: any) {
|
||||||
|
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API: remove leg from call.
|
||||||
|
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/removeleg') && method === 'POST') {
|
||||||
|
try {
|
||||||
|
const callId = url.pathname.split('/')[3];
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
if (!body?.legId) return sendJson(res, { ok: false, error: 'missing legId' }, 400);
|
||||||
|
const ok = callManager?.removeLegFromCall(callId, body.legId) ?? false;
|
||||||
|
return sendJson(res, { ok });
|
||||||
|
} catch (e: any) {
|
||||||
|
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API: transfer leg.
|
||||||
|
if (url.pathname === '/api/transfer' && method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
if (!body?.sourceCallId || !body?.legId || !body?.targetCallId) {
|
||||||
|
return sendJson(res, { ok: false, error: 'missing sourceCallId, legId, or targetCallId' }, 400);
|
||||||
|
}
|
||||||
|
const ok = callManager?.transferLeg(body.sourceCallId, body.legId, body.targetCallId) ?? false;
|
||||||
|
return sendJson(res, { ok });
|
||||||
|
} catch (e: any) {
|
||||||
|
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API: get config (sans passwords).
|
||||||
|
if (url.pathname === '/api/config' && method === 'GET') {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||||
|
const cfg = JSON.parse(raw);
|
||||||
|
const safe = { ...cfg, providers: cfg.providers?.map((p: any) => ({ ...p, password: '••••••' })) };
|
||||||
|
return sendJson(res, safe);
|
||||||
|
} catch (e: any) {
|
||||||
|
return sendJson(res, { ok: false, error: e.message }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API: update config.
|
||||||
|
if (url.pathname === '/api/config' && method === 'POST') {
|
||||||
|
try {
|
||||||
|
const updates = await readJsonBody(req);
|
||||||
|
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||||
|
const cfg = JSON.parse(raw);
|
||||||
|
|
||||||
|
// Update existing providers.
|
||||||
|
if (updates.providers) {
|
||||||
|
for (const up of updates.providers) {
|
||||||
|
const existing = cfg.providers?.find((p: any) => p.id === up.id);
|
||||||
|
if (existing) {
|
||||||
|
if (up.displayName !== undefined) existing.displayName = up.displayName;
|
||||||
|
if (up.password && up.password !== '••••••') existing.password = up.password;
|
||||||
|
if (up.domain !== undefined) existing.domain = up.domain;
|
||||||
|
if (up.outboundProxy !== undefined) existing.outboundProxy = up.outboundProxy;
|
||||||
|
if (up.username !== undefined) existing.username = up.username;
|
||||||
|
if (up.registerIntervalSec !== undefined) existing.registerIntervalSec = up.registerIntervalSec;
|
||||||
|
if (up.codecs !== undefined) existing.codecs = up.codecs;
|
||||||
|
if (up.quirks !== undefined) existing.quirks = up.quirks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a new provider.
|
||||||
|
if (updates.addProvider) {
|
||||||
|
cfg.providers ??= [];
|
||||||
|
cfg.providers.push(updates.addProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a provider.
|
||||||
|
if (updates.removeProvider) {
|
||||||
|
cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updates.removeProvider);
|
||||||
|
// Clean up routing references.
|
||||||
|
if (cfg.routing?.inbound) delete cfg.routing.inbound[updates.removeProvider];
|
||||||
|
if (cfg.routing?.ringBrowsers) delete cfg.routing.ringBrowsers[updates.removeProvider];
|
||||||
|
if (cfg.routing?.outbound?.default === updates.removeProvider) {
|
||||||
|
cfg.routing.outbound.default = cfg.providers[0]?.id || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.devices) {
|
||||||
|
for (const ud of updates.devices) {
|
||||||
|
const existing = cfg.devices?.find((d: any) => d.id === ud.id);
|
||||||
|
if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updates.routing) {
|
||||||
|
if (updates.routing.inbound) cfg.routing.inbound = { ...cfg.routing.inbound, ...updates.routing.inbound };
|
||||||
|
if (updates.routing.ringBrowsers) cfg.routing.ringBrowsers = { ...cfg.routing.ringBrowsers, ...updates.routing.ringBrowsers };
|
||||||
|
}
|
||||||
|
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
|
||||||
|
|
||||||
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
||||||
|
log('[config] updated config.json');
|
||||||
|
onConfigSaved?.();
|
||||||
|
return sendJson(res, { ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static files.
|
||||||
|
const file = staticFiles.get(url.pathname);
|
||||||
|
if (file) {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': file.contentType,
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0',
|
||||||
|
});
|
||||||
|
res.end(file.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA fallback.
|
||||||
|
const index = staticFiles.get('/');
|
||||||
|
if (index) {
|
||||||
|
res.writeHead(200, { 'Content-Type': index.contentType });
|
||||||
|
res.end(index.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not Found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP + WebSocket server (Node.js)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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,
|
||||||
|
): void {
|
||||||
|
const WEB_PORT = 3060;
|
||||||
|
|
||||||
|
loadStaticFiles();
|
||||||
|
|
||||||
|
// Serve HTTPS if cert exists, otherwise fall back to HTTP.
|
||||||
|
const certPath = path.join(process.cwd(), '.nogit', 'cert.pem');
|
||||||
|
const keyPath = path.join(process.cwd(), '.nogit', 'key.pem');
|
||||||
|
let useTls = false;
|
||||||
|
let server: http.Server | https.Server;
|
||||||
|
|
||||||
|
try {
|
||||||
|
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).catch(() => { res.writeHead(500); res.end(); }),
|
||||||
|
);
|
||||||
|
useTls = true;
|
||||||
|
} catch {
|
||||||
|
server = http.createServer((req, res) =>
|
||||||
|
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager).catch(() => { res.writeHead(500); res.end(); }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket server on the same port.
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
|
wss.on('connection', (socket, req) => {
|
||||||
|
const remoteIp = req.socket.remoteAddress || null;
|
||||||
|
wsClients.add(socket);
|
||||||
|
socket.send(JSON.stringify({ type: 'status', data: getStatus(), ts: timestamp() }));
|
||||||
|
|
||||||
|
socket.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.type === 'webrtc-accept' && msg.callId) {
|
||||||
|
log(`[webrtc] browser accepted call ${msg.callId} session=${msg.sessionId || 'none'}`);
|
||||||
|
const ok = callManager?.acceptBrowserCall(msg.callId, msg.sessionId) ?? false;
|
||||||
|
log(`[webrtc] acceptBrowserCall result: ${ok}`);
|
||||||
|
} else if (msg.type === 'webrtc-offer' && msg.sessionId) {
|
||||||
|
callManager?.handleWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) =>
|
||||||
|
log(`[webrtc] offer error: ${e.message}`));
|
||||||
|
} else if (msg.type === 'webrtc-ice' && msg.sessionId) {
|
||||||
|
callManager?.handleWebRtcIce(msg.sessionId, msg.candidate).catch(() => {});
|
||||||
|
} else if (msg.type === 'webrtc-hangup' && msg.sessionId) {
|
||||||
|
callManager?.handleWebRtcHangup(msg.sessionId);
|
||||||
|
} else if (msg.type?.startsWith('webrtc-')) {
|
||||||
|
msg._remoteIp = remoteIp;
|
||||||
|
handleWebRtcSignaling(socket as any, msg);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => wsClients.delete(socket));
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => broadcastWs('status', getStatus()), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function sendJson(res: http.ServerResponse, data: unknown, status = 200): void {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonBody(req: http.IncomingMessage): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (c) => chunks.push(c));
|
||||||
|
req.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
|
||||||
|
catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
199
ts/opusbridge.ts
Normal file
199
ts/opusbridge.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Audio transcoding bridge — uses smartrust to communicate with the Rust
|
||||||
|
* opus-codec binary, which handles Opus ↔ G.722 ↔ PCMU/PCMA transcoding.
|
||||||
|
*
|
||||||
|
* All codec conversion happens in Rust (libopus + SpanDSP G.722 port).
|
||||||
|
* The TypeScript side just passes raw payloads back and forth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import { RustBridge } from '@push.rocks/smartrust';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Command type map for smartrust
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type TCodecCommands = {
|
||||||
|
init: {
|
||||||
|
params: Record<string, never>;
|
||||||
|
result: Record<string, never>;
|
||||||
|
};
|
||||||
|
create_session: {
|
||||||
|
params: { session_id: string };
|
||||||
|
result: Record<string, never>;
|
||||||
|
};
|
||||||
|
destroy_session: {
|
||||||
|
params: { session_id: string };
|
||||||
|
result: Record<string, never>;
|
||||||
|
};
|
||||||
|
transcode: {
|
||||||
|
params: { data_b64: string; from_pt: number; to_pt: number; session_id?: string; direction?: string };
|
||||||
|
result: { data_b64: string };
|
||||||
|
};
|
||||||
|
encode_pcm: {
|
||||||
|
params: { data_b64: string; sample_rate: number; to_pt: number; session_id?: string };
|
||||||
|
result: { data_b64: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bridge singleton
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let bridge: RustBridge<TCodecCommands> | null = null;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
function buildLocalPaths(): string[] {
|
||||||
|
const root = process.cwd();
|
||||||
|
return [
|
||||||
|
path.join(root, 'dist_rust', 'opus-codec'),
|
||||||
|
path.join(root, 'rust', 'target', 'release', 'opus-codec'),
|
||||||
|
path.join(root, 'rust', 'target', 'debug', 'opus-codec'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
let logFn: ((msg: string) => void) | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the audio transcoding bridge. Spawns the Rust binary.
|
||||||
|
*/
|
||||||
|
export async function initCodecBridge(log?: (msg: string) => void): Promise<boolean> {
|
||||||
|
if (initialized && bridge) return true;
|
||||||
|
logFn = log;
|
||||||
|
|
||||||
|
try {
|
||||||
|
bridge = new RustBridge<TCodecCommands>({
|
||||||
|
binaryName: 'opus-codec',
|
||||||
|
localPaths: buildLocalPaths(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const spawned = await bridge.spawn();
|
||||||
|
if (!spawned) {
|
||||||
|
log?.('[codec] failed to spawn opus-codec binary');
|
||||||
|
bridge = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-restart: reset state when the Rust process exits so the next
|
||||||
|
// transcode attempt triggers re-initialization instead of silent failure.
|
||||||
|
bridge.on('exit', () => {
|
||||||
|
logFn?.('[codec] Rust audio transcoder process exited — will re-init on next use');
|
||||||
|
bridge = null;
|
||||||
|
initialized = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.sendCommand('init', {} as any);
|
||||||
|
initialized = true;
|
||||||
|
log?.('[codec] Rust audio transcoder initialized (Opus + G.722 + PCMU/PCMA)');
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
log?.(`[codec] init error: ${e.message}`);
|
||||||
|
bridge = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session management — per-call codec isolation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an isolated codec session. Each session gets its own Opus/G.722
|
||||||
|
* encoder/decoder state, preventing concurrent calls from corrupting each
|
||||||
|
* other's stateful codec predictions.
|
||||||
|
*/
|
||||||
|
export async function createSession(sessionId: string): Promise<boolean> {
|
||||||
|
if (!bridge || !initialized) {
|
||||||
|
// Attempt auto-reinit if bridge died.
|
||||||
|
const ok = await initCodecBridge(logFn);
|
||||||
|
if (!ok) return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await bridge!.sendCommand('create_session', { session_id: sessionId });
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
logFn?.(`[codec] create_session error: ${e?.message || e}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy a codec session, freeing its encoder/decoder state.
|
||||||
|
*/
|
||||||
|
export async function destroySession(sessionId: string): Promise<void> {
|
||||||
|
if (!bridge || !initialized) return;
|
||||||
|
try {
|
||||||
|
await bridge.sendCommand('destroy_session', { session_id: sessionId });
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transcoding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcode an RTP payload between two codecs.
|
||||||
|
* All codec work (Opus, G.722, PCMU, PCMA) + resampling happens in Rust.
|
||||||
|
*
|
||||||
|
* @param data - raw RTP payload (no header)
|
||||||
|
* @param fromPT - source payload type (0=PCMU, 8=PCMA, 9=G.722, 111=Opus)
|
||||||
|
* @param toPT - target payload type
|
||||||
|
* @param sessionId - optional session for isolated codec state
|
||||||
|
* @returns transcoded payload, or null on failure
|
||||||
|
*/
|
||||||
|
export async function transcode(data: Buffer, fromPT: number, toPT: number, sessionId?: string, direction?: string): Promise<Buffer | null> {
|
||||||
|
if (!bridge || !initialized) return null;
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
data_b64: data.toString('base64'),
|
||||||
|
from_pt: fromPT,
|
||||||
|
to_pt: toPT,
|
||||||
|
};
|
||||||
|
if (sessionId) params.session_id = sessionId;
|
||||||
|
if (direction) params.direction = direction;
|
||||||
|
const result = await bridge.sendCommand('transcode', params);
|
||||||
|
return Buffer.from(result.data_b64, 'base64');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode raw 16-bit PCM to a target codec.
|
||||||
|
* @param pcmData - raw 16-bit LE PCM bytes
|
||||||
|
* @param sampleRate - input sample rate (e.g. 22050 for Piper TTS)
|
||||||
|
* @param toPT - target payload type (9=G.722, 111=Opus, 0=PCMU, 8=PCMA)
|
||||||
|
* @param sessionId - optional session for isolated codec state
|
||||||
|
*/
|
||||||
|
export async function encodePcm(pcmData: Buffer, sampleRate: number, toPT: number, sessionId?: string): Promise<Buffer | null> {
|
||||||
|
if (!bridge || !initialized) return null;
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
data_b64: pcmData.toString('base64'),
|
||||||
|
sample_rate: sampleRate,
|
||||||
|
to_pt: toPT,
|
||||||
|
};
|
||||||
|
if (sessionId) params.session_id = sessionId;
|
||||||
|
const result = await bridge.sendCommand('encode_pcm', params);
|
||||||
|
return Buffer.from(result.data_b64, 'base64');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[encodePcm] error:', e?.message || e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if the codec bridge is ready. */
|
||||||
|
export function isCodecReady(): boolean {
|
||||||
|
return initialized && bridge !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shut down the codec bridge. */
|
||||||
|
export function shutdownCodecBridge(): void {
|
||||||
|
if (bridge) {
|
||||||
|
try { bridge.kill(); } catch { /* ignore */ }
|
||||||
|
bridge = null;
|
||||||
|
initialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
306
ts/providerstate.ts
Normal file
306
ts/providerstate.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* Per-provider runtime state and upstream registration.
|
||||||
|
*
|
||||||
|
* Each configured provider gets its own ProviderState instance tracking
|
||||||
|
* registration status, public IP, and the periodic REGISTER cycle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import {
|
||||||
|
SipMessage,
|
||||||
|
generateCallId,
|
||||||
|
generateTag,
|
||||||
|
generateBranch,
|
||||||
|
parseDigestChallenge,
|
||||||
|
computeDigestAuth,
|
||||||
|
} from './sip/index.ts';
|
||||||
|
import type { IEndpoint } from './sip/index.ts';
|
||||||
|
import type { IProviderConfig } from './config.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class ProviderState {
|
||||||
|
readonly config: IProviderConfig;
|
||||||
|
publicIp: string | null;
|
||||||
|
isRegistered = false;
|
||||||
|
registeredAor: string;
|
||||||
|
|
||||||
|
// Registration transaction state.
|
||||||
|
private regCallId: string;
|
||||||
|
private regCSeq = 0;
|
||||||
|
private regFromTag: string;
|
||||||
|
private regTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private sendSip: ((buf: Buffer, dest: IEndpoint) => void) | null = null;
|
||||||
|
private logFn: ((msg: string) => void) | null = null;
|
||||||
|
private onRegistrationChange: ((provider: ProviderState) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(config: IProviderConfig, publicIpSeed: string | null) {
|
||||||
|
this.config = config;
|
||||||
|
this.publicIp = publicIpSeed;
|
||||||
|
this.registeredAor = `sip:${config.username}@${config.domain}`;
|
||||||
|
this.regCallId = generateCallId();
|
||||||
|
this.regFromTag = generateTag();
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(msg: string): void {
|
||||||
|
this.logFn?.(`[provider:${this.config.id}] ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Upstream registration
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the periodic REGISTER cycle with this provider.
|
||||||
|
*/
|
||||||
|
startRegistration(
|
||||||
|
lanIp: string,
|
||||||
|
lanPort: number,
|
||||||
|
sendSip: (buf: Buffer, dest: IEndpoint) => void,
|
||||||
|
log: (msg: string) => void,
|
||||||
|
onRegistrationChange: (provider: ProviderState) => void,
|
||||||
|
): void {
|
||||||
|
this.sendSip = sendSip;
|
||||||
|
this.logFn = log;
|
||||||
|
this.onRegistrationChange = onRegistrationChange;
|
||||||
|
|
||||||
|
// Initial registration.
|
||||||
|
this.sendRegister(lanIp, lanPort);
|
||||||
|
|
||||||
|
// Re-register periodically.
|
||||||
|
const intervalMs = (this.config.registerIntervalSec * 0.85) * 1000;
|
||||||
|
this.regTimer = setInterval(() => this.sendRegister(lanIp, lanPort), intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopRegistration(): void {
|
||||||
|
if (this.regTimer) {
|
||||||
|
clearInterval(this.regTimer);
|
||||||
|
this.regTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendRegister(lanIp: string, lanPort: number): void {
|
||||||
|
this.regCSeq++;
|
||||||
|
const pub = this.publicIp || lanIp;
|
||||||
|
const { config } = this;
|
||||||
|
|
||||||
|
const register = SipMessage.createRequest('REGISTER', `sip:${config.domain}`, {
|
||||||
|
via: { host: pub, port: lanPort },
|
||||||
|
from: { uri: this.registeredAor, tag: this.regFromTag },
|
||||||
|
to: { uri: this.registeredAor },
|
||||||
|
callId: this.regCallId,
|
||||||
|
cseq: this.regCSeq,
|
||||||
|
contact: `<sip:${config.username}@${pub}:${lanPort}>`,
|
||||||
|
maxForwards: 70,
|
||||||
|
extraHeaders: [
|
||||||
|
['Expires', String(config.registerIntervalSec)],
|
||||||
|
['User-Agent', 'SipRouter/1.0'],
|
||||||
|
['Allow', 'INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.log(`REGISTER -> ${config.outboundProxy.address}:${config.outboundProxy.port} (CSeq ${this.regCSeq})`);
|
||||||
|
this.sendSip!(register.serialize(), config.outboundProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming SIP response that belongs to this provider's registration.
|
||||||
|
* Returns true if the message was consumed.
|
||||||
|
*/
|
||||||
|
handleRegistrationResponse(msg: SipMessage): boolean {
|
||||||
|
if (!msg.isResponse) return false;
|
||||||
|
if (msg.callId !== this.regCallId) return false;
|
||||||
|
if (msg.cseqMethod?.toUpperCase() !== 'REGISTER') return false;
|
||||||
|
|
||||||
|
const code = msg.statusCode ?? 0;
|
||||||
|
this.log(`REGISTER <- ${code}`);
|
||||||
|
|
||||||
|
if (code === 200) {
|
||||||
|
const wasRegistered = this.isRegistered;
|
||||||
|
this.isRegistered = true;
|
||||||
|
if (!wasRegistered) {
|
||||||
|
this.log('registered');
|
||||||
|
this.onRegistrationChange?.(this);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 401 || code === 407) {
|
||||||
|
const challengeHeader = code === 401
|
||||||
|
? msg.getHeader('WWW-Authenticate')
|
||||||
|
: msg.getHeader('Proxy-Authenticate');
|
||||||
|
|
||||||
|
if (!challengeHeader) {
|
||||||
|
this.log(`${code} but no challenge header`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenge = parseDigestChallenge(challengeHeader);
|
||||||
|
if (!challenge) {
|
||||||
|
this.log(`${code} could not parse digest challenge`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authValue = computeDigestAuth({
|
||||||
|
username: this.config.username,
|
||||||
|
password: this.config.password,
|
||||||
|
realm: challenge.realm,
|
||||||
|
nonce: challenge.nonce,
|
||||||
|
method: 'REGISTER',
|
||||||
|
uri: `sip:${this.config.domain}`,
|
||||||
|
algorithm: challenge.algorithm,
|
||||||
|
opaque: challenge.opaque,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resend REGISTER with auth.
|
||||||
|
this.regCSeq++;
|
||||||
|
const pub = this.publicIp || 'unknown';
|
||||||
|
// We need lanIp/lanPort but don't have them here — reconstruct from Via.
|
||||||
|
const via = msg.getHeader('Via') || '';
|
||||||
|
const viaHost = via.match(/SIP\/2\.0\/UDP\s+([^;:]+)/)?.[1] || pub;
|
||||||
|
const viaPort = parseInt(via.match(/:(\d+)/)?.[1] || '5070', 10);
|
||||||
|
|
||||||
|
const register = SipMessage.createRequest('REGISTER', `sip:${this.config.domain}`, {
|
||||||
|
via: { host: viaHost, port: viaPort },
|
||||||
|
from: { uri: this.registeredAor, tag: this.regFromTag },
|
||||||
|
to: { uri: this.registeredAor },
|
||||||
|
callId: this.regCallId,
|
||||||
|
cseq: this.regCSeq,
|
||||||
|
contact: `<sip:${this.config.username}@${viaHost}:${viaPort}>`,
|
||||||
|
maxForwards: 70,
|
||||||
|
extraHeaders: [
|
||||||
|
[code === 401 ? 'Authorization' : 'Proxy-Authorization', authValue],
|
||||||
|
['Expires', String(this.config.registerIntervalSec)],
|
||||||
|
['User-Agent', 'SipRouter/1.0'],
|
||||||
|
['Allow', 'INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.log(`REGISTER -> (with auth, CSeq ${this.regCSeq})`);
|
||||||
|
this.sendSip!(register.serialize(), this.config.outboundProxy);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code >= 400) {
|
||||||
|
const wasRegistered = this.isRegistered;
|
||||||
|
this.isRegistered = false;
|
||||||
|
if (wasRegistered) {
|
||||||
|
this.log(`registration lost (${code})`);
|
||||||
|
this.onRegistrationChange?.(this);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true; // consume 1xx etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update public IP from Via received= parameter.
|
||||||
|
*/
|
||||||
|
detectPublicIp(via: string): void {
|
||||||
|
const m = via.match(/received=([\d.]+)/);
|
||||||
|
if (m && m[1] !== this.publicIp) {
|
||||||
|
this.log(`publicIp = ${m[1]}`);
|
||||||
|
this.publicIp = m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider state management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let providerStates: Map<string, ProviderState>;
|
||||||
|
|
||||||
|
export function initProviderStates(
|
||||||
|
providers: IProviderConfig[],
|
||||||
|
publicIpSeed: string | null,
|
||||||
|
): Map<string, ProviderState> {
|
||||||
|
providerStates = new Map();
|
||||||
|
for (const p of providers) {
|
||||||
|
providerStates.set(p.id, new ProviderState(p, publicIpSeed));
|
||||||
|
}
|
||||||
|
return providerStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderState(id: string): ProviderState | null {
|
||||||
|
return providerStates?.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync running provider states with updated config.
|
||||||
|
* - New providers: create state + start registration.
|
||||||
|
* - Removed providers: stop registration + delete state.
|
||||||
|
* - Changed providers: stop old, create new, start registration (preserves detected publicIp).
|
||||||
|
*/
|
||||||
|
export function syncProviderStates(
|
||||||
|
newProviders: IProviderConfig[],
|
||||||
|
publicIpSeed: string | null,
|
||||||
|
lanIp: string,
|
||||||
|
lanPort: number,
|
||||||
|
sendSip: (buf: Buffer, dest: IEndpoint) => void,
|
||||||
|
log: (msg: string) => void,
|
||||||
|
onRegistrationChange: (provider: ProviderState) => void,
|
||||||
|
): void {
|
||||||
|
if (!providerStates) return;
|
||||||
|
|
||||||
|
const newIds = new Set(newProviders.map(p => p.id));
|
||||||
|
const oldIds = new Set(providerStates.keys());
|
||||||
|
|
||||||
|
// Remove providers no longer in config.
|
||||||
|
for (const id of oldIds) {
|
||||||
|
if (!newIds.has(id)) {
|
||||||
|
const ps = providerStates.get(id)!;
|
||||||
|
ps.stopRegistration();
|
||||||
|
providerStates.delete(id);
|
||||||
|
log(`[provider:${id}] removed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of newProviders) {
|
||||||
|
if (!oldIds.has(p.id)) {
|
||||||
|
// New provider.
|
||||||
|
const ps = new ProviderState(p, publicIpSeed);
|
||||||
|
providerStates.set(p.id, ps);
|
||||||
|
ps.startRegistration(lanIp, lanPort, sendSip, log, onRegistrationChange);
|
||||||
|
log(`[provider:${p.id}] added — registration started`);
|
||||||
|
} else {
|
||||||
|
// Existing provider — check if config changed.
|
||||||
|
const existing = providerStates.get(p.id)!;
|
||||||
|
if (JSON.stringify(existing.config) !== JSON.stringify(p)) {
|
||||||
|
existing.stopRegistration();
|
||||||
|
const ps = new ProviderState(p, existing.publicIp || publicIpSeed);
|
||||||
|
providerStates.set(p.id, ps);
|
||||||
|
ps.startRegistration(lanIp, lanPort, sendSip, log, onRegistrationChange);
|
||||||
|
log(`[provider:${p.id}] config changed — re-registering`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find which provider sent a packet, by matching the source address
|
||||||
|
* against all providers' outbound proxy addresses.
|
||||||
|
*/
|
||||||
|
export function getProviderByUpstreamAddress(address: string, port: number): ProviderState | null {
|
||||||
|
if (!providerStates) return null;
|
||||||
|
for (const ps of providerStates.values()) {
|
||||||
|
if (ps.config.outboundProxy.address === address && ps.config.outboundProxy.port === port) {
|
||||||
|
return ps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a response belongs to any provider's registration transaction.
|
||||||
|
*/
|
||||||
|
export function handleProviderRegistrationResponse(msg: SipMessage): boolean {
|
||||||
|
if (!providerStates || !msg.isResponse) return false;
|
||||||
|
for (const ps of providerStates.values()) {
|
||||||
|
if (ps.handleRegistrationResponse(msg)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
239
ts/registrar.ts
Normal file
239
ts/registrar.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* Local SIP registrar — accepts REGISTER from devices and browser clients.
|
||||||
|
*
|
||||||
|
* Devices point their SIP registration at the proxy instead of the upstream
|
||||||
|
* provider. The registrar responds with 200 OK and stores the device's
|
||||||
|
* current contact (source IP:port). Browser softphones register via
|
||||||
|
* WebSocket signaling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
SipMessage,
|
||||||
|
generateTag,
|
||||||
|
} from './sip/index.ts';
|
||||||
|
|
||||||
|
/** Hash a string to a 6-char hex ID. */
|
||||||
|
export function shortHash(input: string): string {
|
||||||
|
return createHash('sha256').update(input).digest('hex').slice(0, 6);
|
||||||
|
}
|
||||||
|
import type { IEndpoint } from './sip/index.ts';
|
||||||
|
import type { IDeviceConfig } from './config.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface IRegisteredDevice {
|
||||||
|
deviceConfig: IDeviceConfig;
|
||||||
|
contact: IEndpoint | null;
|
||||||
|
registeredAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
aor: string;
|
||||||
|
connected: boolean;
|
||||||
|
isBrowser: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeviceStatusEntry {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
contact: IEndpoint | null;
|
||||||
|
aor: string;
|
||||||
|
connected: boolean;
|
||||||
|
isBrowser: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const registeredDevices = new Map<string, IRegisteredDevice>();
|
||||||
|
const browserDevices = new Map<string, IRegisteredDevice>();
|
||||||
|
let knownDevices: IDeviceConfig[] = [];
|
||||||
|
let logFn: (msg: string) => void = () => {};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function initRegistrar(
|
||||||
|
devices: IDeviceConfig[],
|
||||||
|
log: (msg: string) => void,
|
||||||
|
): void {
|
||||||
|
knownDevices = devices;
|
||||||
|
logFn = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a REGISTER from a SIP device. Returns a 200 OK response to send back,
|
||||||
|
* or null if this REGISTER should not be handled by the local registrar.
|
||||||
|
*/
|
||||||
|
export function handleDeviceRegister(
|
||||||
|
msg: SipMessage,
|
||||||
|
rinfo: IEndpoint,
|
||||||
|
): SipMessage | null {
|
||||||
|
if (msg.method !== 'REGISTER') return null;
|
||||||
|
|
||||||
|
const device = knownDevices.find((d) => d.expectedAddress === rinfo.address);
|
||||||
|
if (!device) return null;
|
||||||
|
|
||||||
|
const from = msg.getHeader('From');
|
||||||
|
const aor = from ? SipMessage.extractUri(from) || `sip:${device.extension}@${rinfo.address}` : `sip:${device.extension}@${rinfo.address}`;
|
||||||
|
|
||||||
|
const MAX_EXPIRES = 300;
|
||||||
|
const expiresHeader = msg.getHeader('Expires');
|
||||||
|
const requested = expiresHeader ? parseInt(expiresHeader, 10) : 3600;
|
||||||
|
const expires = Math.min(requested, MAX_EXPIRES);
|
||||||
|
|
||||||
|
const entry: IRegisteredDevice = {
|
||||||
|
deviceConfig: device,
|
||||||
|
contact: { address: rinfo.address, port: rinfo.port },
|
||||||
|
registeredAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + expires * 1000,
|
||||||
|
aor,
|
||||||
|
connected: true,
|
||||||
|
isBrowser: false,
|
||||||
|
};
|
||||||
|
registeredDevices.set(device.id, entry);
|
||||||
|
|
||||||
|
logFn(`[registrar] ${device.displayName} (${device.id}) registered from ${rinfo.address}:${rinfo.port} expires=${expires}`);
|
||||||
|
|
||||||
|
const contact = msg.getHeader('Contact') || `<sip:${rinfo.address}:${rinfo.port}>`;
|
||||||
|
const response = SipMessage.createResponse(200, 'OK', msg, {
|
||||||
|
toTag: generateTag(),
|
||||||
|
contact,
|
||||||
|
extraHeaders: [['Expires', String(expires)]],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a browser softphone as a device.
|
||||||
|
*/
|
||||||
|
export function registerBrowserDevice(sessionId: string, userAgent?: string, remoteIp?: string): void {
|
||||||
|
// Extract a short browser name from the UA string.
|
||||||
|
let browserName = 'Browser';
|
||||||
|
if (userAgent) {
|
||||||
|
if (userAgent.includes('Firefox/')) browserName = 'Firefox';
|
||||||
|
else if (userAgent.includes('Edg/')) browserName = 'Edge';
|
||||||
|
else if (userAgent.includes('Chrome/')) browserName = 'Chrome';
|
||||||
|
else if (userAgent.includes('Safari/') && !userAgent.includes('Chrome/')) browserName = 'Safari';
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: IRegisteredDevice = {
|
||||||
|
deviceConfig: {
|
||||||
|
id: `browser-${shortHash(sessionId)}`,
|
||||||
|
displayName: browserName,
|
||||||
|
expectedAddress: remoteIp || '127.0.0.1',
|
||||||
|
extension: 'webrtc',
|
||||||
|
},
|
||||||
|
contact: null,
|
||||||
|
registeredAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 60 * 1000, // 60s — browser must re-register to stay alive
|
||||||
|
aor: `sip:webrtc@browser`,
|
||||||
|
connected: true,
|
||||||
|
isBrowser: true,
|
||||||
|
};
|
||||||
|
browserDevices.set(sessionId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a browser softphone (on WebSocket close).
|
||||||
|
*/
|
||||||
|
export function unregisterBrowserDevice(sessionId: string): void {
|
||||||
|
browserDevices.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a registered device by its config ID.
|
||||||
|
*/
|
||||||
|
export function getRegisteredDevice(deviceId: string): IRegisteredDevice | null {
|
||||||
|
const entry = registeredDevices.get(deviceId);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
registeredDevices.delete(deviceId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a registered device by source IP address.
|
||||||
|
*/
|
||||||
|
export function getRegisteredDeviceByAddress(address: string): IRegisteredDevice | null {
|
||||||
|
for (const entry of registeredDevices.values()) {
|
||||||
|
if (entry.contact?.address === address && Date.now() <= entry.expiresAt) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an address belongs to a known device (by config expectedAddress).
|
||||||
|
*/
|
||||||
|
export function isKnownDeviceAddress(address: string): boolean {
|
||||||
|
return knownDevices.some((d) => d.expectedAddress === address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all devices for the dashboard.
|
||||||
|
* - Configured devices always show (connected or not).
|
||||||
|
* - Browser devices only show while connected.
|
||||||
|
*/
|
||||||
|
export function getAllDeviceStatuses(): IDeviceStatusEntry[] {
|
||||||
|
const now = Date.now();
|
||||||
|
const result: IDeviceStatusEntry[] = [];
|
||||||
|
|
||||||
|
// Configured devices — always show.
|
||||||
|
for (const dc of knownDevices) {
|
||||||
|
const reg = registeredDevices.get(dc.id);
|
||||||
|
const connected = reg ? now <= reg.expiresAt : false;
|
||||||
|
if (reg && now > reg.expiresAt) {
|
||||||
|
registeredDevices.delete(dc.id);
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
id: dc.id,
|
||||||
|
displayName: dc.displayName,
|
||||||
|
contact: connected && reg ? reg.contact : null,
|
||||||
|
aor: reg?.aor || `sip:${dc.extension}@${dc.expectedAddress}`,
|
||||||
|
connected,
|
||||||
|
isBrowser: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser devices — only while connected.
|
||||||
|
for (const [, entry] of browserDevices) {
|
||||||
|
const ip = entry.deviceConfig.expectedAddress;
|
||||||
|
result.push({
|
||||||
|
id: entry.deviceConfig.id,
|
||||||
|
displayName: entry.deviceConfig.displayName,
|
||||||
|
contact: ip && ip !== '127.0.0.1' ? { address: ip, port: 0 } : null,
|
||||||
|
aor: entry.aor,
|
||||||
|
connected: true,
|
||||||
|
isBrowser: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all currently registered (connected) SIP devices.
|
||||||
|
*/
|
||||||
|
export function getAllRegisteredDevices(): IRegisteredDevice[] {
|
||||||
|
const now = Date.now();
|
||||||
|
const result: IRegisteredDevice[] = [];
|
||||||
|
for (const [id, entry] of registeredDevices) {
|
||||||
|
if (now > entry.expiresAt) {
|
||||||
|
registeredDevices.delete(id);
|
||||||
|
} else {
|
||||||
|
result.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [, entry] of browserDevices) {
|
||||||
|
result.push(entry);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
280
ts/sip/dialog.ts
Normal file
280
ts/sip/dialog.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* SipDialog — tracks the state of a SIP dialog (RFC 3261 §12).
|
||||||
|
*
|
||||||
|
* A dialog is created by a dialog-establishing request (INVITE, SUBSCRIBE, …)
|
||||||
|
* and its 1xx/2xx response. It manages local/remote tags, CSeq counters,
|
||||||
|
* the route set, and provides helpers to build in-dialog requests (ACK, BYE,
|
||||||
|
* re-INVITE, …).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* // Caller (UAC) side — create from the outgoing INVITE we just sent:
|
||||||
|
* const dialog = SipDialog.fromUacInvite(invite);
|
||||||
|
*
|
||||||
|
* // When a 200 OK arrives:
|
||||||
|
* dialog.processResponse(response200);
|
||||||
|
*
|
||||||
|
* // Build ACK for the 2xx:
|
||||||
|
* const ack = dialog.createAck();
|
||||||
|
*
|
||||||
|
* // Later — hang up:
|
||||||
|
* const bye = dialog.createRequest('BYE');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SipMessage } from './message.ts';
|
||||||
|
import { generateTag, generateBranch } from './helpers.ts';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
|
||||||
|
export type TDialogState = 'early' | 'confirmed' | 'terminated';
|
||||||
|
|
||||||
|
export class SipDialog {
|
||||||
|
callId: string;
|
||||||
|
localTag: string;
|
||||||
|
remoteTag: string | null = null;
|
||||||
|
localUri: string;
|
||||||
|
remoteUri: string;
|
||||||
|
localCSeq: number;
|
||||||
|
remoteCSeq: number = 0;
|
||||||
|
routeSet: string[] = [];
|
||||||
|
remoteTarget: string; // Contact URI of the remote party
|
||||||
|
state: TDialogState = 'early';
|
||||||
|
|
||||||
|
// Transport info for sending in-dialog messages.
|
||||||
|
localHost: string;
|
||||||
|
localPort: number;
|
||||||
|
|
||||||
|
constructor(options: {
|
||||||
|
callId: string;
|
||||||
|
localTag: string;
|
||||||
|
remoteTag?: string;
|
||||||
|
localUri: string;
|
||||||
|
remoteUri: string;
|
||||||
|
localCSeq: number;
|
||||||
|
remoteTarget: string;
|
||||||
|
localHost: string;
|
||||||
|
localPort: number;
|
||||||
|
routeSet?: string[];
|
||||||
|
}) {
|
||||||
|
this.callId = options.callId;
|
||||||
|
this.localTag = options.localTag;
|
||||||
|
this.remoteTag = options.remoteTag ?? null;
|
||||||
|
this.localUri = options.localUri;
|
||||||
|
this.remoteUri = options.remoteUri;
|
||||||
|
this.localCSeq = options.localCSeq;
|
||||||
|
this.remoteTarget = options.remoteTarget;
|
||||||
|
this.localHost = options.localHost;
|
||||||
|
this.localPort = options.localPort;
|
||||||
|
this.routeSet = options.routeSet ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Factory: create dialog from an outgoing INVITE (UAC side)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a dialog from an INVITE we are sending.
|
||||||
|
* The dialog enters "early" state; call `processResponse()` when
|
||||||
|
* provisional or final responses arrive.
|
||||||
|
*/
|
||||||
|
static fromUacInvite(invite: SipMessage, localHost: string, localPort: number): SipDialog {
|
||||||
|
const from = invite.getHeader('From') || '';
|
||||||
|
const to = invite.getHeader('To') || '';
|
||||||
|
return new SipDialog({
|
||||||
|
callId: invite.callId,
|
||||||
|
localTag: SipMessage.extractTag(from) || generateTag(),
|
||||||
|
localUri: SipMessage.extractUri(from) || '',
|
||||||
|
remoteUri: SipMessage.extractUri(to) || '',
|
||||||
|
localCSeq: parseInt((invite.getHeader('CSeq') || '1').split(/\s+/)[0], 10),
|
||||||
|
remoteTarget: invite.requestUri || SipMessage.extractUri(to) || '',
|
||||||
|
localHost: localHost,
|
||||||
|
localPort: localPort,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Factory: create dialog from an incoming INVITE (UAS side)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a dialog from an INVITE we received.
|
||||||
|
* Typically used when acting as a UAS (e.g. for call-back scenarios).
|
||||||
|
*/
|
||||||
|
static fromUasInvite(invite: SipMessage, localTag: string, localHost: string, localPort: number): SipDialog {
|
||||||
|
const from = invite.getHeader('From') || '';
|
||||||
|
const to = invite.getHeader('To') || '';
|
||||||
|
const contact = invite.getHeader('Contact');
|
||||||
|
return new SipDialog({
|
||||||
|
callId: invite.callId,
|
||||||
|
localTag,
|
||||||
|
remoteTag: SipMessage.extractTag(from) || undefined,
|
||||||
|
localUri: SipMessage.extractUri(to) || '',
|
||||||
|
remoteUri: SipMessage.extractUri(from) || '',
|
||||||
|
localCSeq: 0,
|
||||||
|
remoteTarget: (contact ? SipMessage.extractUri(contact) : null) || SipMessage.extractUri(from) || '',
|
||||||
|
localHost,
|
||||||
|
localPort,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Response processing
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update dialog state from a received response.
|
||||||
|
* - 1xx with To-tag → early dialog
|
||||||
|
* - 2xx → confirmed dialog
|
||||||
|
* - 3xx–6xx → terminated
|
||||||
|
*/
|
||||||
|
processResponse(response: SipMessage): void {
|
||||||
|
const to = response.getHeader('To') || '';
|
||||||
|
const tag = SipMessage.extractTag(to);
|
||||||
|
const code = response.statusCode ?? 0;
|
||||||
|
// Always update remoteTag from 2xx (RFC 3261 §12.1.2: tag in 2xx is definitive).
|
||||||
|
if (tag && (code >= 200 && code < 300)) {
|
||||||
|
this.remoteTag = tag;
|
||||||
|
} else if (tag && !this.remoteTag) {
|
||||||
|
this.remoteTag = tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update remote target from Contact.
|
||||||
|
const contact = response.getHeader('Contact');
|
||||||
|
if (contact) {
|
||||||
|
const uri = SipMessage.extractUri(contact);
|
||||||
|
if (uri) this.remoteTarget = uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record-Route → route set (in reverse for UAC).
|
||||||
|
if (this.state === 'early') {
|
||||||
|
const rr: string[] = [];
|
||||||
|
for (const [n, v] of response.headers) {
|
||||||
|
if (n.toLowerCase() === 'record-route') rr.push(v);
|
||||||
|
}
|
||||||
|
if (rr.length) this.routeSet = rr.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code >= 200 && code < 300) {
|
||||||
|
this.state = 'confirmed';
|
||||||
|
} else if (code >= 300) {
|
||||||
|
this.state = 'terminated';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Request building
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an in-dialog request (BYE, re-INVITE, INFO, …).
|
||||||
|
* Automatically increments the local CSeq.
|
||||||
|
*/
|
||||||
|
createRequest(method: string, options?: {
|
||||||
|
body?: string;
|
||||||
|
contentType?: string;
|
||||||
|
extraHeaders?: [string, string][];
|
||||||
|
}): SipMessage {
|
||||||
|
this.localCSeq++;
|
||||||
|
const branch = generateBranch();
|
||||||
|
|
||||||
|
const headers: [string, string][] = [
|
||||||
|
['Via', `SIP/2.0/UDP ${this.localHost}:${this.localPort};branch=${branch};rport`],
|
||||||
|
['From', `<${this.localUri}>;tag=${this.localTag}`],
|
||||||
|
['To', `<${this.remoteUri}>${this.remoteTag ? `;tag=${this.remoteTag}` : ''}`],
|
||||||
|
['Call-ID', this.callId],
|
||||||
|
['CSeq', `${this.localCSeq} ${method}`],
|
||||||
|
['Max-Forwards', '70'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Route set → Route headers.
|
||||||
|
for (const route of this.routeSet) {
|
||||||
|
headers.push(['Route', route]);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.push(['Contact', `<sip:${this.localHost}:${this.localPort}>`]);
|
||||||
|
|
||||||
|
if (options?.extraHeaders) headers.push(...options.extraHeaders);
|
||||||
|
|
||||||
|
const body = options?.body || '';
|
||||||
|
if (body && options?.contentType) {
|
||||||
|
headers.push(['Content-Type', options.contentType]);
|
||||||
|
}
|
||||||
|
headers.push(['Content-Length', String(Buffer.byteLength(body, 'utf8'))]);
|
||||||
|
|
||||||
|
// Determine Request-URI from route set or remote target.
|
||||||
|
let ruri = this.remoteTarget;
|
||||||
|
if (this.routeSet.length) {
|
||||||
|
const topRoute = SipMessage.extractUri(this.routeSet[0]);
|
||||||
|
if (topRoute && topRoute.includes(';lr')) {
|
||||||
|
ruri = this.remoteTarget; // loose routing — RURI stays as remote target
|
||||||
|
} else if (topRoute) {
|
||||||
|
ruri = topRoute; // strict routing — top route becomes RURI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SipMessage(`${method} ${ruri} SIP/2.0`, headers, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an ACK for a 2xx response to INVITE (RFC 3261 §13.2.2.4).
|
||||||
|
* ACK for 2xx is a new transaction, so it gets its own Via/branch.
|
||||||
|
*/
|
||||||
|
createAck(): SipMessage {
|
||||||
|
const branch = generateBranch();
|
||||||
|
|
||||||
|
const headers: [string, string][] = [
|
||||||
|
['Via', `SIP/2.0/UDP ${this.localHost}:${this.localPort};branch=${branch};rport`],
|
||||||
|
['From', `<${this.localUri}>;tag=${this.localTag}`],
|
||||||
|
['To', `<${this.remoteUri}>${this.remoteTag ? `;tag=${this.remoteTag}` : ''}`],
|
||||||
|
['Call-ID', this.callId],
|
||||||
|
['CSeq', `${this.localCSeq} ACK`],
|
||||||
|
['Max-Forwards', '70'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const route of this.routeSet) {
|
||||||
|
headers.push(['Route', route]);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.push(['Content-Length', '0']);
|
||||||
|
|
||||||
|
let ruri = this.remoteTarget;
|
||||||
|
if (this.routeSet.length) {
|
||||||
|
const topRoute = SipMessage.extractUri(this.routeSet[0]);
|
||||||
|
if (topRoute && topRoute.includes(';lr')) {
|
||||||
|
ruri = this.remoteTarget;
|
||||||
|
} else if (topRoute) {
|
||||||
|
ruri = topRoute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SipMessage(`ACK ${ruri} SIP/2.0`, headers, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a CANCEL for the original INVITE (same branch, CSeq).
|
||||||
|
* Used before the dialog is confirmed.
|
||||||
|
*/
|
||||||
|
createCancel(originalInvite: SipMessage): SipMessage {
|
||||||
|
const via = originalInvite.getHeader('Via') || '';
|
||||||
|
const from = originalInvite.getHeader('From') || '';
|
||||||
|
const to = originalInvite.getHeader('To') || '';
|
||||||
|
|
||||||
|
const headers: [string, string][] = [
|
||||||
|
['Via', via],
|
||||||
|
['From', from],
|
||||||
|
['To', to],
|
||||||
|
['Call-ID', this.callId],
|
||||||
|
['CSeq', `${this.localCSeq} CANCEL`],
|
||||||
|
['Max-Forwards', '70'],
|
||||||
|
['Content-Length', '0'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const ruri = originalInvite.requestUri || this.remoteTarget;
|
||||||
|
return new SipMessage(`CANCEL ${ruri} SIP/2.0`, headers, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transition the dialog to terminated state. */
|
||||||
|
terminate(): void {
|
||||||
|
this.state = 'terminated';
|
||||||
|
}
|
||||||
|
}
|
||||||
190
ts/sip/helpers.ts
Normal file
190
ts/sip/helpers.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* SIP helper utilities — ID generation and SDP construction.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes, createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ID generators
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Generate a random SIP Call-ID. */
|
||||||
|
export function generateCallId(domain?: string): string {
|
||||||
|
const id = randomBytes(16).toString('hex');
|
||||||
|
return domain ? `${id}@${domain}` : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a random SIP From/To tag. */
|
||||||
|
export function generateTag(): string {
|
||||||
|
return randomBytes(8).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a RFC 3261 compliant Via branch (starts with z9hG4bK magic cookie). */
|
||||||
|
export function generateBranch(): string {
|
||||||
|
return `z9hG4bK-${randomBytes(8).toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Codec registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CODEC_NAMES: Record<number, string> = {
|
||||||
|
0: 'PCMU/8000',
|
||||||
|
3: 'GSM/8000',
|
||||||
|
4: 'G723/8000',
|
||||||
|
8: 'PCMA/8000',
|
||||||
|
9: 'G722/8000',
|
||||||
|
18: 'G729/8000',
|
||||||
|
101: 'telephone-event/8000',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Look up the rtpmap name for a static payload type. */
|
||||||
|
export function codecName(pt: number): string {
|
||||||
|
return CODEC_NAMES[pt] || `unknown/${pt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SDP builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ISdpOptions {
|
||||||
|
/** IP address for the c= and o= lines. */
|
||||||
|
ip: string;
|
||||||
|
/** Audio port for the m=audio line. */
|
||||||
|
port: number;
|
||||||
|
/** RTP payload type numbers (e.g. [9, 0, 8, 101]). */
|
||||||
|
payloadTypes?: number[];
|
||||||
|
/** SDP session ID (random if omitted). */
|
||||||
|
sessionId?: string;
|
||||||
|
/** Session name for the s= line (defaults to '-'). */
|
||||||
|
sessionName?: string;
|
||||||
|
/** Direction attribute (defaults to 'sendrecv'). */
|
||||||
|
direction?: 'sendrecv' | 'recvonly' | 'sendonly' | 'inactive';
|
||||||
|
/** Extra a= lines to append (without "a=" prefix). */
|
||||||
|
attributes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal SDP body suitable for SIP INVITE offers/answers.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const sdp = buildSdp({
|
||||||
|
* ip: '192.168.5.66',
|
||||||
|
* port: 20000,
|
||||||
|
* payloadTypes: [9, 0, 101],
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function buildSdp(options: ISdpOptions): string {
|
||||||
|
const {
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
payloadTypes = [9, 0, 8, 101],
|
||||||
|
sessionId = String(Math.floor(Math.random() * 1e9)),
|
||||||
|
sessionName = '-',
|
||||||
|
direction = 'sendrecv',
|
||||||
|
attributes = [],
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
'v=0',
|
||||||
|
`o=- ${sessionId} ${sessionId} IN IP4 ${ip}`,
|
||||||
|
`s=${sessionName}`,
|
||||||
|
`c=IN IP4 ${ip}`,
|
||||||
|
't=0 0',
|
||||||
|
`m=audio ${port} RTP/AVP ${payloadTypes.join(' ')}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pt of payloadTypes) {
|
||||||
|
const name = CODEC_NAMES[pt];
|
||||||
|
if (name) lines.push(`a=rtpmap:${pt} ${name}`);
|
||||||
|
if (pt === 101) lines.push(`a=fmtp:101 0-16`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`a=${direction}`);
|
||||||
|
for (const attr of attributes) lines.push(`a=${attr}`);
|
||||||
|
lines.push(''); // trailing CRLF
|
||||||
|
|
||||||
|
return lines.join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SIP Digest authentication (RFC 2617)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface IDigestChallenge {
|
||||||
|
realm: string;
|
||||||
|
nonce: string;
|
||||||
|
algorithm?: string;
|
||||||
|
opaque?: string;
|
||||||
|
qop?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `Proxy-Authenticate` or `WWW-Authenticate` header value
|
||||||
|
* into its constituent fields.
|
||||||
|
*/
|
||||||
|
export function parseDigestChallenge(header: string): IDigestChallenge | null {
|
||||||
|
if (!header.toLowerCase().startsWith('digest ')) return null;
|
||||||
|
const params = header.slice(7);
|
||||||
|
const get = (key: string): string | undefined => {
|
||||||
|
const re = new RegExp(`${key}\\s*=\\s*"([^"]*)"`, 'i');
|
||||||
|
const m = params.match(re);
|
||||||
|
if (m) return m[1];
|
||||||
|
// unquoted value
|
||||||
|
const re2 = new RegExp(`${key}\\s*=\\s*([^,\\s]+)`, 'i');
|
||||||
|
const m2 = params.match(re2);
|
||||||
|
return m2 ? m2[1] : undefined;
|
||||||
|
};
|
||||||
|
const realm = get('realm');
|
||||||
|
const nonce = get('nonce');
|
||||||
|
if (!realm || !nonce) return null;
|
||||||
|
return { realm, nonce, algorithm: get('algorithm'), opaque: get('opaque'), qop: get('qop') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function md5(s: string): string {
|
||||||
|
return createHash('md5').update(s).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a SIP Digest `Proxy-Authorization` or `Authorization` header value.
|
||||||
|
*/
|
||||||
|
export function computeDigestAuth(options: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
realm: string;
|
||||||
|
nonce: string;
|
||||||
|
method: string;
|
||||||
|
uri: string;
|
||||||
|
algorithm?: string;
|
||||||
|
opaque?: string;
|
||||||
|
}): string {
|
||||||
|
const ha1 = md5(`${options.username}:${options.realm}:${options.password}`);
|
||||||
|
const ha2 = md5(`${options.method}:${options.uri}`);
|
||||||
|
const response = md5(`${ha1}:${options.nonce}:${ha2}`);
|
||||||
|
|
||||||
|
let header = `Digest username="${options.username}", realm="${options.realm}", ` +
|
||||||
|
`nonce="${options.nonce}", uri="${options.uri}", response="${response}", ` +
|
||||||
|
`algorithm=${options.algorithm || 'MD5'}`;
|
||||||
|
if (options.opaque) header += `, opaque="${options.opaque}"`;
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the audio media port and connection address from an SDP body.
|
||||||
|
* Returns null when no c= + m=audio pair is found.
|
||||||
|
*/
|
||||||
|
export function parseSdpEndpoint(sdp: string): { address: string; port: number } | null {
|
||||||
|
let addr: string | null = null;
|
||||||
|
let port: number | null = null;
|
||||||
|
for (const raw of sdp.replace(/\r\n/g, '\n').split('\n')) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (line.startsWith('c=IN IP4 ')) {
|
||||||
|
addr = line.slice('c=IN IP4 '.length).trim();
|
||||||
|
} else if (line.startsWith('m=audio ')) {
|
||||||
|
const parts = line.split(' ');
|
||||||
|
if (parts.length >= 2) port = parseInt(parts[1], 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addr && port ? { address: addr, port } : null;
|
||||||
|
}
|
||||||
16
ts/sip/index.ts
Normal file
16
ts/sip/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export { SipMessage } from './message.ts';
|
||||||
|
export { SipDialog } from './dialog.ts';
|
||||||
|
export type { TDialogState } from './dialog.ts';
|
||||||
|
export { rewriteSipUri, rewriteSdp } from './rewrite.ts';
|
||||||
|
export {
|
||||||
|
generateCallId,
|
||||||
|
generateTag,
|
||||||
|
generateBranch,
|
||||||
|
codecName,
|
||||||
|
buildSdp,
|
||||||
|
parseSdpEndpoint,
|
||||||
|
parseDigestChallenge,
|
||||||
|
computeDigestAuth,
|
||||||
|
} from './helpers.ts';
|
||||||
|
export type { ISdpOptions, IDigestChallenge } from './helpers.ts';
|
||||||
|
export type { IEndpoint } from './types.ts';
|
||||||
316
ts/sip/message.ts
Normal file
316
ts/sip/message.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
/**
|
||||||
|
* SipMessage — parse, inspect, mutate, and serialize SIP messages.
|
||||||
|
*
|
||||||
|
* Provides a fluent (builder-style) API so callers can chain header
|
||||||
|
* manipulations before serializing:
|
||||||
|
*
|
||||||
|
* const buf = SipMessage.parse(raw)!
|
||||||
|
* .setHeader('Contact', newContact)
|
||||||
|
* .prependHeader('Record-Route', rr)
|
||||||
|
* .updateContentLength()
|
||||||
|
* .serialize();
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { generateCallId, generateTag, generateBranch } from './helpers.ts';
|
||||||
|
|
||||||
|
const SIP_FIRST_LINE_RE = /^(?:[A-Z]+\s+\S+\s+SIP\/\d\.\d|SIP\/\d\.\d\s+\d+\s+)/;
|
||||||
|
|
||||||
|
export class SipMessage {
|
||||||
|
startLine: string;
|
||||||
|
headers: [string, string][];
|
||||||
|
body: string;
|
||||||
|
|
||||||
|
constructor(startLine: string, headers: [string, string][], body: string) {
|
||||||
|
this.startLine = startLine;
|
||||||
|
this.headers = headers;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Parsing
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static parse(buf: Buffer): SipMessage | null {
|
||||||
|
if (!buf.length) return null;
|
||||||
|
if (buf[0] < 0x41 || buf[0] > 0x7a) return null;
|
||||||
|
|
||||||
|
let text: string;
|
||||||
|
try { text = buf.toString('utf8'); } catch { return null; }
|
||||||
|
|
||||||
|
let head: string;
|
||||||
|
let body: string;
|
||||||
|
let sep = text.indexOf('\r\n\r\n');
|
||||||
|
if (sep !== -1) {
|
||||||
|
head = text.slice(0, sep);
|
||||||
|
body = text.slice(sep + 4);
|
||||||
|
} else {
|
||||||
|
sep = text.indexOf('\n\n');
|
||||||
|
if (sep !== -1) {
|
||||||
|
head = text.slice(0, sep);
|
||||||
|
body = text.slice(sep + 2);
|
||||||
|
} else {
|
||||||
|
head = text;
|
||||||
|
body = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = head.replace(/\r\n/g, '\n').split('\n');
|
||||||
|
if (!lines.length || !lines[0]) return null;
|
||||||
|
const startLine = lines[0];
|
||||||
|
if (!SIP_FIRST_LINE_RE.test(startLine)) return null;
|
||||||
|
|
||||||
|
const headers: [string, string][] = [];
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
const colon = line.indexOf(':');
|
||||||
|
if (colon === -1) continue;
|
||||||
|
headers.push([line.slice(0, colon).trim(), line.slice(colon + 1).trim()]);
|
||||||
|
}
|
||||||
|
return new SipMessage(startLine, headers, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Serialization
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
serialize(): Buffer {
|
||||||
|
const head = [this.startLine, ...this.headers.map(([n, v]) => `${n}: ${v}`)].join('\r\n') + '\r\n\r\n';
|
||||||
|
return Buffer.concat([Buffer.from(head, 'utf8'), Buffer.from(this.body || '', 'utf8')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Inspectors
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
get isRequest(): boolean {
|
||||||
|
return !this.startLine.startsWith('SIP/');
|
||||||
|
}
|
||||||
|
|
||||||
|
get isResponse(): boolean {
|
||||||
|
return this.startLine.startsWith('SIP/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Request method (INVITE, REGISTER, ...) or null for responses. */
|
||||||
|
get method(): string | null {
|
||||||
|
if (!this.isRequest) return null;
|
||||||
|
return this.startLine.split(' ')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response status code or null for requests. */
|
||||||
|
get statusCode(): number | null {
|
||||||
|
if (!this.isResponse) return null;
|
||||||
|
return parseInt(this.startLine.split(' ')[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
get callId(): string {
|
||||||
|
return this.getHeader('Call-ID') || 'noid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Method from the CSeq header (e.g. "INVITE"). */
|
||||||
|
get cseqMethod(): string | null {
|
||||||
|
const cseq = this.getHeader('CSeq');
|
||||||
|
if (!cseq) return null;
|
||||||
|
const parts = cseq.trim().split(/\s+/);
|
||||||
|
return parts.length >= 2 ? parts[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for INVITE, SUBSCRIBE, REFER, NOTIFY, UPDATE. */
|
||||||
|
get isDialogEstablishing(): boolean {
|
||||||
|
return /^(INVITE|SUBSCRIBE|REFER|NOTIFY|UPDATE)\s/.test(this.startLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the body carries an SDP payload. */
|
||||||
|
get hasSdpBody(): boolean {
|
||||||
|
const ct = (this.getHeader('Content-Type') || '').toLowerCase();
|
||||||
|
return !!this.body && ct.startsWith('application/sdp');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Header accessors (fluent)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getHeader(name: string): string | null {
|
||||||
|
const nl = name.toLowerCase();
|
||||||
|
for (const [n, v] of this.headers) if (n.toLowerCase() === nl) return v;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overwrites the first header with the given name, or appends it. */
|
||||||
|
setHeader(name: string, value: string): this {
|
||||||
|
const nl = name.toLowerCase();
|
||||||
|
for (const h of this.headers) {
|
||||||
|
if (h[0].toLowerCase() === nl) { h[1] = value; return this; }
|
||||||
|
}
|
||||||
|
this.headers.push([name, value]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inserts a header at the top of the header list. */
|
||||||
|
prependHeader(name: string, value: string): this {
|
||||||
|
this.headers.unshift([name, value]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes all headers with the given name. */
|
||||||
|
removeHeader(name: string): this {
|
||||||
|
const nl = name.toLowerCase();
|
||||||
|
this.headers = this.headers.filter(([n]) => n.toLowerCase() !== nl);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recalculates Content-Length to match the current body. */
|
||||||
|
updateContentLength(): this {
|
||||||
|
const len = Buffer.byteLength(this.body || '', 'utf8');
|
||||||
|
return this.setHeader('Content-Length', String(len));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Start-line mutation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Replaces the Request-URI (second token) of a request start line. */
|
||||||
|
setRequestUri(uri: string): this {
|
||||||
|
if (!this.isRequest) return this;
|
||||||
|
const parts = this.startLine.split(' ');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
parts[1] = uri;
|
||||||
|
this.startLine = parts.join(' ');
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the Request-URI (second token) of a request start line. */
|
||||||
|
get requestUri(): string | null {
|
||||||
|
if (!this.isRequest) return null;
|
||||||
|
return this.startLine.split(' ')[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Factory methods — build new SIP messages from scratch
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a new SIP request.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const invite = SipMessage.createRequest('INVITE', 'sip:user@host', {
|
||||||
|
* from: { uri: 'sip:me@proxy', tag: 'abc' },
|
||||||
|
* to: { uri: 'sip:user@host' },
|
||||||
|
* via: { host: '192.168.5.66', port: 5070 },
|
||||||
|
* contact: '<sip:me@192.168.5.66:5070>',
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
static createRequest(method: string, requestUri: string, options: {
|
||||||
|
via: { host: string; port: number; transport?: string; branch?: string };
|
||||||
|
from: { uri: string; displayName?: string; tag?: string };
|
||||||
|
to: { uri: string; displayName?: string; tag?: string };
|
||||||
|
callId?: string;
|
||||||
|
cseq?: number;
|
||||||
|
contact?: string;
|
||||||
|
maxForwards?: number;
|
||||||
|
body?: string;
|
||||||
|
contentType?: string;
|
||||||
|
extraHeaders?: [string, string][];
|
||||||
|
}): SipMessage {
|
||||||
|
const branch = options.via.branch || generateBranch();
|
||||||
|
const transport = options.via.transport || 'UDP';
|
||||||
|
const fromTag = options.from.tag || generateTag();
|
||||||
|
const callId = options.callId || generateCallId();
|
||||||
|
const cseq = options.cseq ?? 1;
|
||||||
|
|
||||||
|
const fromDisplay = options.from.displayName ? `"${options.from.displayName}" ` : '';
|
||||||
|
const toDisplay = options.to.displayName ? `"${options.to.displayName}" ` : '';
|
||||||
|
const toTag = options.to.tag ? `;tag=${options.to.tag}` : '';
|
||||||
|
|
||||||
|
const headers: [string, string][] = [
|
||||||
|
['Via', `SIP/2.0/${transport} ${options.via.host}:${options.via.port};branch=${branch};rport`],
|
||||||
|
['From', `${fromDisplay}<${options.from.uri}>;tag=${fromTag}`],
|
||||||
|
['To', `${toDisplay}<${options.to.uri}>${toTag}`],
|
||||||
|
['Call-ID', callId],
|
||||||
|
['CSeq', `${cseq} ${method}`],
|
||||||
|
['Max-Forwards', String(options.maxForwards ?? 70)],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options.contact) {
|
||||||
|
headers.push(['Contact', options.contact]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.extraHeaders) {
|
||||||
|
headers.push(...options.extraHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = options.body || '';
|
||||||
|
if (body && options.contentType) {
|
||||||
|
headers.push(['Content-Type', options.contentType]);
|
||||||
|
}
|
||||||
|
headers.push(['Content-Length', String(Buffer.byteLength(body, 'utf8'))]);
|
||||||
|
|
||||||
|
return new SipMessage(`${method} ${requestUri} SIP/2.0`, headers, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a SIP response to an incoming request.
|
||||||
|
*
|
||||||
|
* Copies Via, From, To, Call-ID, and CSeq from the original request.
|
||||||
|
*/
|
||||||
|
static createResponse(
|
||||||
|
statusCode: number,
|
||||||
|
reasonPhrase: string,
|
||||||
|
request: SipMessage,
|
||||||
|
options?: {
|
||||||
|
toTag?: string;
|
||||||
|
contact?: string;
|
||||||
|
body?: string;
|
||||||
|
contentType?: string;
|
||||||
|
extraHeaders?: [string, string][];
|
||||||
|
},
|
||||||
|
): SipMessage {
|
||||||
|
const headers: [string, string][] = [];
|
||||||
|
|
||||||
|
// Copy all Via headers (order matters).
|
||||||
|
for (const [n, v] of request.headers) {
|
||||||
|
if (n.toLowerCase() === 'via') headers.push(['Via', v]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// From — copied verbatim.
|
||||||
|
const from = request.getHeader('From');
|
||||||
|
if (from) headers.push(['From', from]);
|
||||||
|
|
||||||
|
// To — add tag if provided and not already present.
|
||||||
|
let to = request.getHeader('To') || '';
|
||||||
|
if (options?.toTag && !to.includes('tag=')) {
|
||||||
|
to += `;tag=${options.toTag}`;
|
||||||
|
}
|
||||||
|
headers.push(['To', to]);
|
||||||
|
|
||||||
|
headers.push(['Call-ID', request.callId]);
|
||||||
|
|
||||||
|
const cseq = request.getHeader('CSeq');
|
||||||
|
if (cseq) headers.push(['CSeq', cseq]);
|
||||||
|
|
||||||
|
if (options?.contact) headers.push(['Contact', options.contact]);
|
||||||
|
if (options?.extraHeaders) headers.push(...options.extraHeaders);
|
||||||
|
|
||||||
|
const body = options?.body || '';
|
||||||
|
if (body && options?.contentType) {
|
||||||
|
headers.push(['Content-Type', options.contentType]);
|
||||||
|
}
|
||||||
|
headers.push(['Content-Length', String(Buffer.byteLength(body, 'utf8'))]);
|
||||||
|
|
||||||
|
return new SipMessage(`SIP/2.0 ${statusCode} ${reasonPhrase}`, headers, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the tag from a From or To header value. */
|
||||||
|
static extractTag(headerValue: string): string | null {
|
||||||
|
const m = headerValue.match(/;tag=([^\s;>]+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the URI from an addr-spec or name-addr (From/To/Contact). */
|
||||||
|
static extractUri(headerValue: string): string | null {
|
||||||
|
const m = headerValue.match(/<([^>]+)>/);
|
||||||
|
return m ? m[1] : headerValue.trim().split(/[;>]/)[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
228
ts/sip/readme.md
Normal file
228
ts/sip/readme.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# ts/sip — SIP Protocol Library
|
||||||
|
|
||||||
|
A zero-dependency SIP (Session Initiation Protocol) library for Deno / Node.
|
||||||
|
Provides parsing, construction, mutation, and dialog management for SIP
|
||||||
|
messages, plus helpers for SDP bodies and URI rewriting.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `message.ts` | `SipMessage` class — parse, inspect, mutate, serialize |
|
||||||
|
| `dialog.ts` | `SipDialog` class — track dialog state, build in-dialog requests |
|
||||||
|
| `helpers.ts` | ID generators, codec registry, SDP builder/parser |
|
||||||
|
| `rewrite.ts` | SIP URI and SDP body rewriting |
|
||||||
|
| `types.ts` | Shared types (`IEndpoint`) |
|
||||||
|
| `index.ts` | Barrel re-export |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
SipMessage,
|
||||||
|
SipDialog,
|
||||||
|
buildSdp,
|
||||||
|
parseSdpEndpoint,
|
||||||
|
rewriteSipUri,
|
||||||
|
rewriteSdp,
|
||||||
|
generateCallId,
|
||||||
|
generateTag,
|
||||||
|
generateBranch,
|
||||||
|
} from './sip/index.ts';
|
||||||
|
```
|
||||||
|
|
||||||
|
## SipMessage
|
||||||
|
|
||||||
|
### Parsing
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
|
||||||
|
const raw = Buffer.from(
|
||||||
|
'INVITE sip:user@example.com SIP/2.0\r\n' +
|
||||||
|
'Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK776\r\n' +
|
||||||
|
'From: <sip:alice@example.com>;tag=abc\r\n' +
|
||||||
|
'To: <sip:bob@example.com>\r\n' +
|
||||||
|
'Call-ID: a84b4c76e66710@10.0.0.1\r\n' +
|
||||||
|
'CSeq: 1 INVITE\r\n' +
|
||||||
|
'Content-Length: 0\r\n\r\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const msg = SipMessage.parse(raw);
|
||||||
|
// msg.method → "INVITE"
|
||||||
|
// msg.isRequest → true
|
||||||
|
// msg.callId → "a84b4c76e66710@10.0.0.1"
|
||||||
|
// msg.cseqMethod → "INVITE"
|
||||||
|
// msg.isDialogEstablishing → true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fluent mutation
|
||||||
|
|
||||||
|
All setter methods return `this` for chaining:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const buf = SipMessage.parse(raw)!
|
||||||
|
.setHeader('Contact', '<sip:proxy@192.168.1.1:5070>')
|
||||||
|
.prependHeader('Record-Route', '<sip:192.168.1.1:5070;lr>')
|
||||||
|
.updateContentLength()
|
||||||
|
.serialize();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building requests from scratch
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const invite = SipMessage.createRequest('INVITE', 'sip:+4930123@voip.example.com', {
|
||||||
|
via: { host: '192.168.5.66', port: 5070 },
|
||||||
|
from: { uri: 'sip:alice@example.com', displayName: 'Alice' },
|
||||||
|
to: { uri: 'sip:+4930123@voip.example.com' },
|
||||||
|
contact: '<sip:192.168.5.66:5070>',
|
||||||
|
body: sdpBody,
|
||||||
|
contentType: 'application/sdp',
|
||||||
|
});
|
||||||
|
// Call-ID, From tag, Via branch are auto-generated if not provided.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building responses
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const ok = SipMessage.createResponse(200, 'OK', incomingInvite, {
|
||||||
|
toTag: generateTag(),
|
||||||
|
contact: '<sip:192.168.5.66:5070>',
|
||||||
|
body: answerSdp,
|
||||||
|
contentType: 'application/sdp',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inspectors
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `isRequest` | `boolean` | True for requests (INVITE, BYE, ...) |
|
||||||
|
| `isResponse` | `boolean` | True for responses (SIP/2.0 200 OK, ...) |
|
||||||
|
| `method` | `string \| null` | Request method or null |
|
||||||
|
| `statusCode` | `number \| null` | Response status code or null |
|
||||||
|
| `callId` | `string` | Call-ID header value |
|
||||||
|
| `cseqMethod` | `string \| null` | Method from CSeq header |
|
||||||
|
| `requestUri` | `string \| null` | Request-URI (second token of start line) |
|
||||||
|
| `isDialogEstablishing` | `boolean` | INVITE, SUBSCRIBE, REFER, NOTIFY, UPDATE |
|
||||||
|
| `hasSdpBody` | `boolean` | Body present with Content-Type: application/sdp |
|
||||||
|
|
||||||
|
### Static helpers
|
||||||
|
|
||||||
|
```ts
|
||||||
|
SipMessage.extractTag('<sip:alice@x.com>;tag=abc') // → "abc"
|
||||||
|
SipMessage.extractUri('"Alice" <sip:alice@x.com>') // → "sip:alice@x.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
## SipDialog
|
||||||
|
|
||||||
|
Tracks dialog state per RFC 3261 §12. A dialog is created from a
|
||||||
|
dialog-establishing request and updated as responses arrive.
|
||||||
|
|
||||||
|
### UAC (caller) side
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 1. Build and send INVITE
|
||||||
|
const invite = SipMessage.createRequest('INVITE', destUri, { ... });
|
||||||
|
const dialog = SipDialog.fromUacInvite(invite, '192.168.5.66', 5070);
|
||||||
|
|
||||||
|
// 2. Process responses as they arrive
|
||||||
|
dialog.processResponse(trying100); // state stays 'early'
|
||||||
|
dialog.processResponse(ringing180); // state stays 'early', remoteTag learned
|
||||||
|
dialog.processResponse(ok200); // state → 'confirmed'
|
||||||
|
|
||||||
|
// 3. ACK the 200
|
||||||
|
const ack = dialog.createAck();
|
||||||
|
|
||||||
|
// 4. In-dialog requests
|
||||||
|
const bye = dialog.createRequest('BYE');
|
||||||
|
dialog.terminate();
|
||||||
|
```
|
||||||
|
|
||||||
|
### UAS (callee) side
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const dialog = SipDialog.fromUasInvite(incomingInvite, generateTag(), localHost, localPort);
|
||||||
|
```
|
||||||
|
|
||||||
|
### CANCEL (before answer)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const cancel = dialog.createCancel(originalInvite);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dialog states
|
||||||
|
|
||||||
|
`'early'` → `'confirmed'` → `'terminated'`
|
||||||
|
|
||||||
|
## Helpers
|
||||||
|
|
||||||
|
### ID generation
|
||||||
|
|
||||||
|
```ts
|
||||||
|
generateCallId() // → "a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5"
|
||||||
|
generateCallId('example.com') // → "a3f8b2c1...@example.com"
|
||||||
|
generateTag() // → "1a2b3c4d5e6f7a8b"
|
||||||
|
generateBranch() // → "z9hG4bK-1a2b3c4d5e6f7a8b"
|
||||||
|
```
|
||||||
|
|
||||||
|
### SDP builder
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const sdp = buildSdp({
|
||||||
|
ip: '192.168.5.66',
|
||||||
|
port: 20000,
|
||||||
|
payloadTypes: [9, 0, 8, 101], // G.722, PCMU, PCMA, telephone-event
|
||||||
|
direction: 'sendrecv',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### SDP parser
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const ep = parseSdpEndpoint(sdpBody);
|
||||||
|
// → { address: '10.0.0.1', port: 20000 } or null
|
||||||
|
```
|
||||||
|
|
||||||
|
### Codec names
|
||||||
|
|
||||||
|
```ts
|
||||||
|
codecName(9) // → "G722/8000"
|
||||||
|
codecName(0) // → "PCMU/8000"
|
||||||
|
codecName(101) // → "telephone-event/8000"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rewriting
|
||||||
|
|
||||||
|
### SIP URI
|
||||||
|
|
||||||
|
Replaces the host:port in all `sip:` / `sips:` URIs found in a header value:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
rewriteSipUri('<sip:user@10.0.0.1:5060>', '203.0.113.1', 5070)
|
||||||
|
// → '<sip:user@203.0.113.1:5070>'
|
||||||
|
```
|
||||||
|
|
||||||
|
### SDP body
|
||||||
|
|
||||||
|
Rewrites the connection address and audio media port, returning the original
|
||||||
|
endpoint that was replaced:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { body, original } = rewriteSdp(sdpBody, '203.0.113.1', 20000);
|
||||||
|
// original → { address: '10.0.0.1', port: 8000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
This library is intentionally low-level — it operates on individual messages
|
||||||
|
and dialogs rather than providing a full SIP stack with transport and
|
||||||
|
transaction layers. This makes it suitable for building:
|
||||||
|
|
||||||
|
- **SIP proxies** — parse, rewrite headers/SDP, serialize, forward
|
||||||
|
- **B2BUA (back-to-back user agents)** — manage two dialogs, bridge media
|
||||||
|
- **SIP testing tools** — craft and send arbitrary messages
|
||||||
|
- **Protocol analyzers** — parse and inspect SIP traffic
|
||||||
|
|
||||||
|
The library does not manage sockets, timers, or retransmissions — those
|
||||||
|
concerns belong to the application layer.
|
||||||
54
ts/sip/rewrite.ts
Normal file
54
ts/sip/rewrite.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* SIP URI and SDP body rewriting helpers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IEndpoint } from './types.ts';
|
||||||
|
|
||||||
|
const SIP_URI_RE = /(sips?:)([^@>;,\s]+@)?([^>;,\s:]+)(:\d+)?/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the host:port in every `sip:` / `sips:` URI found in `value`.
|
||||||
|
*/
|
||||||
|
export function rewriteSipUri(value: string, host: string, port: number): string {
|
||||||
|
return value.replace(SIP_URI_RE, (_m, scheme: string, userpart?: string) =>
|
||||||
|
`${scheme}${userpart || ''}${host}:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites the connection address (`c=`) and audio media port (`m=audio`)
|
||||||
|
* in an SDP body. Returns the rewritten body together with the original
|
||||||
|
* endpoint that was replaced (if any).
|
||||||
|
*/
|
||||||
|
export function rewriteSdp(
|
||||||
|
body: string,
|
||||||
|
ip: string,
|
||||||
|
port: number,
|
||||||
|
): { body: string; original: IEndpoint | null } {
|
||||||
|
let origAddr: string | null = null;
|
||||||
|
let origPort: number | null = null;
|
||||||
|
|
||||||
|
const out = body
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
if (line.startsWith('c=IN IP4 ')) {
|
||||||
|
origAddr = line.slice('c=IN IP4 '.length).trim();
|
||||||
|
return `c=IN IP4 ${ip}`;
|
||||||
|
}
|
||||||
|
if (line.startsWith('m=audio ')) {
|
||||||
|
const parts = line.split(' ');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
origPort = parseInt(parts[1], 10);
|
||||||
|
parts[1] = String(port);
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
})
|
||||||
|
.join('\r\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
body: out,
|
||||||
|
original: origAddr && origPort ? { address: origAddr, port: origPort } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
8
ts/sip/types.ts
Normal file
8
ts/sip/types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Shared SIP types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IEndpoint {
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
283
ts/sipproxy.ts
Normal file
283
ts/sipproxy.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* SIP proxy — hub model entry point.
|
||||||
|
*
|
||||||
|
* Thin bootstrap that wires together:
|
||||||
|
* - UDP socket for all SIP signaling
|
||||||
|
* - CallManager (the hub model core)
|
||||||
|
* - Provider registration
|
||||||
|
* - Local device registrar
|
||||||
|
* - WebRTC signaling
|
||||||
|
* - Web dashboard
|
||||||
|
* - Rust codec bridge
|
||||||
|
*
|
||||||
|
* All call/media logic lives in ts/call/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dgram from 'node:dgram';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
|
||||||
|
import { SipMessage } from './sip/index.ts';
|
||||||
|
import type { IEndpoint } from './sip/index.ts';
|
||||||
|
import { loadConfig, getProviderForOutbound } from './config.ts';
|
||||||
|
import type { IAppConfig, IProviderConfig } from './config.ts';
|
||||||
|
import {
|
||||||
|
initProviderStates,
|
||||||
|
syncProviderStates,
|
||||||
|
getProviderByUpstreamAddress,
|
||||||
|
handleProviderRegistrationResponse,
|
||||||
|
} from './providerstate.ts';
|
||||||
|
import {
|
||||||
|
initRegistrar,
|
||||||
|
handleDeviceRegister,
|
||||||
|
isKnownDeviceAddress,
|
||||||
|
getAllDeviceStatuses,
|
||||||
|
} from './registrar.ts';
|
||||||
|
import { broadcastWs, initWebUi } from './frontend.ts';
|
||||||
|
import {
|
||||||
|
initWebRtcSignaling,
|
||||||
|
sendToBrowserDevice,
|
||||||
|
getAllBrowserDeviceIds,
|
||||||
|
getBrowserDeviceWs,
|
||||||
|
} from './webrtcbridge.ts';
|
||||||
|
import { initCodecBridge } from './opusbridge.ts';
|
||||||
|
import { initAnnouncement } from './announcement.ts';
|
||||||
|
import { CallManager } from './call/index.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let appConfig: IAppConfig = loadConfig();
|
||||||
|
const { proxy } = appConfig;
|
||||||
|
|
||||||
|
const LAN_IP = proxy.lanIp;
|
||||||
|
const LAN_PORT = proxy.lanPort;
|
||||||
|
|
||||||
|
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)}`;
|
||||||
|
|
||||||
|
function now(): string {
|
||||||
|
return new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(msg: string): void {
|
||||||
|
const line = `${now()} ${msg}\n`;
|
||||||
|
fs.appendFileSync(LOG_PATH, line);
|
||||||
|
process.stdout.write(line);
|
||||||
|
broadcastWs('log', { message: msg });
|
||||||
|
}
|
||||||
|
|
||||||
|
function logPacket(label: string, data: Buffer): void {
|
||||||
|
const head = `\n========== ${now()} ${label} (${data.length}b) ==========\n`;
|
||||||
|
const looksText = data.length > 0 && data[0] >= 0x41 && data[0] <= 0x7a;
|
||||||
|
const body = looksText
|
||||||
|
? data.toString('utf8')
|
||||||
|
: `[${data.length} bytes binary] ${data.toString('hex').slice(0, 80)}`;
|
||||||
|
fs.appendFileSync(LOG_PATH, head + body + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Initialize subsystems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const providerStates = initProviderStates(appConfig.providers, proxy.publicIpSeed);
|
||||||
|
|
||||||
|
initRegistrar(appConfig.devices, log);
|
||||||
|
|
||||||
|
const callManager = new CallManager({
|
||||||
|
appConfig,
|
||||||
|
sendSip: (buf, dest) => sock.send(buf, dest.port, dest.address),
|
||||||
|
log,
|
||||||
|
broadcastWs,
|
||||||
|
getProviderState: (id) => providerStates.get(id),
|
||||||
|
getAllBrowserDeviceIds,
|
||||||
|
sendToBrowserDevice,
|
||||||
|
getBrowserDeviceWs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize WebRTC signaling (browser device registration only).
|
||||||
|
initWebRtcSignaling({ log });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status snapshot (fed to web dashboard)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getStatus() {
|
||||||
|
const providers: unknown[] = [];
|
||||||
|
for (const ps of providerStates.values()) {
|
||||||
|
providers.push({
|
||||||
|
id: ps.config.id,
|
||||||
|
displayName: ps.config.displayName,
|
||||||
|
registered: ps.isRegistered,
|
||||||
|
publicIp: ps.publicIp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
instanceId,
|
||||||
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||||
|
lanIp: LAN_IP,
|
||||||
|
providers,
|
||||||
|
devices: getAllDeviceStatuses(),
|
||||||
|
calls: callManager.getStatus(),
|
||||||
|
callHistory: callManager.getHistory(),
|
||||||
|
contacts: appConfig.contacts || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main UDP socket
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const sock = dgram.createSocket('udp4');
|
||||||
|
|
||||||
|
sock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
|
||||||
|
try {
|
||||||
|
const ps = getProviderByUpstreamAddress(rinfo.address, rinfo.port);
|
||||||
|
|
||||||
|
const msg = SipMessage.parse(data);
|
||||||
|
if (!msg) {
|
||||||
|
// Non-SIP data — forward raw based on direction.
|
||||||
|
if (ps) {
|
||||||
|
// From provider, forward to... nowhere useful without a call context.
|
||||||
|
logPacket(`UP->DN RAW (unparsed) from ${rinfo.address}:${rinfo.port}`, data);
|
||||||
|
} else {
|
||||||
|
// From device, forward to default provider.
|
||||||
|
const dp = getProviderForOutbound(appConfig);
|
||||||
|
if (dp) sock.send(data, dp.outboundProxy.port, dp.outboundProxy.address);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Provider registration responses — consumed by providerstate.
|
||||||
|
if (handleProviderRegistrationResponse(msg)) return;
|
||||||
|
|
||||||
|
// 2. Device REGISTER — handled by local registrar.
|
||||||
|
if (!ps && msg.method === 'REGISTER') {
|
||||||
|
const response = handleDeviceRegister(msg, { address: rinfo.address, port: rinfo.port });
|
||||||
|
if (response) {
|
||||||
|
sock.send(response.serialize(), rinfo.port, rinfo.address);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Route to existing call by SIP Call-ID.
|
||||||
|
if (callManager.routeSipMessage(msg, rinfo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. New inbound call from provider.
|
||||||
|
if (ps && msg.isRequest && msg.method === 'INVITE') {
|
||||||
|
logPacket(`[new inbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
|
||||||
|
|
||||||
|
// Detect public IP from Via.
|
||||||
|
const via = msg.getHeader('Via');
|
||||||
|
if (via) ps.detectPublicIp(via);
|
||||||
|
|
||||||
|
callManager.createInboundCall(ps, msg, { address: rinfo.address, port: rinfo.port });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. New outbound call from device (passthrough).
|
||||||
|
if (!ps && msg.isRequest && msg.method === 'INVITE') {
|
||||||
|
logPacket(`[new outbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
|
||||||
|
const provider = getProviderForOutbound(appConfig);
|
||||||
|
if (provider) {
|
||||||
|
const provState = providerStates.get(provider.id);
|
||||||
|
if (provState) {
|
||||||
|
callManager.handlePassthroughOutbound(msg, { address: rinfo.address, port: rinfo.port }, provider, provState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Fallback: forward based on direction (for mid-dialog messages
|
||||||
|
// that don't match any tracked call, e.g. OPTIONS, NOTIFY).
|
||||||
|
if (ps) {
|
||||||
|
// From provider -> forward to device.
|
||||||
|
logPacket(`[fallback inbound] from ${rinfo.address}:${rinfo.port}`, data);
|
||||||
|
const via = msg.getHeader('Via');
|
||||||
|
if (via) ps.detectPublicIp(via);
|
||||||
|
// Try to figure out where to send it...
|
||||||
|
// For now, just log. These should become rare once all calls are tracked.
|
||||||
|
log(`[fallback] unrouted inbound ${msg.isRequest ? msg.method : msg.statusCode} Call-ID=${msg.callId.slice(0, 30)}`);
|
||||||
|
} else {
|
||||||
|
// From device -> forward to provider.
|
||||||
|
logPacket(`[fallback outbound] from ${rinfo.address}:${rinfo.port}`, data);
|
||||||
|
const provider = getProviderForOutbound(appConfig);
|
||||||
|
if (provider) sock.send(msg.serialize(), provider.outboundProxy.port, provider.outboundProxy.address);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
log(`[err] ${e?.stack || e}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sock.on('error', (err: Error) => log(`[main] sock err: ${err.message}`));
|
||||||
|
|
||||||
|
sock.bind(LAN_PORT, '0.0.0.0', () => {
|
||||||
|
const providerList = appConfig.providers.map((p) => p.displayName).join(', ');
|
||||||
|
const deviceList = appConfig.devices.map((d) => d.displayName).join(', ');
|
||||||
|
log(`sip proxy bound 0.0.0.0:${LAN_PORT} | providers: ${providerList} | devices: ${deviceList}`);
|
||||||
|
|
||||||
|
// Start upstream provider registrations.
|
||||||
|
for (const ps of providerStates.values()) {
|
||||||
|
ps.startRegistration(
|
||||||
|
LAN_IP,
|
||||||
|
LAN_PORT,
|
||||||
|
(buf, dest) => sock.send(buf, dest.port, dest.address),
|
||||||
|
log,
|
||||||
|
(provider) => broadcastWs('registration', { providerId: provider.config.id, registered: provider.isRegistered }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize audio codec bridge (Rust binary via smartrust).
|
||||||
|
initCodecBridge(log)
|
||||||
|
.then(() => initAnnouncement(log))
|
||||||
|
.catch((e) => log(`[codec] init failed: ${e}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Web UI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
initWebUi(
|
||||||
|
getStatus,
|
||||||
|
log,
|
||||||
|
(number, deviceId, providerId) => {
|
||||||
|
const call = callManager.createOutboundCall(number, deviceId, providerId);
|
||||||
|
return call ? { id: call.id } : null;
|
||||||
|
},
|
||||||
|
(callId) => callManager.hangup(callId),
|
||||||
|
() => {
|
||||||
|
// Reload config after UI save.
|
||||||
|
try {
|
||||||
|
const fresh = loadConfig();
|
||||||
|
Object.assign(appConfig, fresh);
|
||||||
|
// Sync provider registrations: add new, remove deleted, re-register changed.
|
||||||
|
syncProviderStates(
|
||||||
|
fresh.providers,
|
||||||
|
proxy.publicIpSeed,
|
||||||
|
LAN_IP,
|
||||||
|
LAN_PORT,
|
||||||
|
(buf, dest) => sock.send(buf, dest.port, dest.address),
|
||||||
|
log,
|
||||||
|
(provider) => broadcastWs('registration', { providerId: provider.config.id, registered: provider.isRegistered }),
|
||||||
|
);
|
||||||
|
log('[config] reloaded config after save');
|
||||||
|
} catch (e: any) {
|
||||||
|
log(`[config] reload failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
callManager,
|
||||||
|
);
|
||||||
|
|
||||||
|
process.on('SIGINT', () => { log('SIGINT, exiting'); process.exit(0); });
|
||||||
|
process.on('SIGTERM', () => { log('SIGTERM, exiting'); process.exit(0); });
|
||||||
124
ts/webrtcbridge.ts
Normal file
124
ts/webrtcbridge.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* WebRTC signaling — browser device registration and WebSocket dispatch.
|
||||||
|
*
|
||||||
|
* This module handles ONLY the signaling side:
|
||||||
|
* - 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import { registerBrowserDevice, unregisterBrowserDevice, shortHash } from './registrar.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface IWebRtcSignalingConfig {
|
||||||
|
log: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config: IWebRtcSignalingConfig;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State: WS ↔ deviceId mapping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const wsToSession = new WeakMap<WebSocket, string>();
|
||||||
|
const deviceIdToWs = new Map<string, WebSocket>();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function initWebRtcSignaling(cfg: IWebRtcSignalingConfig): void {
|
||||||
|
config = cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a WebRTC signaling message from a browser client.
|
||||||
|
* Only handles registration; offer/ice/hangup are routed through CallManager.
|
||||||
|
*/
|
||||||
|
export function handleWebRtcSignaling(
|
||||||
|
ws: WebSocket,
|
||||||
|
message: { type: string; sessionId?: string; [key: string]: any },
|
||||||
|
): void {
|
||||||
|
const { type } = message;
|
||||||
|
|
||||||
|
if (type === 'webrtc-register') {
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a specific browser device by its device ID.
|
||||||
|
*/
|
||||||
|
export function sendToBrowserDevice(deviceId: string, data: unknown): boolean {
|
||||||
|
const ws = deviceIdToWs.get(deviceId);
|
||||||
|
if (!ws) return false;
|
||||||
|
wsSend(ws, data);
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function getAllBrowserDeviceIds(): string[] {
|
||||||
|
return [...deviceIdToWs.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Registration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function handleRegister(ws: WebSocket, sessionId: string, userAgent?: string, remoteIp?: string): void {
|
||||||
|
// Clean up any previous browser device from this same WS connection.
|
||||||
|
const prevSession = wsToSession.get(ws);
|
||||||
|
if (prevSession && prevSession !== sessionId) {
|
||||||
|
unregisterBrowserDevice(prevSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterBrowserDevice(sessionId);
|
||||||
|
registerBrowserDevice(sessionId, userAgent, remoteIp);
|
||||||
|
wsToSession.set(ws, sessionId);
|
||||||
|
config.log(`[webrtc:${sessionId.slice(0, 8)}] browser registered as device`);
|
||||||
|
|
||||||
|
const deviceId = `browser-${shortHash(sessionId)}`;
|
||||||
|
deviceIdToWs.set(deviceId, ws);
|
||||||
|
|
||||||
|
// Echo back the assigned device ID.
|
||||||
|
wsSend(ws, { type: 'webrtc-registered', deviceId });
|
||||||
|
|
||||||
|
// Only add close handler once per WS connection.
|
||||||
|
if (!prevSession) {
|
||||||
|
ws.on('close', () => {
|
||||||
|
const sid = wsToSession.get(ws) || sessionId;
|
||||||
|
config.log(`[webrtc:${sid.slice(0, 8)}] browser disconnected`);
|
||||||
|
deviceIdToWs.delete(`browser-${shortHash(sid)}`);
|
||||||
|
unregisterBrowserDevice(sid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function wsSend(ws: WebSocket, data: unknown): void {
|
||||||
|
try {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
8
ts_web/00_commitinfo_data.ts
Normal file
8
ts_web/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
|
*/
|
||||||
|
export const commitinfo = {
|
||||||
|
name: 'siprouter',
|
||||||
|
version: '1.8.0',
|
||||||
|
description: 'undefined'
|
||||||
|
}
|
||||||
11
ts_web/elements/index.ts
Normal file
11
ts_web/elements/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Views
|
||||||
|
export * from './sipproxy-app.js';
|
||||||
|
export * from './sipproxy-view-overview.js';
|
||||||
|
export * from './sipproxy-view-calls.js';
|
||||||
|
export * from './sipproxy-view-phone.js';
|
||||||
|
export * from './sipproxy-view-contacts.js';
|
||||||
|
export * from './sipproxy-view-providers.js';
|
||||||
|
export * from './sipproxy-view-log.js';
|
||||||
|
|
||||||
|
// Sub-components (used within views)
|
||||||
|
export * from './sipproxy-devices.js';
|
||||||
10
ts_web/elements/shared/css.ts
Normal file
10
ts_web/elements/shared/css.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { css } from '../../plugins.js';
|
||||||
|
|
||||||
|
export const viewHostCss = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
margin: auto;
|
||||||
|
max-width: 1280px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
1
ts_web/elements/shared/index.ts
Normal file
1
ts_web/elements/shared/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { viewHostCss } from './css.js';
|
||||||
90
ts_web/elements/sipproxy-app.ts
Normal file
90
ts_web/elements/sipproxy-app.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||||
|
import { deesCatalog } from '../plugins.js';
|
||||||
|
import { NotificationManager } from '../state/notification-manager.js';
|
||||||
|
import { appRouter } from '../router.js';
|
||||||
|
import { SipproxyViewOverview } from './sipproxy-view-overview.js';
|
||||||
|
import { SipproxyViewCalls } from './sipproxy-view-calls.js';
|
||||||
|
import { SipproxyViewPhone } from './sipproxy-view-phone.js';
|
||||||
|
import { SipproxyViewContacts } from './sipproxy-view-contacts.js';
|
||||||
|
import { SipproxyViewProviders } from './sipproxy-view-providers.js';
|
||||||
|
import { SipproxyViewLog } from './sipproxy-view-log.js';
|
||||||
|
|
||||||
|
const VIEW_TABS = [
|
||||||
|
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: SipproxyViewOverview },
|
||||||
|
{ name: 'Calls', iconName: 'lucide:phone', element: SipproxyViewCalls },
|
||||||
|
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
|
||||||
|
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
|
||||||
|
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
||||||
|
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map slug -> tab for routing.
|
||||||
|
const SLUG_TO_TAB = new Map(VIEW_TABS.map((t) => [t.name.toLowerCase(), t]));
|
||||||
|
|
||||||
|
@customElement('sipproxy-app')
|
||||||
|
export class SipproxyApp extends DeesElement {
|
||||||
|
private notificationManager = new NotificationManager();
|
||||||
|
private appdash: InstanceType<typeof deesCatalog.DeesSimpleAppDash> | null = null;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host { display: block; height: 100%; }
|
||||||
|
dees-simple-appdash { height: 100%; }
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
private suppressViewSelectEvent = false;
|
||||||
|
|
||||||
|
async firstUpdated() {
|
||||||
|
this.appdash = this.shadowRoot?.querySelector('dees-simple-appdash') as InstanceType<typeof deesCatalog.DeesSimpleAppDash>;
|
||||||
|
if (this.appdash) {
|
||||||
|
this.notificationManager.init(this.appdash);
|
||||||
|
|
||||||
|
// Listen for user tab selections — sync URL.
|
||||||
|
this.appdash.addEventListener('view-select', ((e: CustomEvent) => {
|
||||||
|
if (this.suppressViewSelectEvent) return;
|
||||||
|
const viewName: string = e.detail?.view?.name || e.detail?.name || '';
|
||||||
|
const slug = viewName.toLowerCase();
|
||||||
|
if (slug && slug !== appRouter.getCurrentView()) {
|
||||||
|
appRouter.navigateTo(slug as any, true);
|
||||||
|
}
|
||||||
|
}) as EventListener);
|
||||||
|
|
||||||
|
// Wire up router -> appdash (for browser back/forward).
|
||||||
|
appRouter.setNavigateHandler((view) => {
|
||||||
|
const tab = SLUG_TO_TAB.get(view);
|
||||||
|
if (tab && this.appdash) {
|
||||||
|
this.suppressViewSelectEvent = true;
|
||||||
|
this.appdash.loadView(tab);
|
||||||
|
this.suppressViewSelectEvent = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deep link: if URL isn't "overview", navigate to the right tab.
|
||||||
|
const initial = appRouter.getCurrentView();
|
||||||
|
if (initial !== 'overview') {
|
||||||
|
const tab = SLUG_TO_TAB.get(initial);
|
||||||
|
if (tab) {
|
||||||
|
this.suppressViewSelectEvent = true;
|
||||||
|
this.appdash.loadView(tab);
|
||||||
|
this.suppressViewSelectEvent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.notificationManager.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<dees-simple-appdash
|
||||||
|
.name=${'SipRouter'}
|
||||||
|
.viewTabs=${VIEW_TABS}
|
||||||
|
></dees-simple-appdash>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
ts_web/elements/sipproxy-devices.ts
Normal file
56
ts_web/elements/sipproxy-devices.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { DeesElement, customElement, html, css, cssManager, property, type TemplateResult } from '../plugins.js';
|
||||||
|
import type { IDeviceStatus } from '../state/appstate.js';
|
||||||
|
|
||||||
|
@customElement('sipproxy-devices')
|
||||||
|
export class SipproxyDevices extends DeesElement {
|
||||||
|
@property({ type: Array }) accessor devices: IDeviceStatus[] = [];
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host { display: block; margin-bottom: 1.5rem; }
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<dees-table
|
||||||
|
heading1="Devices"
|
||||||
|
heading2="${this.devices.length} registered"
|
||||||
|
dataName="devices"
|
||||||
|
.data=${this.devices}
|
||||||
|
.rowKey=${'id'}
|
||||||
|
highlight-updates="flash"
|
||||||
|
.searchable=${false}
|
||||||
|
.columns=${[
|
||||||
|
{
|
||||||
|
key: 'displayName',
|
||||||
|
header: 'Device',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
value: (row: any) => (row.connected ? 'Connected' : 'Disconnected'),
|
||||||
|
renderer: (val: string, row: any) => {
|
||||||
|
const on = row.connected;
|
||||||
|
return html`
|
||||||
|
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;${on ? 'background:#4ade80;box-shadow:0 0 6px #4ade80' : 'background:#f87171;box-shadow:0 0 6px #f87171'}"></span>
|
||||||
|
<span style="color:${on ? '#4ade80' : '#f87171'}">${val}</span>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contact',
|
||||||
|
header: 'Contact',
|
||||||
|
renderer: (_val: any, row: any) => {
|
||||||
|
const c = row.contact;
|
||||||
|
const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--';
|
||||||
|
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
670
ts_web/elements/sipproxy-view-calls.ts
Normal file
670
ts_web/elements/sipproxy-view-calls.ts
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||||
|
import { deesCatalog } from '../plugins.js';
|
||||||
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
import { appState, type IAppState, type ICallStatus, type ICallHistoryEntry, type ILegStatus, type IDeviceStatus } from '../state/appstate.js';
|
||||||
|
import { viewHostCss } from './shared/index.js';
|
||||||
|
|
||||||
|
const STATE_LABELS: Record<string, string> = {
|
||||||
|
'setting-up': 'Setting Up',
|
||||||
|
'ringing': 'Ringing',
|
||||||
|
'connected': 'Connected',
|
||||||
|
'on-hold': 'On Hold',
|
||||||
|
'transferring': 'Transferring',
|
||||||
|
'terminating': 'Hanging Up',
|
||||||
|
'terminated': 'Ended',
|
||||||
|
};
|
||||||
|
|
||||||
|
function stateBadgeStyle(s: string): string {
|
||||||
|
if (s.includes('ringing') || s === 'setting-up') return 'background:#854d0e;color:#fbbf24';
|
||||||
|
if (s === 'connected') return 'background:#166534;color:#4ade80';
|
||||||
|
if (s === 'on-hold') return 'background:#1e3a5f;color:#38bdf8';
|
||||||
|
return 'background:#7f1d1d;color:#f87171';
|
||||||
|
}
|
||||||
|
|
||||||
|
function legTypeBadgeStyle(type: string): string {
|
||||||
|
if (type === 'sip-device') return 'background:#1e3a5f;color:#38bdf8';
|
||||||
|
if (type === 'sip-provider') return 'background:#4a1d7a;color:#c084fc';
|
||||||
|
if (type === 'webrtc') return 'background:#065f46;color:#34d399';
|
||||||
|
return 'background:#374151;color:#9ca3af';
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEG_TYPE_LABELS: Record<string, string> = {
|
||||||
|
'sip-device': 'SIP Device',
|
||||||
|
'sip-provider': 'SIP Provider',
|
||||||
|
'webrtc': 'WebRTC',
|
||||||
|
};
|
||||||
|
|
||||||
|
function directionIcon(dir: string): string {
|
||||||
|
if (dir === 'inbound') return '\u2199';
|
||||||
|
if (dir === 'outbound') return '\u2197';
|
||||||
|
return '\u2194';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
if (m > 0) return `${m}m ${String(s).padStart(2, '0')}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ts: number): string {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('sipproxy-view-calls')
|
||||||
|
export class SipproxyViewCalls extends DeesElement {
|
||||||
|
@state() accessor appData: IAppState = appState.getState();
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calls-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active call tile content */
|
||||||
|
.call-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--dees-color-bg-primary, #0f172a);
|
||||||
|
border: 1px solid var(--dees-color-border-default, #334155);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-parties {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-parties .label {
|
||||||
|
color: var(--dees-color-text-secondary, #64748b);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-duration {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--dees-color-text-secondary, #94a3b8);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-id {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #475569;
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-body {
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legs-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legs-table th {
|
||||||
|
text-align: left;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--dees-color-border-default, #334155);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legs-table td {
|
||||||
|
padding: 8px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border-bottom: 1px solid var(--dees-color-border-subtle, rgba(51, 65, 85, 0.5));
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legs-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { opacity: 0.85; }
|
||||||
|
.btn:active { opacity: 0.7; }
|
||||||
|
.btn-danger { background: #dc2626; color: #fff; }
|
||||||
|
.btn-primary { background: #2563eb; color: #fff; }
|
||||||
|
.btn-secondary { background: #334155; color: #e2e8f0; }
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
background: transparent;
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.empty-state-icon { font-size: 2.5rem; margin-bottom: 12px; opacity: 0.4; }
|
||||||
|
.empty-state-text { font-size: 0.9rem; font-weight: 500; }
|
||||||
|
.empty-state-sub { font-size: 0.75rem; margin-top: 4px; }
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.rxSubscriptions.push({
|
||||||
|
unsubscribe: appState.subscribe((s) => {
|
||||||
|
this.appData = s;
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAddParticipant(call: ICallStatus) {
|
||||||
|
const devices = this.appData.devices?.filter((d) => d.connected) || [];
|
||||||
|
let selectedDeviceId = devices.length > 0 ? devices[0].id : '';
|
||||||
|
|
||||||
|
await deesCatalog.DeesModal.createAndShow({
|
||||||
|
heading: 'Add Participant',
|
||||||
|
width: 'small',
|
||||||
|
showCloseButton: true,
|
||||||
|
content: html`
|
||||||
|
<div style="padding: 0.5rem 0;">
|
||||||
|
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
||||||
|
Select Device
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.85rem;outline:none;box-sizing:border-box;"
|
||||||
|
@change=${(e: Event) => {
|
||||||
|
selectedDeviceId = (e.target as HTMLSelectElement).value;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${devices.map(
|
||||||
|
(d) => html`<option value=${d.id}>${d.displayName}${d.isBrowser ? ' (Browser)' : ''}</option>`,
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
${devices.length === 0
|
||||||
|
? html`<div style="color:#f87171;font-size:.75rem;margin-top:.5rem;">No connected devices available.</div>`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Add',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
action: async (modalRef: any) => {
|
||||||
|
if (!selectedDeviceId) return;
|
||||||
|
const res = await appState.apiAddLeg(call.id, selectedDeviceId);
|
||||||
|
if (res.ok) {
|
||||||
|
deesCatalog.DeesToast.success('Participant added');
|
||||||
|
} else {
|
||||||
|
deesCatalog.DeesToast.error('Failed to add participant');
|
||||||
|
}
|
||||||
|
modalRef.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalRef: any) => {
|
||||||
|
modalRef.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAddExternal(call: ICallStatus) {
|
||||||
|
const providers = this.appData.providers?.filter((p) => p.registered) || [];
|
||||||
|
let number = '';
|
||||||
|
let selectedProviderId = providers.length > 0 ? providers[0].id : '';
|
||||||
|
|
||||||
|
await deesCatalog.DeesModal.createAndShow({
|
||||||
|
heading: 'Add External Participant',
|
||||||
|
width: 'small',
|
||||||
|
showCloseButton: true,
|
||||||
|
content: html`
|
||||||
|
<div style="padding: 0.5rem 0;">
|
||||||
|
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
||||||
|
Phone Number
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
placeholder="Enter number to dial..."
|
||||||
|
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.95rem;font-family:'JetBrains Mono',monospace;outline:none;box-sizing:border-box;margin-bottom:12px;"
|
||||||
|
@input=${(e: InputEvent) => { number = (e.target as HTMLInputElement).value; }}
|
||||||
|
>
|
||||||
|
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
||||||
|
Via Provider
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.85rem;outline:none;box-sizing:border-box;"
|
||||||
|
@change=${(e: Event) => { selectedProviderId = (e.target as HTMLSelectElement).value; }}
|
||||||
|
>
|
||||||
|
${providers.map(
|
||||||
|
(p) => html`<option value=${p.id}>${p.displayName}</option>`,
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
${providers.length === 0
|
||||||
|
? html`<div style="color:#f87171;font-size:.75rem;margin-top:.5rem;">No registered providers available.</div>`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Dial',
|
||||||
|
iconName: 'lucide:phoneOutgoing',
|
||||||
|
action: async (modalRef: any) => {
|
||||||
|
if (!number.trim()) {
|
||||||
|
deesCatalog.DeesToast.error('Enter a phone number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await appState.apiAddExternal(call.id, number.trim(), selectedProviderId || undefined);
|
||||||
|
if (res.ok) {
|
||||||
|
deesCatalog.DeesToast.success(`Dialing ${number}...`);
|
||||||
|
} else {
|
||||||
|
deesCatalog.DeesToast.error('Failed to dial external number');
|
||||||
|
}
|
||||||
|
modalRef.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalRef: any) => {
|
||||||
|
modalRef.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRemoveLeg(call: ICallStatus, leg: ILegStatus) {
|
||||||
|
const res = await appState.apiRemoveLeg(call.id, leg.id);
|
||||||
|
if (res.ok) {
|
||||||
|
deesCatalog.DeesToast.success('Leg removed');
|
||||||
|
} else {
|
||||||
|
deesCatalog.DeesToast.error('Failed to remove leg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTransfer(call: ICallStatus) {
|
||||||
|
let targetCallId = '';
|
||||||
|
let targetLegId = '';
|
||||||
|
|
||||||
|
const otherCalls =
|
||||||
|
this.appData.calls?.filter((c) => c.id !== call.id && c.state !== 'terminated') || [];
|
||||||
|
|
||||||
|
await deesCatalog.DeesModal.createAndShow({
|
||||||
|
heading: 'Transfer Call',
|
||||||
|
width: 'small',
|
||||||
|
showCloseButton: true,
|
||||||
|
content: html`
|
||||||
|
<div style="padding: 0.5rem 0;">
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
||||||
|
Target Call ID
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.85rem;outline:none;box-sizing:border-box;"
|
||||||
|
@change=${(e: Event) => {
|
||||||
|
targetCallId = (e.target as HTMLSelectElement).value;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select a call...</option>
|
||||||
|
${otherCalls.map(
|
||||||
|
(c) =>
|
||||||
|
html`<option value=${c.id}>
|
||||||
|
${c.direction} - ${c.callerNumber || '?'} -> ${c.calleeNumber || '?'}
|
||||||
|
</option>`,
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
||||||
|
Leg ID to transfer
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.85rem;outline:none;box-sizing:border-box;"
|
||||||
|
@change=${(e: Event) => {
|
||||||
|
targetLegId = (e.target as HTMLSelectElement).value;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select a leg...</option>
|
||||||
|
${call.legs.map(
|
||||||
|
(l) =>
|
||||||
|
html`<option value=${l.id}>${LEG_TYPE_LABELS[l.type] || l.type} - ${l.state}</option>`,
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
${otherCalls.length === 0
|
||||||
|
? html`<div style="color:#f87171;font-size:.75rem;margin-top:.75rem;">
|
||||||
|
No other active calls to transfer to.
|
||||||
|
</div>`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Transfer',
|
||||||
|
iconName: 'lucide:arrow-right-left',
|
||||||
|
action: async (modalRef: any) => {
|
||||||
|
if (!targetCallId || !targetLegId) {
|
||||||
|
deesCatalog.DeesToast.error('Please select both a target call and a leg');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await appState.apiTransfer(call.id, targetLegId, targetCallId);
|
||||||
|
if (res.ok) {
|
||||||
|
deesCatalog.DeesToast.success('Transfer initiated');
|
||||||
|
} else {
|
||||||
|
deesCatalog.DeesToast.error('Transfer failed');
|
||||||
|
}
|
||||||
|
modalRef.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalRef: any) => {
|
||||||
|
modalRef.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleHangup(call: ICallStatus) {
|
||||||
|
await appState.apiHangup(call.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHistoryColumns() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'direction',
|
||||||
|
header: 'Direction',
|
||||||
|
renderer: (val: string) => {
|
||||||
|
return html`<span style="margin-right:6px">${directionIcon(val)}</span><span class="badge" style="background:#374151;color:#9ca3af">${val}</span>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'callerNumber',
|
||||||
|
header: 'From',
|
||||||
|
renderer: (val: string | null) =>
|
||||||
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.8rem">${val || '-'}</span>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'calleeNumber',
|
||||||
|
header: 'To',
|
||||||
|
renderer: (val: string | null) =>
|
||||||
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.8rem">${val || '-'}</span>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'providerUsed',
|
||||||
|
header: 'Provider',
|
||||||
|
renderer: (val: string | null) => val || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'startedAt',
|
||||||
|
header: 'Time',
|
||||||
|
renderer: (val: number) =>
|
||||||
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${fmtTime(val)}</span>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'duration',
|
||||||
|
header: 'Duration',
|
||||||
|
renderer: (val: number) =>
|
||||||
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${fmtDuration(val)}</span>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCallCard(call: ICallStatus): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<dees-tile>
|
||||||
|
<div slot="header">
|
||||||
|
<div class="call-header">
|
||||||
|
<div class="direction-icon">${directionIcon(call.direction)}</div>
|
||||||
|
<div class="call-parties">
|
||||||
|
${call.callerNumber
|
||||||
|
? html`<span class="label">From</span>${call.callerNumber}`
|
||||||
|
: ''}
|
||||||
|
${call.callerNumber && call.calleeNumber ? html` → ` : ''}
|
||||||
|
${call.calleeNumber
|
||||||
|
? html`<span class="label">To</span>${call.calleeNumber}`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
<div class="call-meta">
|
||||||
|
<span class="badge" style="${stateBadgeStyle(call.state)}">
|
||||||
|
${STATE_LABELS[call.state] || call.state}
|
||||||
|
</span>
|
||||||
|
${call.providerUsed
|
||||||
|
? html`<span class="badge" style="background:#374151;color:#9ca3af">${call.providerUsed}</span>`
|
||||||
|
: ''}
|
||||||
|
<span class="call-duration">${fmtDuration(call.duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="call-id">${call.id}</div>
|
||||||
|
<div class="call-body">
|
||||||
|
${call.legs.length
|
||||||
|
? html`
|
||||||
|
<table class="legs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Remote</th>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>Codec</th>
|
||||||
|
<th>Pkts In</th>
|
||||||
|
<th>Pkts Out</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${call.legs.map(
|
||||||
|
(leg) => html`
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="badge" style="${legTypeBadgeStyle(leg.type)}">
|
||||||
|
${LEG_TYPE_LABELS[leg.type] || leg.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" style="${stateBadgeStyle(leg.state)}">
|
||||||
|
${leg.state}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
${leg.remoteMedia
|
||||||
|
? `${leg.remoteMedia.address}:${leg.remoteMedia.port}`
|
||||||
|
: '--'}
|
||||||
|
</td>
|
||||||
|
<td>${leg.rtpPort ?? '--'}</td>
|
||||||
|
<td>
|
||||||
|
${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}
|
||||||
|
</td>
|
||||||
|
<td>${leg.pktReceived}</td>
|
||||||
|
<td>${leg.pktSent}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="btn btn-remove"
|
||||||
|
@click=${() => this.handleRemoveLeg(call, leg)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
: html`<div style="color:#64748b;font-size:.75rem;font-style:italic;margin-bottom:8px;">
|
||||||
|
No legs
|
||||||
|
</div>`}
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-primary" @click=${() => this.handleAddParticipant(call)}>
|
||||||
|
Add Device
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" @click=${() => this.handleAddExternal(call)}>
|
||||||
|
Add External
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click=${() => this.handleTransfer(call)}>
|
||||||
|
Transfer
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" @click=${() => this.handleHangup(call)}>
|
||||||
|
Hang Up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dees-tile>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const { appData } = this;
|
||||||
|
const activeCalls = appData.calls?.filter((c) => c.state !== 'terminated') || [];
|
||||||
|
const history = appData.callHistory || [];
|
||||||
|
const inboundCount = activeCalls.filter((c) => c.direction === 'inbound').length;
|
||||||
|
const outboundCount = activeCalls.filter((c) => c.direction === 'outbound').length;
|
||||||
|
|
||||||
|
const tiles: IStatsTile[] = [
|
||||||
|
{
|
||||||
|
id: 'active',
|
||||||
|
title: 'Active Calls',
|
||||||
|
value: activeCalls.length,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:phone',
|
||||||
|
color: 'hsl(142.1 76.2% 36.3%)',
|
||||||
|
description: activeCalls.length === 1 ? '1 call' : `${activeCalls.length} calls`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inbound',
|
||||||
|
title: 'Inbound',
|
||||||
|
value: inboundCount,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:phone-incoming',
|
||||||
|
description: 'Incoming calls',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'outbound',
|
||||||
|
title: 'Outbound',
|
||||||
|
value: outboundCount,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:phone-outgoing',
|
||||||
|
description: 'Outgoing calls',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="view-section">
|
||||||
|
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-section">
|
||||||
|
<div class="calls-list">
|
||||||
|
${activeCalls.length > 0
|
||||||
|
? activeCalls.map((call) => this.renderCallCard(call))
|
||||||
|
: html`
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">📞</div>
|
||||||
|
<div class="empty-state-text">No active calls</div>
|
||||||
|
<div class="empty-state-sub">
|
||||||
|
Calls will appear here when they are in progress
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-section">
|
||||||
|
<dees-table
|
||||||
|
heading1="Call History"
|
||||||
|
heading2="${history.length} calls"
|
||||||
|
dataName="calls"
|
||||||
|
.data=${history}
|
||||||
|
.rowKey=${'id'}
|
||||||
|
.searchable=${true}
|
||||||
|
.columns=${this.getHistoryColumns()}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
372
ts_web/elements/sipproxy-view-contacts.ts
Normal file
372
ts_web/elements/sipproxy-view-contacts.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||||
|
import { deesCatalog } from '../plugins.js';
|
||||||
|
import { appState, type IAppState, type IContact } from '../state/appstate.js';
|
||||||
|
import { appRouter } from '../router.js';
|
||||||
|
import { viewHostCss } from './shared/index.js';
|
||||||
|
import type { IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
@customElement('sipproxy-view-contacts')
|
||||||
|
export class SipproxyViewContacts extends DeesElement {
|
||||||
|
@state() accessor appData: IAppState = appState.getState();
|
||||||
|
@state() accessor saving = false;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.view-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.rxSubscriptions.push({
|
||||||
|
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- CRUD operations ----------
|
||||||
|
|
||||||
|
private async openAddModal() {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
const formData = { name: '', number: '', company: '', notes: '' };
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Add Contact',
|
||||||
|
content: html`
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||||
|
<dees-input-text
|
||||||
|
.label=${'Name'}
|
||||||
|
.required=${true}
|
||||||
|
.value=${''}
|
||||||
|
@input=${(e: Event) => { formData.name = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.label=${'Number'}
|
||||||
|
.required=${true}
|
||||||
|
.value=${''}
|
||||||
|
@input=${(e: Event) => { formData.number = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.label=${'Company'}
|
||||||
|
.value=${''}
|
||||||
|
@input=${(e: Event) => { formData.company = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.label=${'Notes'}
|
||||||
|
.value=${''}
|
||||||
|
@input=${(e: Event) => { formData.notes = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
action: async (modal) => { modal.destroy(); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Add Contact',
|
||||||
|
action: async (modal) => {
|
||||||
|
const name = formData.name.trim();
|
||||||
|
const number = formData.number.trim();
|
||||||
|
|
||||||
|
if (!name || !number) {
|
||||||
|
deesCatalog.DeesToast.error('Name and number are required', 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newContact: IContact = {
|
||||||
|
id: `contact-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name,
|
||||||
|
number,
|
||||||
|
company: formData.company.trim() || undefined,
|
||||||
|
notes: formData.notes.trim() || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.saveContacts([...this.appData.contacts, newContact]);
|
||||||
|
modal.destroy();
|
||||||
|
deesCatalog.DeesToast.info('Contact added');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openEditModal(contact: IContact) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
name: contact.name,
|
||||||
|
number: contact.number,
|
||||||
|
company: contact.company || '',
|
||||||
|
notes: contact.notes || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Edit Contact',
|
||||||
|
content: html`
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||||
|
<dees-input-text
|
||||||
|
.label=${'Name'}
|
||||||
|
.required=${true}
|
||||||
|
.value=${formData.name}
|
||||||
|
@input=${(e: Event) => { formData.name = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.label=${'Number'}
|
||||||
|
.required=${true}
|
||||||
|
.value=${formData.number}
|
||||||
|
@input=${(e: Event) => { formData.number = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.label=${'Company'}
|
||||||
|
.value=${formData.company}
|
||||||
|
@input=${(e: Event) => { formData.company = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.label=${'Notes'}
|
||||||
|
.value=${formData.notes}
|
||||||
|
@input=${(e: Event) => { formData.notes = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
action: async (modal) => { modal.destroy(); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Save Changes',
|
||||||
|
action: async (modal) => {
|
||||||
|
const name = formData.name.trim();
|
||||||
|
const number = formData.number.trim();
|
||||||
|
|
||||||
|
if (!name || !number) {
|
||||||
|
deesCatalog.DeesToast.error('Name and number are required', 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedContacts = this.appData.contacts.map((c) =>
|
||||||
|
c.id === contact.id
|
||||||
|
? { ...c, name, number, company: formData.company.trim() || undefined, notes: formData.notes.trim() || undefined }
|
||||||
|
: c,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.saveContacts(updatedContacts);
|
||||||
|
modal.destroy();
|
||||||
|
deesCatalog.DeesToast.info('Contact updated');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteContact(contact: IContact) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Delete Contact',
|
||||||
|
content: html`
|
||||||
|
<p style="color: #e2e8f0; margin: 0 0 8px;">
|
||||||
|
Are you sure you want to delete <strong>${contact.name}</strong>?
|
||||||
|
</p>
|
||||||
|
<p style="color: #94a3b8; font-size: 0.85rem; margin: 0;">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
action: async (modal) => { modal.destroy(); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
action: async (modal) => {
|
||||||
|
const updatedContacts = this.appData.contacts.filter((c) => c.id !== contact.id);
|
||||||
|
await this.saveContacts(updatedContacts);
|
||||||
|
modal.destroy();
|
||||||
|
deesCatalog.DeesToast.info('Contact deleted');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toggleStar(contact: IContact) {
|
||||||
|
const updatedContacts = this.appData.contacts.map((c) =>
|
||||||
|
c.id === contact.id ? { ...c, starred: !c.starred } : c,
|
||||||
|
);
|
||||||
|
await this.saveContacts(updatedContacts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveContacts(contacts: IContact[]) {
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
const config = await appState.apiGetConfig();
|
||||||
|
config.contacts = contacts;
|
||||||
|
await appState.apiSaveConfig(config);
|
||||||
|
} catch (e: any) {
|
||||||
|
deesCatalog.DeesToast.error(`Save failed: ${e.message}`, 4000);
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getColumns() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'starred',
|
||||||
|
header: '',
|
||||||
|
sortable: false,
|
||||||
|
renderer: (val: boolean | undefined, row: IContact) => {
|
||||||
|
const starred = val === true;
|
||||||
|
return html`
|
||||||
|
<span
|
||||||
|
style="cursor:pointer;font-size:1.2rem;color:${starred ? '#fbbf24' : '#475569'};transition:color 0.15s;"
|
||||||
|
@click=${(e: Event) => { e.stopPropagation(); this.toggleStar(row); }}
|
||||||
|
>${starred ? '\u2605' : '\u2606'}</span>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'number',
|
||||||
|
header: 'Number',
|
||||||
|
renderer: (val: string) =>
|
||||||
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.85rem;letter-spacing:.02em">${val}</span>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'company',
|
||||||
|
header: 'Company',
|
||||||
|
renderer: (val: string | undefined) => val || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notes',
|
||||||
|
header: 'Notes',
|
||||||
|
renderer: (val: string | undefined) =>
|
||||||
|
html`<span style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;color:#94a3b8" title=${val || ''}>${val || '-'}</span>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDataActions() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Call',
|
||||||
|
iconName: 'lucide:phone' as any,
|
||||||
|
type: ['inRow'] as any,
|
||||||
|
actionFunc: async ({ item }: { item: IContact }) => {
|
||||||
|
appState.selectContact(item);
|
||||||
|
appRouter.navigateTo('phone' as any);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Star',
|
||||||
|
iconName: 'lucide:star' as any,
|
||||||
|
type: ['inRow'] as any,
|
||||||
|
actionFunc: async ({ item }: { item: IContact }) => {
|
||||||
|
await this.toggleStar(item);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Edit',
|
||||||
|
iconName: 'lucide:pencil' as any,
|
||||||
|
type: ['inRow'] as any,
|
||||||
|
actionFunc: async ({ item }: { item: IContact }) => {
|
||||||
|
await this.openEditModal(item);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2' as any,
|
||||||
|
type: ['inRow'] as any,
|
||||||
|
actionFunc: async ({ item }: { item: IContact }) => {
|
||||||
|
await this.deleteContact(item);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Add Contact',
|
||||||
|
iconName: 'lucide:plus' as any,
|
||||||
|
type: ['header'] as any,
|
||||||
|
actionFunc: async () => {
|
||||||
|
await this.openAddModal();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Render ----------
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const contacts = this.appData.contacts || [];
|
||||||
|
const companies = new Set(
|
||||||
|
contacts.map((c) => c.company?.trim()).filter((c) => c && c.length > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tiles: IStatsTile[] = [
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
title: 'Total Contacts',
|
||||||
|
value: contacts.length,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:contactRound',
|
||||||
|
description: contacts.length === 1 ? '1 contact' : `${contacts.length} contacts`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'starred',
|
||||||
|
title: 'Starred',
|
||||||
|
value: contacts.filter((c) => c.starred).length,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:star',
|
||||||
|
color: 'hsl(45 93% 47%)',
|
||||||
|
description: 'Quick-dial contacts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'companies',
|
||||||
|
title: 'Companies',
|
||||||
|
value: companies.size,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:building2',
|
||||||
|
description: `${companies.size} unique`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="view-section">
|
||||||
|
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-section">
|
||||||
|
<dees-table
|
||||||
|
heading1="Contacts"
|
||||||
|
heading2="${contacts.length} total"
|
||||||
|
dataName="contacts"
|
||||||
|
.data=${this.sortedContacts(contacts)}
|
||||||
|
.rowKey=${'id'}
|
||||||
|
.searchable=${true}
|
||||||
|
.columns=${this.getColumns()}
|
||||||
|
.dataActions=${this.getDataActions()}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortedContacts(contacts: IContact[]): IContact[] {
|
||||||
|
return [...contacts].sort((a, b) => {
|
||||||
|
if (a.starred && !b.starred) return -1;
|
||||||
|
if (!a.starred && b.starred) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
71
ts_web/elements/sipproxy-view-log.ts
Normal file
71
ts_web/elements/sipproxy-view-log.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||||
|
import { deesCatalog } from '../plugins.js';
|
||||||
|
import { appState, type IAppState } from '../state/appstate.js';
|
||||||
|
|
||||||
|
@customElement('sipproxy-view-log')
|
||||||
|
export class SipproxyViewLog extends DeesElement {
|
||||||
|
@state() accessor appData: IAppState = appState.getState();
|
||||||
|
|
||||||
|
private chartLog: any = null;
|
||||||
|
private lastLogCount = 0;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host { display: block; padding: 1rem; height: 100%; }
|
||||||
|
dees-chart-log { height: calc(100vh - 120px); }
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.rxSubscriptions.push({
|
||||||
|
unsubscribe: appState.subscribe((s) => {
|
||||||
|
const prev = this.appData;
|
||||||
|
this.appData = s;
|
||||||
|
this.pushNewLogs(prev.logLines, s.logLines);
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
firstUpdated() {
|
||||||
|
this.chartLog = this.shadowRoot?.querySelector('dees-chart-log');
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushNewLogs(oldLines: string[], newLines: string[]) {
|
||||||
|
if (!this.chartLog || !newLines.length) return;
|
||||||
|
|
||||||
|
// Only push lines that are new since last update.
|
||||||
|
const newCount = newLines.length;
|
||||||
|
if (newCount <= this.lastLogCount) return;
|
||||||
|
|
||||||
|
const fresh = newLines.slice(this.lastLogCount);
|
||||||
|
this.lastLogCount = newCount;
|
||||||
|
|
||||||
|
for (const line of fresh) {
|
||||||
|
const level = this.detectLevel(line);
|
||||||
|
this.chartLog.addLog(level, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectLevel(line: string): 'debug' | 'info' | 'warn' | 'error' | 'success' {
|
||||||
|
if (line.includes('[err]') || line.includes('error') || line.includes('ERR')) return 'error';
|
||||||
|
if (line.includes('WARN') || line.includes('warn')) return 'warn';
|
||||||
|
if (line.includes('registered') || line.includes('CONNECTED')) return 'success';
|
||||||
|
if (line.includes('[rtp') || line.includes('[detect]')) return 'debug';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<dees-chart-log
|
||||||
|
label="SIP Trace Log"
|
||||||
|
mode="structured"
|
||||||
|
.autoScroll=${true}
|
||||||
|
.maxEntries=${500}
|
||||||
|
.showMetrics=${true}
|
||||||
|
.highlightKeywords=${['REGISTER', 'INVITE', 'BYE', 'registered', 'error', 'CONNECTED']}
|
||||||
|
></dees-chart-log>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
207
ts_web/elements/sipproxy-view-overview.ts
Normal file
207
ts_web/elements/sipproxy-view-overview.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||||
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
import { appState, type IAppState } from '../state/appstate.js';
|
||||||
|
import { viewHostCss } from './shared/index.js';
|
||||||
|
|
||||||
|
@customElement('sipproxy-view-overview')
|
||||||
|
export class SipproxyViewOverview extends DeesElement {
|
||||||
|
@state() accessor appData: IAppState = appState.getState();
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #64748b;
|
||||||
|
margin: 32px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading:first-of-type {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.rxSubscriptions.push({
|
||||||
|
unsubscribe: appState.subscribe((s) => {
|
||||||
|
this.appData = s;
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fmtUptime(sec: number): string {
|
||||||
|
const d = Math.floor(sec / 86400);
|
||||||
|
const h = Math.floor((sec % 86400) / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||||
|
return `${h}h ${String(m).padStart(2, '0')}m ${String(s).padStart(2, '0')}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const { appData } = this;
|
||||||
|
const activeCalls = appData.calls?.filter((c) => c.state !== 'terminated') || [];
|
||||||
|
const activeCount = activeCalls.length;
|
||||||
|
const registeredProviders = appData.providers?.filter((p) => p.registered).length || 0;
|
||||||
|
const connectedDevices = appData.devices?.filter((d) => d.connected).length || 0;
|
||||||
|
const inboundCalls = activeCalls.filter((c) => c.direction === 'inbound').length;
|
||||||
|
const outboundCalls = activeCalls.filter((c) => c.direction === 'outbound').length;
|
||||||
|
const webrtcSessions = activeCalls.reduce(
|
||||||
|
(sum, c) => sum + c.legs.filter((l) => l.type === 'webrtc').length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalRtpPackets = activeCalls.reduce(
|
||||||
|
(sum, c) => sum + c.legs.reduce((lsum, l) => lsum + l.pktSent + l.pktReceived, 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tiles: IStatsTile[] = [
|
||||||
|
{
|
||||||
|
id: 'active-calls',
|
||||||
|
title: 'Active Calls',
|
||||||
|
value: activeCount,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:phone',
|
||||||
|
color: 'hsl(142.1 76.2% 36.3%)',
|
||||||
|
description: activeCount === 1 ? '1 call in progress' : `${activeCount} calls in progress`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'providers',
|
||||||
|
title: 'Registered Providers',
|
||||||
|
value: registeredProviders,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:server',
|
||||||
|
color: 'hsl(217.2 91.2% 59.8%)',
|
||||||
|
description: `${appData.providers?.length || 0} configured`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'devices',
|
||||||
|
title: 'Connected Devices',
|
||||||
|
value: connectedDevices,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:wifi',
|
||||||
|
color: 'hsl(270 70% 60%)',
|
||||||
|
description: `${appData.devices?.length || 0} total`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uptime',
|
||||||
|
title: 'Uptime',
|
||||||
|
value: this.fmtUptime(appData.uptime),
|
||||||
|
type: 'text',
|
||||||
|
icon: 'lucide:clock',
|
||||||
|
description: 'Since last restart',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inbound',
|
||||||
|
title: 'Inbound Calls',
|
||||||
|
value: inboundCalls,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:phone-incoming',
|
||||||
|
description: 'Currently active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'outbound',
|
||||||
|
title: 'Outbound Calls',
|
||||||
|
value: outboundCalls,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:phone-outgoing',
|
||||||
|
description: 'Currently active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'webrtc',
|
||||||
|
title: 'WebRTC Sessions',
|
||||||
|
value: webrtcSessions,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:globe',
|
||||||
|
color: 'hsl(166 72% 40%)',
|
||||||
|
description: 'Browser connections',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rtp-packets',
|
||||||
|
title: 'Total RTP Packets',
|
||||||
|
value: totalRtpPackets,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:radio',
|
||||||
|
description: 'Sent + received across legs',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const allDevices = appData.devices || [];
|
||||||
|
const onlineCount = allDevices.filter((d) => d.connected).length;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<dees-statsgrid
|
||||||
|
.tiles=${tiles}
|
||||||
|
.minTileWidth=${220}
|
||||||
|
.gap=${16}
|
||||||
|
></dees-statsgrid>
|
||||||
|
|
||||||
|
<div class="section-heading">Devices</div>
|
||||||
|
<dees-table
|
||||||
|
heading1="Devices"
|
||||||
|
heading2="${onlineCount} of ${allDevices.length} online"
|
||||||
|
dataName="devices"
|
||||||
|
.data=${allDevices}
|
||||||
|
.rowKey=${'id'}
|
||||||
|
.searchable=${false}
|
||||||
|
.columns=${[
|
||||||
|
{
|
||||||
|
key: 'connected',
|
||||||
|
header: 'Status',
|
||||||
|
renderer: (val: boolean) => {
|
||||||
|
const on = val === true;
|
||||||
|
return html`
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:6px">
|
||||||
|
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;${on ? 'background:#4ade80;box-shadow:0 0 6px #4ade80' : 'background:#f87171;box-shadow:0 0 6px #f87171'}"></span>
|
||||||
|
<span style="font-size:.7rem;font-weight:600;text-transform:uppercase;color:${on ? '#4ade80' : '#f87171'}">${on ? 'Online' : 'Offline'}</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'displayName',
|
||||||
|
header: 'Device',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: 'Type',
|
||||||
|
value: (row: any) => (row.isBrowser ? 'Browser' : 'SIP Device'),
|
||||||
|
renderer: (val: string, row: any) => {
|
||||||
|
const isBrowser = row.isBrowser;
|
||||||
|
const bg = isBrowser ? '#065f46' : '#1e3a5f';
|
||||||
|
const fg = isBrowser ? '#34d399' : '#38bdf8';
|
||||||
|
return html`<span style="display:inline-block;font-size:.6rem;padding:2px 6px;border-radius:3px;font-weight:600;text-transform:uppercase;background:${bg};color:${fg}">${val}</span>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contact',
|
||||||
|
header: 'Contact',
|
||||||
|
renderer: (_val: any, row: any) => {
|
||||||
|
const c = row.contact;
|
||||||
|
const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--';
|
||||||
|
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'aor',
|
||||||
|
header: 'AOR',
|
||||||
|
renderer: (val: any) =>
|
||||||
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${val || '--'}</span>`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
815
ts_web/elements/sipproxy-view-phone.ts
Normal file
815
ts_web/elements/sipproxy-view-phone.ts
Normal file
@@ -0,0 +1,815 @@
|
|||||||
|
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||||
|
import { deesCatalog } from '../plugins.js';
|
||||||
|
import { appState, type IAppState, type IContact } from '../state/appstate.js';
|
||||||
|
import { WebRtcClient, getAudioDevices, type IAudioDevices } from '../state/webrtc-client.js';
|
||||||
|
import { viewHostCss } from './shared/index.js';
|
||||||
|
|
||||||
|
interface IIncomingCall {
|
||||||
|
callId: string;
|
||||||
|
from: string;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level singleton — survives view mount/unmount cycles.
|
||||||
|
let sharedRtcClient: WebRtcClient | null = null;
|
||||||
|
let sharedKeepAliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let sharedRegistered = false;
|
||||||
|
|
||||||
|
@customElement('sipproxy-view-phone')
|
||||||
|
export class SipproxyViewPhone extends DeesElement {
|
||||||
|
@state() accessor appData: IAppState = appState.getState();
|
||||||
|
|
||||||
|
// WebRTC state
|
||||||
|
@state() accessor rtcState: string = 'idle';
|
||||||
|
@state() accessor registered = false;
|
||||||
|
@state() accessor incomingCalls: IIncomingCall[] = [];
|
||||||
|
@state() accessor activeCallId: string | null = null;
|
||||||
|
@state() accessor audioDevices: IAudioDevices = { inputs: [], outputs: [] };
|
||||||
|
@state() accessor selectedInput: string = '';
|
||||||
|
@state() accessor selectedOutput: string = '';
|
||||||
|
@state() accessor localLevel: number = 0;
|
||||||
|
@state() accessor remoteLevel: number = 0;
|
||||||
|
|
||||||
|
// Dialer state
|
||||||
|
@state() accessor dialNumber = '';
|
||||||
|
@state() accessor dialStatus = '';
|
||||||
|
@state() accessor calling = false;
|
||||||
|
@state() accessor currentCallId: string | null = null;
|
||||||
|
@state() accessor selectedDeviceId: string = '';
|
||||||
|
@state() accessor selectedProviderId: string = '';
|
||||||
|
@state() accessor callDuration: number = 0;
|
||||||
|
|
||||||
|
private rtcClient: WebRtcClient | null = null;
|
||||||
|
private levelTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private durationTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Two-column layout ---------- */
|
||||||
|
.phone-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tile content padding ---------- */
|
||||||
|
.tile-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Dialer inputs ---------- */
|
||||||
|
.dialer-inputs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.btn:active:not(:disabled) { transform: scale(0.97); }
|
||||||
|
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.btn-call {
|
||||||
|
background: linear-gradient(135deg, #16a34a, #15803d);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(22, 163, 74, 0.3);
|
||||||
|
}
|
||||||
|
.btn-hangup {
|
||||||
|
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dial-status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--dees-color-text-secondary, #94a3b8);
|
||||||
|
min-height: 1.2em;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contact card (passport-style) */
|
||||||
|
.contact-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.10), rgba(56, 189, 248, 0.06));
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.contact-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #2563eb, #0ea5e9, #38bdf8);
|
||||||
|
}
|
||||||
|
.contact-card-avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #0ea5e9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
||||||
|
}
|
||||||
|
.contact-card-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.contact-card-name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.contact-card-number {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: #38bdf8;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.contact-card-company {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--dees-color-text-secondary, #64748b);
|
||||||
|
}
|
||||||
|
.contact-card-clear {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.contact-card-clear:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #f87171;
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Starred contacts grid */
|
||||||
|
.contacts-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--dees-color-border-subtle, #334155);
|
||||||
|
}
|
||||||
|
.contacts-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--dees-color-text-secondary, #64748b);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.contacts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.btn-contact {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--dees-color-border-default, #1e3a5f);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(30, 58, 95, 0.4);
|
||||||
|
color: #38bdf8;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-contact:hover:not(:disabled) {
|
||||||
|
background: rgba(37, 99, 235, 0.25);
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
.btn-contact:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.btn-contact .contact-name { display: block; margin-bottom: 2px; }
|
||||||
|
.btn-contact .contact-number {
|
||||||
|
display: block; font-size: 0.7rem; color: #64748b;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.btn-contact .contact-company {
|
||||||
|
display: block; font-size: 0.65rem; color: #475569; margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Phone status ---------- */
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--dees-color-bg-primary, #0f172a);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid var(--dees-color-border-default, #334155);
|
||||||
|
}
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.dot.on { background: #4ade80; box-shadow: 0 0 8px #4ade80; }
|
||||||
|
.dot.off { background: #f87171; box-shadow: 0 0 8px #f87171; }
|
||||||
|
.dot.pending { background: #fbbf24; box-shadow: 0 0 8px #fbbf24; animation: dotPulse 1.5s ease-in-out infinite; }
|
||||||
|
@keyframes dotPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
.status-label { font-size: 0.9rem; font-weight: 500; }
|
||||||
|
.status-detail { font-size: 0.75rem; color: var(--dees-color-text-secondary, #64748b); margin-left: auto; }
|
||||||
|
|
||||||
|
/* Active call banner */
|
||||||
|
.active-call-banner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: linear-gradient(135deg, rgba(22, 163, 74, 0.15), rgba(16, 185, 129, 0.1));
|
||||||
|
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.active-call-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.active-call-pulse { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; animation: dotPulse 1s ease-in-out infinite; }
|
||||||
|
.active-call-label { font-size: 0.85rem; font-weight: 600; color: #4ade80; }
|
||||||
|
.active-call-duration { font-size: 0.8rem; color: #94a3b8; margin-left: auto; font-family: 'JetBrains Mono', monospace; }
|
||||||
|
.active-call-number { font-size: 1rem; font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
/* Audio device dropdowns spacing */
|
||||||
|
dees-input-dropdown {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Level meters */
|
||||||
|
.levels {
|
||||||
|
display: flex; gap: 16px; margin-bottom: 16px; padding: 12px 16px;
|
||||||
|
background: var(--dees-color-bg-primary, #0f172a); border-radius: 10px;
|
||||||
|
border: 1px solid var(--dees-color-border-default, #334155);
|
||||||
|
}
|
||||||
|
.level-group { flex: 1; }
|
||||||
|
.level-label { font-size: 0.65rem; color: #64748b; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.level-bar-bg { height: 6px; border-radius: 3px; background: #1e293b; overflow: hidden; }
|
||||||
|
.level-bar { height: 100%; border-radius: 3px; transition: width 60ms linear; }
|
||||||
|
.level-bar.mic { background: linear-gradient(90deg, #4ade80, #22c55e); }
|
||||||
|
.level-bar.spk { background: linear-gradient(90deg, #38bdf8, #0ea5e9); }
|
||||||
|
|
||||||
|
/* Incoming calls */
|
||||||
|
.incoming-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--dees-color-border-subtle, #334155); }
|
||||||
|
.incoming-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--dees-color-text-secondary, #94a3b8); margin-bottom: 10px; }
|
||||||
|
.incoming-row {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 12px 14px;
|
||||||
|
background: rgba(251, 191, 36, 0.06); border-radius: 10px; margin-bottom: 8px;
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||||
|
}
|
||||||
|
.incoming-ring { font-size: 0.7rem; font-weight: 700; color: #fbbf24; animation: dotPulse 1s infinite; letter-spacing: 0.04em; }
|
||||||
|
.incoming-from { flex: 1; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; }
|
||||||
|
.btn-sm { padding: 8px 16px; border: none; border-radius: 8px; font-weight: 600; font-size: 0.8rem; cursor: pointer; touch-action: manipulation; }
|
||||||
|
.btn-accept { background: #16a34a; color: #fff; }
|
||||||
|
.btn-accept:active { background: #166534; }
|
||||||
|
.btn-reject { background: #dc2626; color: #fff; }
|
||||||
|
.btn-reject:active { background: #991b1b; }
|
||||||
|
.no-incoming { font-size: 0.8rem; color: #475569; padding: 8px 0; }
|
||||||
|
|
||||||
|
/* ---------- Responsive ---------- */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.phone-layout { grid-template-columns: 1fr; }
|
||||||
|
.call-actions { flex-direction: column; }
|
||||||
|
.btn { padding: 14px 20px; font-size: 1rem; }
|
||||||
|
.incoming-row { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.rxSubscriptions.push({
|
||||||
|
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
||||||
|
} as any);
|
||||||
|
this.tryAutoRegister();
|
||||||
|
this.loadAudioDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
if (this.durationTimer) clearInterval(this.durationTimer);
|
||||||
|
this.stopLevelMeter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Audio device loading ----------
|
||||||
|
|
||||||
|
private async loadAudioDevices() {
|
||||||
|
this.audioDevices = await getAudioDevices();
|
||||||
|
if (!this.selectedInput && this.audioDevices.inputs.length) {
|
||||||
|
this.selectedInput = this.audioDevices.inputs[0].deviceId;
|
||||||
|
}
|
||||||
|
if (!this.selectedOutput && this.audioDevices.outputs.length) {
|
||||||
|
this.selectedOutput = this.audioDevices.outputs[0].deviceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- WebRTC registration ----------
|
||||||
|
|
||||||
|
private tryAutoRegister() {
|
||||||
|
if (sharedRtcClient && sharedRegistered) {
|
||||||
|
this.rtcClient = sharedRtcClient;
|
||||||
|
this.registered = true;
|
||||||
|
this.rtcState = sharedRtcClient.state;
|
||||||
|
this.setupSignalingHandler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = (window as any).__sipRouterWs as WebSocket | undefined;
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.registerSoftphone(ws);
|
||||||
|
} else {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const ws2 = (window as any).__sipRouterWs as WebSocket | undefined;
|
||||||
|
if (ws2?.readyState === WebSocket.OPEN) {
|
||||||
|
clearInterval(timer);
|
||||||
|
this.registerSoftphone(ws2);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
setTimeout(() => clearInterval(timer), 10000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerSoftphone(ws: WebSocket) {
|
||||||
|
if (!sharedRtcClient) {
|
||||||
|
sharedRtcClient = new WebRtcClient(() => {});
|
||||||
|
}
|
||||||
|
this.rtcClient = sharedRtcClient;
|
||||||
|
|
||||||
|
(this.rtcClient as any).onStateChange = (s: string) => {
|
||||||
|
this.rtcState = s;
|
||||||
|
if (s === 'connected') this.startLevelMeter();
|
||||||
|
else if (s === 'idle' || s === 'error') this.stopLevelMeter();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.selectedInput) this.rtcClient.setInputDevice(this.selectedInput);
|
||||||
|
if (this.selectedOutput) this.rtcClient.setOutputDevice(this.selectedOutput);
|
||||||
|
|
||||||
|
this.rtcClient.setWebSocket(ws);
|
||||||
|
this.setupSignalingHandler();
|
||||||
|
|
||||||
|
if (!sharedRegistered) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'webrtc-register',
|
||||||
|
sessionId: this.rtcClient.id,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (sharedKeepAliveTimer) clearInterval(sharedKeepAliveTimer);
|
||||||
|
sharedKeepAliveTimer = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN && sharedRtcClient) {
|
||||||
|
ws.send(JSON.stringify({ type: 'webrtc-register', sessionId: sharedRtcClient.id }));
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
sharedRegistered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.registered = true;
|
||||||
|
this.rtcState = this.rtcClient.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSignalingHandler() {
|
||||||
|
(window as any).__sipRouterWebRtcHandler = (msg: any) => {
|
||||||
|
this.rtcClient?.handleSignaling(msg);
|
||||||
|
|
||||||
|
if (msg.type === 'webrtc-registered') {
|
||||||
|
const d = msg.data || msg;
|
||||||
|
if (d.deviceId) appState.setBrowserDeviceId(d.deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'webrtc-incoming') {
|
||||||
|
const d = msg.data || msg;
|
||||||
|
if (!this.incomingCalls.find((c) => c.callId === d.callId)) {
|
||||||
|
this.incomingCalls = [...this.incomingCalls, {
|
||||||
|
callId: d.callId,
|
||||||
|
from: d.from || 'Unknown',
|
||||||
|
time: Date.now(),
|
||||||
|
}];
|
||||||
|
deesCatalog.DeesToast.show({
|
||||||
|
message: `Incoming call from ${d.from || 'Unknown'}`,
|
||||||
|
type: 'info',
|
||||||
|
duration: 5000,
|
||||||
|
position: 'top-right',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'webrtc-call-ended') {
|
||||||
|
const d = msg.data || msg;
|
||||||
|
this.incomingCalls = this.incomingCalls.filter((c) => c.callId !== d.callId);
|
||||||
|
if (this.activeCallId === d.callId) {
|
||||||
|
this.rtcClient?.hangup();
|
||||||
|
this.activeCallId = null;
|
||||||
|
this.stopDurationTimer();
|
||||||
|
deesCatalog.DeesToast.info('Call ended');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Level meters ----------
|
||||||
|
|
||||||
|
private startLevelMeter() {
|
||||||
|
this.stopLevelMeter();
|
||||||
|
this.levelTimer = setInterval(() => {
|
||||||
|
if (this.rtcClient) {
|
||||||
|
this.localLevel = this.rtcClient.getLocalLevel();
|
||||||
|
this.remoteLevel = this.rtcClient.getRemoteLevel();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopLevelMeter() {
|
||||||
|
if (this.levelTimer) {
|
||||||
|
clearInterval(this.levelTimer);
|
||||||
|
this.levelTimer = null;
|
||||||
|
}
|
||||||
|
this.localLevel = 0;
|
||||||
|
this.remoteLevel = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Call duration ----------
|
||||||
|
|
||||||
|
private startDurationTimer() {
|
||||||
|
this.stopDurationTimer();
|
||||||
|
this.callDuration = 0;
|
||||||
|
this.durationTimer = setInterval(() => {
|
||||||
|
this.callDuration++;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopDurationTimer() {
|
||||||
|
if (this.durationTimer) {
|
||||||
|
clearInterval(this.durationTimer);
|
||||||
|
this.durationTimer = null;
|
||||||
|
}
|
||||||
|
this.callDuration = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fmtDuration(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Call actions ----------
|
||||||
|
|
||||||
|
private async acceptCall(callId: string) {
|
||||||
|
this.incomingCalls = this.incomingCalls.filter((c) => c.callId !== callId);
|
||||||
|
this.activeCallId = callId;
|
||||||
|
|
||||||
|
if (this.rtcClient) {
|
||||||
|
if (this.selectedInput) this.rtcClient.setInputDevice(this.selectedInput);
|
||||||
|
if (this.selectedOutput) this.rtcClient.setOutputDevice(this.selectedOutput);
|
||||||
|
await this.rtcClient.startCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = (window as any).__sipRouterWs as WebSocket | undefined;
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'webrtc-accept', callId, sessionId: this.rtcClient?.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startDurationTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectCall(callId: string) {
|
||||||
|
const ws = (window as any).__sipRouterWs as WebSocket | undefined;
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'webrtc-reject', callId }));
|
||||||
|
}
|
||||||
|
this.incomingCalls = this.incomingCalls.filter((c) => c.callId !== callId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectContact(contact: IContact) {
|
||||||
|
appState.selectContact(contact);
|
||||||
|
this.dialNumber = contact.number;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearContact() {
|
||||||
|
appState.clearSelectedContact();
|
||||||
|
this.dialNumber = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async makeCall(number?: string) {
|
||||||
|
const num = number || this.dialNumber.trim();
|
||||||
|
if (!num) {
|
||||||
|
this.dialStatus = 'Please enter a phone number';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dialNumber = num;
|
||||||
|
this.calling = true;
|
||||||
|
this.dialStatus = 'Initiating call...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await appState.apiCall(
|
||||||
|
num,
|
||||||
|
this.selectedDeviceId || undefined,
|
||||||
|
this.selectedProviderId || undefined,
|
||||||
|
);
|
||||||
|
if (res.ok && res.callId) {
|
||||||
|
this.currentCallId = res.callId;
|
||||||
|
this.dialStatus = 'Call initiated';
|
||||||
|
this.startDurationTimer();
|
||||||
|
appState.clearSelectedContact();
|
||||||
|
} else {
|
||||||
|
this.dialStatus = `Error: ${res.error || 'unknown'}`;
|
||||||
|
deesCatalog.DeesToast.error(`Call failed: ${res.error || 'unknown'}`, 4000);
|
||||||
|
this.calling = false;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.dialStatus = `Failed: ${e.message}`;
|
||||||
|
deesCatalog.DeesToast.error(`Call failed: ${e.message}`, 4000);
|
||||||
|
this.calling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hangup() {
|
||||||
|
if (!this.currentCallId) return;
|
||||||
|
this.dialStatus = 'Hanging up...';
|
||||||
|
try {
|
||||||
|
await appState.apiHangup(this.currentCallId);
|
||||||
|
this.dialStatus = 'Call ended';
|
||||||
|
this.currentCallId = null;
|
||||||
|
this.calling = false;
|
||||||
|
this.stopDurationTimer();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.dialStatus = `Hangup failed: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Helpers ----------
|
||||||
|
|
||||||
|
private getConnectedDevices() {
|
||||||
|
return this.appData.devices.filter((d) => d.connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRegisteredProviders() {
|
||||||
|
return this.appData.providers.filter((p) => p.registered);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDotClass(): string {
|
||||||
|
if (this.rtcState === 'connected') return 'on';
|
||||||
|
if (this.rtcState === 'connecting' || this.rtcState === 'requesting-mic') return 'pending';
|
||||||
|
if (this.registered) return 'on';
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStateLabel(): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
idle: 'Registered - Ready',
|
||||||
|
'requesting-mic': 'Requesting Microphone...',
|
||||||
|
connecting: 'Connecting Audio...',
|
||||||
|
connected: 'On Call',
|
||||||
|
error: 'Error',
|
||||||
|
};
|
||||||
|
return labels[this.rtcState] || this.rtcState;
|
||||||
|
}
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
const active = this.appData.calls?.find((c) => c.state !== 'terminated' && c.direction === 'outbound');
|
||||||
|
if (active) {
|
||||||
|
this.currentCallId = active.id;
|
||||||
|
this.calling = true;
|
||||||
|
} else if (this.calling && !active) {
|
||||||
|
this.calling = false;
|
||||||
|
this.currentCallId = null;
|
||||||
|
this.stopDurationTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = this.getConnectedDevices();
|
||||||
|
if (this.selectedDeviceId && !connected.find((d) => d.id === this.selectedDeviceId)) {
|
||||||
|
this.selectedDeviceId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Render ----------
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="phone-layout">
|
||||||
|
${this.renderDialer()}
|
||||||
|
${this.renderPhoneStatus()}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDialer(): TemplateResult {
|
||||||
|
const connected = this.getConnectedDevices();
|
||||||
|
const registeredProviders = this.getRegisteredProviders();
|
||||||
|
const selectedContact = this.appData.selectedContact;
|
||||||
|
const starredContacts = this.appData.contacts.filter((c) => c.starred);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<dees-tile heading="Dialer">
|
||||||
|
<div class="tile-body">
|
||||||
|
<div class="dialer-inputs">
|
||||||
|
${selectedContact ? html`
|
||||||
|
<div class="contact-card">
|
||||||
|
<div class="contact-card-avatar">
|
||||||
|
${selectedContact.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div class="contact-card-info">
|
||||||
|
<div class="contact-card-name">${selectedContact.name}</div>
|
||||||
|
<div class="contact-card-number">${selectedContact.number}</div>
|
||||||
|
${selectedContact.company ? html`
|
||||||
|
<div class="contact-card-company">${selectedContact.company}</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="contact-card-clear"
|
||||||
|
@click=${() => this.clearContact()}
|
||||||
|
title="Clear selection"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
` : html`
|
||||||
|
<dees-input-text
|
||||||
|
.label=${'Phone Number'}
|
||||||
|
.value=${this.dialNumber}
|
||||||
|
@input=${(e: Event) => { this.dialNumber = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<dees-input-dropdown
|
||||||
|
.label=${'Call from'}
|
||||||
|
.options=${connected.map((d) => ({
|
||||||
|
option: `${d.displayName}${d.id === this.appData.browserDeviceId ? ' (this browser)' : d.isBrowser ? ' (WebRTC)' : ''}`,
|
||||||
|
key: d.id,
|
||||||
|
}))}
|
||||||
|
.selectedOption=${this.selectedDeviceId ? {
|
||||||
|
option: connected.find((d) => d.id === this.selectedDeviceId)?.displayName || this.selectedDeviceId,
|
||||||
|
key: this.selectedDeviceId,
|
||||||
|
} : null}
|
||||||
|
@selectedOption=${(e: CustomEvent) => { this.selectedDeviceId = e.detail.key; }}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
|
||||||
|
<dees-input-dropdown
|
||||||
|
.label=${'Via provider'}
|
||||||
|
.options=${[
|
||||||
|
{ option: 'Default', key: '' },
|
||||||
|
...registeredProviders.map((p) => ({ option: p.displayName, key: p.id })),
|
||||||
|
]}
|
||||||
|
.selectedOption=${{ option: this.selectedProviderId ? (registeredProviders.find((p) => p.id === this.selectedProviderId)?.displayName || this.selectedProviderId) : 'Default', key: this.selectedProviderId || '' }}
|
||||||
|
@selectedOption=${(e: CustomEvent) => { this.selectedProviderId = e.detail.key; }}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="call-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-call"
|
||||||
|
?disabled=${this.calling || !this.selectedDeviceId}
|
||||||
|
@click=${() => this.makeCall(selectedContact?.number)}
|
||||||
|
>Call</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-hangup"
|
||||||
|
?disabled=${!this.currentCallId}
|
||||||
|
@click=${() => this.hangup()}
|
||||||
|
>Hang Up</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.dialStatus ? html`<div class="dial-status">${this.dialStatus}</div>` : ''}
|
||||||
|
|
||||||
|
${starredContacts.length ? html`
|
||||||
|
<div class="contacts-section">
|
||||||
|
<div class="contacts-label">Quick Dial</div>
|
||||||
|
<div class="contacts-grid">
|
||||||
|
${starredContacts.map((c) => html`
|
||||||
|
<button
|
||||||
|
class="btn-contact"
|
||||||
|
?disabled=${this.calling}
|
||||||
|
@click=${() => this.selectContact(c)}
|
||||||
|
>
|
||||||
|
<span class="contact-name">${c.name}</span>
|
||||||
|
<span class="contact-number">${c.number}</span>
|
||||||
|
${c.company ? html`<span class="contact-company">${c.company}</span>` : ''}
|
||||||
|
</button>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</dees-tile>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPhoneStatus(): TemplateResult {
|
||||||
|
const micPct = Math.min(100, Math.round(this.localLevel * 300));
|
||||||
|
const spkPct = Math.min(100, Math.round(this.remoteLevel * 300));
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<dees-tile heading="Phone Status">
|
||||||
|
<div class="tile-body">
|
||||||
|
<!-- Registration status -->
|
||||||
|
<div class="status-indicator">
|
||||||
|
<span class="dot ${this.getDotClass()}"></span>
|
||||||
|
<span class="status-label">${this.registered ? this.getStateLabel() : 'Connecting...'}</span>
|
||||||
|
${this.appData.browserDeviceId ? html`
|
||||||
|
<span class="status-detail">ID: ${this.appData.browserDeviceId.slice(0, 12)}...</span>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active call banner -->
|
||||||
|
${this.rtcState === 'connected' || this.calling ? html`
|
||||||
|
<div class="active-call-banner">
|
||||||
|
<div class="active-call-header">
|
||||||
|
<span class="active-call-pulse"></span>
|
||||||
|
<span class="active-call-label">Active Call</span>
|
||||||
|
<span class="active-call-duration">${this.fmtDuration(this.callDuration)}</span>
|
||||||
|
</div>
|
||||||
|
${this.dialNumber ? html`
|
||||||
|
<div class="active-call-number">${this.dialNumber}</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Level meters -->
|
||||||
|
${this.rtcState === 'connected' ? html`
|
||||||
|
<div class="levels">
|
||||||
|
<div class="level-group">
|
||||||
|
<div class="level-label">Microphone</div>
|
||||||
|
<div class="level-bar-bg"><div class="level-bar mic" style="width:${micPct}%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="level-group">
|
||||||
|
<div class="level-label">Speaker</div>
|
||||||
|
<div class="level-bar-bg"><div class="level-bar spk" style="width:${spkPct}%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Audio device selectors -->
|
||||||
|
<dees-input-dropdown
|
||||||
|
.label=${'Microphone'}
|
||||||
|
.enableSearch=${false}
|
||||||
|
.options=${this.audioDevices.inputs.map((d) => ({
|
||||||
|
option: d.label || 'Microphone',
|
||||||
|
key: d.deviceId,
|
||||||
|
}))}
|
||||||
|
.selectedOption=${this.selectedInput ? {
|
||||||
|
option: this.audioDevices.inputs.find((d) => d.deviceId === this.selectedInput)?.label || 'Microphone',
|
||||||
|
key: this.selectedInput,
|
||||||
|
} : null}
|
||||||
|
@selectedOption=${(e: CustomEvent) => { this.selectedInput = e.detail.key; this.rtcClient?.setInputDevice(this.selectedInput); }}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.label=${'Speaker'}
|
||||||
|
.enableSearch=${false}
|
||||||
|
.options=${this.audioDevices.outputs.map((d) => ({
|
||||||
|
option: d.label || 'Speaker',
|
||||||
|
key: d.deviceId,
|
||||||
|
}))}
|
||||||
|
.selectedOption=${this.selectedOutput ? {
|
||||||
|
option: this.audioDevices.outputs.find((d) => d.deviceId === this.selectedOutput)?.label || 'Speaker',
|
||||||
|
key: this.selectedOutput,
|
||||||
|
} : null}
|
||||||
|
@selectedOption=${(e: CustomEvent) => { this.selectedOutput = e.detail.key; this.rtcClient?.setOutputDevice(this.selectedOutput); }}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
|
||||||
|
<!-- Incoming calls -->
|
||||||
|
<div class="incoming-section">
|
||||||
|
<div class="incoming-label">Incoming Calls</div>
|
||||||
|
${this.incomingCalls.length ? this.incomingCalls.map((call) => html`
|
||||||
|
<div class="incoming-row">
|
||||||
|
<span class="incoming-ring">RINGING</span>
|
||||||
|
<span class="incoming-from">${call.from}</span>
|
||||||
|
<button class="btn-sm btn-accept" @click=${() => this.acceptCall(call.callId)}>Accept</button>
|
||||||
|
<button class="btn-sm btn-reject" @click=${() => this.rejectCall(call.callId)}>Reject</button>
|
||||||
|
</div>
|
||||||
|
`) : html`
|
||||||
|
<div class="no-incoming">No incoming calls</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dees-tile>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
607
ts_web/elements/sipproxy-view-providers.ts
Normal file
607
ts_web/elements/sipproxy-view-providers.ts
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||||
|
import { deesCatalog } from '../plugins.js';
|
||||||
|
import { appState, type IAppState, type IProviderStatus } from '../state/appstate.js';
|
||||||
|
import { viewHostCss } from './shared/index.js';
|
||||||
|
import type { IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default provider templates
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PROVIDER_TEMPLATES = {
|
||||||
|
sipgate: {
|
||||||
|
domain: 'sipgate.de',
|
||||||
|
outboundProxy: { address: 'sipgate.de', port: 5060 },
|
||||||
|
registerIntervalSec: 300,
|
||||||
|
codecs: [9, 0, 8, 101],
|
||||||
|
quirks: { earlyMediaSilence: false },
|
||||||
|
},
|
||||||
|
o2: {
|
||||||
|
domain: 'sip.alice-voip.de',
|
||||||
|
outboundProxy: { address: 'sip.alice-voip.de', port: 5060 },
|
||||||
|
registerIntervalSec: 300,
|
||||||
|
codecs: [9, 0, 8, 101],
|
||||||
|
quirks: { earlyMediaSilence: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function slugify(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '') || `provider-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// View element
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@customElement('sipproxy-view-providers')
|
||||||
|
export class SipproxyViewProviders extends DeesElement {
|
||||||
|
@state() accessor appData: IAppState = appState.getState();
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.view-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- lifecycle -----------------------------------------------------------
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.rxSubscriptions.push({
|
||||||
|
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stats tiles ---------------------------------------------------------
|
||||||
|
|
||||||
|
private getStatsTiles(): IStatsTile[] {
|
||||||
|
const providers = this.appData.providers || [];
|
||||||
|
const total = providers.length;
|
||||||
|
const registered = providers.filter((p) => p.registered).length;
|
||||||
|
const unregistered = total - registered;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
title: 'Total Providers',
|
||||||
|
value: total,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:server',
|
||||||
|
description: 'Configured SIP trunks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'registered',
|
||||||
|
title: 'Registered',
|
||||||
|
value: registered,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:check-circle',
|
||||||
|
color: 'hsl(142.1 76.2% 36.3%)',
|
||||||
|
description: 'Active registrations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unregistered',
|
||||||
|
title: 'Unregistered',
|
||||||
|
value: unregistered,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:alert-circle',
|
||||||
|
color: unregistered > 0 ? 'hsl(0 84.2% 60.2%)' : undefined,
|
||||||
|
description: unregistered > 0 ? 'Needs attention' : 'All healthy',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- table columns -------------------------------------------------------
|
||||||
|
|
||||||
|
private getColumns() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'displayName',
|
||||||
|
header: 'Name',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'domain',
|
||||||
|
header: 'Domain',
|
||||||
|
value: (row: IProviderStatus) => (row as any).domain || '--',
|
||||||
|
renderer: (val: string) =>
|
||||||
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${val}</span>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'registered',
|
||||||
|
header: 'Status',
|
||||||
|
value: (row: IProviderStatus) => (row.registered ? 'Registered' : 'Not Registered'),
|
||||||
|
renderer: (val: string, row: IProviderStatus) => {
|
||||||
|
const on = row.registered;
|
||||||
|
return html`
|
||||||
|
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;${on ? 'background:#4ade80;box-shadow:0 0 6px #4ade80' : 'background:#f87171;box-shadow:0 0 6px #f87171'}"></span>
|
||||||
|
<span style="color:${on ? '#4ade80' : '#f87171'}">${val}</span>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'publicIp',
|
||||||
|
header: 'Public IP',
|
||||||
|
renderer: (val: any) =>
|
||||||
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${val || '--'}</span>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- table actions -------------------------------------------------------
|
||||||
|
|
||||||
|
private getDataActions() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Edit',
|
||||||
|
iconName: 'lucide:pencil',
|
||||||
|
type: ['inRow'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
await this.openEditModal(actionData.item.id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash-2',
|
||||||
|
type: ['inRow'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
await this.confirmDelete(actionData.item);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Add Provider',
|
||||||
|
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');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- add provider modal --------------------------------------------------
|
||||||
|
|
||||||
|
private async openAddModal(
|
||||||
|
template?: typeof PROVIDER_TEMPLATES.sipgate,
|
||||||
|
templateName?: string,
|
||||||
|
) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
displayName: templateName || '',
|
||||||
|
domain: template?.domain || '',
|
||||||
|
outboundProxyAddress: template?.outboundProxy?.address || '',
|
||||||
|
outboundProxyPort: String(template?.outboundProxy?.port ?? 5060),
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
registerIntervalSec: String(template?.registerIntervalSec ?? 300),
|
||||||
|
codecs: template?.codecs ? template.codecs.join(', ') : '9, 0, 8, 101',
|
||||||
|
earlyMediaSilence: template?.quirks?.earlyMediaSilence ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const heading = template
|
||||||
|
? `Add ${templateName} Provider`
|
||||||
|
: 'Add Provider';
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading,
|
||||||
|
width: 'small',
|
||||||
|
showCloseButton: true,
|
||||||
|
content: html`
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'displayName'}
|
||||||
|
.label=${'Display Name'}
|
||||||
|
.value=${formData.displayName}
|
||||||
|
@input=${(e: Event) => { formData.displayName = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'domain'}
|
||||||
|
.label=${'Domain'}
|
||||||
|
.value=${formData.domain}
|
||||||
|
@input=${(e: Event) => { formData.domain = (e.target as any).value; }}
|
||||||
|
></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; }}
|
||||||
|
></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; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'username'}
|
||||||
|
.label=${'Username / Auth ID'}
|
||||||
|
.value=${formData.username}
|
||||||
|
@input=${(e: Event) => { formData.username = (e.target as any).value; }}
|
||||||
|
></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; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'registerIntervalSec'}
|
||||||
|
.label=${'Register Interval (sec)'}
|
||||||
|
.value=${formData.registerIntervalSec}
|
||||||
|
@input=${(e: Event) => { formData.registerIntervalSec = (e.target as any).value; }}
|
||||||
|
></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; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'earlyMediaSilence'}
|
||||||
|
.label=${'Early Media Silence (quirk)'}
|
||||||
|
.value=${formData.earlyMediaSilence}
|
||||||
|
@newValue=${(e: CustomEvent) => { formData.earlyMediaSilence = e.detail; }}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalRef: any) => {
|
||||||
|
modalRef.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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
|
||||||
|
.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(),
|
||||||
|
outboundProxy: {
|
||||||
|
address: formData.outboundProxyAddress.trim() || formData.domain.trim(),
|
||||||
|
port: parseInt(formData.outboundProxyPort, 10) || 5060,
|
||||||
|
},
|
||||||
|
username: formData.username.trim(),
|
||||||
|
password: formData.password,
|
||||||
|
registerIntervalSec: parseInt(formData.registerIntervalSec, 10) || 300,
|
||||||
|
codecs,
|
||||||
|
quirks: {
|
||||||
|
earlyMediaSilence: formData.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');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to create provider:', err);
|
||||||
|
deesCatalog.DeesToast.error('Failed to create provider');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- edit provider modal -------------------------------------------------
|
||||||
|
|
||||||
|
private async openEditModal(providerId: string) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
let cfg: any;
|
||||||
|
try {
|
||||||
|
cfg = await appState.apiGetConfig();
|
||||||
|
} catch {
|
||||||
|
deesCatalog.DeesToast.error('Failed to load configuration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = cfg.providers?.find((p: any) => p.id === providerId);
|
||||||
|
if (!provider) {
|
||||||
|
deesCatalog.DeesToast.error('Provider not found in configuration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDevices: { id: string; displayName: string }[] = (cfg.devices || []).map((d: any) => ({
|
||||||
|
id: d.id,
|
||||||
|
displayName: d.displayName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
displayName: provider.displayName || '',
|
||||||
|
domain: provider.domain || '',
|
||||||
|
outboundProxyAddress: provider.outboundProxy?.address || '',
|
||||||
|
outboundProxyPort: String(provider.outboundProxy?.port ?? 5060),
|
||||||
|
username: provider.username || '',
|
||||||
|
password: provider.password || '',
|
||||||
|
registerIntervalSec: String(provider.registerIntervalSec ?? 300),
|
||||||
|
codecs: (provider.codecs || []).join(', '),
|
||||||
|
earlyMediaSilence: provider.quirks?.earlyMediaSilence ?? false,
|
||||||
|
inboundDevices: [...(cfg.routing?.inbound?.[providerId] || [])] as string[],
|
||||||
|
ringBrowsers: cfg.routing?.ringBrowsers?.[providerId] ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: `Edit Provider: ${formData.displayName}`,
|
||||||
|
width: 'small',
|
||||||
|
showCloseButton: true,
|
||||||
|
content: html`
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'displayName'}
|
||||||
|
.label=${'Display Name'}
|
||||||
|
.value=${formData.displayName}
|
||||||
|
@input=${(e: Event) => { formData.displayName = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'domain'}
|
||||||
|
.label=${'Domain'}
|
||||||
|
.value=${formData.domain}
|
||||||
|
@input=${(e: Event) => { formData.domain = (e.target as any).value; }}
|
||||||
|
></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; }}
|
||||||
|
></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; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'username'}
|
||||||
|
.label=${'Username / Auth ID'}
|
||||||
|
.value=${formData.username}
|
||||||
|
@input=${(e: Event) => { formData.username = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'password'}
|
||||||
|
.label=${'Password'}
|
||||||
|
.isPasswordBool=${true}
|
||||||
|
.value=${formData.password}
|
||||||
|
.description=${'Leave unchanged to keep existing password'}
|
||||||
|
@input=${(e: Event) => { formData.password = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'registerIntervalSec'}
|
||||||
|
.label=${'Register Interval (sec)'}
|
||||||
|
.value=${formData.registerIntervalSec}
|
||||||
|
@input=${(e: Event) => { formData.registerIntervalSec = (e.target as any).value; }}
|
||||||
|
></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; }}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'earlyMediaSilence'}
|
||||||
|
.label=${'Early Media Silence (quirk)'}
|
||||||
|
.value=${formData.earlyMediaSilence}
|
||||||
|
@newValue=${(e: CustomEvent) => { formData.earlyMediaSilence = e.detail; }}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
|
||||||
|
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
||||||
|
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;font-weight:600;">
|
||||||
|
Inbound Routing
|
||||||
|
</div>
|
||||||
|
<div style="font-size:.8rem;color:#64748b;margin-bottom:12px;">
|
||||||
|
Select which devices should ring when this provider receives an incoming call.
|
||||||
|
</div>
|
||||||
|
${allDevices.map((d) => html`
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${`inbound-${d.id}`}
|
||||||
|
.label=${d.displayName}
|
||||||
|
.value=${formData.inboundDevices.includes(d.id)}
|
||||||
|
@newValue=${(e: CustomEvent) => {
|
||||||
|
if (e.detail) {
|
||||||
|
if (!formData.inboundDevices.includes(d.id)) {
|
||||||
|
formData.inboundDevices = [...formData.inboundDevices, d.id];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formData.inboundDevices = formData.inboundDevices.filter((id) => id !== d.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
`)}
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'ringBrowsers'}
|
||||||
|
.label=${'Also ring all connected browsers'}
|
||||||
|
.value=${formData.ringBrowsers}
|
||||||
|
@newValue=${(e: CustomEvent) => { formData.ringBrowsers = e.detail; }}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalRef: any) => {
|
||||||
|
modalRef.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Save',
|
||||||
|
iconName: 'lucide:check',
|
||||||
|
action: async (modalRef: any) => {
|
||||||
|
try {
|
||||||
|
const codecs = formData.codecs
|
||||||
|
.split(',')
|
||||||
|
.map((s: string) => parseInt(s.trim(), 10))
|
||||||
|
.filter((n: number) => !isNaN(n));
|
||||||
|
|
||||||
|
const updates: any = {
|
||||||
|
providers: [{
|
||||||
|
id: providerId,
|
||||||
|
displayName: formData.displayName.trim(),
|
||||||
|
domain: formData.domain.trim(),
|
||||||
|
outboundProxy: {
|
||||||
|
address: formData.outboundProxyAddress.trim() || formData.domain.trim(),
|
||||||
|
port: parseInt(formData.outboundProxyPort, 10) || 5060,
|
||||||
|
},
|
||||||
|
username: formData.username.trim(),
|
||||||
|
registerIntervalSec: parseInt(formData.registerIntervalSec, 10) || 300,
|
||||||
|
codecs,
|
||||||
|
quirks: {
|
||||||
|
earlyMediaSilence: formData.earlyMediaSilence,
|
||||||
|
},
|
||||||
|
}] as any[],
|
||||||
|
routing: {
|
||||||
|
inbound: { [providerId]: formData.inboundDevices },
|
||||||
|
ringBrowsers: { [providerId]: formData.ringBrowsers },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only send password if it was changed (not the masked placeholder).
|
||||||
|
if (formData.password && !formData.password.match(/^\*+$/)) {
|
||||||
|
updates.providers[0].password = formData.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await appState.apiSaveConfig(updates);
|
||||||
|
if (result.ok) {
|
||||||
|
modalRef.destroy();
|
||||||
|
deesCatalog.DeesToast.success('Provider updated');
|
||||||
|
} else {
|
||||||
|
deesCatalog.DeesToast.error('Failed to save changes');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Save failed:', err);
|
||||||
|
deesCatalog.DeesToast.error('Failed to save changes');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- delete confirmation -------------------------------------------------
|
||||||
|
|
||||||
|
private async confirmDelete(provider: IProviderStatus) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Delete Provider',
|
||||||
|
width: 'small',
|
||||||
|
showCloseButton: true,
|
||||||
|
content: html`
|
||||||
|
<div style="padding:8px 0;font-size:.9rem;color:#e2e8f0;">
|
||||||
|
Are you sure you want to delete
|
||||||
|
<strong style="color:#f87171;">${provider.displayName}</strong>?
|
||||||
|
This action cannot be undone.
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalRef: any) => {
|
||||||
|
modalRef.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash-2',
|
||||||
|
action: async (modalRef: any) => {
|
||||||
|
try {
|
||||||
|
const result = await appState.apiSaveConfig({
|
||||||
|
removeProvider: provider.id,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
modalRef.destroy();
|
||||||
|
deesCatalog.DeesToast.success(`Provider "${provider.displayName}" deleted`);
|
||||||
|
} else {
|
||||||
|
deesCatalog.DeesToast.error('Failed to delete provider');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Delete failed:', err);
|
||||||
|
deesCatalog.DeesToast.error('Failed to delete provider');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- render --------------------------------------------------------------
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const providers = this.appData.providers || [];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="view-section">
|
||||||
|
<dees-statsgrid
|
||||||
|
.tiles=${this.getStatsTiles()}
|
||||||
|
.minTileWidth=${220}
|
||||||
|
.gap=${16}
|
||||||
|
></dees-statsgrid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-section">
|
||||||
|
<dees-table
|
||||||
|
heading1="Providers"
|
||||||
|
heading2="${providers.length} configured"
|
||||||
|
dataName="providers"
|
||||||
|
.data=${providers}
|
||||||
|
.rowKey=${'id'}
|
||||||
|
.searchable=${true}
|
||||||
|
.columns=${this.getColumns()}
|
||||||
|
.dataActions=${this.getDataActions()}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
ts_web/index.ts
Normal file
9
ts_web/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { appState } from './state/appstate.js';
|
||||||
|
import { appRouter } from './router.js';
|
||||||
|
import './elements/index.js';
|
||||||
|
|
||||||
|
// Initialize URL router.
|
||||||
|
appRouter.init();
|
||||||
|
|
||||||
|
// Connect to WebSocket once at startup.
|
||||||
|
appState.connect();
|
||||||
13
ts_web/plugins.ts
Normal file
13
ts_web/plugins.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
property,
|
||||||
|
state,
|
||||||
|
type TemplateResult,
|
||||||
|
type CSSResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
export * as deesCatalog from '@design.estate/dees-catalog';
|
||||||
64
ts_web/router.ts
Normal file
64
ts_web/router.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* URL router for the SipRouter dashboard.
|
||||||
|
* Maps URL paths to views in dees-simple-appdash.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VIEWS = ['overview', 'calls', 'phone', 'contacts', 'providers', 'log'] as const;
|
||||||
|
type TViewSlug = (typeof VIEWS)[number];
|
||||||
|
|
||||||
|
class AppRouter {
|
||||||
|
private currentView: TViewSlug = 'overview';
|
||||||
|
private onNavigate: ((view: TViewSlug) => void) | null = null;
|
||||||
|
private suppressPush = false;
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
// Parse initial URL.
|
||||||
|
const path = location.pathname.replace(/^\/+/, '').split('/')[0] || 'overview';
|
||||||
|
if (VIEWS.includes(path as TViewSlug)) {
|
||||||
|
this.currentView = path as TViewSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle browser back/forward.
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
const p = location.pathname.replace(/^\/+/, '').split('/')[0] || 'overview';
|
||||||
|
if (VIEWS.includes(p as TViewSlug)) {
|
||||||
|
this.suppressPush = true;
|
||||||
|
this.navigateTo(p as TViewSlug);
|
||||||
|
this.suppressPush = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setNavigateHandler(handler: (view: TViewSlug) => void): void {
|
||||||
|
this.onNavigate = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateTo(view: TViewSlug, skipCallback = false): void {
|
||||||
|
this.currentView = view;
|
||||||
|
if (!this.suppressPush) {
|
||||||
|
const url = `/${view}`;
|
||||||
|
if (location.pathname !== url) {
|
||||||
|
history.pushState(null, '', url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!skipCallback) {
|
||||||
|
this.onNavigate?.(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when the user selects a tab in dees-simple-appdash. */
|
||||||
|
onViewSelect(viewName: string): void {
|
||||||
|
const slug = viewName.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
const mapped = VIEWS.find((v) => v === slug || viewName.toLowerCase().startsWith(v));
|
||||||
|
if (mapped) {
|
||||||
|
this.navigateTo(mapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentView(): TViewSlug {
|
||||||
|
return this.currentView;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appRouter = new AppRouter();
|
||||||
|
export type { TViewSlug };
|
||||||
253
ts_web/state/appstate.ts
Normal file
253
ts_web/state/appstate.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* Application state — receives live updates from the proxy via WebSocket.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IProviderStatus {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
registered: boolean;
|
||||||
|
publicIp: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
state: string;
|
||||||
|
remoteMedia: { address: string; port: number } | null;
|
||||||
|
rtpPort: number | null;
|
||||||
|
pktSent: number;
|
||||||
|
pktReceived: number;
|
||||||
|
codec: string | null;
|
||||||
|
transcoding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ICallHistoryEntry {
|
||||||
|
id: string;
|
||||||
|
direction: 'inbound' | 'outbound' | 'internal';
|
||||||
|
callerNumber: string | null;
|
||||||
|
calleeNumber: string | null;
|
||||||
|
providerUsed: string | null;
|
||||||
|
startedAt: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IContact {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
company?: string;
|
||||||
|
notes?: string;
|
||||||
|
starred?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAppState {
|
||||||
|
connected: boolean;
|
||||||
|
browserDeviceId: string | null;
|
||||||
|
uptime: number;
|
||||||
|
providers: IProviderStatus[];
|
||||||
|
devices: IDeviceStatus[];
|
||||||
|
calls: ICallStatus[];
|
||||||
|
callHistory: ICallHistoryEntry[];
|
||||||
|
contacts: IContact[];
|
||||||
|
selectedContact: IContact | null;
|
||||||
|
logLines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LOG = 200;
|
||||||
|
let knownInstanceId: string | null = null;
|
||||||
|
|
||||||
|
class AppStateManager {
|
||||||
|
private state: IAppState = {
|
||||||
|
connected: false,
|
||||||
|
browserDeviceId: null,
|
||||||
|
uptime: 0,
|
||||||
|
providers: [],
|
||||||
|
devices: [],
|
||||||
|
calls: [],
|
||||||
|
callHistory: [],
|
||||||
|
contacts: [],
|
||||||
|
selectedContact: null,
|
||||||
|
logLines: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
private listeners = new Set<(state: IAppState) => void>();
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
|
||||||
|
getState(): IAppState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: (state: IAppState) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private update(partial: Partial<IAppState>): void {
|
||||||
|
this.state = { ...this.state, ...partial };
|
||||||
|
for (const fn of this.listeners) fn(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(msg: string): void {
|
||||||
|
const lines = [...this.state.logLines, msg];
|
||||||
|
if (lines.length > MAX_LOG) lines.splice(0, lines.length - MAX_LOG);
|
||||||
|
this.update({ logLines: lines });
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
// Guard against duplicate connections.
|
||||||
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
this.ws = ws;
|
||||||
|
(window as any).__sipRouterWs = ws;
|
||||||
|
this.update({ connected: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
this.ws = null;
|
||||||
|
this.update({ connected: false });
|
||||||
|
setTimeout(() => this.connect(), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => ws.close();
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const m = JSON.parse(ev.data);
|
||||||
|
if (m.type === 'status') {
|
||||||
|
// Auto-reload if backend restarted (different instance).
|
||||||
|
if (m.data.instanceId) {
|
||||||
|
if (knownInstanceId && knownInstanceId !== m.data.instanceId) {
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
knownInstanceId = m.data.instanceId;
|
||||||
|
}
|
||||||
|
this.update({
|
||||||
|
uptime: m.data.uptime,
|
||||||
|
providers: m.data.providers || [],
|
||||||
|
devices: m.data.devices || [],
|
||||||
|
calls: m.data.calls || [],
|
||||||
|
callHistory: m.data.callHistory || [],
|
||||||
|
contacts: m.data.contacts || [],
|
||||||
|
});
|
||||||
|
} else if (m.type === 'log') {
|
||||||
|
this.addLog(`${m.ts} ${m.data.message}`);
|
||||||
|
} else if (m.type === 'call-update') {
|
||||||
|
// Real-time call state update — will be picked up by next status snapshot.
|
||||||
|
} else if (m.type?.startsWith('webrtc-')) {
|
||||||
|
// Route to any registered WebRTC signaling handler.
|
||||||
|
const handler = (window as any).__sipRouterWebRtcHandler;
|
||||||
|
if (handler) handler(m);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setBrowserDeviceId(deviceId: string): void {
|
||||||
|
this.update({ browserDeviceId: deviceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
selectContact(contact: IContact | null): void {
|
||||||
|
this.update({ selectedContact: contact });
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelectedContact(): void {
|
||||||
|
this.update({ selectedContact: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async apiCall(number: string, deviceId?: string, providerId?: string): Promise<{ ok: boolean; callId?: string; error?: string }> {
|
||||||
|
const res = await fetch('/api/call', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ number, deviceId, providerId }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async apiHangup(callId: string): Promise<{ ok: boolean }> {
|
||||||
|
const res = await fetch('/api/hangup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ callId }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async apiAddLeg(callId: string, deviceId: string): Promise<{ ok: boolean }> {
|
||||||
|
const res = await fetch(`/api/call/${callId}/addleg`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ deviceId }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async apiAddExternal(callId: string, number: string, providerId?: string): Promise<{ ok: boolean }> {
|
||||||
|
const res = await fetch(`/api/call/${callId}/addexternal`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ number, providerId }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async apiRemoveLeg(callId: string, legId: string): Promise<{ ok: boolean }> {
|
||||||
|
const res = await fetch(`/api/call/${callId}/removeleg`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ legId }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async apiTransfer(sourceCallId: string, legId: string, targetCallId: string): Promise<{ ok: boolean }> {
|
||||||
|
const res = await fetch('/api/transfer', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sourceCallId, legId, targetCallId }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async apiGetConfig(): Promise<any> {
|
||||||
|
const res = await fetch('/api/config');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async apiSaveConfig(updates: any): Promise<{ ok: boolean }> {
|
||||||
|
const res = await fetch('/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appState = new AppStateManager();
|
||||||
215
ts_web/state/notification-manager.ts
Normal file
215
ts_web/state/notification-manager.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Notification manager — drives global banners and toasts based on app state changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { deesCatalog } from '../plugins.js';
|
||||||
|
import { appState, type IAppState } from './appstate.js';
|
||||||
|
|
||||||
|
type BannerType = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
type ToastType = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export class NotificationManager {
|
||||||
|
private appdash: InstanceType<typeof deesCatalog.DeesSimpleAppDash> | null = null;
|
||||||
|
private prevState: IAppState | null = null;
|
||||||
|
private unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
private activeBannerIds = new Set<string>();
|
||||||
|
private userDismissedIds = new Set<string>();
|
||||||
|
|
||||||
|
private suppressToastsUntil = 0;
|
||||||
|
private wsDisconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private wsWasConnected = false;
|
||||||
|
|
||||||
|
init(appdash: InstanceType<typeof deesCatalog.DeesSimpleAppDash>): void {
|
||||||
|
this.appdash = appdash;
|
||||||
|
this.suppressToastsUntil = Date.now() + 3000;
|
||||||
|
|
||||||
|
appdash.addEventListener('message-dismiss', ((e: CustomEvent) => {
|
||||||
|
const id = e.detail?.id;
|
||||||
|
if (id) {
|
||||||
|
this.userDismissedIds.add(id);
|
||||||
|
this.activeBannerIds.delete(id);
|
||||||
|
}
|
||||||
|
}) as EventListener);
|
||||||
|
|
||||||
|
this.unsubscribe = appState.subscribe((state) => this.onStateChange(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.unsubscribe?.();
|
||||||
|
this.unsubscribe = null;
|
||||||
|
if (this.wsDisconnectTimer) {
|
||||||
|
clearTimeout(this.wsDisconnectTimer);
|
||||||
|
this.wsDisconnectTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Change detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private onStateChange(newState: IAppState): void {
|
||||||
|
const prev = this.prevState;
|
||||||
|
|
||||||
|
// First state: snapshot and exit without notifications.
|
||||||
|
if (!prev) {
|
||||||
|
this.prevState = newState;
|
||||||
|
// Exception: if we start disconnected, show the banner immediately.
|
||||||
|
if (!newState.connected) {
|
||||||
|
this.addBanner('ws-disconnected', 'error', 'Connection to server lost. Reconnecting...', { dismissible: false });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.checkWebSocketConnectivity(prev, newState);
|
||||||
|
this.checkProviders(prev, newState);
|
||||||
|
this.checkDevices(prev, newState);
|
||||||
|
this.checkOriginatedCalls(prev, newState);
|
||||||
|
|
||||||
|
this.prevState = newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebSocket connectivity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private checkWebSocketConnectivity(prev: IAppState, next: IAppState): void {
|
||||||
|
if (prev.connected && !next.connected) {
|
||||||
|
// Disconnected.
|
||||||
|
this.addBanner('ws-disconnected', 'error', 'Connection to server lost. Reconnecting...', { dismissible: false });
|
||||||
|
this.wsWasConnected = true;
|
||||||
|
// Debounce: set timer so we can suppress toast on rapid reconnect.
|
||||||
|
if (this.wsDisconnectTimer) clearTimeout(this.wsDisconnectTimer);
|
||||||
|
this.wsDisconnectTimer = setTimeout(() => { this.wsDisconnectTimer = null; }, 500);
|
||||||
|
} else if (!prev.connected && next.connected) {
|
||||||
|
// Reconnected.
|
||||||
|
this.removeBanner('ws-disconnected');
|
||||||
|
if (this.wsWasConnected && !this.wsDisconnectTimer) {
|
||||||
|
this.showToast('Reconnected to server', 'success');
|
||||||
|
}
|
||||||
|
if (this.wsDisconnectTimer) {
|
||||||
|
clearTimeout(this.wsDisconnectTimer);
|
||||||
|
this.wsDisconnectTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider registration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private checkProviders(prev: IAppState, next: IAppState): void {
|
||||||
|
const prevMap = new Map(prev.providers.map((p) => [p.id, p]));
|
||||||
|
const nextMap = new Map(next.providers.map((p) => [p.id, p]));
|
||||||
|
|
||||||
|
for (const [id, np] of nextMap) {
|
||||||
|
const pp = prevMap.get(id);
|
||||||
|
if (!pp) continue; // New provider appeared — no transition to report.
|
||||||
|
|
||||||
|
const bannerId = `provider-unreg-${id}`;
|
||||||
|
|
||||||
|
if (pp.registered && !np.registered) {
|
||||||
|
// Registration lost.
|
||||||
|
this.addBanner(bannerId, 'warning', `${np.displayName}: registration lost`);
|
||||||
|
} else if (!pp.registered && np.registered) {
|
||||||
|
// Registration restored.
|
||||||
|
this.removeBanner(bannerId);
|
||||||
|
this.showToast(`${np.displayName} registered`, 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Device connectivity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private checkDevices(prev: IAppState, next: IAppState): void {
|
||||||
|
const prevMap = new Map(prev.devices.map((d) => [d.id, d]));
|
||||||
|
const nextMap = new Map(next.devices.map((d) => [d.id, d]));
|
||||||
|
|
||||||
|
for (const [id, nd] of nextMap) {
|
||||||
|
// Skip browser devices for banners — connections are transient.
|
||||||
|
if (nd.isBrowser) continue;
|
||||||
|
|
||||||
|
const pd = prevMap.get(id);
|
||||||
|
if (!pd) continue; // New device appeared — no transition.
|
||||||
|
|
||||||
|
const bannerId = `device-offline-${id}`;
|
||||||
|
|
||||||
|
if (pd.connected && !nd.connected) {
|
||||||
|
this.addBanner(bannerId, 'warning', `${nd.displayName} disconnected`);
|
||||||
|
} else if (!pd.connected && nd.connected) {
|
||||||
|
this.removeBanner(bannerId);
|
||||||
|
this.showToast(`${nd.displayName} connected`, 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle devices that disappeared entirely (browser device left).
|
||||||
|
for (const [id, pd] of prevMap) {
|
||||||
|
if (pd.isBrowser) continue;
|
||||||
|
if (!nextMap.has(id) && pd.connected) {
|
||||||
|
this.addBanner(`device-offline-${id}`, 'warning', `${pd.displayName} disconnected`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Originated call state transitions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private checkOriginatedCalls(prev: IAppState, next: IAppState): void {
|
||||||
|
const prevMap = new Map(prev.calls.map((c) => [c.id, c]));
|
||||||
|
|
||||||
|
for (const nc of next.calls) {
|
||||||
|
const pc = prevMap.get(nc.id);
|
||||||
|
if (!pc) continue;
|
||||||
|
|
||||||
|
const label = nc.calleeNumber || nc.callerNumber || nc.id;
|
||||||
|
if (pc.state !== 'connected' && nc.state === 'connected') {
|
||||||
|
this.showToast(`Call ${label} connected`, 'success');
|
||||||
|
} else if (pc.state !== 'terminated' && nc.state === 'terminated') {
|
||||||
|
this.showToast(`Call ${label} ended`, 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Banner helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private addBanner(id: string, type: BannerType, message: string, opts?: { dismissible?: boolean }): void {
|
||||||
|
if (!this.appdash) return;
|
||||||
|
if (this.activeBannerIds.has(id)) return;
|
||||||
|
if (this.userDismissedIds.has(id)) return;
|
||||||
|
|
||||||
|
this.appdash.addMessage({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
dismissible: opts?.dismissible ?? true,
|
||||||
|
});
|
||||||
|
this.activeBannerIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeBanner(id: string): void {
|
||||||
|
if (!this.appdash) return;
|
||||||
|
if (!this.activeBannerIds.has(id)) {
|
||||||
|
// Also clear user-dismissed tracking so the banner can appear again if the problem recurs.
|
||||||
|
this.userDismissedIds.delete(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.appdash.removeMessage(id);
|
||||||
|
this.activeBannerIds.delete(id);
|
||||||
|
this.userDismissedIds.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Toast helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private showToast(message: string, type: ToastType = 'info', duration = 3000): void {
|
||||||
|
if (Date.now() < this.suppressToastsUntil) return;
|
||||||
|
deesCatalog.DeesToast.show({ message, type, duration, position: 'top-right' });
|
||||||
|
}
|
||||||
|
}
|
||||||
248
ts_web/state/webrtc-client.ts
Normal file
248
ts_web/state/webrtc-client.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Browser-side WebRTC client — manages audio capture, playback, and peer connection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IAudioDevices {
|
||||||
|
inputs: MediaDeviceInfo[];
|
||||||
|
outputs: MediaDeviceInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebRtcClient {
|
||||||
|
private pc: RTCPeerConnection | null = null;
|
||||||
|
private localStream: MediaStream | null = null;
|
||||||
|
private remoteAudio: HTMLAudioElement | null = null;
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private sessionId: string;
|
||||||
|
private onStateChange: (state: string) => void;
|
||||||
|
|
||||||
|
// Audio analysis for level meters.
|
||||||
|
private localAnalyser: AnalyserNode | null = null;
|
||||||
|
private remoteAnalyser: AnalyserNode | null = null;
|
||||||
|
private audioCtx: AudioContext | null = null;
|
||||||
|
|
||||||
|
// Device selection.
|
||||||
|
private selectedInputId: string = '';
|
||||||
|
private selectedOutputId: string = '';
|
||||||
|
|
||||||
|
state: 'idle' | 'requesting-mic' | 'connecting' | 'connected' | 'error' = 'idle';
|
||||||
|
|
||||||
|
constructor(onStateChange: (state: string) => void) {
|
||||||
|
this.sessionId = `web-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`;
|
||||||
|
this.onStateChange = onStateChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWebSocket(ws: WebSocket): void {
|
||||||
|
this.ws = ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputDevice(deviceId: string): void {
|
||||||
|
this.selectedInputId = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOutputDevice(deviceId: string): void {
|
||||||
|
this.selectedOutputId = deviceId;
|
||||||
|
if (this.remoteAudio && 'setSinkId' in this.remoteAudio) {
|
||||||
|
(this.remoteAudio as any).setSinkId(deviceId).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSignaling(msg: { type: string; sessionId?: string; sdp?: string; candidate?: any; error?: string }): void {
|
||||||
|
if (msg.sessionId !== this.sessionId) return;
|
||||||
|
|
||||||
|
if (msg.type === 'webrtc-answer' && msg.sdp) {
|
||||||
|
this.handleAnswer(msg.sdp);
|
||||||
|
} else if (msg.type === 'webrtc-ice' && msg.candidate) {
|
||||||
|
this.handleRemoteIce(msg.candidate);
|
||||||
|
} else if (msg.type === 'webrtc-error') {
|
||||||
|
this.setState('error');
|
||||||
|
console.error('[webrtc] server error:', msg.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startCall(): Promise<void> {
|
||||||
|
this.setState('requesting-mic');
|
||||||
|
|
||||||
|
const audioConstraints: MediaTrackConstraints = {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
};
|
||||||
|
if (this.selectedInputId) {
|
||||||
|
audioConstraints.deviceId = { exact: this.selectedInputId };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.localStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: audioConstraints,
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[webrtc] mic access denied:', err);
|
||||||
|
this.setState('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState('connecting');
|
||||||
|
|
||||||
|
// Set up AudioContext for level meters.
|
||||||
|
this.audioCtx = new AudioContext();
|
||||||
|
const localSource = this.audioCtx.createMediaStreamSource(this.localStream);
|
||||||
|
this.localAnalyser = this.audioCtx.createAnalyser();
|
||||||
|
this.localAnalyser.fftSize = 256;
|
||||||
|
localSource.connect(this.localAnalyser);
|
||||||
|
|
||||||
|
this.pc = new RTCPeerConnection({
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add local audio track.
|
||||||
|
for (const track of this.localStream.getTracks()) {
|
||||||
|
this.pc.addTrack(track, this.localStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle remote audio (incoming from SIP provider via proxy).
|
||||||
|
this.pc.ontrack = (event) => {
|
||||||
|
console.log('[webrtc] ontrack fired, streams:', event.streams.length);
|
||||||
|
this.remoteAudio = new Audio();
|
||||||
|
this.remoteAudio.autoplay = true;
|
||||||
|
this.remoteAudio.srcObject = event.streams[0] || new MediaStream([event.track]);
|
||||||
|
|
||||||
|
// Route to selected output device.
|
||||||
|
if (this.selectedOutputId && 'setSinkId' in this.remoteAudio) {
|
||||||
|
(this.remoteAudio as any).setSinkId(this.selectedOutputId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.remoteAudio.play().catch((e) => console.warn('[webrtc] autoplay blocked:', e));
|
||||||
|
|
||||||
|
// Set up remote audio analyser for level meter.
|
||||||
|
if (this.audioCtx && event.streams[0]) {
|
||||||
|
const remoteSource = this.audioCtx.createMediaStreamSource(event.streams[0]);
|
||||||
|
this.remoteAnalyser = this.audioCtx.createAnalyser();
|
||||||
|
this.remoteAnalyser.fftSize = 256;
|
||||||
|
remoteSource.connect(this.remoteAnalyser);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send ICE candidates to server.
|
||||||
|
this.pc.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
this.wsSend({
|
||||||
|
type: 'webrtc-ice',
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
candidate: event.candidate.toJSON(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pc.onconnectionstatechange = () => {
|
||||||
|
if (this.pc?.connectionState === 'connected') {
|
||||||
|
this.setState('connected');
|
||||||
|
} else if (this.pc?.connectionState === 'failed') {
|
||||||
|
this.setState('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create offer and send to server.
|
||||||
|
const offer = await this.pc.createOffer();
|
||||||
|
await this.pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
this.wsSend({
|
||||||
|
type: 'webrtc-offer',
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
sdp: offer.sdp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get current mic input level (0-1). */
|
||||||
|
getLocalLevel(): number {
|
||||||
|
return this.getLevel(this.localAnalyser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get current remote audio level (0-1). */
|
||||||
|
getRemoteLevel(): number {
|
||||||
|
return this.getLevel(this.remoteAnalyser);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLevel(analyser: AnalyserNode | null): number {
|
||||||
|
if (!analyser) return 0;
|
||||||
|
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
analyser.getByteTimeDomainData(data);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const v = (data[i] - 128) / 128;
|
||||||
|
sum += v * v;
|
||||||
|
}
|
||||||
|
return Math.sqrt(sum / data.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
hangup(): void {
|
||||||
|
this.wsSend({
|
||||||
|
type: 'webrtc-hangup',
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
});
|
||||||
|
this.cleanup();
|
||||||
|
this.setState('idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAnswer(sdp: string): Promise<void> {
|
||||||
|
if (!this.pc) return;
|
||||||
|
await this.pc.setRemoteDescription({ type: 'answer', sdp });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRemoteIce(candidate: RTCIceCandidateInit): Promise<void> {
|
||||||
|
if (!this.pc) return;
|
||||||
|
try {
|
||||||
|
await this.pc.addIceCandidate(candidate);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[webrtc] ice error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(state: typeof this.state): void {
|
||||||
|
this.state = state;
|
||||||
|
this.onStateChange(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
||||||
|
this.localStream = null;
|
||||||
|
this.localAnalyser = null;
|
||||||
|
this.remoteAnalyser = null;
|
||||||
|
if (this.remoteAudio) {
|
||||||
|
this.remoteAudio.pause();
|
||||||
|
this.remoteAudio.srcObject = null;
|
||||||
|
this.remoteAudio = null;
|
||||||
|
}
|
||||||
|
this.audioCtx?.close().catch(() => {});
|
||||||
|
this.audioCtx = null;
|
||||||
|
this.pc?.close();
|
||||||
|
this.pc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private wsSend(data: unknown): void {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): string {
|
||||||
|
return this.sessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enumerate audio input/output devices. */
|
||||||
|
export async function getAudioDevices(): Promise<IAudioDevices> {
|
||||||
|
try {
|
||||||
|
// Need to request mic first to get labels.
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
return {
|
||||||
|
inputs: devices.filter((d) => d.kind === 'audioinput'),
|
||||||
|
outputs: devices.filter((d) => d.kind === 'audiooutput'),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { inputs: [], outputs: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user