Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f543ff1568 | |||
| c63a759689 | |||
| a02146633b | |||
| f78639dd19 | |||
| 2aca5f1510 | |||
| 73b28f5f57 | |||
| 10ad432a4c | |||
| 66112091a2 | |||
| c9ae747c95 | |||
| 45f9b9c15c | |||
| 7d59361352 | |||
| 6a130db7c7 | |||
| 93f671f1f9 | |||
| 36eab44e28 | |||
| 9e5aa35fee | |||
| 82f2742db5 | |||
| 239e2ac81d | |||
| ad253f823f | |||
| 3132ba8cbb | |||
| f3b18a7170 | |||
| e6bd64a534 |
79
changelog.md
79
changelog.md
@@ -1,5 +1,84 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-10 - 1.17.2 - fix(proxy-engine)
|
||||
use negotiated SDP payload types when wiring SIP legs and enable default nnnoiseless features for telephony denoising
|
||||
|
||||
- Select the negotiated codec payload type from SDP answers instead of always using the first offered codec
|
||||
- Preserve the device leg's preferred payload type from its own INVITE SDP when attaching it to the mixer
|
||||
- Enable default nnnoiseless features in codec-lib and proxy-engine dependencies
|
||||
|
||||
## 2026-04-10 - 1.17.1 - fix(proxy-engine,codec-lib,sip-proto,ts)
|
||||
preserve negotiated media details and improve RTP audio handling across call legs
|
||||
|
||||
- Use native Opus float encode/decode to avoid unnecessary i16 quantization in the f32 audio path.
|
||||
- Parse full RTP headers including extensions and sequence numbers, then sort inbound packets before decoding to keep codec state stable for out-of-order audio.
|
||||
- Capture negotiated codec payload types from SDP offers and answers and include codec, RTP port, remote media, and metadata in leg_added events.
|
||||
- Emit leg_state_changed and leg_removed events more consistently so the dashboard reflects leg lifecycle updates accurately.
|
||||
|
||||
## 2026-04-10 - 1.17.0 - feat(proxy-engine)
|
||||
upgrade the internal audio bus to 48kHz f32 with per-leg denoising and improve SIP leg routing
|
||||
|
||||
- switch mixer, prompt playback, and tool leg audio handling from 16kHz i16 to 48kHz f32 for higher-quality internal processing
|
||||
- add f32 decode/encode and resampling support plus standalone RNNoise denoiser creation in codec-lib
|
||||
- apply per-leg inbound noise suppression in the mixer before mix-minus generation
|
||||
- fix passthrough call routing by matching the actual leg from the signaling source address when Call-IDs are shared
|
||||
- correct dialed number extraction from bare SIP request URIs by parsing the user part directly
|
||||
|
||||
## 2026-04-10 - 1.16.0 - feat(proxy-engine)
|
||||
integrate Kokoro TTS generation into proxy-engine and simplify TypeScript prompt handling to use cached WAV files
|
||||
|
||||
- adds a generate_tts command to proxy-engine with lazy-loaded Kokoro model support and WAV output generation
|
||||
- removes standalone opus-codec and tts-engine workspace binaries by consolidating TTS generation into proxy-engine
|
||||
- updates announcement and prompt cache flows to generate and cache WAV files on disk instead of pre-encoding RTP frames in TypeScript
|
||||
|
||||
## 2026-04-10 - 1.15.0 - feat(proxy-engine)
|
||||
add device leg, leg transfer, and leg replacement call controls
|
||||
|
||||
- adds proxy-engine commands and call manager support for inviting a registered SIP device into an active call
|
||||
- supports transferring an existing leg between calls while preserving the active connection and updating mixer routing
|
||||
- supports replacing a call leg by removing the current leg and dialing a new outbound destination
|
||||
- wires the frontend add-leg API and TypeScript bridge to the new device leg and leg control commands
|
||||
|
||||
## 2026-04-10 - 1.14.0 - feat(proxy-engine)
|
||||
add multiparty call mixing with dynamic SIP and WebRTC leg management
|
||||
|
||||
- replace passthrough call handling with a mixer-backed call model that tracks multiple legs and exposes leg status in call state output
|
||||
- add mixer and leg I/O infrastructure to bridge SIP RTP and WebRTC audio through channel-based mix-minus processing
|
||||
- introduce add_leg and remove_leg proxy commands and wire frontend bridge APIs to manage external call legs
|
||||
- emit leg lifecycle events for observability and mark unimplemented device-leg and transfer HTTP endpoints with 501 responses
|
||||
|
||||
## 2026-04-10 - 1.13.0 - feat(proxy-engine,webrtc)
|
||||
add B2BUA SIP leg handling and WebRTC call bridging for outbound calls
|
||||
|
||||
- introduce a new SipLeg module to manage outbound provider dialogs, including INVITE lifecycle, digest auth retries, ACK handling, media endpoint tracking, and termination
|
||||
- store outbound dashboard calls as B2BUA calls in the call manager and emit provider media details on call_answered for bridge setup
|
||||
- separate SIP and WebRTC engine locking to avoid contention and deadlocks while linking sessions to call RTP sockets
|
||||
- add bidirectional RTP bridging between provider SIP media and browser WebRTC audio using the allocated RTP socket
|
||||
- wire browser webrtc-accept events in the frontend and sipproxy so session-to-call linking can occur when media and acceptance arrive in either order
|
||||
|
||||
## 2026-04-10 - 1.12.0 - feat(proxy-engine)
|
||||
add Rust-based outbound calling, WebRTC bridging, and voicemail handling
|
||||
|
||||
- adds outbound call origination through the Rust proxy engine with dashboard make_call support
|
||||
- routes unanswered inbound calls to voicemail, including greeting playback, beep generation, and WAV message recording
|
||||
- introduces Rust WebRTC session handling and SIP audio bridging, replacing the previous TypeScript WebRTC path
|
||||
- moves SIP registration and routing responsibilities further into the Rust proxy engine and removes legacy TypeScript call/SIP modules
|
||||
|
||||
## 2026-04-10 - 1.11.0 - feat(rust-proxy-engine)
|
||||
add a Rust SIP proxy engine with shared SIP and codec libraries
|
||||
|
||||
- add new Rust workspace crates for proxy-engine, sip-proto, and codec-lib
|
||||
- move transcoding logic out of opus-codec into reusable codec-lib and keep opus-codec as a thin CLI wrapper
|
||||
- implement SIP message parsing, dialog handling, SDP/URI rewriting, provider registration, device registration, call management, RTP relay, and DTMF detection in Rust
|
||||
- add a TypeScript proxy bridge and update the SIP proxy entrypoint to spawn and configure the Rust engine as the SIP data plane
|
||||
|
||||
## 2026-04-10 - 1.10.0 - feat(call, voicemail, ivr)
|
||||
add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management
|
||||
|
||||
- introduces system call legs, DTMF detection, prompt caching, and audio recording to support automated call handling
|
||||
- adds configurable voiceboxes and IVR menus to routing, including voicemail fallback on busy or no-answer flows
|
||||
- exposes voicemail message APIs, message waiting counts, and new dashboard views for voicemail and IVR management
|
||||
|
||||
## 2026-04-10 - 1.9.0 - feat(routing)
|
||||
add rule-based SIP routing for inbound and outbound calls with dashboard route management
|
||||
|
||||
|
||||
BIN
nogit/voicemail/default/msg-1775825168199.wav
Normal file
BIN
nogit/voicemail/default/msg-1775825168199.wav
Normal file
Binary file not shown.
BIN
nogit/voicemail/default/msg-1775840000387.wav
Normal file
BIN
nogit/voicemail/default/msg-1775840000387.wav
Normal file
Binary file not shown.
BIN
nogit/voicemail/default/msg-1775840014276.wav
Normal file
BIN
nogit/voicemail/default/msg-1775840014276.wav
Normal file
Binary file not shown.
BIN
nogit/voicemail/default/msg-1775840439400.wav
Normal file
BIN
nogit/voicemail/default/msg-1775840439400.wav
Normal file
Binary file not shown.
BIN
nogit/voicemail/default/msg-1775840447441.wav
Normal file
BIN
nogit/voicemail/default/msg-1775840447441.wav
Normal file
Binary file not shown.
BIN
nogit/voicemail/default/msg-1775840454835.wav
Normal file
BIN
nogit/voicemail/default/msg-1775840454835.wav
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "siprouter",
|
||||
"version": "1.9.0",
|
||||
"version": "1.17.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -14,7 +14,6 @@
|
||||
"@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": {
|
||||
|
||||
2199
rust/Cargo.lock
generated
2199
rust/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
||||
[workspace]
|
||||
members = ["crates/opus-codec", "crates/tts-engine"]
|
||||
members = [
|
||||
"crates/codec-lib",
|
||||
"crates/sip-proto",
|
||||
"crates/proxy-engine",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
|
||||
10
rust/crates/codec-lib/Cargo.toml
Normal file
10
rust/crates/codec-lib/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "codec-lib"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
audiopus = "0.3.0-rc.0"
|
||||
ezk-g722 = "0.1"
|
||||
rubato = "0.14"
|
||||
nnnoiseless = "0.5"
|
||||
472
rust/crates/codec-lib/src/lib.rs
Normal file
472
rust/crates/codec-lib/src/lib.rs
Normal file
@@ -0,0 +1,472 @@
|
||||
//! Audio codec library for the SIP router.
|
||||
//!
|
||||
//! Handles Opus ↔ G.722 ↔ PCMU/PCMA transcoding with ML noise suppression.
|
||||
//! Used by the `proxy-engine` binary for all audio transcoding.
|
||||
|
||||
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 ezk_g722::libg722::{self, Bitrate};
|
||||
use nnnoiseless::DenoiseState;
|
||||
use rubato::{FftFixedIn, Resampler};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---- Payload type constants ------------------------------------------------
|
||||
|
||||
pub const PT_PCMU: u8 = 0;
|
||||
pub const PT_PCMA: u8 = 8;
|
||||
pub const PT_G722: u8 = 9;
|
||||
pub const PT_OPUS: u8 = 111;
|
||||
|
||||
/// Return the native sample rate for a given payload type.
|
||||
pub fn codec_sample_rate(pt: u8) -> u32 {
|
||||
match pt {
|
||||
PT_OPUS => 48000,
|
||||
PT_G722 => 16000,
|
||||
_ => 8000, // PCMU, PCMA
|
||||
}
|
||||
}
|
||||
|
||||
// ---- G.711 µ-law (PCMU) ---------------------------------------------------
|
||||
|
||||
pub fn mulaw_encode(sample: i16) -> u8 {
|
||||
const BIAS: i16 = 0x84;
|
||||
const CLIP: i16 = 32635;
|
||||
let sign = if sample < 0 { 0x80u8 } else { 0 };
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn mulaw_decode(mulaw: u8) -> i16 {
|
||||
let v = !mulaw;
|
||||
let sign = v & 0x80;
|
||||
let exp = (v >> 4) & 0x07;
|
||||
let mantissa = v & 0x0f;
|
||||
// Use i32 to avoid overflow when exp=7, mantissa=15 (result > i16::MAX).
|
||||
let mut sample = (((mantissa as i32) << 4) + 0x84) << exp;
|
||||
sample -= 0x84;
|
||||
let sample = if sign != 0 { -sample } else { sample };
|
||||
sample.clamp(-32768, 32767) as i16
|
||||
}
|
||||
|
||||
// ---- G.711 A-law (PCMA) ---------------------------------------------------
|
||||
|
||||
pub fn alaw_encode(sample: i16) -> u8 {
|
||||
let sign = if sample >= 0 { 0x80u8 } else { 0 };
|
||||
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
|
||||
}
|
||||
|
||||
pub fn alaw_decode(alaw: u8) -> i16 {
|
||||
let v = alaw ^ 0x55;
|
||||
let sign = v & 0x80;
|
||||
let exp = (v >> 4) & 0x07;
|
||||
let mantissa = v & 0x0f;
|
||||
// Use i32 to avoid overflow for extreme values.
|
||||
let sample = if exp == 0 {
|
||||
((mantissa as i32) << 4) + 8
|
||||
} else {
|
||||
(((mantissa as i32) << 4) + 0x108) << (exp - 1)
|
||||
};
|
||||
let sample = if sign != 0 { sample } else { -sample };
|
||||
sample.clamp(-32768, 32767) as i16
|
||||
}
|
||||
|
||||
// ---- TranscodeState --------------------------------------------------------
|
||||
|
||||
/// Per-session codec state holding Opus, G.722, resampler, and denoiser instances.
|
||||
///
|
||||
/// Each concurrent call should get its own `TranscodeState` to prevent stateful
|
||||
/// codecs (Opus, G.722 ADPCM) from corrupting each other.
|
||||
pub 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>>,
|
||||
/// Cached f32 FFT resamplers keyed by (from_rate, to_rate, chunk_size).
|
||||
resamplers_f32: HashMap<(u32, u32, usize), FftFixedIn<f32>>,
|
||||
/// ML noise suppression for the SIP-bound direction.
|
||||
denoiser_to_sip: Box<DenoiseState<'static>>,
|
||||
/// ML noise suppression for the browser-bound direction.
|
||||
denoiser_to_browser: Box<DenoiseState<'static>>,
|
||||
}
|
||||
|
||||
impl TranscodeState {
|
||||
/// Create a new transcoding session with fresh codec state.
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let mut opus_enc =
|
||||
OpusEncoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
||||
.map_err(|e| format!("opus encoder: {e}"))?;
|
||||
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(),
|
||||
resamplers_f32: 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.
|
||||
pub 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);
|
||||
|
||||
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();
|
||||
|
||||
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}"))?;
|
||||
|
||||
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.
|
||||
pub fn denoise(denoiser: &mut DenoiseState, pcm: &[i16]) -> Vec<i16> {
|
||||
let frame_size = DenoiseState::FRAME_SIZE; // 480
|
||||
let total = pcm.len();
|
||||
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),
|
||||
);
|
||||
}
|
||||
if whole < total {
|
||||
output.extend_from_slice(&pcm[whole..]);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// Transcode audio payload from one codec to another.
|
||||
///
|
||||
/// `direction`: `Some("to_sip")` or `Some("to_browser")` selects per-direction
|
||||
/// denoiser. `None` skips denoising (backward compat).
|
||||
pub 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());
|
||||
}
|
||||
|
||||
let (pcm, rate) = self.decode_to_pcm(data, from_pt)?;
|
||||
|
||||
let processed = if let Some(dir) = direction {
|
||||
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);
|
||||
let target_rate = codec_sample_rate(to_pt);
|
||||
self.resample(&denoised, 48000, target_rate)?
|
||||
} else {
|
||||
let target_rate = codec_sample_rate(to_pt);
|
||||
if rate == target_rate {
|
||||
pcm
|
||||
} else {
|
||||
self.resample(&pcm, rate, target_rate)?
|
||||
}
|
||||
};
|
||||
|
||||
self.encode_from_pcm(&processed, to_pt)
|
||||
}
|
||||
|
||||
/// Decode an encoded audio payload to raw 16-bit PCM samples.
|
||||
/// Returns (samples, sample_rate).
|
||||
pub 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
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode raw PCM samples to an audio codec.
|
||||
pub 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}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- f32 API for high-quality internal bus ----------------------------
|
||||
|
||||
/// Decode an encoded audio payload to f32 PCM samples in [-1.0, 1.0].
|
||||
/// Returns (samples, sample_rate).
|
||||
///
|
||||
/// For Opus, uses native float decode (no i16 quantization).
|
||||
/// For G.722/G.711, decodes to i16 then converts (codec is natively i16).
|
||||
pub fn decode_to_f32(&mut self, data: &[u8], pt: u8) -> Result<(Vec<f32>, u32), String> {
|
||||
match pt {
|
||||
PT_OPUS => {
|
||||
let mut pcm = vec![0.0f32; 5760]; // up to 120ms at 48kHz
|
||||
let packet =
|
||||
OpusPacket::try_from(data).map_err(|e| format!("opus packet: {e}"))?;
|
||||
let out =
|
||||
MutSignals::try_from(&mut pcm[..]).map_err(|e| format!("opus signals: {e}"))?;
|
||||
let n: usize = self
|
||||
.opus_dec
|
||||
.decode_float(Some(packet), out, false)
|
||||
.map_err(|e| format!("opus decode_float: {e}"))?
|
||||
.into();
|
||||
pcm.truncate(n);
|
||||
Ok((pcm, 48000))
|
||||
}
|
||||
_ => {
|
||||
// G.722, PCMU, PCMA: natively i16 codecs — decode then convert.
|
||||
let (pcm_i16, rate) = self.decode_to_pcm(data, pt)?;
|
||||
let pcm_f32 = pcm_i16.iter().map(|&s| s as f32 / 32768.0).collect();
|
||||
Ok((pcm_f32, rate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode f32 PCM samples ([-1.0, 1.0]) to an audio codec.
|
||||
///
|
||||
/// For Opus, uses native float encode (no i16 quantization).
|
||||
/// For G.722/G.711, converts to i16 then encodes (codec is natively i16).
|
||||
pub fn encode_from_f32(&mut self, pcm: &[f32], pt: u8) -> Result<Vec<u8>, String> {
|
||||
match pt {
|
||||
PT_OPUS => {
|
||||
let mut buf = vec![0u8; 4000];
|
||||
let n: usize = self
|
||||
.opus_enc
|
||||
.encode_float(pcm, &mut buf)
|
||||
.map_err(|e| format!("opus encode_float: {e}"))?
|
||||
.into();
|
||||
buf.truncate(n);
|
||||
Ok(buf)
|
||||
}
|
||||
_ => {
|
||||
// G.722, PCMU, PCMA: natively i16 codecs.
|
||||
let pcm_i16: Vec<i16> = pcm
|
||||
.iter()
|
||||
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
|
||||
.collect();
|
||||
self.encode_from_pcm(&pcm_i16, pt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// High-quality sample rate conversion for f32 PCM using rubato FFT resampler.
|
||||
/// Uses a separate cache from the i16 resampler.
|
||||
pub fn resample_f32(
|
||||
&mut self,
|
||||
pcm: &[f32],
|
||||
from_rate: u32,
|
||||
to_rate: u32,
|
||||
) -> Result<Vec<f32>, 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);
|
||||
|
||||
if !self.resamplers_f32.contains_key(&key) {
|
||||
let r =
|
||||
FftFixedIn::<f32>::new(from_rate as usize, to_rate as usize, chunk, 1, 1)
|
||||
.map_err(|e| format!("resampler f32 {from_rate}->{to_rate}: {e}"))?;
|
||||
self.resamplers_f32.insert(key, r);
|
||||
}
|
||||
let resampler = self.resamplers_f32.get_mut(&key).unwrap();
|
||||
|
||||
let input = vec![pcm.to_vec()];
|
||||
let result = resampler
|
||||
.process(&input, None)
|
||||
.map_err(|e| format!("resample f32 {from_rate}->{to_rate}: {e}"))?;
|
||||
|
||||
Ok(result[0].clone())
|
||||
}
|
||||
|
||||
/// Apply RNNoise ML noise suppression to 48kHz f32 PCM audio.
|
||||
/// Processes in 480-sample (10ms) frames. State persists across calls.
|
||||
/// Operates natively in f32 — no i16 conversion overhead.
|
||||
pub fn denoise_f32(denoiser: &mut DenoiseState, pcm: &[f32]) -> Vec<f32> {
|
||||
let frame_size = DenoiseState::FRAME_SIZE; // 480
|
||||
let total = pcm.len();
|
||||
let whole = (total / frame_size) * frame_size;
|
||||
let mut output = Vec::with_capacity(total);
|
||||
let mut out_buf = [0.0f32; 480];
|
||||
|
||||
// nnnoiseless expects f32 samples scaled as i16 range (-32768..32767).
|
||||
for offset in (0..whole).step_by(frame_size) {
|
||||
let input: Vec<f32> = pcm[offset..offset + frame_size]
|
||||
.iter()
|
||||
.map(|&s| s * 32768.0)
|
||||
.collect();
|
||||
denoiser.process_frame(&mut out_buf, &input);
|
||||
output.extend(out_buf.iter().map(|&s| s / 32768.0));
|
||||
}
|
||||
if whole < total {
|
||||
output.extend_from_slice(&pcm[whole..]);
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new standalone denoiser for per-leg inbound processing.
|
||||
pub fn new_denoiser() -> Box<DenoiseState<'static>> {
|
||||
DenoiseState::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mulaw_roundtrip() {
|
||||
for sample in [-32768i16, -1000, -1, 0, 1, 1000, 32767] {
|
||||
let encoded = mulaw_encode(sample);
|
||||
let decoded = mulaw_decode(encoded);
|
||||
// µ-law is lossy; verify the decoded value is close.
|
||||
assert!((sample as i32 - decoded as i32).abs() < 1000,
|
||||
"µ-law roundtrip failed for {sample}: got {decoded}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alaw_roundtrip() {
|
||||
for sample in [-32768i16, -1000, -1, 0, 1, 1000, 32767] {
|
||||
let encoded = alaw_encode(sample);
|
||||
let decoded = alaw_decode(encoded);
|
||||
assert!((sample as i32 - decoded as i32).abs() < 1000,
|
||||
"A-law roundtrip failed for {sample}: got {decoded}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_sample_rates() {
|
||||
assert_eq!(codec_sample_rate(PT_OPUS), 48000);
|
||||
assert_eq!(codec_sample_rate(PT_G722), 16000);
|
||||
assert_eq!(codec_sample_rate(PT_PCMU), 8000);
|
||||
assert_eq!(codec_sample_rate(PT_PCMA), 8000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcode_same_pt_is_passthrough() {
|
||||
let mut st = TranscodeState::new().unwrap();
|
||||
let data = vec![0u8; 160];
|
||||
let result = st.transcode(&data, PT_PCMU, PT_PCMU, None).unwrap();
|
||||
assert_eq!(result, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcmu_to_pcma_roundtrip() {
|
||||
let mut st = TranscodeState::new().unwrap();
|
||||
// 160 bytes = 20ms of PCMU at 8kHz
|
||||
let pcmu_data: Vec<u8> = (0..160).map(|i| mulaw_encode((i as i16 * 200) - 16000)).collect();
|
||||
let pcma = st.transcode(&pcmu_data, PT_PCMU, PT_PCMA, None).unwrap();
|
||||
assert_eq!(pcma.len(), 160); // Same frame size
|
||||
let back = st.transcode(&pcma, PT_PCMA, PT_PCMU, None).unwrap();
|
||||
assert_eq!(back.len(), 160);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
[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 }
|
||||
@@ -1,464 +0,0 @@
|
||||
/// 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))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,26 @@
|
||||
[package]
|
||||
name = "tts-engine"
|
||||
name = "proxy-engine"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "tts-engine"
|
||||
name = "proxy-engine"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
codec-lib = { path = "../codec-lib" }
|
||||
sip-proto = { path = "../sip-proto" }
|
||||
nnnoiseless = "0.5"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
regex-lite = "0.1"
|
||||
webrtc = "0.8"
|
||||
rand = "0.8"
|
||||
hound = "3.5"
|
||||
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"
|
||||
240
rust/crates/proxy-engine/src/audio_player.rs
Normal file
240
rust/crates/proxy-engine/src/audio_player.rs
Normal file
@@ -0,0 +1,240 @@
|
||||
//! Audio player — reads a WAV file and streams it as RTP packets.
|
||||
//! Also provides prompt preparation for the leg interaction system.
|
||||
|
||||
use crate::rtp::{build_rtp_header, rtp_clock_increment};
|
||||
use codec_lib::{codec_sample_rate, TranscodeState};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::time::{self, Duration};
|
||||
|
||||
/// Mixing sample rate used by the mixer (must stay in sync with mixer::MIX_RATE).
|
||||
const MIX_RATE: u32 = 48000;
|
||||
/// Samples per 20ms frame at the mixing rate.
|
||||
const MIX_FRAME_SIZE: usize = 960;
|
||||
|
||||
/// Play a WAV file as RTP to a destination.
|
||||
/// Returns when playback is complete.
|
||||
pub async fn play_wav_file(
|
||||
file_path: &str,
|
||||
socket: Arc<UdpSocket>,
|
||||
dest: SocketAddr,
|
||||
codec_pt: u8,
|
||||
ssrc: u32,
|
||||
) -> Result<u32, String> {
|
||||
let path = Path::new(file_path);
|
||||
if !path.exists() {
|
||||
return Err(format!("WAV file not found: {file_path}"));
|
||||
}
|
||||
|
||||
// Read WAV file.
|
||||
let mut reader =
|
||||
hound::WavReader::open(path).map_err(|e| format!("open WAV {file_path}: {e}"))?;
|
||||
let spec = reader.spec();
|
||||
let wav_rate = spec.sample_rate;
|
||||
|
||||
// Read all samples as i16.
|
||||
let samples: Vec<i16> = if spec.bits_per_sample == 16 {
|
||||
reader
|
||||
.samples::<i16>()
|
||||
.filter_map(|s| s.ok())
|
||||
.collect()
|
||||
} else if spec.bits_per_sample == 32 && spec.sample_format == hound::SampleFormat::Float {
|
||||
reader
|
||||
.samples::<f32>()
|
||||
.filter_map(|s| s.ok())
|
||||
.map(|s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
|
||||
.collect()
|
||||
} else {
|
||||
return Err(format!(
|
||||
"unsupported WAV format: {}bit {:?}",
|
||||
spec.bits_per_sample, spec.sample_format
|
||||
));
|
||||
};
|
||||
|
||||
if samples.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Create codec state for encoding.
|
||||
let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?;
|
||||
|
||||
// Resample to target codec rate.
|
||||
let target_rate = codec_sample_rate(codec_pt);
|
||||
let resampled = if wav_rate != target_rate {
|
||||
transcoder
|
||||
.resample(&samples, wav_rate, target_rate)
|
||||
.map_err(|e| format!("resample: {e}"))?
|
||||
} else {
|
||||
samples
|
||||
};
|
||||
|
||||
// Calculate frame size (20ms of audio at target rate).
|
||||
let frame_samples = (target_rate as usize) / 50; // 20ms = 1/50 second
|
||||
|
||||
// Stream as RTP at 20ms intervals.
|
||||
let mut seq: u16 = 0;
|
||||
let mut ts: u32 = 0;
|
||||
let mut offset = 0;
|
||||
let mut interval = time::interval(Duration::from_millis(20));
|
||||
let mut frames_sent = 0u32;
|
||||
|
||||
while offset < resampled.len() {
|
||||
interval.tick().await;
|
||||
|
||||
let end = (offset + frame_samples).min(resampled.len());
|
||||
let frame = &resampled[offset..end];
|
||||
|
||||
// Pad short final frame with silence.
|
||||
let frame_data = if frame.len() < frame_samples {
|
||||
let mut padded = frame.to_vec();
|
||||
padded.resize(frame_samples, 0);
|
||||
padded
|
||||
} else {
|
||||
frame.to_vec()
|
||||
};
|
||||
|
||||
// Encode to target codec.
|
||||
let encoded = match transcoder.encode_from_pcm(&frame_data, codec_pt) {
|
||||
Ok(e) if !e.is_empty() => e,
|
||||
_ => {
|
||||
offset += frame_samples;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Build RTP packet.
|
||||
let header = build_rtp_header(codec_pt, seq, ts, ssrc);
|
||||
let mut packet = header.to_vec();
|
||||
packet.extend_from_slice(&encoded);
|
||||
|
||||
let _ = socket.send_to(&packet, dest).await;
|
||||
|
||||
seq = seq.wrapping_add(1);
|
||||
ts = ts.wrapping_add(rtp_clock_increment(codec_pt));
|
||||
offset += frame_samples;
|
||||
frames_sent += 1;
|
||||
}
|
||||
|
||||
Ok(frames_sent)
|
||||
}
|
||||
|
||||
/// Generate and play a beep tone (sine wave) as RTP.
|
||||
pub async fn play_beep(
|
||||
socket: Arc<UdpSocket>,
|
||||
dest: SocketAddr,
|
||||
codec_pt: u8,
|
||||
ssrc: u32,
|
||||
start_seq: u16,
|
||||
start_ts: u32,
|
||||
freq_hz: u32,
|
||||
duration_ms: u32,
|
||||
) -> Result<(u16, u32), String> {
|
||||
let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?;
|
||||
let target_rate = codec_sample_rate(codec_pt);
|
||||
let frame_samples = (target_rate as usize) / 50;
|
||||
let total_samples = (target_rate as usize * duration_ms as usize) / 1000;
|
||||
|
||||
// Generate sine wave.
|
||||
let amplitude = 16000i16;
|
||||
let sine: Vec<i16> = (0..total_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / target_rate as f64;
|
||||
(amplitude as f64 * (2.0 * std::f64::consts::PI * freq_hz as f64 * t).sin()) as i16
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut seq = start_seq;
|
||||
let mut ts = start_ts;
|
||||
let mut offset = 0;
|
||||
let mut interval = time::interval(Duration::from_millis(20));
|
||||
|
||||
while offset < sine.len() {
|
||||
interval.tick().await;
|
||||
|
||||
let end = (offset + frame_samples).min(sine.len());
|
||||
let mut frame = sine[offset..end].to_vec();
|
||||
frame.resize(frame_samples, 0);
|
||||
|
||||
let encoded = match transcoder.encode_from_pcm(&frame, codec_pt) {
|
||||
Ok(e) if !e.is_empty() => e,
|
||||
_ => {
|
||||
offset += frame_samples;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let header = build_rtp_header(codec_pt, seq, ts, ssrc);
|
||||
let mut packet = header.to_vec();
|
||||
packet.extend_from_slice(&encoded);
|
||||
let _ = socket.send_to(&packet, dest).await;
|
||||
|
||||
seq = seq.wrapping_add(1);
|
||||
ts = ts.wrapping_add(rtp_clock_increment(codec_pt));
|
||||
offset += frame_samples;
|
||||
}
|
||||
|
||||
Ok((seq, ts))
|
||||
}
|
||||
|
||||
/// Load a WAV file and split it into 20ms f32 PCM frames at 48kHz.
|
||||
/// Used by the leg interaction system to prepare prompt audio for the mixer.
|
||||
pub fn load_prompt_pcm_frames(wav_path: &str) -> Result<Vec<Vec<f32>>, String> {
|
||||
let path = Path::new(wav_path);
|
||||
if !path.exists() {
|
||||
return Err(format!("WAV file not found: {wav_path}"));
|
||||
}
|
||||
|
||||
let mut reader =
|
||||
hound::WavReader::open(path).map_err(|e| format!("open WAV {wav_path}: {e}"))?;
|
||||
let spec = reader.spec();
|
||||
let wav_rate = spec.sample_rate;
|
||||
|
||||
// Read all samples as f32 in [-1.0, 1.0].
|
||||
let samples: Vec<f32> = if spec.bits_per_sample == 16 {
|
||||
reader
|
||||
.samples::<i16>()
|
||||
.filter_map(|s| s.ok())
|
||||
.map(|s| s as f32 / 32768.0)
|
||||
.collect()
|
||||
} else if spec.bits_per_sample == 32 && spec.sample_format == hound::SampleFormat::Float {
|
||||
reader
|
||||
.samples::<f32>()
|
||||
.filter_map(|s| s.ok())
|
||||
.collect()
|
||||
} else {
|
||||
return Err(format!(
|
||||
"unsupported WAV format: {}bit {:?}",
|
||||
spec.bits_per_sample, spec.sample_format
|
||||
));
|
||||
};
|
||||
|
||||
if samples.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Resample to MIX_RATE (48kHz) if needed.
|
||||
let resampled = if wav_rate != MIX_RATE {
|
||||
let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?;
|
||||
transcoder
|
||||
.resample_f32(&samples, wav_rate, MIX_RATE)
|
||||
.map_err(|e| format!("resample: {e}"))?
|
||||
} else {
|
||||
samples
|
||||
};
|
||||
|
||||
// Split into MIX_FRAME_SIZE (960) sample frames.
|
||||
let mut frames = Vec::new();
|
||||
let mut offset = 0;
|
||||
while offset < resampled.len() {
|
||||
let end = (offset + MIX_FRAME_SIZE).min(resampled.len());
|
||||
let mut frame = resampled[offset..end].to_vec();
|
||||
// Pad short final frame with silence.
|
||||
frame.resize(MIX_FRAME_SIZE, 0.0);
|
||||
frames.push(frame);
|
||||
offset += MIX_FRAME_SIZE;
|
||||
}
|
||||
|
||||
Ok(frames)
|
||||
}
|
||||
252
rust/crates/proxy-engine/src/call.rs
Normal file
252
rust/crates/proxy-engine/src/call.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
//! Call hub — owns N legs and a mixer task.
|
||||
//!
|
||||
//! Every call has a central mixer that provides mix-minus audio to all
|
||||
//! participants. Legs can be added and removed dynamically mid-call.
|
||||
|
||||
use crate::mixer::{MixerCommand, RtpPacket};
|
||||
use crate::sip_leg::SipLeg;
|
||||
use sip_proto::message::SipMessage;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
pub type LegId = String;
|
||||
|
||||
/// Call state machine.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CallState {
|
||||
SettingUp,
|
||||
Ringing,
|
||||
Connected,
|
||||
Voicemail,
|
||||
Terminated,
|
||||
}
|
||||
|
||||
impl CallState {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SettingUp => "setting-up",
|
||||
Self::Ringing => "ringing",
|
||||
Self::Connected => "connected",
|
||||
Self::Voicemail => "voicemail",
|
||||
Self::Terminated => "terminated",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CallDirection {
|
||||
Inbound,
|
||||
Outbound,
|
||||
}
|
||||
|
||||
impl CallDirection {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Inbound => "inbound",
|
||||
Self::Outbound => "outbound",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of a call leg.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LegKind {
|
||||
SipProvider,
|
||||
SipDevice,
|
||||
WebRtc,
|
||||
Media, // voicemail playback, IVR, recording
|
||||
Tool, // observer leg for recording, transcription, etc.
|
||||
}
|
||||
|
||||
impl LegKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SipProvider => "sip-provider",
|
||||
Self::SipDevice => "sip-device",
|
||||
Self::WebRtc => "webrtc",
|
||||
Self::Media => "media",
|
||||
Self::Tool => "tool",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-leg state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LegState {
|
||||
Inviting,
|
||||
Ringing,
|
||||
Connected,
|
||||
Terminated,
|
||||
}
|
||||
|
||||
impl LegState {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Inviting => "inviting",
|
||||
Self::Ringing => "ringing",
|
||||
Self::Connected => "connected",
|
||||
Self::Terminated => "terminated",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a single leg in a call.
|
||||
pub struct LegInfo {
|
||||
pub id: LegId,
|
||||
pub kind: LegKind,
|
||||
pub state: LegState,
|
||||
pub codec_pt: u8,
|
||||
|
||||
/// For SIP legs: the SIP dialog manager (handles 407 auth, BYE, etc).
|
||||
pub sip_leg: Option<SipLeg>,
|
||||
/// For SIP legs: the SIP Call-ID for message routing.
|
||||
pub sip_call_id: Option<String>,
|
||||
/// For WebRTC legs: the session ID in WebRtcEngine.
|
||||
pub webrtc_session_id: Option<String>,
|
||||
/// The RTP socket allocated for this leg.
|
||||
pub rtp_socket: Option<Arc<UdpSocket>>,
|
||||
/// The RTP port number.
|
||||
pub rtp_port: u16,
|
||||
/// The remote media endpoint (learned from SDP or address learning).
|
||||
pub remote_media: Option<SocketAddr>,
|
||||
/// SIP signaling address (provider or device).
|
||||
pub signaling_addr: Option<SocketAddr>,
|
||||
|
||||
/// Flexible key-value metadata (consent state, tool config, etc.).
|
||||
/// Persisted into call history on call end.
|
||||
pub metadata: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// A multiparty call with N legs and a central mixer.
|
||||
pub struct Call {
|
||||
pub id: String,
|
||||
pub state: CallState,
|
||||
pub direction: CallDirection,
|
||||
pub created_at: Instant,
|
||||
|
||||
// Metadata.
|
||||
pub caller_number: Option<String>,
|
||||
pub callee_number: Option<String>,
|
||||
pub provider_id: String,
|
||||
|
||||
/// Original INVITE from the device (for device-originated outbound calls).
|
||||
/// Used to construct proper 180/200/error responses back to the device.
|
||||
pub device_invite: Option<SipMessage>,
|
||||
|
||||
/// All legs in this call, keyed by leg ID.
|
||||
pub legs: HashMap<LegId, LegInfo>,
|
||||
|
||||
/// Channel to send commands to the mixer task.
|
||||
pub mixer_cmd_tx: mpsc::Sender<MixerCommand>,
|
||||
|
||||
/// Handle to the mixer task (aborted on call teardown).
|
||||
mixer_task: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Call {
|
||||
pub fn new(
|
||||
id: String,
|
||||
direction: CallDirection,
|
||||
provider_id: String,
|
||||
mixer_cmd_tx: mpsc::Sender<MixerCommand>,
|
||||
mixer_task: JoinHandle<()>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
state: CallState::SettingUp,
|
||||
direction,
|
||||
created_at: Instant::now(),
|
||||
caller_number: None,
|
||||
callee_number: None,
|
||||
provider_id,
|
||||
device_invite: None,
|
||||
legs: HashMap::new(),
|
||||
mixer_cmd_tx,
|
||||
mixer_task: Some(mixer_task),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a leg to the mixer. Sends the AddLeg command with channel endpoints.
|
||||
pub async fn add_leg_to_mixer(
|
||||
&self,
|
||||
leg_id: &str,
|
||||
codec_pt: u8,
|
||||
inbound_rx: mpsc::Receiver<RtpPacket>,
|
||||
outbound_tx: mpsc::Sender<Vec<u8>>,
|
||||
) {
|
||||
let _ = self
|
||||
.mixer_cmd_tx
|
||||
.send(MixerCommand::AddLeg {
|
||||
leg_id: leg_id.to_string(),
|
||||
codec_pt,
|
||||
inbound_rx,
|
||||
outbound_tx,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Remove a leg from the mixer.
|
||||
pub async fn remove_leg_from_mixer(&self, leg_id: &str) {
|
||||
let _ = self
|
||||
.mixer_cmd_tx
|
||||
.send(MixerCommand::RemoveLeg {
|
||||
leg_id: leg_id.to_string(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub fn duration_secs(&self) -> u64 {
|
||||
self.created_at.elapsed().as_secs()
|
||||
}
|
||||
|
||||
/// Shut down the mixer and abort its task.
|
||||
pub async fn shutdown_mixer(&mut self) {
|
||||
let _ = self.mixer_cmd_tx.send(MixerCommand::Shutdown).await;
|
||||
if let Some(handle) = self.mixer_task.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce a JSON status snapshot for the dashboard.
|
||||
pub fn to_status_json(&self) -> serde_json::Value {
|
||||
let legs: Vec<serde_json::Value> = self
|
||||
.legs
|
||||
.values()
|
||||
.filter(|l| l.state != LegState::Terminated)
|
||||
.map(|l| {
|
||||
let metadata: serde_json::Value = if l.metadata.is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::Value::Object(
|
||||
l.metadata.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
|
||||
)
|
||||
};
|
||||
serde_json::json!({
|
||||
"id": l.id,
|
||||
"type": l.kind.as_str(),
|
||||
"state": l.state.as_str(),
|
||||
"codec": sip_proto::helpers::codec_name(l.codec_pt),
|
||||
"rtpPort": l.rtp_port,
|
||||
"remoteMedia": l.remote_media.map(|a| format!("{}:{}", a.ip(), a.port())),
|
||||
"metadata": metadata,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::json!({
|
||||
"id": self.id,
|
||||
"state": self.state.as_str(),
|
||||
"direction": self.direction.as_str(),
|
||||
"callerNumber": self.caller_number,
|
||||
"calleeNumber": self.callee_number,
|
||||
"providerUsed": self.provider_id,
|
||||
"duration": self.duration_secs(),
|
||||
"legs": legs,
|
||||
})
|
||||
}
|
||||
}
|
||||
1653
rust/crates/proxy-engine/src/call_manager.rs
Normal file
1653
rust/crates/proxy-engine/src/call_manager.rs
Normal file
File diff suppressed because it is too large
Load Diff
325
rust/crates/proxy-engine/src/config.rs
Normal file
325
rust/crates/proxy-engine/src/config.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
//! Configuration types received from the TypeScript control plane.
|
||||
//!
|
||||
//! TypeScript loads config from `.nogit/config.json` and sends it to the
|
||||
//! proxy engine via the `configure` command. These types mirror the TS interfaces.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// Network endpoint.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Endpoint {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Endpoint {
|
||||
/// Resolve to a SocketAddr. Handles both IP addresses and hostnames.
|
||||
pub fn to_socket_addr(&self) -> Option<SocketAddr> {
|
||||
// Try direct parse first (IP address).
|
||||
if let Ok(addr) = format!("{}:{}", self.address, self.port).parse() {
|
||||
return Some(addr);
|
||||
}
|
||||
// DNS resolution for hostnames.
|
||||
use std::net::ToSocketAddrs;
|
||||
format!("{}:{}", self.address, self.port)
|
||||
.to_socket_addrs()
|
||||
.ok()
|
||||
.and_then(|mut addrs| addrs.next())
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider quirks for codec/protocol workarounds.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Quirks {
|
||||
#[serde(rename = "earlyMediaSilence")]
|
||||
pub early_media_silence: bool,
|
||||
#[serde(rename = "silencePayloadType")]
|
||||
pub silence_payload_type: Option<u8>,
|
||||
#[serde(rename = "silenceMaxPackets")]
|
||||
pub silence_max_packets: Option<u32>,
|
||||
}
|
||||
|
||||
/// A SIP trunk provider configuration.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub id: String,
|
||||
#[serde(rename = "displayName")]
|
||||
pub display_name: String,
|
||||
pub domain: String,
|
||||
#[serde(rename = "outboundProxy")]
|
||||
pub outbound_proxy: Endpoint,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(rename = "registerIntervalSec")]
|
||||
pub register_interval_sec: u32,
|
||||
pub codecs: Vec<u8>,
|
||||
pub quirks: Quirks,
|
||||
}
|
||||
|
||||
/// A SIP device (phone) configuration.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DeviceConfig {
|
||||
pub id: String,
|
||||
#[serde(rename = "displayName")]
|
||||
pub display_name: String,
|
||||
#[serde(rename = "expectedAddress")]
|
||||
pub expected_address: String,
|
||||
pub extension: String,
|
||||
}
|
||||
|
||||
/// Route match criteria.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RouteMatch {
|
||||
pub direction: String, // "inbound" | "outbound"
|
||||
#[serde(rename = "numberPattern")]
|
||||
pub number_pattern: Option<String>,
|
||||
#[serde(rename = "callerPattern")]
|
||||
pub caller_pattern: Option<String>,
|
||||
#[serde(rename = "sourceProvider")]
|
||||
pub source_provider: Option<String>,
|
||||
#[serde(rename = "sourceDevice")]
|
||||
pub source_device: Option<String>,
|
||||
}
|
||||
|
||||
/// Route action.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RouteAction {
|
||||
pub targets: Option<Vec<String>>,
|
||||
#[serde(rename = "ringBrowsers")]
|
||||
pub ring_browsers: Option<bool>,
|
||||
#[serde(rename = "voicemailBox")]
|
||||
pub voicemail_box: Option<String>,
|
||||
#[serde(rename = "ivrMenuId")]
|
||||
pub ivr_menu_id: Option<String>,
|
||||
#[serde(rename = "noAnswerTimeout")]
|
||||
pub no_answer_timeout: Option<u32>,
|
||||
pub provider: Option<String>,
|
||||
#[serde(rename = "failoverProviders")]
|
||||
pub failover_providers: Option<Vec<String>>,
|
||||
#[serde(rename = "stripPrefix")]
|
||||
pub strip_prefix: Option<String>,
|
||||
#[serde(rename = "prependPrefix")]
|
||||
pub prepend_prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// A routing rule.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Route {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub priority: i32,
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "match")]
|
||||
pub match_criteria: RouteMatch,
|
||||
pub action: RouteAction,
|
||||
}
|
||||
|
||||
/// Proxy network settings.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ProxyConfig {
|
||||
#[serde(rename = "lanIp")]
|
||||
pub lan_ip: String,
|
||||
#[serde(rename = "lanPort")]
|
||||
pub lan_port: u16,
|
||||
#[serde(rename = "publicIpSeed")]
|
||||
pub public_ip_seed: Option<String>,
|
||||
#[serde(rename = "rtpPortRange")]
|
||||
pub rtp_port_range: RtpPortRange,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RtpPortRange {
|
||||
pub min: u16,
|
||||
pub max: u16,
|
||||
}
|
||||
|
||||
/// Full application config pushed from TypeScript.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub proxy: ProxyConfig,
|
||||
pub providers: Vec<ProviderConfig>,
|
||||
pub devices: Vec<DeviceConfig>,
|
||||
pub routing: RoutingConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RoutingConfig {
|
||||
pub routes: Vec<Route>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pattern matching (ported from ts/config.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Test a value against a pattern string.
|
||||
/// - None/empty: matches everything (wildcard)
|
||||
/// - Trailing '*': prefix match
|
||||
/// - Starts with '/': regex match
|
||||
/// - Otherwise: exact match
|
||||
pub fn matches_pattern(pattern: Option<&str>, value: &str) -> bool {
|
||||
let pattern = match pattern {
|
||||
None => return true,
|
||||
Some(p) if p.is_empty() => return true,
|
||||
Some(p) => p,
|
||||
};
|
||||
|
||||
// Prefix match: "+49*"
|
||||
if pattern.ends_with('*') {
|
||||
return value.starts_with(&pattern[..pattern.len() - 1]);
|
||||
}
|
||||
|
||||
// Regex match: "/^\\+49/" or "/pattern/i"
|
||||
if pattern.starts_with('/') {
|
||||
if let Some(last_slash) = pattern[1..].rfind('/') {
|
||||
let re_str = &pattern[1..1 + last_slash];
|
||||
let flags = &pattern[2 + last_slash..];
|
||||
let case_insensitive = flags.contains('i');
|
||||
if let Ok(re) = if case_insensitive {
|
||||
regex_lite::Regex::new(&format!("(?i){re_str}"))
|
||||
} else {
|
||||
regex_lite::Regex::new(re_str)
|
||||
} {
|
||||
return re.is_match(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exact match.
|
||||
value == pattern
|
||||
}
|
||||
|
||||
/// Result of resolving an outbound route.
|
||||
pub struct OutboundRouteResult {
|
||||
pub provider: ProviderConfig,
|
||||
pub transformed_number: String,
|
||||
}
|
||||
|
||||
/// Result of resolving an inbound route.
|
||||
pub struct InboundRouteResult {
|
||||
pub device_ids: Vec<String>,
|
||||
pub ring_browsers: bool,
|
||||
pub voicemail_box: Option<String>,
|
||||
pub ivr_menu_id: Option<String>,
|
||||
pub no_answer_timeout: Option<u32>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Resolve which provider to use for an outbound call.
|
||||
pub fn resolve_outbound_route(
|
||||
&self,
|
||||
dialed_number: &str,
|
||||
source_device_id: Option<&str>,
|
||||
is_provider_registered: &dyn Fn(&str) -> bool,
|
||||
) -> Option<OutboundRouteResult> {
|
||||
let mut routes: Vec<&Route> = self
|
||||
.routing
|
||||
.routes
|
||||
.iter()
|
||||
.filter(|r| r.enabled && r.match_criteria.direction == "outbound")
|
||||
.collect();
|
||||
routes.sort_by(|a, b| b.priority.cmp(&a.priority));
|
||||
|
||||
for route in &routes {
|
||||
let m = &route.match_criteria;
|
||||
|
||||
if !matches_pattern(m.number_pattern.as_deref(), dialed_number) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sd) = &m.source_device {
|
||||
if source_device_id != Some(sd.as_str()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Find a registered provider.
|
||||
let mut candidates: Vec<&str> = Vec::new();
|
||||
if let Some(p) = &route.action.provider {
|
||||
candidates.push(p);
|
||||
}
|
||||
if let Some(fps) = &route.action.failover_providers {
|
||||
candidates.extend(fps.iter().map(|s| s.as_str()));
|
||||
}
|
||||
|
||||
for pid in candidates {
|
||||
let provider = match self.providers.iter().find(|p| p.id == pid) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
if !is_provider_registered(pid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut num = dialed_number.to_string();
|
||||
if let Some(strip) = &route.action.strip_prefix {
|
||||
if num.starts_with(strip.as_str()) {
|
||||
num = num[strip.len()..].to_string();
|
||||
}
|
||||
}
|
||||
if let Some(prepend) = &route.action.prepend_prefix {
|
||||
num = format!("{prepend}{num}");
|
||||
}
|
||||
|
||||
return Some(OutboundRouteResult {
|
||||
provider: provider.clone(),
|
||||
transformed_number: num,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: first provider.
|
||||
self.providers.first().map(|p| OutboundRouteResult {
|
||||
provider: p.clone(),
|
||||
transformed_number: dialed_number.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve which devices to ring for an inbound call.
|
||||
pub fn resolve_inbound_route(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
called_number: &str,
|
||||
caller_number: &str,
|
||||
) -> InboundRouteResult {
|
||||
let mut routes: Vec<&Route> = self
|
||||
.routing
|
||||
.routes
|
||||
.iter()
|
||||
.filter(|r| r.enabled && r.match_criteria.direction == "inbound")
|
||||
.collect();
|
||||
routes.sort_by(|a, b| b.priority.cmp(&a.priority));
|
||||
|
||||
for route in &routes {
|
||||
let m = &route.match_criteria;
|
||||
|
||||
if let Some(sp) = &m.source_provider {
|
||||
if sp != provider_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !matches_pattern(m.number_pattern.as_deref(), called_number) {
|
||||
continue;
|
||||
}
|
||||
if !matches_pattern(m.caller_pattern.as_deref(), caller_number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return InboundRouteResult {
|
||||
device_ids: route.action.targets.clone().unwrap_or_default(),
|
||||
ring_browsers: route.action.ring_browsers.unwrap_or(false),
|
||||
voicemail_box: route.action.voicemail_box.clone(),
|
||||
ivr_menu_id: route.action.ivr_menu_id.clone(),
|
||||
no_answer_timeout: route.action.no_answer_timeout,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: ring all devices + browsers.
|
||||
InboundRouteResult {
|
||||
device_ids: vec![],
|
||||
ring_browsers: true,
|
||||
voicemail_box: None,
|
||||
ivr_menu_id: None,
|
||||
no_answer_timeout: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
200
rust/crates/proxy-engine/src/dtmf.rs
Normal file
200
rust/crates/proxy-engine/src/dtmf.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
//! DTMF detection — parses RFC 2833 telephone-event RTP packets.
|
||||
//!
|
||||
//! Deduplicates repeated packets (same digit sent multiple times with
|
||||
//! increasing duration) and fires once per detected digit.
|
||||
//!
|
||||
//! Ported from ts/call/dtmf-detector.ts.
|
||||
|
||||
use crate::ipc::{emit_event, OutTx};
|
||||
|
||||
/// RFC 2833 event ID → character mapping.
|
||||
const EVENT_CHARS: &[char] = &[
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#', 'A', 'B', 'C', 'D',
|
||||
];
|
||||
|
||||
/// Safety timeout: report digit if no End packet arrives within this many ms.
|
||||
const SAFETY_TIMEOUT_MS: u64 = 200;
|
||||
|
||||
/// DTMF detector for a single RTP stream.
|
||||
pub struct DtmfDetector {
|
||||
/// Negotiated telephone-event payload type (default 101).
|
||||
telephone_event_pt: u8,
|
||||
/// Clock rate for duration calculation (default 8000 Hz).
|
||||
clock_rate: u32,
|
||||
/// Call ID for event emission.
|
||||
call_id: String,
|
||||
|
||||
// Deduplication state.
|
||||
current_event_id: Option<u8>,
|
||||
current_event_ts: Option<u32>,
|
||||
current_event_reported: bool,
|
||||
current_event_duration: u16,
|
||||
|
||||
out_tx: OutTx,
|
||||
}
|
||||
|
||||
impl DtmfDetector {
|
||||
pub fn new(call_id: String, out_tx: OutTx) -> Self {
|
||||
Self {
|
||||
telephone_event_pt: 101,
|
||||
clock_rate: 8000,
|
||||
call_id,
|
||||
current_event_id: None,
|
||||
current_event_ts: None,
|
||||
current_event_reported: false,
|
||||
current_event_duration: 0,
|
||||
out_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed an RTP packet. Checks PT; ignores non-DTMF packets.
|
||||
/// Returns Some(digit_char) if a digit was detected.
|
||||
pub fn process_rtp(&mut self, data: &[u8]) -> Option<char> {
|
||||
if data.len() < 16 {
|
||||
return None; // 12-byte header + 4-byte telephone-event minimum
|
||||
}
|
||||
|
||||
let pt = data[1] & 0x7F;
|
||||
if pt != self.telephone_event_pt {
|
||||
return None;
|
||||
}
|
||||
|
||||
let marker = (data[1] & 0x80) != 0;
|
||||
let rtp_timestamp = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
|
||||
|
||||
// Parse telephone-event payload.
|
||||
let event_id = data[12];
|
||||
let end_bit = (data[13] & 0x80) != 0;
|
||||
let duration = u16::from_be_bytes([data[14], data[15]]);
|
||||
|
||||
if event_id as usize >= EVENT_CHARS.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Detect new event.
|
||||
let is_new = marker
|
||||
|| self.current_event_id != Some(event_id)
|
||||
|| self.current_event_ts != Some(rtp_timestamp);
|
||||
|
||||
if is_new {
|
||||
// Report pending unreported event.
|
||||
let pending = self.report_pending();
|
||||
|
||||
self.current_event_id = Some(event_id);
|
||||
self.current_event_ts = Some(rtp_timestamp);
|
||||
self.current_event_reported = false;
|
||||
self.current_event_duration = duration;
|
||||
|
||||
if pending.is_some() {
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
|
||||
if duration > self.current_event_duration {
|
||||
self.current_event_duration = duration;
|
||||
}
|
||||
|
||||
// Report on End bit (first time only).
|
||||
if end_bit && !self.current_event_reported {
|
||||
self.current_event_reported = true;
|
||||
let digit = EVENT_CHARS[event_id as usize];
|
||||
let duration_ms = (self.current_event_duration as f64 / self.clock_rate as f64) * 1000.0;
|
||||
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"dtmf_digit",
|
||||
serde_json::json!({
|
||||
"call_id": self.call_id,
|
||||
"digit": digit.to_string(),
|
||||
"duration_ms": duration_ms.round() as u32,
|
||||
"source": "rfc2833",
|
||||
}),
|
||||
);
|
||||
|
||||
return Some(digit);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Report a pending unreported event.
|
||||
fn report_pending(&mut self) -> Option<char> {
|
||||
if let Some(event_id) = self.current_event_id {
|
||||
if !self.current_event_reported && (event_id as usize) < EVENT_CHARS.len() {
|
||||
self.current_event_reported = true;
|
||||
let digit = EVENT_CHARS[event_id as usize];
|
||||
let duration_ms =
|
||||
(self.current_event_duration as f64 / self.clock_rate as f64) * 1000.0;
|
||||
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"dtmf_digit",
|
||||
serde_json::json!({
|
||||
"call_id": self.call_id,
|
||||
"digit": digit.to_string(),
|
||||
"duration_ms": duration_ms.round() as u32,
|
||||
"source": "rfc2833",
|
||||
}),
|
||||
);
|
||||
|
||||
return Some(digit);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Process a SIP INFO message body for DTMF.
|
||||
pub fn process_sip_info(&mut self, content_type: &str, body: &str) -> Option<char> {
|
||||
let ct = content_type.to_ascii_lowercase();
|
||||
|
||||
if ct.contains("application/dtmf-relay") {
|
||||
// Format: "Signal= 5\r\nDuration= 160\r\n"
|
||||
let signal = body
|
||||
.lines()
|
||||
.find(|l| l.to_ascii_lowercase().starts_with("signal"))
|
||||
.and_then(|l| l.split('=').nth(1))
|
||||
.map(|s| s.trim().to_string())?;
|
||||
|
||||
if signal.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let digit = signal.chars().next()?.to_ascii_uppercase();
|
||||
if !"0123456789*#ABCD".contains(digit) {
|
||||
return None;
|
||||
}
|
||||
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"dtmf_digit",
|
||||
serde_json::json!({
|
||||
"call_id": self.call_id,
|
||||
"digit": digit.to_string(),
|
||||
"source": "sip-info",
|
||||
}),
|
||||
);
|
||||
|
||||
return Some(digit);
|
||||
}
|
||||
|
||||
if ct.contains("application/dtmf") {
|
||||
let digit = body.trim().chars().next()?.to_ascii_uppercase();
|
||||
if !"0123456789*#ABCD".contains(digit) {
|
||||
return None;
|
||||
}
|
||||
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"dtmf_digit",
|
||||
serde_json::json!({
|
||||
"call_id": self.call_id,
|
||||
"digit": digit.to_string(),
|
||||
"source": "sip-info",
|
||||
}),
|
||||
);
|
||||
|
||||
return Some(digit);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
47
rust/crates/proxy-engine/src/ipc.rs
Normal file
47
rust/crates/proxy-engine/src/ipc.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
//! IPC protocol — command dispatch and event emission.
|
||||
//!
|
||||
//! All communication with the TypeScript control plane goes through
|
||||
//! JSON-line messages on stdin/stdout (smartrust protocol).
|
||||
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Sender for serialized stdout output.
|
||||
pub type OutTx = mpsc::UnboundedSender<String>;
|
||||
|
||||
/// A command received from the TypeScript control plane.
|
||||
#[derive(Deserialize)]
|
||||
pub struct Command {
|
||||
pub id: String,
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Send a response to a command.
|
||||
pub fn respond(tx: &OutTx, id: &str, success: bool, result: Option<serde_json::Value>, error: Option<&str>) {
|
||||
let mut resp = serde_json::json!({ "id": id, "success": success });
|
||||
if let Some(r) = result {
|
||||
resp["result"] = r;
|
||||
}
|
||||
if let Some(e) = error {
|
||||
resp["error"] = serde_json::Value::String(e.to_string());
|
||||
}
|
||||
let _ = tx.send(resp.to_string());
|
||||
}
|
||||
|
||||
/// Send a success response.
|
||||
pub fn respond_ok(tx: &OutTx, id: &str, result: serde_json::Value) {
|
||||
respond(tx, id, true, Some(result), None);
|
||||
}
|
||||
|
||||
/// Send an error response.
|
||||
pub fn respond_err(tx: &OutTx, id: &str, error: &str) {
|
||||
respond(tx, id, false, None, Some(error));
|
||||
}
|
||||
|
||||
/// Emit an event to the TypeScript control plane.
|
||||
pub fn emit_event(tx: &OutTx, event: &str, data: serde_json::Value) {
|
||||
let msg = serde_json::json!({ "event": event, "data": data });
|
||||
let _ = tx.send(msg.to_string());
|
||||
}
|
||||
100
rust/crates/proxy-engine/src/leg_io.rs
Normal file
100
rust/crates/proxy-engine/src/leg_io.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
//! Leg I/O task spawners.
|
||||
//!
|
||||
//! Each SIP leg gets two tasks:
|
||||
//! - Inbound: recv_from on RTP socket → strip header → send RtpPacket to mixer channel
|
||||
//! - Outbound: recv encoded RTP from mixer channel → send_to remote media endpoint
|
||||
//!
|
||||
//! WebRTC leg I/O is handled inside webrtc_engine.rs (on_track + track.write).
|
||||
|
||||
use crate::mixer::RtpPacket;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Channel pair for connecting a leg to the mixer.
|
||||
pub struct LegChannels {
|
||||
/// Mixer receives decoded packets from this leg.
|
||||
pub inbound_tx: mpsc::Sender<RtpPacket>,
|
||||
pub inbound_rx: mpsc::Receiver<RtpPacket>,
|
||||
/// Mixer sends encoded RTP to this leg.
|
||||
pub outbound_tx: mpsc::Sender<Vec<u8>>,
|
||||
pub outbound_rx: mpsc::Receiver<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Create a channel pair for a leg.
|
||||
pub fn create_leg_channels() -> LegChannels {
|
||||
let (inbound_tx, inbound_rx) = mpsc::channel::<RtpPacket>(64);
|
||||
let (outbound_tx, outbound_rx) = mpsc::channel::<Vec<u8>>(8);
|
||||
LegChannels {
|
||||
inbound_tx,
|
||||
inbound_rx,
|
||||
outbound_tx,
|
||||
outbound_rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the inbound I/O task for a SIP leg.
|
||||
/// Reads RTP from the socket, parses the variable-length header (RFC 3550),
|
||||
/// and sends the payload to the mixer.
|
||||
/// Returns the JoinHandle (exits when the inbound_tx channel is dropped).
|
||||
pub fn spawn_sip_inbound(
|
||||
rtp_socket: Arc<UdpSocket>,
|
||||
inbound_tx: mpsc::Sender<RtpPacket>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 1500];
|
||||
loop {
|
||||
match rtp_socket.recv_from(&mut buf).await {
|
||||
Ok((n, _from)) => {
|
||||
if n < 12 {
|
||||
continue; // Too small for RTP header.
|
||||
}
|
||||
let pt = buf[1] & 0x7F;
|
||||
let marker = (buf[1] & 0x80) != 0;
|
||||
let seq = u16::from_be_bytes([buf[2], buf[3]]);
|
||||
let timestamp = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]);
|
||||
|
||||
// RFC 3550: header length = 12 + (CC * 4) + optional extension.
|
||||
let cc = (buf[0] & 0x0F) as usize;
|
||||
let has_extension = (buf[0] & 0x10) != 0;
|
||||
let mut offset = 12 + cc * 4;
|
||||
if has_extension {
|
||||
if offset + 4 > n {
|
||||
continue; // Malformed: extension header truncated.
|
||||
}
|
||||
let ext_len = u16::from_be_bytes([buf[offset + 2], buf[offset + 3]]) as usize;
|
||||
offset += 4 + ext_len * 4;
|
||||
}
|
||||
if offset >= n {
|
||||
continue; // No payload after header.
|
||||
}
|
||||
|
||||
let payload = buf[offset..n].to_vec();
|
||||
if payload.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if inbound_tx.send(RtpPacket { payload, payload_type: pt, marker, seq, timestamp }).await.is_err() {
|
||||
break; // Channel closed — leg removed.
|
||||
}
|
||||
}
|
||||
Err(_) => break, // Socket error.
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the outbound I/O task for a SIP leg.
|
||||
/// Reads encoded RTP packets from the mixer and sends them to the remote media endpoint.
|
||||
/// Returns the JoinHandle (exits when the outbound_rx channel is closed).
|
||||
pub fn spawn_sip_outbound(
|
||||
rtp_socket: Arc<UdpSocket>,
|
||||
remote_media: SocketAddr,
|
||||
mut outbound_rx: mpsc::Receiver<Vec<u8>>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
while let Some(rtp_data) = outbound_rx.recv().await {
|
||||
let _ = rtp_socket.send_to(&rtp_data, remote_media).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
1248
rust/crates/proxy-engine/src/main.rs
Normal file
1248
rust/crates/proxy-engine/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
605
rust/crates/proxy-engine/src/mixer.rs
Normal file
605
rust/crates/proxy-engine/src/mixer.rs
Normal file
@@ -0,0 +1,605 @@
|
||||
//! Audio mixer — mix-minus engine for multiparty calls.
|
||||
//!
|
||||
//! Each Call spawns one mixer task. Legs communicate with the mixer via
|
||||
//! tokio mpsc channels — no shared mutable state, no lock contention.
|
||||
//!
|
||||
//! Internal bus format: 48kHz f32 PCM (960 samples per 20ms frame).
|
||||
//! All encoding/decoding happens at leg boundaries. Per-leg inbound denoising at 48kHz.
|
||||
//!
|
||||
//! The mixer runs a 20ms tick loop:
|
||||
//! 1. Drain inbound channels, decode to f32, resample to 48kHz, denoise per-leg
|
||||
//! 2. Compute total mix (sum of all **participant** legs' f32 PCM as f64)
|
||||
//! 3. For each participant leg: mix-minus = total - own, resample to leg codec rate, encode, send
|
||||
//! 4. For each isolated leg: play prompt frame or silence, check DTMF
|
||||
//! 5. For each tool leg: send per-source unmerged audio batch
|
||||
//! 6. Forward DTMF between participant legs only
|
||||
|
||||
use crate::ipc::{emit_event, OutTx};
|
||||
use crate::rtp::{build_rtp_header, rtp_clock_increment};
|
||||
use codec_lib::{codec_sample_rate, new_denoiser, TranscodeState};
|
||||
use nnnoiseless::DenoiseState;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{self, Duration, MissedTickBehavior};
|
||||
|
||||
/// Mixing sample rate — 48kHz. Opus is native, G.722 needs 3× upsample, G.711 needs 6× upsample.
|
||||
/// All processing (denoising, mixing) happens at this rate in f32 for maximum quality.
|
||||
const MIX_RATE: u32 = 48000;
|
||||
/// Samples per 20ms frame at the mixing rate.
|
||||
const MIX_FRAME_SIZE: usize = 960; // 48000 * 0.020
|
||||
|
||||
/// A raw RTP payload received from a leg (no RTP header).
|
||||
pub struct RtpPacket {
|
||||
pub payload: Vec<u8>,
|
||||
pub payload_type: u8,
|
||||
/// RTP marker bit (first packet of a DTMF event, etc.).
|
||||
pub marker: bool,
|
||||
/// RTP sequence number for reordering.
|
||||
pub seq: u16,
|
||||
/// RTP timestamp from the original packet header.
|
||||
pub timestamp: u32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Leg roles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What role a leg currently plays in the mixer.
|
||||
enum LegRole {
|
||||
/// Normal participant: contributes to mix, receives mix-minus.
|
||||
Participant,
|
||||
/// Temporarily isolated for IVR/consent interaction.
|
||||
Isolated(IsolationState),
|
||||
}
|
||||
|
||||
struct IsolationState {
|
||||
/// PCM frames at MIX_RATE (960 samples each, 48kHz f32) queued for playback.
|
||||
prompt_frames: VecDeque<Vec<f32>>,
|
||||
/// Digits that complete the interaction (e.g., ['1', '2']).
|
||||
expected_digits: Vec<char>,
|
||||
/// Ticks remaining before timeout (decremented each tick after prompt ends).
|
||||
timeout_ticks_remaining: u32,
|
||||
/// Whether we've finished playing the prompt.
|
||||
prompt_done: bool,
|
||||
/// Channel to send the result back to the command handler.
|
||||
result_tx: Option<oneshot::Sender<InteractionResult>>,
|
||||
}
|
||||
|
||||
/// Result of a leg interaction (consent prompt, IVR, etc.).
|
||||
pub enum InteractionResult {
|
||||
/// The participant pressed one of the expected digits.
|
||||
Digit(char),
|
||||
/// No digit was received within the timeout.
|
||||
Timeout,
|
||||
/// The leg was removed or the call tore down before completion.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool legs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Type of tool leg.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ToolType {
|
||||
Recording,
|
||||
Transcription,
|
||||
}
|
||||
|
||||
/// Per-source audio delivered to a tool leg each mixer tick.
|
||||
pub struct ToolAudioBatch {
|
||||
pub sources: Vec<ToolAudioSource>,
|
||||
}
|
||||
|
||||
/// One participant's 20ms audio frame.
|
||||
pub struct ToolAudioSource {
|
||||
pub leg_id: String,
|
||||
/// PCM at 48kHz f32, MIX_FRAME_SIZE (960) samples.
|
||||
pub pcm_48k: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Internal storage for a tool leg inside the mixer.
|
||||
struct ToolLegSlot {
|
||||
#[allow(dead_code)]
|
||||
tool_type: ToolType,
|
||||
audio_tx: mpsc::Sender<ToolAudioBatch>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Commands sent to the mixer task via a control channel.
|
||||
pub enum MixerCommand {
|
||||
/// Add a new participant leg to the mix.
|
||||
AddLeg {
|
||||
leg_id: String,
|
||||
codec_pt: u8,
|
||||
inbound_rx: mpsc::Receiver<RtpPacket>,
|
||||
outbound_tx: mpsc::Sender<Vec<u8>>,
|
||||
},
|
||||
/// Remove a leg from the mix (channels are dropped, I/O tasks exit).
|
||||
RemoveLeg { leg_id: String },
|
||||
/// Shut down the mixer.
|
||||
Shutdown,
|
||||
|
||||
/// Isolate a leg and start an interaction (consent prompt, IVR).
|
||||
/// The leg is removed from the mix and hears the prompt instead.
|
||||
/// DTMF from the leg is checked against expected_digits.
|
||||
StartInteraction {
|
||||
leg_id: String,
|
||||
/// PCM frames at MIX_RATE (48kHz f32), each 960 samples.
|
||||
prompt_pcm_frames: Vec<Vec<f32>>,
|
||||
expected_digits: Vec<char>,
|
||||
timeout_ms: u32,
|
||||
result_tx: oneshot::Sender<InteractionResult>,
|
||||
},
|
||||
/// Cancel an in-progress interaction (e.g., leg being removed).
|
||||
CancelInteraction { leg_id: String },
|
||||
|
||||
/// Add a tool leg that receives per-source unmerged audio.
|
||||
AddToolLeg {
|
||||
leg_id: String,
|
||||
tool_type: ToolType,
|
||||
audio_tx: mpsc::Sender<ToolAudioBatch>,
|
||||
},
|
||||
/// Remove a tool leg (drops the channel, background task finalizes).
|
||||
RemoveToolLeg { leg_id: String },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixer internals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Internal per-leg state inside the mixer.
|
||||
struct MixerLegSlot {
|
||||
codec_pt: u8,
|
||||
transcoder: TranscodeState,
|
||||
/// Per-leg inbound denoiser (48kHz, 480-sample frames).
|
||||
denoiser: Box<DenoiseState<'static>>,
|
||||
inbound_rx: mpsc::Receiver<RtpPacket>,
|
||||
outbound_tx: mpsc::Sender<Vec<u8>>,
|
||||
/// Last decoded+denoised PCM frame at MIX_RATE (960 samples, 48kHz f32).
|
||||
last_pcm_frame: Vec<f32>,
|
||||
/// Number of consecutive ticks with no inbound packet.
|
||||
silent_ticks: u32,
|
||||
// RTP output state.
|
||||
rtp_seq: u16,
|
||||
rtp_ts: u32,
|
||||
rtp_ssrc: u32,
|
||||
/// Current role of this leg in the mixer.
|
||||
role: LegRole,
|
||||
}
|
||||
|
||||
/// Spawn the mixer task for a call. Returns the command sender and task handle.
|
||||
pub fn spawn_mixer(
|
||||
call_id: String,
|
||||
out_tx: OutTx,
|
||||
) -> (mpsc::Sender<MixerCommand>, JoinHandle<()>) {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<MixerCommand>(32);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
mixer_loop(call_id, cmd_rx, out_tx).await;
|
||||
});
|
||||
|
||||
(cmd_tx, handle)
|
||||
}
|
||||
|
||||
/// The 20ms mixing loop.
|
||||
async fn mixer_loop(
|
||||
call_id: String,
|
||||
mut cmd_rx: mpsc::Receiver<MixerCommand>,
|
||||
out_tx: OutTx,
|
||||
) {
|
||||
let mut legs: HashMap<String, MixerLegSlot> = HashMap::new();
|
||||
let mut tool_legs: HashMap<String, ToolLegSlot> = HashMap::new();
|
||||
let mut interval = time::interval(Duration::from_millis(20));
|
||||
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// ── 1. Process control commands (non-blocking). ─────────────
|
||||
loop {
|
||||
match cmd_rx.try_recv() {
|
||||
Ok(MixerCommand::AddLeg {
|
||||
leg_id,
|
||||
codec_pt,
|
||||
inbound_rx,
|
||||
outbound_tx,
|
||||
}) => {
|
||||
let transcoder = match TranscodeState::new() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"mixer_error",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"leg_id": leg_id,
|
||||
"error": format!("codec init: {e}"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
legs.insert(
|
||||
leg_id,
|
||||
MixerLegSlot {
|
||||
codec_pt,
|
||||
transcoder,
|
||||
denoiser: new_denoiser(),
|
||||
inbound_rx,
|
||||
outbound_tx,
|
||||
last_pcm_frame: vec![0.0f32; MIX_FRAME_SIZE],
|
||||
silent_ticks: 0,
|
||||
rtp_seq: 0,
|
||||
rtp_ts: 0,
|
||||
rtp_ssrc: rand::random(),
|
||||
role: LegRole::Participant,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(MixerCommand::RemoveLeg { leg_id }) => {
|
||||
// If the leg is isolated, send Cancelled before dropping.
|
||||
if let Some(slot) = legs.get_mut(&leg_id) {
|
||||
if let LegRole::Isolated(ref mut state) = slot.role {
|
||||
if let Some(tx) = state.result_tx.take() {
|
||||
let _ = tx.send(InteractionResult::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
legs.remove(&leg_id);
|
||||
// Channels drop → I/O tasks exit cleanly.
|
||||
}
|
||||
Ok(MixerCommand::Shutdown) => {
|
||||
// Cancel all outstanding interactions before shutting down.
|
||||
for slot in legs.values_mut() {
|
||||
if let LegRole::Isolated(ref mut state) = slot.role {
|
||||
if let Some(tx) = state.result_tx.take() {
|
||||
let _ = tx.send(InteractionResult::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
Ok(MixerCommand::StartInteraction {
|
||||
leg_id,
|
||||
prompt_pcm_frames,
|
||||
expected_digits,
|
||||
timeout_ms,
|
||||
result_tx,
|
||||
}) => {
|
||||
if let Some(slot) = legs.get_mut(&leg_id) {
|
||||
// Cancel any existing interaction first.
|
||||
if let LegRole::Isolated(ref mut old_state) = slot.role {
|
||||
if let Some(tx) = old_state.result_tx.take() {
|
||||
let _ = tx.send(InteractionResult::Cancelled);
|
||||
}
|
||||
}
|
||||
let timeout_ticks = timeout_ms / 20;
|
||||
slot.role = LegRole::Isolated(IsolationState {
|
||||
prompt_frames: VecDeque::from(prompt_pcm_frames),
|
||||
expected_digits,
|
||||
timeout_ticks_remaining: timeout_ticks,
|
||||
prompt_done: false,
|
||||
result_tx: Some(result_tx),
|
||||
});
|
||||
} else {
|
||||
// Leg not found — immediately cancel.
|
||||
let _ = result_tx.send(InteractionResult::Cancelled);
|
||||
}
|
||||
}
|
||||
Ok(MixerCommand::CancelInteraction { leg_id }) => {
|
||||
if let Some(slot) = legs.get_mut(&leg_id) {
|
||||
if let LegRole::Isolated(ref mut state) = slot.role {
|
||||
if let Some(tx) = state.result_tx.take() {
|
||||
let _ = tx.send(InteractionResult::Cancelled);
|
||||
}
|
||||
}
|
||||
slot.role = LegRole::Participant;
|
||||
}
|
||||
}
|
||||
Ok(MixerCommand::AddToolLeg {
|
||||
leg_id,
|
||||
tool_type,
|
||||
audio_tx,
|
||||
}) => {
|
||||
tool_legs.insert(leg_id, ToolLegSlot { tool_type, audio_tx });
|
||||
}
|
||||
Ok(MixerCommand::RemoveToolLeg { leg_id }) => {
|
||||
tool_legs.remove(&leg_id);
|
||||
// Dropping the ToolLegSlot drops audio_tx → background task sees channel close.
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => break,
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => return,
|
||||
}
|
||||
}
|
||||
|
||||
if legs.is_empty() && tool_legs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 2. Drain inbound packets, decode to 48kHz f32 PCM. ────
|
||||
// DTMF (PT 101) packets are collected separately.
|
||||
// Audio packets are sorted by sequence number and decoded
|
||||
// in order to maintain codec state (critical for G.722 ADPCM).
|
||||
let leg_ids: Vec<String> = legs.keys().cloned().collect();
|
||||
let mut dtmf_forward: Vec<(String, RtpPacket)> = Vec::new();
|
||||
|
||||
for lid in &leg_ids {
|
||||
let slot = legs.get_mut(lid).unwrap();
|
||||
|
||||
// Drain channel — collect DTMF separately, collect ALL audio packets.
|
||||
let mut audio_packets: Vec<RtpPacket> = Vec::new();
|
||||
loop {
|
||||
match slot.inbound_rx.try_recv() {
|
||||
Ok(pkt) => {
|
||||
if pkt.payload_type == 101 {
|
||||
// DTMF telephone-event: collect for processing.
|
||||
dtmf_forward.push((lid.clone(), pkt));
|
||||
} else {
|
||||
audio_packets.push(pkt);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
if !audio_packets.is_empty() {
|
||||
slot.silent_ticks = 0;
|
||||
|
||||
// Sort by sequence number for correct codec state progression.
|
||||
// This prevents G.722 ADPCM state corruption from out-of-order packets.
|
||||
audio_packets.sort_by_key(|p| p.seq);
|
||||
|
||||
// Decode ALL packets in order (maintains codec state),
|
||||
// but only keep the last decoded frame for mixing.
|
||||
for pkt in &audio_packets {
|
||||
match slot.transcoder.decode_to_f32(&pkt.payload, pkt.payload_type) {
|
||||
Ok((pcm, rate)) => {
|
||||
// Resample to 48kHz mixing rate if needed.
|
||||
let pcm_48k = if rate == MIX_RATE {
|
||||
pcm
|
||||
} else {
|
||||
slot.transcoder
|
||||
.resample_f32(&pcm, rate, MIX_RATE)
|
||||
.unwrap_or_else(|_| vec![0.0f32; MIX_FRAME_SIZE])
|
||||
};
|
||||
// Per-leg inbound denoising at 48kHz.
|
||||
// Only for SIP telephony legs — WebRTC browsers
|
||||
// already apply noise suppression via getUserMedia.
|
||||
let processed = if slot.codec_pt != codec_lib::PT_OPUS {
|
||||
TranscodeState::denoise_f32(&mut slot.denoiser, &pcm_48k)
|
||||
} else {
|
||||
pcm_48k
|
||||
};
|
||||
// Pad or truncate to exactly MIX_FRAME_SIZE.
|
||||
let mut frame = processed;
|
||||
frame.resize(MIX_FRAME_SIZE, 0.0);
|
||||
slot.last_pcm_frame = frame;
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
} else if dtmf_forward.iter().any(|(src, _)| src == lid) {
|
||||
// Got DTMF but no audio — don't bump silent_ticks (DTMF counts as activity).
|
||||
slot.silent_ticks = 0;
|
||||
} else {
|
||||
slot.silent_ticks += 1;
|
||||
// After 150 ticks (3 seconds) of silence, zero out to avoid stale audio.
|
||||
if slot.silent_ticks > 150 {
|
||||
slot.last_pcm_frame = vec![0.0f32; MIX_FRAME_SIZE];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Compute total mix from PARTICIPANT legs only. ────────
|
||||
// Accumulate as f64 to prevent precision loss when summing f32.
|
||||
let mut total_mix = vec![0.0f64; MIX_FRAME_SIZE];
|
||||
for slot in legs.values() {
|
||||
if matches!(slot.role, LegRole::Participant) {
|
||||
for (i, &s) in slot.last_pcm_frame.iter().enumerate().take(MIX_FRAME_SIZE) {
|
||||
total_mix[i] += s as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Per-leg output. ──────────────────────────────────────
|
||||
// Collect interaction completions to apply after the loop
|
||||
// (can't mutate role while iterating mutably for encode).
|
||||
let mut completed_interactions: Vec<(String, InteractionResult)> = Vec::new();
|
||||
|
||||
for (lid, slot) in legs.iter_mut() {
|
||||
match &mut slot.role {
|
||||
LegRole::Participant => {
|
||||
// Mix-minus: total minus this leg's own contribution, clamped to [-1.0, 1.0].
|
||||
let mut mix_minus = Vec::with_capacity(MIX_FRAME_SIZE);
|
||||
for i in 0..MIX_FRAME_SIZE {
|
||||
let sample =
|
||||
(total_mix[i] - slot.last_pcm_frame[i] as f64) as f32;
|
||||
mix_minus.push(sample.clamp(-1.0, 1.0));
|
||||
}
|
||||
|
||||
// Resample from 48kHz to the leg's codec native rate.
|
||||
let target_rate = codec_sample_rate(slot.codec_pt);
|
||||
let resampled = if target_rate == MIX_RATE {
|
||||
mix_minus
|
||||
} else {
|
||||
slot.transcoder
|
||||
.resample_f32(&mix_minus, MIX_RATE, target_rate)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// Encode to the leg's codec (f32 → i16 → codec inside encode_from_f32).
|
||||
let encoded =
|
||||
match slot.transcoder.encode_from_f32(&resampled, slot.codec_pt) {
|
||||
Ok(e) if !e.is_empty() => e,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Build RTP packet with header.
|
||||
let header =
|
||||
build_rtp_header(slot.codec_pt, slot.rtp_seq, slot.rtp_ts, slot.rtp_ssrc);
|
||||
let mut rtp = header.to_vec();
|
||||
rtp.extend_from_slice(&encoded);
|
||||
|
||||
slot.rtp_seq = slot.rtp_seq.wrapping_add(1);
|
||||
slot.rtp_ts = slot.rtp_ts.wrapping_add(rtp_clock_increment(slot.codec_pt));
|
||||
|
||||
// Non-blocking send — drop frame if channel is full.
|
||||
let _ = slot.outbound_tx.try_send(rtp);
|
||||
}
|
||||
LegRole::Isolated(state) => {
|
||||
// Check for DTMF digit from this leg.
|
||||
let mut matched_digit: Option<char> = None;
|
||||
for (src_lid, dtmf_pkt) in &dtmf_forward {
|
||||
if src_lid == lid && dtmf_pkt.payload.len() >= 4 {
|
||||
let event_id = dtmf_pkt.payload[0];
|
||||
let end_bit = (dtmf_pkt.payload[1] & 0x80) != 0;
|
||||
if end_bit {
|
||||
const EVENT_CHARS: &[char] = &[
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#',
|
||||
'A', 'B', 'C', 'D',
|
||||
];
|
||||
if let Some(&ch) = EVENT_CHARS.get(event_id as usize) {
|
||||
if state.expected_digits.contains(&ch) {
|
||||
matched_digit = Some(ch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(digit) = matched_digit {
|
||||
// Interaction complete — digit matched.
|
||||
completed_interactions
|
||||
.push((lid.clone(), InteractionResult::Digit(digit)));
|
||||
} else {
|
||||
// Play prompt frame or silence.
|
||||
let pcm_frame = if let Some(frame) = state.prompt_frames.pop_front() {
|
||||
frame
|
||||
} else {
|
||||
state.prompt_done = true;
|
||||
vec![0.0f32; MIX_FRAME_SIZE]
|
||||
};
|
||||
|
||||
// Encode prompt frame to the leg's codec.
|
||||
let target_rate = codec_sample_rate(slot.codec_pt);
|
||||
let resampled = if target_rate == MIX_RATE {
|
||||
pcm_frame
|
||||
} else {
|
||||
slot.transcoder
|
||||
.resample_f32(&pcm_frame, MIX_RATE, target_rate)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
if let Ok(encoded) =
|
||||
slot.transcoder.encode_from_f32(&resampled, slot.codec_pt)
|
||||
{
|
||||
if !encoded.is_empty() {
|
||||
let header = build_rtp_header(
|
||||
slot.codec_pt,
|
||||
slot.rtp_seq,
|
||||
slot.rtp_ts,
|
||||
slot.rtp_ssrc,
|
||||
);
|
||||
let mut rtp = header.to_vec();
|
||||
rtp.extend_from_slice(&encoded);
|
||||
slot.rtp_seq = slot.rtp_seq.wrapping_add(1);
|
||||
slot.rtp_ts = slot
|
||||
.rtp_ts
|
||||
.wrapping_add(rtp_clock_increment(slot.codec_pt));
|
||||
let _ = slot.outbound_tx.try_send(rtp);
|
||||
}
|
||||
}
|
||||
|
||||
// Check timeout (only after prompt finishes).
|
||||
if state.prompt_done {
|
||||
if state.timeout_ticks_remaining == 0 {
|
||||
completed_interactions
|
||||
.push((lid.clone(), InteractionResult::Timeout));
|
||||
} else {
|
||||
state.timeout_ticks_remaining -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply completed interactions — revert legs to Participant.
|
||||
for (lid, result) in completed_interactions {
|
||||
if let Some(slot) = legs.get_mut(&lid) {
|
||||
if let LegRole::Isolated(ref mut state) = slot.role {
|
||||
if let Some(tx) = state.result_tx.take() {
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
}
|
||||
slot.role = LegRole::Participant;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Distribute per-source audio to tool legs. ────────────
|
||||
if !tool_legs.is_empty() {
|
||||
// Collect participant PCM frames (computed in step 2).
|
||||
let sources: Vec<ToolAudioSource> = legs
|
||||
.iter()
|
||||
.filter(|(_, s)| matches!(s.role, LegRole::Participant))
|
||||
.map(|(lid, s)| ToolAudioSource {
|
||||
leg_id: lid.clone(),
|
||||
pcm_48k: s.last_pcm_frame.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
for tool in tool_legs.values() {
|
||||
let batch = ToolAudioBatch {
|
||||
sources: sources
|
||||
.iter()
|
||||
.map(|s| ToolAudioSource {
|
||||
leg_id: s.leg_id.clone(),
|
||||
pcm_48k: s.pcm_48k.clone(),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
// Non-blocking send — drop batch if tool can't keep up.
|
||||
let _ = tool.audio_tx.try_send(batch);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Forward DTMF packets between participant legs only. ──
|
||||
for (source_lid, dtmf_pkt) in &dtmf_forward {
|
||||
// Skip if the source is an isolated leg (its DTMF was handled in step 4).
|
||||
if let Some(src_slot) = legs.get(source_lid) {
|
||||
if matches!(src_slot.role, LegRole::Isolated(_)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (target_lid, target_slot) in legs.iter_mut() {
|
||||
if target_lid == source_lid {
|
||||
continue; // Don't echo DTMF back to sender.
|
||||
}
|
||||
// Don't forward to isolated legs.
|
||||
if matches!(target_slot.role, LegRole::Isolated(_)) {
|
||||
continue;
|
||||
}
|
||||
let mut header = build_rtp_header(
|
||||
101,
|
||||
target_slot.rtp_seq,
|
||||
target_slot.rtp_ts,
|
||||
target_slot.rtp_ssrc,
|
||||
);
|
||||
if dtmf_pkt.marker {
|
||||
header[1] |= 0x80; // Set marker bit.
|
||||
}
|
||||
let mut rtp_out = header.to_vec();
|
||||
rtp_out.extend_from_slice(&dtmf_pkt.payload);
|
||||
target_slot.rtp_seq = target_slot.rtp_seq.wrapping_add(1);
|
||||
// Don't increment rtp_ts for DTMF — it shares timestamp context with audio.
|
||||
let _ = target_slot.outbound_tx.try_send(rtp_out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
378
rust/crates/proxy-engine/src/provider.rs
Normal file
378
rust/crates/proxy-engine/src/provider.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
//! Provider registration state machine.
|
||||
//!
|
||||
//! Handles the REGISTER cycle with upstream SIP providers:
|
||||
//! - Sends periodic REGISTER messages
|
||||
//! - Handles 401/407 Digest authentication challenges
|
||||
//! - Detects public IP from Via received= parameter
|
||||
//! - Emits registration state events to TypeScript
|
||||
//!
|
||||
//! Ported from ts/providerstate.ts.
|
||||
|
||||
use crate::config::ProviderConfig;
|
||||
use crate::ipc::{emit_event, OutTx};
|
||||
use sip_proto::helpers::{
|
||||
compute_digest_auth, generate_branch, generate_call_id, generate_tag, parse_digest_challenge,
|
||||
};
|
||||
use sip_proto::message::{RequestOptions, SipMessage};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::{self, Duration};
|
||||
|
||||
/// Runtime state for a single SIP provider.
|
||||
pub struct ProviderState {
|
||||
pub config: ProviderConfig,
|
||||
pub public_ip: Option<String>,
|
||||
pub is_registered: bool,
|
||||
pub registered_aor: String,
|
||||
|
||||
// Registration transaction state.
|
||||
reg_call_id: String,
|
||||
reg_cseq: u32,
|
||||
reg_from_tag: String,
|
||||
|
||||
// Network.
|
||||
lan_ip: String,
|
||||
lan_port: u16,
|
||||
}
|
||||
|
||||
impl ProviderState {
|
||||
pub fn new(config: ProviderConfig, public_ip_seed: Option<&str>) -> Self {
|
||||
let aor = format!("sip:{}@{}", config.username, config.domain);
|
||||
Self {
|
||||
public_ip: public_ip_seed.map(|s| s.to_string()),
|
||||
is_registered: false,
|
||||
registered_aor: aor,
|
||||
reg_call_id: generate_call_id(None),
|
||||
reg_cseq: 0,
|
||||
reg_from_tag: generate_tag(),
|
||||
lan_ip: String::new(),
|
||||
lan_port: 0,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build and send a REGISTER request.
|
||||
pub fn build_register(&mut self) -> Vec<u8> {
|
||||
self.reg_cseq += 1;
|
||||
let pub_ip = self.public_ip.as_deref().unwrap_or(&self.lan_ip);
|
||||
|
||||
let register = SipMessage::create_request(
|
||||
"REGISTER",
|
||||
&format!("sip:{}", self.config.domain),
|
||||
RequestOptions {
|
||||
via_host: pub_ip.to_string(),
|
||||
via_port: self.lan_port,
|
||||
via_transport: None,
|
||||
via_branch: Some(generate_branch()),
|
||||
from_uri: self.registered_aor.clone(),
|
||||
from_display_name: None,
|
||||
from_tag: Some(self.reg_from_tag.clone()),
|
||||
to_uri: self.registered_aor.clone(),
|
||||
to_display_name: None,
|
||||
to_tag: None,
|
||||
call_id: Some(self.reg_call_id.clone()),
|
||||
cseq: Some(self.reg_cseq),
|
||||
contact: Some(format!(
|
||||
"<sip:{}@{}:{}>",
|
||||
self.config.username, pub_ip, self.lan_port
|
||||
)),
|
||||
max_forwards: Some(70),
|
||||
body: None,
|
||||
content_type: None,
|
||||
extra_headers: Some(vec![
|
||||
(
|
||||
"Expires".to_string(),
|
||||
self.config.register_interval_sec.to_string(),
|
||||
),
|
||||
("User-Agent".to_string(), "SipRouter/1.0".to_string()),
|
||||
(
|
||||
"Allow".to_string(),
|
||||
"INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE"
|
||||
.to_string(),
|
||||
),
|
||||
]),
|
||||
},
|
||||
);
|
||||
register.serialize()
|
||||
}
|
||||
|
||||
/// Handle a SIP response that might be for this provider's REGISTER.
|
||||
/// Returns true if the message was consumed.
|
||||
pub fn handle_registration_response(&mut self, msg: &SipMessage) -> Option<Vec<u8>> {
|
||||
if !msg.is_response() {
|
||||
return None;
|
||||
}
|
||||
if msg.call_id() != self.reg_call_id {
|
||||
return None;
|
||||
}
|
||||
let cseq_method = msg.cseq_method().unwrap_or("");
|
||||
if !cseq_method.eq_ignore_ascii_case("REGISTER") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let code = msg.status_code().unwrap_or(0);
|
||||
|
||||
if code == 200 {
|
||||
self.is_registered = true;
|
||||
return Some(Vec::new()); // consumed, no reply needed
|
||||
}
|
||||
|
||||
if code == 401 || code == 407 {
|
||||
let challenge_header = if code == 401 {
|
||||
msg.get_header("WWW-Authenticate")
|
||||
} else {
|
||||
msg.get_header("Proxy-Authenticate")
|
||||
};
|
||||
|
||||
let challenge_header = match challenge_header {
|
||||
Some(h) => h,
|
||||
None => return Some(Vec::new()), // consumed but no challenge
|
||||
};
|
||||
|
||||
let challenge = match parse_digest_challenge(challenge_header) {
|
||||
Some(c) => c,
|
||||
None => return Some(Vec::new()),
|
||||
};
|
||||
|
||||
let auth_value = compute_digest_auth(
|
||||
&self.config.username,
|
||||
&self.config.password,
|
||||
&challenge.realm,
|
||||
&challenge.nonce,
|
||||
"REGISTER",
|
||||
&format!("sip:{}", self.config.domain),
|
||||
challenge.algorithm.as_deref(),
|
||||
challenge.opaque.as_deref(),
|
||||
);
|
||||
|
||||
// Resend REGISTER with auth credentials.
|
||||
self.reg_cseq += 1;
|
||||
let pub_ip = self.public_ip.as_deref().unwrap_or(&self.lan_ip);
|
||||
|
||||
let auth_header_name = if code == 401 {
|
||||
"Authorization"
|
||||
} else {
|
||||
"Proxy-Authorization"
|
||||
};
|
||||
|
||||
let register = SipMessage::create_request(
|
||||
"REGISTER",
|
||||
&format!("sip:{}", self.config.domain),
|
||||
RequestOptions {
|
||||
via_host: pub_ip.to_string(),
|
||||
via_port: self.lan_port,
|
||||
via_transport: None,
|
||||
via_branch: Some(generate_branch()),
|
||||
from_uri: self.registered_aor.clone(),
|
||||
from_display_name: None,
|
||||
from_tag: Some(self.reg_from_tag.clone()),
|
||||
to_uri: self.registered_aor.clone(),
|
||||
to_display_name: None,
|
||||
to_tag: None,
|
||||
call_id: Some(self.reg_call_id.clone()),
|
||||
cseq: Some(self.reg_cseq),
|
||||
contact: Some(format!(
|
||||
"<sip:{}@{}:{}>",
|
||||
self.config.username, pub_ip, self.lan_port
|
||||
)),
|
||||
max_forwards: Some(70),
|
||||
body: None,
|
||||
content_type: None,
|
||||
extra_headers: Some(vec![
|
||||
(auth_header_name.to_string(), auth_value),
|
||||
(
|
||||
"Expires".to_string(),
|
||||
self.config.register_interval_sec.to_string(),
|
||||
),
|
||||
("User-Agent".to_string(), "SipRouter/1.0".to_string()),
|
||||
(
|
||||
"Allow".to_string(),
|
||||
"INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE"
|
||||
.to_string(),
|
||||
),
|
||||
]),
|
||||
},
|
||||
);
|
||||
return Some(register.serialize());
|
||||
}
|
||||
|
||||
if code >= 400 {
|
||||
self.is_registered = false;
|
||||
}
|
||||
|
||||
Some(Vec::new()) // consumed
|
||||
}
|
||||
|
||||
/// Detect public IP from Via received= parameter.
|
||||
pub fn detect_public_ip(&mut self, via: &str) {
|
||||
if let Some(m) = via.find("received=") {
|
||||
let rest = &via[m + 9..];
|
||||
let end = rest
|
||||
.find(|c: char| !c.is_ascii_digit() && c != '.')
|
||||
.unwrap_or(rest.len());
|
||||
let ip = &rest[..end];
|
||||
if !ip.is_empty() && self.public_ip.as_deref() != Some(ip) {
|
||||
self.public_ip = Some(ip.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_network(&mut self, lan_ip: &str, lan_port: u16) {
|
||||
self.lan_ip = lan_ip.to_string();
|
||||
self.lan_port = lan_port;
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages all provider states and their registration cycles.
|
||||
pub struct ProviderManager {
|
||||
providers: Vec<Arc<Mutex<ProviderState>>>,
|
||||
out_tx: OutTx,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
pub fn new(out_tx: OutTx) -> Self {
|
||||
Self {
|
||||
providers: Vec::new(),
|
||||
out_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize providers from config and start registration cycles.
|
||||
pub async fn configure(
|
||||
&mut self,
|
||||
configs: &[ProviderConfig],
|
||||
public_ip_seed: Option<&str>,
|
||||
lan_ip: &str,
|
||||
lan_port: u16,
|
||||
socket: Arc<UdpSocket>,
|
||||
) {
|
||||
self.providers.clear();
|
||||
|
||||
for cfg in configs {
|
||||
let mut ps = ProviderState::new(cfg.clone(), public_ip_seed);
|
||||
ps.set_network(lan_ip, lan_port);
|
||||
let ps = Arc::new(Mutex::new(ps));
|
||||
self.providers.push(ps.clone());
|
||||
|
||||
// Start the registration cycle.
|
||||
let socket = socket.clone();
|
||||
let out_tx = self.out_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
provider_register_loop(ps, socket, out_tx).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to handle a SIP response as a provider registration response.
|
||||
/// Returns true if consumed.
|
||||
pub async fn handle_response(
|
||||
&self,
|
||||
msg: &SipMessage,
|
||||
socket: &UdpSocket,
|
||||
) -> bool {
|
||||
for ps_arc in &self.providers {
|
||||
let mut ps = ps_arc.lock().await;
|
||||
let was_registered = ps.is_registered;
|
||||
if let Some(reply) = ps.handle_registration_response(msg) {
|
||||
// If there's a reply to send (e.g. auth retry).
|
||||
if !reply.is_empty() {
|
||||
if let Some(dest) = ps.config.outbound_proxy.to_socket_addr() {
|
||||
let _ = socket.send_to(&reply, dest).await;
|
||||
}
|
||||
}
|
||||
// Emit registration state change.
|
||||
if ps.is_registered != was_registered {
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"provider_registered",
|
||||
serde_json::json!({
|
||||
"provider_id": ps.config.id,
|
||||
"registered": ps.is_registered,
|
||||
"public_ip": ps.public_ip,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find which provider sent a packet by matching source address.
|
||||
pub async fn find_by_address(&self, addr: &SocketAddr) -> Option<Arc<Mutex<ProviderState>>> {
|
||||
for ps_arc in &self.providers {
|
||||
let ps = ps_arc.lock().await;
|
||||
let proxy_addr = format!(
|
||||
"{}:{}",
|
||||
ps.config.outbound_proxy.address, ps.config.outbound_proxy.port
|
||||
);
|
||||
if let Ok(expected) = proxy_addr.parse::<SocketAddr>() {
|
||||
if expected == *addr {
|
||||
return Some(ps_arc.clone());
|
||||
}
|
||||
}
|
||||
// Also match by IP only (port may differ).
|
||||
if ps.config.outbound_proxy.address == addr.ip().to_string() {
|
||||
return Some(ps_arc.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find a provider by its config ID (e.g. "easybell").
|
||||
pub async fn find_by_provider_id(&self, provider_id: &str) -> Option<Arc<Mutex<ProviderState>>> {
|
||||
for ps_arc in &self.providers {
|
||||
let ps = ps_arc.lock().await;
|
||||
if ps.config.id == provider_id {
|
||||
return Some(ps_arc.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a provider is currently registered.
|
||||
pub async fn is_registered(&self, provider_id: &str) -> bool {
|
||||
for ps_arc in &self.providers {
|
||||
let ps = ps_arc.lock().await;
|
||||
if ps.config.id == provider_id {
|
||||
return ps.is_registered;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Registration loop for a single provider.
|
||||
async fn provider_register_loop(
|
||||
ps: Arc<Mutex<ProviderState>>,
|
||||
socket: Arc<UdpSocket>,
|
||||
_out_tx: OutTx,
|
||||
) {
|
||||
// Initial registration.
|
||||
{
|
||||
let mut state = ps.lock().await;
|
||||
let register_buf = state.build_register();
|
||||
if let Some(dest) = state.config.outbound_proxy.to_socket_addr() {
|
||||
let _ = socket.send_to(®ister_buf, dest).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-register periodically (85% of the interval).
|
||||
let interval_sec = {
|
||||
let state = ps.lock().await;
|
||||
(state.config.register_interval_sec as f64 * 0.85) as u64
|
||||
};
|
||||
let mut interval = time::interval(Duration::from_secs(interval_sec.max(30)));
|
||||
interval.tick().await; // skip first immediate tick
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let mut state = ps.lock().await;
|
||||
let register_buf = state.build_register();
|
||||
if let Some(dest) = state.config.outbound_proxy.to_socket_addr() {
|
||||
let _ = socket.send_to(®ister_buf, dest).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
182
rust/crates/proxy-engine/src/recorder.rs
Normal file
182
rust/crates/proxy-engine/src/recorder.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
//! Audio recorder — receives RTP packets and writes a WAV file.
|
||||
|
||||
use codec_lib::TranscodeState;
|
||||
use std::path::Path;
|
||||
|
||||
/// Active recording session.
|
||||
pub struct Recorder {
|
||||
writer: hound::WavWriter<std::io::BufWriter<std::fs::File>>,
|
||||
transcoder: TranscodeState,
|
||||
source_pt: u8,
|
||||
total_samples: u64,
|
||||
sample_rate: u32,
|
||||
max_samples: Option<u64>,
|
||||
file_path: String,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
/// Create a new recorder that writes to a WAV file.
|
||||
/// `source_pt` is the RTP payload type of the incoming audio.
|
||||
/// `max_duration_ms` optionally limits the recording length.
|
||||
pub fn new(
|
||||
file_path: &str,
|
||||
source_pt: u8,
|
||||
max_duration_ms: Option<u64>,
|
||||
) -> Result<Self, String> {
|
||||
// Ensure parent directory exists.
|
||||
if let Some(parent) = Path::new(file_path).parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("create dir: {e}"))?;
|
||||
}
|
||||
|
||||
let sample_rate = 8000u32; // Record at 8kHz (standard telephony)
|
||||
let spec = hound::WavSpec {
|
||||
channels: 1,
|
||||
sample_rate,
|
||||
bits_per_sample: 16,
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
|
||||
let writer = hound::WavWriter::create(file_path, spec)
|
||||
.map_err(|e| format!("create WAV {file_path}: {e}"))?;
|
||||
|
||||
let transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?;
|
||||
|
||||
let max_samples = max_duration_ms.map(|ms| (sample_rate as u64 * ms) / 1000);
|
||||
|
||||
Ok(Self {
|
||||
writer,
|
||||
transcoder,
|
||||
source_pt,
|
||||
total_samples: 0,
|
||||
sample_rate,
|
||||
max_samples,
|
||||
file_path: file_path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a recorder that writes raw PCM at a given sample rate.
|
||||
/// Used by tool legs that already have decoded PCM (no RTP processing needed).
|
||||
pub fn new_pcm(file_path: &str, sample_rate: u32, max_duration_ms: Option<u64>) -> Result<Self, String> {
|
||||
if let Some(parent) = Path::new(file_path).parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("create dir: {e}"))?;
|
||||
}
|
||||
|
||||
let spec = hound::WavSpec {
|
||||
channels: 1,
|
||||
sample_rate,
|
||||
bits_per_sample: 16,
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
|
||||
let writer = hound::WavWriter::create(file_path, spec)
|
||||
.map_err(|e| format!("create WAV {file_path}: {e}"))?;
|
||||
|
||||
// source_pt is unused for PCM recording; set to 0.
|
||||
let transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?;
|
||||
let max_samples = max_duration_ms.map(|ms| (sample_rate as u64 * ms) / 1000);
|
||||
|
||||
Ok(Self {
|
||||
writer,
|
||||
transcoder,
|
||||
source_pt: 0,
|
||||
total_samples: 0,
|
||||
sample_rate,
|
||||
max_samples,
|
||||
file_path: file_path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Write raw PCM samples directly (no RTP decoding).
|
||||
/// Returns true if recording should continue, false if max duration reached.
|
||||
pub fn write_pcm(&mut self, samples: &[i16]) -> bool {
|
||||
for &sample in samples {
|
||||
if self.writer.write_sample(sample).is_err() {
|
||||
return false;
|
||||
}
|
||||
self.total_samples += 1;
|
||||
if let Some(max) = self.max_samples {
|
||||
if self.total_samples >= max {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Process an incoming RTP packet (full packet with header).
|
||||
/// Returns true if recording should continue, false if max duration reached.
|
||||
pub fn process_rtp(&mut self, data: &[u8]) -> bool {
|
||||
if data.len() <= 12 {
|
||||
return true; // Too short, skip.
|
||||
}
|
||||
|
||||
let pt = data[1] & 0x7F;
|
||||
// Skip telephone-event (DTMF) packets.
|
||||
if pt == 101 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let payload = &data[12..];
|
||||
if payload.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Decode to PCM.
|
||||
let (pcm, rate) = match self.transcoder.decode_to_pcm(payload, self.source_pt) {
|
||||
Ok(result) => result,
|
||||
Err(_) => return true, // Decode failed, skip packet.
|
||||
};
|
||||
|
||||
// Resample to 8kHz if needed.
|
||||
let pcm_8k = if rate != self.sample_rate {
|
||||
match self.transcoder.resample(&pcm, rate, self.sample_rate) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return true,
|
||||
}
|
||||
} else {
|
||||
pcm
|
||||
};
|
||||
|
||||
// Write samples.
|
||||
for &sample in &pcm_8k {
|
||||
if let Err(_) = self.writer.write_sample(sample) {
|
||||
return false;
|
||||
}
|
||||
self.total_samples += 1;
|
||||
|
||||
if let Some(max) = self.max_samples {
|
||||
if self.total_samples >= max {
|
||||
return false; // Max duration reached.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Stop recording and finalize the WAV file.
|
||||
pub fn stop(self) -> RecordingResult {
|
||||
let duration_ms = if self.sample_rate > 0 {
|
||||
(self.total_samples * 1000) / self.sample_rate as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Writer is finalized on drop (writes RIFF header sizes).
|
||||
drop(self.writer);
|
||||
|
||||
RecordingResult {
|
||||
file_path: self.file_path,
|
||||
duration_ms,
|
||||
total_samples: self.total_samples,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RecordingResult {
|
||||
pub file_path: String,
|
||||
pub duration_ms: u64,
|
||||
pub total_samples: u64,
|
||||
}
|
||||
171
rust/crates/proxy-engine/src/registrar.rs
Normal file
171
rust/crates/proxy-engine/src/registrar.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
//! Device registrar — accepts REGISTER from SIP phones and tracks contacts.
|
||||
//!
|
||||
//! When a device sends REGISTER, the registrar responds with 200 OK
|
||||
//! and stores the device's current contact (source IP:port).
|
||||
//!
|
||||
//! Ported from ts/registrar.ts.
|
||||
|
||||
use crate::config::DeviceConfig;
|
||||
use crate::ipc::{emit_event, OutTx};
|
||||
use sip_proto::helpers::generate_tag;
|
||||
use sip_proto::message::{ResponseOptions, SipMessage};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const MAX_EXPIRES: u32 = 300;
|
||||
|
||||
/// A registered device entry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegisteredDevice {
|
||||
pub device_id: String,
|
||||
pub display_name: String,
|
||||
pub extension: String,
|
||||
pub contact_addr: SocketAddr,
|
||||
pub registered_at: Instant,
|
||||
pub expires_at: Instant,
|
||||
pub aor: String,
|
||||
}
|
||||
|
||||
/// Manages device registrations.
|
||||
pub struct Registrar {
|
||||
/// Known device configs (from app config).
|
||||
devices: Vec<DeviceConfig>,
|
||||
/// Currently registered devices, keyed by device ID.
|
||||
registered: HashMap<String, RegisteredDevice>,
|
||||
out_tx: OutTx,
|
||||
}
|
||||
|
||||
impl Registrar {
|
||||
pub fn new(out_tx: OutTx) -> Self {
|
||||
Self {
|
||||
devices: Vec::new(),
|
||||
registered: HashMap::new(),
|
||||
out_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the known device list from config.
|
||||
pub fn configure(&mut self, devices: &[DeviceConfig]) {
|
||||
self.devices = devices.to_vec();
|
||||
}
|
||||
|
||||
/// Try to handle a SIP REGISTER from a device.
|
||||
/// Returns Some(response_bytes) if handled, None if not a known device.
|
||||
pub fn handle_register(
|
||||
&mut self,
|
||||
msg: &SipMessage,
|
||||
from_addr: SocketAddr,
|
||||
) -> Option<Vec<u8>> {
|
||||
if msg.method() != Some("REGISTER") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the device by matching the source IP against expectedAddress.
|
||||
let from_ip = from_addr.ip().to_string();
|
||||
let device = self.devices.iter().find(|d| d.expected_address == from_ip)?;
|
||||
|
||||
let from_header = msg.get_header("From").unwrap_or("");
|
||||
let aor = SipMessage::extract_uri(from_header)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("sip:{}@{}", device.extension, from_ip));
|
||||
|
||||
let expires_header = msg.get_header("Expires");
|
||||
let requested: u32 = expires_header
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(3600);
|
||||
let expires = requested.min(MAX_EXPIRES);
|
||||
|
||||
let entry = RegisteredDevice {
|
||||
device_id: device.id.clone(),
|
||||
display_name: device.display_name.clone(),
|
||||
extension: device.extension.clone(),
|
||||
contact_addr: from_addr,
|
||||
registered_at: Instant::now(),
|
||||
expires_at: Instant::now() + Duration::from_secs(expires as u64),
|
||||
aor: aor.clone(),
|
||||
};
|
||||
self.registered.insert(device.id.clone(), entry);
|
||||
|
||||
// Emit event to TypeScript.
|
||||
emit_event(
|
||||
&self.out_tx,
|
||||
"device_registered",
|
||||
serde_json::json!({
|
||||
"device_id": device.id,
|
||||
"display_name": device.display_name,
|
||||
"address": from_ip,
|
||||
"port": from_addr.port(),
|
||||
"aor": aor,
|
||||
"expires": expires,
|
||||
}),
|
||||
);
|
||||
|
||||
// Build 200 OK response.
|
||||
let contact = msg
|
||||
.get_header("Contact")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("<sip:{}:{}>", from_ip, from_addr.port()));
|
||||
|
||||
let response = SipMessage::create_response(
|
||||
200,
|
||||
"OK",
|
||||
msg,
|
||||
Some(ResponseOptions {
|
||||
to_tag: Some(generate_tag()),
|
||||
contact: Some(contact),
|
||||
extra_headers: Some(vec![(
|
||||
"Expires".to_string(),
|
||||
expires.to_string(),
|
||||
)]),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
|
||||
Some(response.serialize())
|
||||
}
|
||||
|
||||
/// Get the contact address for a registered device.
|
||||
pub fn get_device_contact(&self, device_id: &str) -> Option<SocketAddr> {
|
||||
let entry = self.registered.get(device_id)?;
|
||||
if Instant::now() > entry.expires_at {
|
||||
return None;
|
||||
}
|
||||
Some(entry.contact_addr)
|
||||
}
|
||||
|
||||
/// Check if a source address belongs to a known device.
|
||||
pub fn is_known_device_address(&self, addr: &str) -> bool {
|
||||
self.devices.iter().any(|d| d.expected_address == addr)
|
||||
}
|
||||
|
||||
/// Find a registered device by its source IP address.
|
||||
pub fn find_by_address(&self, addr: &SocketAddr) -> Option<&RegisteredDevice> {
|
||||
let ip = addr.ip().to_string();
|
||||
self.registered.values().find(|e| {
|
||||
e.contact_addr.ip().to_string() == ip && Instant::now() <= e.expires_at
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all device statuses for the dashboard.
|
||||
pub fn get_all_statuses(&self) -> Vec<serde_json::Value> {
|
||||
let now = Instant::now();
|
||||
let mut result = Vec::new();
|
||||
|
||||
for dc in &self.devices {
|
||||
let reg = self.registered.get(&dc.id);
|
||||
let connected = reg.map(|r| now <= r.expires_at).unwrap_or(false);
|
||||
result.push(serde_json::json!({
|
||||
"id": dc.id,
|
||||
"displayName": dc.display_name,
|
||||
"address": reg.filter(|_| connected).map(|r| r.contact_addr.ip().to_string()),
|
||||
"port": reg.filter(|_| connected).map(|r| r.contact_addr.port()),
|
||||
"aor": reg.map(|r| r.aor.as_str()).unwrap_or(""),
|
||||
"connected": connected,
|
||||
"isBrowser": false,
|
||||
}));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
158
rust/crates/proxy-engine/src/rtp.rs
Normal file
158
rust/crates/proxy-engine/src/rtp.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
//! RTP port pool and media forwarding.
|
||||
//!
|
||||
//! Manages a pool of even-numbered UDP ports for RTP media.
|
||||
//! Each port gets a bound tokio UdpSocket. Supports:
|
||||
//! - Direct forwarding (SIP-to-SIP, no transcoding)
|
||||
//! - Transcoding forwarding (via codec-lib, e.g. G.722 ↔ Opus)
|
||||
//! - Silence generation
|
||||
//! - NAT priming
|
||||
//!
|
||||
//! Ported from ts/call/rtp-port-pool.ts + sip-leg.ts RTP handling.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
/// A single RTP port allocation.
|
||||
pub struct RtpAllocation {
|
||||
pub port: u16,
|
||||
pub socket: Arc<UdpSocket>,
|
||||
}
|
||||
|
||||
/// RTP port pool — allocates even-numbered UDP ports.
|
||||
pub struct RtpPortPool {
|
||||
min: u16,
|
||||
max: u16,
|
||||
allocated: HashMap<u16, Arc<UdpSocket>>,
|
||||
}
|
||||
|
||||
impl RtpPortPool {
|
||||
pub fn new(min: u16, max: u16) -> Self {
|
||||
let min = if min % 2 == 0 { min } else { min + 1 };
|
||||
Self {
|
||||
min,
|
||||
max,
|
||||
allocated: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate an even-numbered port and bind a UDP socket.
|
||||
pub async fn allocate(&mut self) -> Option<RtpAllocation> {
|
||||
let mut port = self.min;
|
||||
while port < self.max {
|
||||
if !self.allocated.contains_key(&port) {
|
||||
match UdpSocket::bind(format!("0.0.0.0:{port}")).await {
|
||||
Ok(sock) => {
|
||||
let sock = Arc::new(sock);
|
||||
self.allocated.insert(port, sock.clone());
|
||||
return Some(RtpAllocation { port, socket: sock });
|
||||
}
|
||||
Err(_) => {
|
||||
// Port in use, try next.
|
||||
}
|
||||
}
|
||||
}
|
||||
port += 2;
|
||||
}
|
||||
None // Pool exhausted.
|
||||
}
|
||||
|
||||
/// Release a port back to the pool.
|
||||
pub fn release(&mut self, port: u16) {
|
||||
self.allocated.remove(&port);
|
||||
// Socket is dropped when the last Arc reference goes away.
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
self.allocated.len()
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> usize {
|
||||
((self.max - self.min) / 2) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// An active RTP relay between two endpoints.
|
||||
/// Receives on `local_socket` and forwards to `remote_addr`.
|
||||
pub struct RtpRelay {
|
||||
pub local_port: u16,
|
||||
pub local_socket: Arc<UdpSocket>,
|
||||
pub remote_addr: Option<SocketAddr>,
|
||||
/// If set, transcode packets using this codec session before forwarding.
|
||||
pub transcode: Option<TranscodeConfig>,
|
||||
/// Packets received counter.
|
||||
pub pkt_received: u64,
|
||||
/// Packets sent counter.
|
||||
pub pkt_sent: u64,
|
||||
}
|
||||
|
||||
pub struct TranscodeConfig {
|
||||
pub from_pt: u8,
|
||||
pub to_pt: u8,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
impl RtpRelay {
|
||||
pub fn new(port: u16, socket: Arc<UdpSocket>) -> Self {
|
||||
Self {
|
||||
local_port: port,
|
||||
local_socket: socket,
|
||||
remote_addr: None,
|
||||
transcode: None,
|
||||
pkt_received: 0,
|
||||
pkt_sent: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_remote(&mut self, addr: SocketAddr) {
|
||||
self.remote_addr = Some(addr);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a 1-byte NAT priming packet to open a pinhole.
|
||||
pub async fn prime_nat(socket: &UdpSocket, remote: SocketAddr) {
|
||||
let _ = socket.send_to(&[0u8], remote).await;
|
||||
}
|
||||
|
||||
/// Build an RTP silence frame for PCMU (payload type 0).
|
||||
pub fn silence_frame_pcmu() -> Vec<u8> {
|
||||
// 12-byte RTP header + 160 bytes of µ-law silence (0xFF)
|
||||
let mut frame = vec![0u8; 172];
|
||||
frame[0] = 0x80; // V=2
|
||||
frame[1] = 0; // PT=0 (PCMU)
|
||||
// seq, timestamp, ssrc left as 0 — caller should set these
|
||||
frame[12..].fill(0xFF); // µ-law silence
|
||||
frame
|
||||
}
|
||||
|
||||
/// Build an RTP silence frame for G.722 (payload type 9).
|
||||
pub fn silence_frame_g722() -> Vec<u8> {
|
||||
// 12-byte RTP header + 160 bytes of G.722 silence
|
||||
let mut frame = vec![0u8; 172];
|
||||
frame[0] = 0x80; // V=2
|
||||
frame[1] = 9; // PT=9 (G.722)
|
||||
// G.722 silence: all zeros is valid silence
|
||||
frame
|
||||
}
|
||||
|
||||
/// Build an RTP header with the given parameters.
|
||||
pub fn build_rtp_header(pt: u8, seq: u16, timestamp: u32, ssrc: u32) -> [u8; 12] {
|
||||
let mut header = [0u8; 12];
|
||||
header[0] = 0x80; // V=2
|
||||
header[1] = pt & 0x7F;
|
||||
header[2..4].copy_from_slice(&seq.to_be_bytes());
|
||||
header[4..8].copy_from_slice(×tamp.to_be_bytes());
|
||||
header[8..12].copy_from_slice(&ssrc.to_be_bytes());
|
||||
header
|
||||
}
|
||||
|
||||
/// Get the RTP clock increment per 20ms frame for a payload type.
|
||||
pub fn rtp_clock_increment(pt: u8) -> u32 {
|
||||
match pt {
|
||||
9 => 160, // G.722: 8000 Hz clock rate (despite 16kHz audio) × 0.02s
|
||||
0 | 8 => 160, // PCMU/PCMA: 8000 × 0.02
|
||||
111 => 960, // Opus: 48000 × 0.02
|
||||
_ => 160,
|
||||
}
|
||||
}
|
||||
475
rust/crates/proxy-engine/src/sip_leg.rs
Normal file
475
rust/crates/proxy-engine/src/sip_leg.rs
Normal file
@@ -0,0 +1,475 @@
|
||||
//! SipLeg — manages one side of a B2BUA call.
|
||||
//!
|
||||
//! Handles the full INVITE lifecycle:
|
||||
//! - Send INVITE with SDP
|
||||
//! - Handle 407 Proxy Authentication (digest auth retry)
|
||||
//! - Handle 200 OK (ACK, learn media endpoint)
|
||||
//! - Handle BYE/CANCEL (teardown)
|
||||
//! - Track SIP dialog state (early → confirmed → terminated)
|
||||
//!
|
||||
//! Ported from ts/call/sip-leg.ts.
|
||||
|
||||
use sip_proto::dialog::{DialogState, SipDialog};
|
||||
use sip_proto::helpers::{
|
||||
build_sdp, compute_digest_auth, generate_branch, generate_tag, parse_digest_challenge,
|
||||
parse_sdp_endpoint, SdpOptions,
|
||||
};
|
||||
use sip_proto::message::{RequestOptions, SipMessage};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
/// State of a SIP leg.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LegState {
|
||||
Inviting,
|
||||
Ringing,
|
||||
Connected,
|
||||
Terminating,
|
||||
Terminated,
|
||||
}
|
||||
|
||||
/// Configuration for creating a SIP leg.
|
||||
pub struct SipLegConfig {
|
||||
/// Proxy LAN IP (for Via, Contact, SDP).
|
||||
pub lan_ip: String,
|
||||
/// Proxy LAN port.
|
||||
pub lan_port: u16,
|
||||
/// Public IP (for provider-facing legs).
|
||||
pub public_ip: Option<String>,
|
||||
/// SIP target endpoint (provider outbound proxy or device address).
|
||||
pub sip_target: SocketAddr,
|
||||
/// Provider credentials (for 407 auth).
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub registered_aor: Option<String>,
|
||||
/// Codec payload types to offer.
|
||||
pub codecs: Vec<u8>,
|
||||
/// Our RTP port for SDP.
|
||||
pub rtp_port: u16,
|
||||
}
|
||||
|
||||
/// A SIP leg with full dialog management.
|
||||
pub struct SipLeg {
|
||||
pub id: String,
|
||||
pub state: LegState,
|
||||
pub config: SipLegConfig,
|
||||
pub dialog: Option<SipDialog>,
|
||||
|
||||
/// The INVITE we sent (needed for CANCEL and 407 ACK).
|
||||
invite: Option<SipMessage>,
|
||||
/// Original unauthenticated INVITE (for re-ACKing retransmitted 407s).
|
||||
orig_invite: Option<SipMessage>,
|
||||
/// Whether we've attempted digest auth.
|
||||
auth_attempted: bool,
|
||||
|
||||
/// Remote media endpoint (learned from SDP in 200 OK).
|
||||
pub remote_media: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
impl SipLeg {
|
||||
pub fn new(id: String, config: SipLegConfig) -> Self {
|
||||
Self {
|
||||
id,
|
||||
state: LegState::Inviting,
|
||||
config,
|
||||
dialog: None,
|
||||
invite: None,
|
||||
orig_invite: None,
|
||||
auth_attempted: false,
|
||||
remote_media: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build and send an INVITE to establish this leg.
|
||||
pub async fn send_invite(
|
||||
&mut self,
|
||||
from_uri: &str,
|
||||
to_uri: &str,
|
||||
sip_call_id: &str,
|
||||
socket: &UdpSocket,
|
||||
) {
|
||||
let ip = self
|
||||
.config
|
||||
.public_ip
|
||||
.as_deref()
|
||||
.unwrap_or(&self.config.lan_ip);
|
||||
|
||||
let sdp = build_sdp(&SdpOptions {
|
||||
ip,
|
||||
port: self.config.rtp_port,
|
||||
payload_types: &self.config.codecs,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let invite = SipMessage::create_request(
|
||||
"INVITE",
|
||||
to_uri,
|
||||
RequestOptions {
|
||||
via_host: ip.to_string(),
|
||||
via_port: self.config.lan_port,
|
||||
via_transport: None,
|
||||
via_branch: Some(generate_branch()),
|
||||
from_uri: from_uri.to_string(),
|
||||
from_display_name: None,
|
||||
from_tag: Some(generate_tag()),
|
||||
to_uri: to_uri.to_string(),
|
||||
to_display_name: None,
|
||||
to_tag: None,
|
||||
call_id: Some(sip_call_id.to_string()),
|
||||
cseq: Some(1),
|
||||
contact: Some(format!("<sip:{ip}:{}>", self.config.lan_port)),
|
||||
max_forwards: Some(70),
|
||||
body: Some(sdp),
|
||||
content_type: Some("application/sdp".to_string()),
|
||||
extra_headers: Some(vec![
|
||||
("User-Agent".to_string(), "SipRouter/1.0".to_string()),
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
self.dialog = Some(SipDialog::from_uac_invite(&invite, ip, self.config.lan_port));
|
||||
self.invite = Some(invite.clone());
|
||||
self.state = LegState::Inviting;
|
||||
|
||||
let _ = socket.send_to(&invite.serialize(), self.config.sip_target).await;
|
||||
}
|
||||
|
||||
/// Handle an incoming SIP message routed to this leg.
|
||||
/// Returns an optional reply to send (e.g. ACK, auth retry INVITE).
|
||||
pub fn handle_message(&mut self, msg: &SipMessage) -> SipLegAction {
|
||||
if msg.is_response() {
|
||||
self.handle_response(msg)
|
||||
} else {
|
||||
self.handle_request(msg)
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_response(&mut self, msg: &SipMessage) -> SipLegAction {
|
||||
let code = msg.status_code().unwrap_or(0);
|
||||
let cseq_method = msg.cseq_method().unwrap_or("").to_uppercase();
|
||||
|
||||
if cseq_method != "INVITE" {
|
||||
return SipLegAction::None;
|
||||
}
|
||||
|
||||
// Handle retransmitted 407 for the original unauthenticated INVITE.
|
||||
if self.auth_attempted {
|
||||
if let Some(dialog) = &self.dialog {
|
||||
let response_cseq: u32 = msg
|
||||
.get_header("CSeq")
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
if response_cseq < dialog.local_cseq && code >= 400 {
|
||||
// ACK the retransmitted error response.
|
||||
if let Some(orig) = &self.orig_invite {
|
||||
let ack = build_non_2xx_ack(orig, msg);
|
||||
return SipLegAction::Send(ack.serialize());
|
||||
}
|
||||
return SipLegAction::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 407 Proxy Authentication Required.
|
||||
if code == 407 {
|
||||
return self.handle_auth_challenge(msg);
|
||||
}
|
||||
|
||||
// Update dialog state.
|
||||
if let Some(dialog) = &mut self.dialog {
|
||||
dialog.process_response(msg);
|
||||
}
|
||||
|
||||
if code == 180 || code == 183 {
|
||||
self.state = LegState::Ringing;
|
||||
SipLegAction::StateChange(LegState::Ringing)
|
||||
} else if code >= 200 && code < 300 {
|
||||
// ACK the 200 OK.
|
||||
let ack_buf = if let Some(dialog) = &self.dialog {
|
||||
let ack = dialog.create_ack();
|
||||
Some(ack.serialize())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// If already connected (200 retransmit), just re-ACK.
|
||||
if self.state == LegState::Connected {
|
||||
return match ack_buf {
|
||||
Some(buf) => SipLegAction::Send(buf),
|
||||
None => SipLegAction::None,
|
||||
};
|
||||
}
|
||||
|
||||
// Learn media endpoint from SDP.
|
||||
if msg.has_sdp_body() {
|
||||
if let Some(ep) = parse_sdp_endpoint(&msg.body) {
|
||||
if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() {
|
||||
self.remote_media = Some(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.state = LegState::Connected;
|
||||
|
||||
match ack_buf {
|
||||
Some(buf) => SipLegAction::ConnectedWithAck(buf),
|
||||
None => SipLegAction::StateChange(LegState::Connected),
|
||||
}
|
||||
} else if code >= 300 {
|
||||
self.state = LegState::Terminated;
|
||||
if let Some(dialog) = &mut self.dialog {
|
||||
dialog.terminate();
|
||||
}
|
||||
SipLegAction::Terminated(format!("rejected_{code}"))
|
||||
} else {
|
||||
SipLegAction::None // 1xx provisional
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_auth_challenge(&mut self, msg: &SipMessage) -> SipLegAction {
|
||||
if self.auth_attempted {
|
||||
self.state = LegState::Terminated;
|
||||
if let Some(dialog) = &mut self.dialog {
|
||||
dialog.terminate();
|
||||
}
|
||||
return SipLegAction::Terminated("auth_rejected".to_string());
|
||||
}
|
||||
self.auth_attempted = true;
|
||||
|
||||
let challenge_header = match msg.get_header("Proxy-Authenticate") {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
self.state = LegState::Terminated;
|
||||
return SipLegAction::Terminated("407_no_challenge".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let challenge = match parse_digest_challenge(challenge_header) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
self.state = LegState::Terminated;
|
||||
return SipLegAction::Terminated("407_bad_challenge".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let password = match &self.config.password {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
self.state = LegState::Terminated;
|
||||
return SipLegAction::Terminated("407_no_password".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let aor = match &self.config.registered_aor {
|
||||
Some(a) => a.clone(),
|
||||
None => {
|
||||
self.state = LegState::Terminated;
|
||||
return SipLegAction::Terminated("407_no_aor".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let username = aor
|
||||
.trim_start_matches("sip:")
|
||||
.trim_start_matches("sips:")
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let dest_uri = self
|
||||
.invite
|
||||
.as_ref()
|
||||
.and_then(|i| i.request_uri())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let auth_value = compute_digest_auth(
|
||||
&username,
|
||||
&password,
|
||||
&challenge.realm,
|
||||
&challenge.nonce,
|
||||
"INVITE",
|
||||
&dest_uri,
|
||||
challenge.algorithm.as_deref(),
|
||||
challenge.opaque.as_deref(),
|
||||
);
|
||||
|
||||
// ACK the 407.
|
||||
let mut ack_buf = None;
|
||||
if let Some(invite) = &self.invite {
|
||||
let ack = build_non_2xx_ack(invite, msg);
|
||||
ack_buf = Some(ack.serialize());
|
||||
}
|
||||
|
||||
// Save original INVITE for retransmission handling.
|
||||
self.orig_invite = self.invite.clone();
|
||||
|
||||
// Build authenticated INVITE with same From tag, CSeq=2.
|
||||
let ip = self
|
||||
.config
|
||||
.public_ip
|
||||
.as_deref()
|
||||
.unwrap_or(&self.config.lan_ip);
|
||||
let from_tag = self
|
||||
.dialog
|
||||
.as_ref()
|
||||
.map(|d| d.local_tag.clone())
|
||||
.unwrap_or_else(generate_tag);
|
||||
|
||||
let sdp = build_sdp(&SdpOptions {
|
||||
ip,
|
||||
port: self.config.rtp_port,
|
||||
payload_types: &self.config.codecs,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let call_id = self
|
||||
.dialog
|
||||
.as_ref()
|
||||
.map(|d| d.call_id.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let invite_auth = SipMessage::create_request(
|
||||
"INVITE",
|
||||
&dest_uri,
|
||||
RequestOptions {
|
||||
via_host: ip.to_string(),
|
||||
via_port: self.config.lan_port,
|
||||
via_transport: None,
|
||||
via_branch: Some(generate_branch()),
|
||||
from_uri: aor,
|
||||
from_display_name: None,
|
||||
from_tag: Some(from_tag),
|
||||
to_uri: dest_uri.clone(),
|
||||
to_display_name: None,
|
||||
to_tag: None,
|
||||
call_id: Some(call_id),
|
||||
cseq: Some(2),
|
||||
contact: Some(format!("<sip:{ip}:{}>", self.config.lan_port)),
|
||||
max_forwards: Some(70),
|
||||
body: Some(sdp),
|
||||
content_type: Some("application/sdp".to_string()),
|
||||
extra_headers: Some(vec![
|
||||
("Proxy-Authorization".to_string(), auth_value),
|
||||
("User-Agent".to_string(), "SipRouter/1.0".to_string()),
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
self.invite = Some(invite_auth.clone());
|
||||
if let Some(dialog) = &mut self.dialog {
|
||||
dialog.local_cseq = 2;
|
||||
}
|
||||
|
||||
// Return both the ACK for the 407 and the new authenticated INVITE.
|
||||
let invite_buf = invite_auth.serialize();
|
||||
SipLegAction::AuthRetry {
|
||||
ack_407: ack_buf,
|
||||
invite_with_auth: invite_buf,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(&mut self, msg: &SipMessage) -> SipLegAction {
|
||||
let method = msg.method().unwrap_or("");
|
||||
|
||||
if method == "BYE" {
|
||||
let ok = SipMessage::create_response(200, "OK", msg, None);
|
||||
self.state = LegState::Terminated;
|
||||
if let Some(dialog) = &mut self.dialog {
|
||||
dialog.terminate();
|
||||
}
|
||||
return SipLegAction::SendAndTerminate(ok.serialize(), "bye".to_string());
|
||||
}
|
||||
|
||||
if method == "INFO" {
|
||||
let ok = SipMessage::create_response(200, "OK", msg, None);
|
||||
return SipLegAction::Send(ok.serialize());
|
||||
}
|
||||
|
||||
SipLegAction::None
|
||||
}
|
||||
|
||||
/// Build a BYE or CANCEL to tear down this leg.
|
||||
pub fn build_hangup(&mut self) -> Option<Vec<u8>> {
|
||||
let dialog = self.dialog.as_mut()?;
|
||||
|
||||
let msg = if dialog.state == DialogState::Confirmed {
|
||||
dialog.create_request("BYE", None, None, None)
|
||||
} else if dialog.state == DialogState::Early {
|
||||
if let Some(invite) = &self.invite {
|
||||
dialog.create_cancel(invite)
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
self.state = LegState::Terminating;
|
||||
dialog.terminate();
|
||||
Some(msg.serialize())
|
||||
}
|
||||
|
||||
/// Get the SIP Call-ID for routing.
|
||||
pub fn sip_call_id(&self) -> Option<&str> {
|
||||
self.dialog.as_ref().map(|d| d.call_id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Actions produced by the SipLeg message handler.
|
||||
pub enum SipLegAction {
|
||||
/// No action needed.
|
||||
None,
|
||||
/// Send a SIP message (ACK, 200 OK to INFO, etc.).
|
||||
Send(Vec<u8>),
|
||||
/// Leg state changed.
|
||||
StateChange(LegState),
|
||||
/// Connected — send this ACK.
|
||||
ConnectedWithAck(Vec<u8>),
|
||||
/// Terminated with a reason.
|
||||
Terminated(String),
|
||||
/// Send 200 OK and terminate.
|
||||
SendAndTerminate(Vec<u8>, String),
|
||||
/// 407 auth retry — send ACK for 407, then send new INVITE with auth.
|
||||
AuthRetry {
|
||||
ack_407: Option<Vec<u8>>,
|
||||
invite_with_auth: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Build an ACK for a non-2xx response (same transaction as the INVITE).
|
||||
fn build_non_2xx_ack(original_invite: &SipMessage, response: &SipMessage) -> SipMessage {
|
||||
let via = original_invite.get_header("Via").unwrap_or("").to_string();
|
||||
let from = original_invite
|
||||
.get_header("From")
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let to = response.get_header("To").unwrap_or("").to_string();
|
||||
let call_id = original_invite.call_id().to_string();
|
||||
let cseq_num: u32 = original_invite
|
||||
.get_header("CSeq")
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1);
|
||||
|
||||
let ruri = original_invite
|
||||
.request_uri()
|
||||
.unwrap_or("sip:unknown")
|
||||
.to_string();
|
||||
|
||||
SipMessage::new(
|
||||
format!("ACK {ruri} SIP/2.0"),
|
||||
vec![
|
||||
("Via".to_string(), via),
|
||||
("From".to_string(), from),
|
||||
("To".to_string(), to),
|
||||
("Call-ID".to_string(), call_id),
|
||||
("CSeq".to_string(), format!("{cseq_num} ACK")),
|
||||
("Max-Forwards".to_string(), "70".to_string()),
|
||||
("Content-Length".to_string(), "0".to_string()),
|
||||
],
|
||||
String::new(),
|
||||
)
|
||||
}
|
||||
67
rust/crates/proxy-engine/src/sip_transport.rs
Normal file
67
rust/crates/proxy-engine/src/sip_transport.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
//! SIP UDP transport — owns the main SIP socket.
|
||||
//!
|
||||
//! Binds a UDP socket, receives SIP messages, and provides a send method.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
/// The SIP UDP transport layer.
|
||||
pub struct SipTransport {
|
||||
socket: Arc<UdpSocket>,
|
||||
}
|
||||
|
||||
impl SipTransport {
|
||||
/// Bind a UDP socket on the given address (e.g. "0.0.0.0:5070").
|
||||
pub async fn bind(bind_addr: &str) -> Result<Self, String> {
|
||||
let socket = UdpSocket::bind(bind_addr)
|
||||
.await
|
||||
.map_err(|e| format!("bind {bind_addr}: {e}"))?;
|
||||
Ok(Self {
|
||||
socket: Arc::new(socket),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a clone of the socket Arc for the receiver task.
|
||||
pub fn socket(&self) -> Arc<UdpSocket> {
|
||||
self.socket.clone()
|
||||
}
|
||||
|
||||
/// Send a raw SIP message to a destination.
|
||||
pub async fn send_to(&self, data: &[u8], dest: SocketAddr) -> Result<usize, String> {
|
||||
self.socket
|
||||
.send_to(data, dest)
|
||||
.await
|
||||
.map_err(|e| format!("send to {dest}: {e}"))
|
||||
}
|
||||
|
||||
/// Send a raw SIP message to an address:port pair.
|
||||
pub async fn send_to_addr(&self, data: &[u8], addr: &str, port: u16) -> Result<usize, String> {
|
||||
let dest: SocketAddr = format!("{addr}:{port}")
|
||||
.parse()
|
||||
.map_err(|e| format!("bad address {addr}:{port}: {e}"))?;
|
||||
self.send_to(data, dest).await
|
||||
}
|
||||
|
||||
/// Spawn the UDP receive loop. Calls the handler for every received packet.
|
||||
pub fn spawn_receiver<F>(
|
||||
&self,
|
||||
handler: F,
|
||||
) where
|
||||
F: Fn(&[u8], SocketAddr) + Send + 'static,
|
||||
{
|
||||
let socket = self.socket.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65535];
|
||||
loop {
|
||||
match socket.recv_from(&mut buf).await {
|
||||
Ok((n, addr)) => handler(&buf[..n], addr),
|
||||
Err(e) => {
|
||||
eprintln!("[sip_transport] recv error: {e}");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
143
rust/crates/proxy-engine/src/tool_leg.rs
Normal file
143
rust/crates/proxy-engine/src/tool_leg.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
//! Tool leg consumers — background tasks that process per-source unmerged audio.
|
||||
//!
|
||||
//! Tool legs are observer legs that receive individual audio streams from each
|
||||
//! participant in a call. The mixer pipes `ToolAudioBatch` every 20ms containing
|
||||
//! each participant's decoded PCM@48kHz f32 tagged with source leg ID.
|
||||
//!
|
||||
//! Consumers:
|
||||
//! - **Recording**: writes per-source WAV files for speaker-separated recording.
|
||||
//! - **Transcription**: stub for future Whisper integration (accumulates audio in Rust).
|
||||
|
||||
use crate::ipc::{emit_event, OutTx};
|
||||
use crate::mixer::ToolAudioBatch;
|
||||
use crate::recorder::Recorder;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recording consumer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawn a recording tool leg that writes per-source WAV files.
|
||||
///
|
||||
/// Returns the channel sender (for the mixer to send batches) and the task handle.
|
||||
/// When the channel is closed (tool leg removed), all WAV files are finalized
|
||||
/// and a `tool_recording_done` event is emitted.
|
||||
pub fn spawn_recording_tool(
|
||||
tool_leg_id: String,
|
||||
call_id: String,
|
||||
base_dir: String,
|
||||
out_tx: OutTx,
|
||||
) -> (mpsc::Sender<ToolAudioBatch>, JoinHandle<()>) {
|
||||
let (tx, mut rx) = mpsc::channel::<ToolAudioBatch>(64);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut recorders: HashMap<String, Recorder> = HashMap::new();
|
||||
|
||||
while let Some(batch) = rx.recv().await {
|
||||
for source in &batch.sources {
|
||||
// Skip silence-only frames (near-zero = no audio activity).
|
||||
let has_audio = source.pcm_48k.iter().any(|&s| s.abs() > 1e-6);
|
||||
if !has_audio && !recorders.contains_key(&source.leg_id) {
|
||||
continue; // Don't create a file for silence-only sources.
|
||||
}
|
||||
|
||||
let recorder = recorders.entry(source.leg_id.clone()).or_insert_with(|| {
|
||||
let path = format!("{}/{}-{}.wav", base_dir, call_id, source.leg_id);
|
||||
Recorder::new_pcm(&path, 48000, None).unwrap_or_else(|e| {
|
||||
panic!("failed to create recorder for {}: {e}", source.leg_id);
|
||||
})
|
||||
});
|
||||
|
||||
// Convert f32 [-1.0, 1.0] to i16 for WAV writing.
|
||||
let pcm_i16: Vec<i16> = source.pcm_48k
|
||||
.iter()
|
||||
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
|
||||
.collect();
|
||||
if !recorder.write_pcm(&pcm_i16) {
|
||||
// Max duration reached — stop recording this source.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel closed — finalize all recordings.
|
||||
let mut files = Vec::new();
|
||||
for (leg_id, rec) in recorders {
|
||||
let result = rec.stop();
|
||||
files.push(serde_json::json!({
|
||||
"source_leg_id": leg_id,
|
||||
"file_path": result.file_path,
|
||||
"duration_ms": result.duration_ms,
|
||||
}));
|
||||
}
|
||||
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"tool_recording_done",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"tool_leg_id": tool_leg_id,
|
||||
"files": files,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
(tx, handle)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transcription consumer (stub — real plumbing, stub consumer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawn a transcription tool leg.
|
||||
///
|
||||
/// The plumbing is fully real: it receives per-source unmerged PCM@48kHz f32 from
|
||||
/// the mixer every 20ms. The consumer is a stub that accumulates audio and
|
||||
/// reports metadata on close. Future: will stream to a Whisper HTTP endpoint.
|
||||
pub fn spawn_transcription_tool(
|
||||
tool_leg_id: String,
|
||||
call_id: String,
|
||||
out_tx: OutTx,
|
||||
) -> (mpsc::Sender<ToolAudioBatch>, JoinHandle<()>) {
|
||||
let (tx, mut rx) = mpsc::channel::<ToolAudioBatch>(64);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
// Track per-source sample counts for duration reporting.
|
||||
let mut source_samples: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
while let Some(batch) = rx.recv().await {
|
||||
for source in &batch.sources {
|
||||
*source_samples.entry(source.leg_id.clone()).or_insert(0) +=
|
||||
source.pcm_48k.len() as u64;
|
||||
|
||||
// TODO: Future — accumulate chunks and stream to Whisper endpoint.
|
||||
// For now, the audio is received and counted but not processed.
|
||||
}
|
||||
}
|
||||
|
||||
// Channel closed — report metadata.
|
||||
let sources: Vec<serde_json::Value> = source_samples
|
||||
.iter()
|
||||
.map(|(leg_id, samples)| {
|
||||
serde_json::json!({
|
||||
"source_leg_id": leg_id,
|
||||
"duration_ms": (samples * 1000) / 48000,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"tool_transcription_done",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"tool_leg_id": tool_leg_id,
|
||||
"sources": sources,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
(tx, handle)
|
||||
}
|
||||
138
rust/crates/proxy-engine/src/tts.rs
Normal file
138
rust/crates/proxy-engine/src/tts.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
//! Text-to-speech engine — synthesizes text to WAV files using Kokoro neural TTS.
|
||||
//!
|
||||
//! The model is loaded lazily on first use. If the model/voices files are not
|
||||
//! present, the generate command returns an error and the TS side falls back
|
||||
//! to espeak-ng.
|
||||
|
||||
use kokoro_tts::{KokoroTts, Voice};
|
||||
use std::path::Path;
|
||||
|
||||
/// Wraps the Kokoro TTS engine with lazy model loading.
|
||||
pub struct TtsEngine {
|
||||
tts: Option<KokoroTts>,
|
||||
/// Path that was used to load the current model (for cache invalidation).
|
||||
loaded_model_path: String,
|
||||
loaded_voices_path: String,
|
||||
}
|
||||
|
||||
impl TtsEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tts: None,
|
||||
loaded_model_path: String::new(),
|
||||
loaded_voices_path: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a WAV file from text.
|
||||
///
|
||||
/// Params (from IPC JSON):
|
||||
/// - `model`: path to the ONNX model file
|
||||
/// - `voices`: path to the voices.bin file
|
||||
/// - `voice`: voice name (e.g. "af_bella")
|
||||
/// - `text`: text to synthesize
|
||||
/// - `output`: output WAV file path
|
||||
pub async fn generate(&mut self, params: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let model_path = params.get("model").and_then(|v| v.as_str())
|
||||
.ok_or("missing 'model' param")?;
|
||||
let voices_path = params.get("voices").and_then(|v| v.as_str())
|
||||
.ok_or("missing 'voices' param")?;
|
||||
let voice_name = params.get("voice").and_then(|v| v.as_str())
|
||||
.unwrap_or("af_bella");
|
||||
let text = params.get("text").and_then(|v| v.as_str())
|
||||
.ok_or("missing 'text' param")?;
|
||||
let output_path = params.get("output").and_then(|v| v.as_str())
|
||||
.ok_or("missing 'output' param")?;
|
||||
|
||||
if text.is_empty() {
|
||||
return Err("empty text".into());
|
||||
}
|
||||
|
||||
// Check that model/voices files exist.
|
||||
if !Path::new(model_path).exists() {
|
||||
return Err(format!("model not found: {model_path}"));
|
||||
}
|
||||
if !Path::new(voices_path).exists() {
|
||||
return Err(format!("voices not found: {voices_path}"));
|
||||
}
|
||||
|
||||
// Lazy-load or reload if paths changed.
|
||||
if self.tts.is_none()
|
||||
|| self.loaded_model_path != model_path
|
||||
|| self.loaded_voices_path != voices_path
|
||||
{
|
||||
eprintln!("[tts] loading model: {model_path}");
|
||||
let tts = KokoroTts::new(model_path, voices_path)
|
||||
.await
|
||||
.map_err(|e| format!("model load failed: {e:?}"))?;
|
||||
self.tts = Some(tts);
|
||||
self.loaded_model_path = model_path.to_string();
|
||||
self.loaded_voices_path = voices_path.to_string();
|
||||
}
|
||||
|
||||
let tts = self.tts.as_ref().unwrap();
|
||||
let voice = select_voice(voice_name);
|
||||
|
||||
eprintln!("[tts] synthesizing voice '{voice_name}': \"{text}\"");
|
||||
let (samples, duration) = tts.synth(text, voice)
|
||||
.await
|
||||
.map_err(|e| format!("synthesis failed: {e:?}"))?;
|
||||
eprintln!("[tts] synthesized {} samples in {duration:?}", samples.len());
|
||||
|
||||
// Write 24kHz 16-bit mono WAV.
|
||||
let spec = hound::WavSpec {
|
||||
channels: 1,
|
||||
sample_rate: 24000,
|
||||
bits_per_sample: 16,
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
|
||||
let mut writer = hound::WavWriter::create(output_path, spec)
|
||||
.map_err(|e| format!("WAV create failed: {e}"))?;
|
||||
for &sample in &samples {
|
||||
let s16 = (sample * 32767.0).round().clamp(-32768.0, 32767.0) as i16;
|
||||
writer.write_sample(s16).map_err(|e| format!("WAV write: {e}"))?;
|
||||
}
|
||||
writer.finalize().map_err(|e| format!("WAV finalize: {e}"))?;
|
||||
|
||||
eprintln!("[tts] wrote {output_path}");
|
||||
Ok(serde_json::json!({ "output": output_path }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map voice name string to Kokoro Voice enum variant.
|
||||
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] unknown voice '{name}', falling back to af_bella");
|
||||
Voice::AfBella(1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
137
rust/crates/proxy-engine/src/voicemail.rs
Normal file
137
rust/crates/proxy-engine/src/voicemail.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
//! Voicemail session — answer → play greeting → beep → record → done.
|
||||
|
||||
use crate::audio_player::{play_beep, play_wav_file};
|
||||
use crate::ipc::{emit_event, OutTx};
|
||||
use crate::recorder::Recorder;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
/// Run a voicemail session on an RTP port.
|
||||
///
|
||||
/// 1. Plays the greeting WAV file to the caller
|
||||
/// 2. Plays a beep tone
|
||||
/// 3. Records the caller's message until BYE or max duration
|
||||
///
|
||||
/// The RTP receive loop is separate — it feeds packets to the recorder
|
||||
/// via the returned channel.
|
||||
pub async fn run_voicemail_session(
|
||||
rtp_socket: Arc<UdpSocket>,
|
||||
provider_media: SocketAddr,
|
||||
codec_pt: u8,
|
||||
greeting_wav: Option<String>,
|
||||
recording_path: String,
|
||||
max_recording_ms: u64,
|
||||
call_id: String,
|
||||
caller_number: String,
|
||||
out_tx: OutTx,
|
||||
) {
|
||||
let ssrc: u32 = rand::random();
|
||||
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"voicemail_started",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"caller_number": caller_number,
|
||||
}),
|
||||
);
|
||||
|
||||
// Step 1: Play greeting.
|
||||
let mut next_seq: u16 = 0;
|
||||
let mut next_ts: u32 = 0;
|
||||
|
||||
if let Some(wav_path) = &greeting_wav {
|
||||
match play_wav_file(wav_path, rtp_socket.clone(), provider_media, codec_pt, ssrc).await {
|
||||
Ok(frames) => {
|
||||
next_seq = frames as u16;
|
||||
next_ts = frames * crate::rtp::rtp_clock_increment(codec_pt);
|
||||
}
|
||||
Err(e) => {
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"voicemail_error",
|
||||
serde_json::json!({ "call_id": call_id, "error": format!("greeting: {e}") }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Play beep (1kHz, 500ms).
|
||||
match play_beep(
|
||||
rtp_socket.clone(),
|
||||
provider_media,
|
||||
codec_pt,
|
||||
ssrc,
|
||||
next_seq,
|
||||
next_ts,
|
||||
1000,
|
||||
500,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((_seq, _ts)) => {}
|
||||
Err(e) => {
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"voicemail_error",
|
||||
serde_json::json!({ "call_id": call_id, "error": format!("beep: {e}") }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Record incoming audio.
|
||||
let recorder = match Recorder::new(&recording_path, codec_pt, Some(max_recording_ms)) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"voicemail_error",
|
||||
serde_json::json!({ "call_id": call_id, "error": format!("recorder: {e}") }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Receive RTP and feed to recorder.
|
||||
let result = record_from_socket(rtp_socket, recorder, max_recording_ms).await;
|
||||
|
||||
// Step 4: Done — emit recording result.
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"recording_done",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"file_path": result.file_path,
|
||||
"duration_ms": result.duration_ms,
|
||||
"caller_number": caller_number,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Read RTP packets from the socket and feed them to the recorder.
|
||||
/// Returns when the socket errors out (BYE closes the call/socket)
|
||||
/// or max duration is reached.
|
||||
async fn record_from_socket(
|
||||
socket: Arc<UdpSocket>,
|
||||
mut recorder: Recorder,
|
||||
max_ms: u64,
|
||||
) -> crate::recorder::RecordingResult {
|
||||
let mut buf = vec![0u8; 65535];
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(max_ms + 2000);
|
||||
|
||||
loop {
|
||||
let timeout = tokio::time::timeout_at(deadline, socket.recv_from(&mut buf));
|
||||
match timeout.await {
|
||||
Ok(Ok((n, _addr))) => {
|
||||
if !recorder.process_rtp(&buf[..n]) {
|
||||
break; // Max duration reached.
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => break, // Socket error (closed).
|
||||
Err(_) => break, // Timeout (max duration + grace).
|
||||
}
|
||||
}
|
||||
|
||||
recorder.stop()
|
||||
}
|
||||
327
rust/crates/proxy-engine/src/webrtc_engine.rs
Normal file
327
rust/crates/proxy-engine/src/webrtc_engine.rs
Normal file
@@ -0,0 +1,327 @@
|
||||
//! WebRTC engine — manages browser PeerConnections.
|
||||
//!
|
||||
//! Audio bridging is now channel-based:
|
||||
//! - Browser Opus audio → on_track → mixer inbound channel
|
||||
//! - Mixer outbound channel → Opus RTP → TrackLocalStaticRTP → browser
|
||||
//!
|
||||
//! The mixer handles all transcoding. The WebRTC engine just shuttles raw Opus.
|
||||
|
||||
use crate::ipc::{emit_event, OutTx};
|
||||
use crate::mixer::RtpPacket;
|
||||
use codec_lib::PT_OPUS;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use webrtc::api::media_engine::MediaEngine;
|
||||
use webrtc::api::APIBuilder;
|
||||
use webrtc::ice_transport::ice_candidate::RTCIceCandidateInit;
|
||||
use webrtc::peer_connection::configuration::RTCConfiguration;
|
||||
use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState;
|
||||
use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
|
||||
use webrtc::peer_connection::RTCPeerConnection;
|
||||
use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability;
|
||||
use webrtc::track::track_local::track_local_static_rtp::TrackLocalStaticRTP;
|
||||
use webrtc::track::track_local::{TrackLocal, TrackLocalWriter};
|
||||
|
||||
/// A managed WebRTC session.
|
||||
struct WebRtcSession {
|
||||
pc: Arc<RTCPeerConnection>,
|
||||
local_track: Arc<TrackLocalStaticRTP>,
|
||||
call_id: Option<String>,
|
||||
/// Channel sender for forwarding browser Opus audio to the mixer.
|
||||
/// Set when the session is linked to a call via link_to_mixer().
|
||||
mixer_tx: Arc<Mutex<Option<mpsc::Sender<RtpPacket>>>>,
|
||||
}
|
||||
|
||||
/// Manages all WebRTC sessions.
|
||||
pub struct WebRtcEngine {
|
||||
sessions: HashMap<String, WebRtcSession>,
|
||||
out_tx: OutTx,
|
||||
}
|
||||
|
||||
impl WebRtcEngine {
|
||||
pub fn new(out_tx: OutTx) -> Self {
|
||||
Self {
|
||||
sessions: HashMap::new(),
|
||||
out_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a WebRTC offer from a browser — create PeerConnection, return SDP answer.
|
||||
pub async fn handle_offer(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
offer_sdp: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut media_engine = MediaEngine::default();
|
||||
media_engine
|
||||
.register_default_codecs()
|
||||
.map_err(|e| format!("register codecs: {e}"))?;
|
||||
|
||||
let api = APIBuilder::new()
|
||||
.with_media_engine(media_engine)
|
||||
.build();
|
||||
|
||||
let config = RTCConfiguration {
|
||||
ice_servers: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let pc = api
|
||||
.new_peer_connection(config)
|
||||
.await
|
||||
.map_err(|e| format!("create peer connection: {e}"))?;
|
||||
let pc = Arc::new(pc);
|
||||
|
||||
// Local audio track for sending audio to browser (Opus).
|
||||
let local_track = Arc::new(TrackLocalStaticRTP::new(
|
||||
RTCRtpCodecCapability {
|
||||
mime_type: "audio/opus".to_string(),
|
||||
clock_rate: 48000,
|
||||
channels: 1,
|
||||
..Default::default()
|
||||
},
|
||||
"audio".to_string(),
|
||||
"siprouter".to_string(),
|
||||
));
|
||||
|
||||
let _sender = pc
|
||||
.add_track(local_track.clone() as Arc<dyn TrackLocal + Send + Sync>)
|
||||
.await
|
||||
.map_err(|e| format!("add track: {e}"))?;
|
||||
|
||||
// Shared mixer channel sender (populated when linked to a call).
|
||||
let mixer_tx: Arc<Mutex<Option<mpsc::Sender<RtpPacket>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
// ICE candidate handler.
|
||||
let out_tx_ice = self.out_tx.clone();
|
||||
let sid_ice = session_id.to_string();
|
||||
pc.on_ice_candidate(Box::new(move |candidate| {
|
||||
let out_tx = out_tx_ice.clone();
|
||||
let sid = sid_ice.clone();
|
||||
Box::pin(async move {
|
||||
if let Some(c) = candidate {
|
||||
if let Ok(json) = c.to_json() {
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"webrtc_ice_candidate",
|
||||
serde_json::json!({
|
||||
"session_id": sid,
|
||||
"candidate": json.candidate,
|
||||
"sdp_mid": json.sdp_mid,
|
||||
"sdp_mline_index": json.sdp_mline_index,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
// Connection state handler.
|
||||
let out_tx_state = self.out_tx.clone();
|
||||
let sid_state = session_id.to_string();
|
||||
pc.on_peer_connection_state_change(Box::new(move |state| {
|
||||
let out_tx = out_tx_state.clone();
|
||||
let sid = sid_state.clone();
|
||||
Box::pin(async move {
|
||||
let state_str = match state {
|
||||
RTCPeerConnectionState::Connected => "connected",
|
||||
RTCPeerConnectionState::Disconnected => "disconnected",
|
||||
RTCPeerConnectionState::Failed => "failed",
|
||||
RTCPeerConnectionState::Closed => "closed",
|
||||
RTCPeerConnectionState::New => "new",
|
||||
RTCPeerConnectionState::Connecting => "connecting",
|
||||
_ => "unknown",
|
||||
};
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"webrtc_state",
|
||||
serde_json::json!({ "session_id": sid, "state": state_str }),
|
||||
);
|
||||
})
|
||||
}));
|
||||
|
||||
// Track handler — receives Opus audio from the browser.
|
||||
// Forwards raw Opus payload to the mixer channel (when linked).
|
||||
let out_tx_track = self.out_tx.clone();
|
||||
let sid_track = session_id.to_string();
|
||||
let mixer_tx_for_track = mixer_tx.clone();
|
||||
pc.on_track(Box::new(move |track, _receiver, _transceiver| {
|
||||
let out_tx = out_tx_track.clone();
|
||||
let sid = sid_track.clone();
|
||||
let mixer_tx = mixer_tx_for_track.clone();
|
||||
Box::pin(async move {
|
||||
let codec_info = track.codec();
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"webrtc_track",
|
||||
serde_json::json!({
|
||||
"session_id": sid,
|
||||
"kind": track.kind().to_string(),
|
||||
"codec": codec_info.capability.mime_type,
|
||||
}),
|
||||
);
|
||||
|
||||
// Spawn browser→mixer forwarding task.
|
||||
tokio::spawn(browser_to_mixer_loop(track, mixer_tx, out_tx, sid));
|
||||
})
|
||||
}));
|
||||
|
||||
// Set remote offer.
|
||||
let offer = RTCSessionDescription::offer(offer_sdp.to_string())
|
||||
.map_err(|e| format!("parse offer: {e}"))?;
|
||||
pc.set_remote_description(offer)
|
||||
.await
|
||||
.map_err(|e| format!("set remote description: {e}"))?;
|
||||
|
||||
// Create answer.
|
||||
let answer = pc
|
||||
.create_answer(None)
|
||||
.await
|
||||
.map_err(|e| format!("create answer: {e}"))?;
|
||||
let answer_sdp = answer.sdp.clone();
|
||||
pc.set_local_description(answer)
|
||||
.await
|
||||
.map_err(|e| format!("set local description: {e}"))?;
|
||||
|
||||
self.sessions.insert(
|
||||
session_id.to_string(),
|
||||
WebRtcSession {
|
||||
pc,
|
||||
local_track,
|
||||
call_id: None,
|
||||
mixer_tx,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(answer_sdp)
|
||||
}
|
||||
|
||||
/// Link a WebRTC session to a call's mixer via channels.
|
||||
/// - `inbound_tx`: browser audio goes TO the mixer through this channel
|
||||
/// - `outbound_rx`: mixed audio comes FROM the mixer through this channel
|
||||
pub async fn link_to_mixer(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
call_id: &str,
|
||||
inbound_tx: mpsc::Sender<RtpPacket>,
|
||||
outbound_rx: mpsc::Receiver<Vec<u8>>,
|
||||
) -> bool {
|
||||
let session = match self.sessions.get_mut(session_id) {
|
||||
Some(s) => s,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
session.call_id = Some(call_id.to_string());
|
||||
|
||||
// Set the mixer sender so the on_track loop starts forwarding.
|
||||
{
|
||||
let mut tx = session.mixer_tx.lock().await;
|
||||
*tx = Some(inbound_tx);
|
||||
}
|
||||
|
||||
// Spawn mixer→browser outbound task.
|
||||
let local_track = session.local_track.clone();
|
||||
tokio::spawn(mixer_to_browser_loop(outbound_rx, local_track));
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn add_ice_candidate(
|
||||
&self,
|
||||
session_id: &str,
|
||||
candidate: &str,
|
||||
sdp_mid: Option<&str>,
|
||||
sdp_mline_index: Option<u16>,
|
||||
) -> Result<(), String> {
|
||||
let session = self
|
||||
.sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("session {session_id} not found"))?;
|
||||
|
||||
let init = RTCIceCandidateInit {
|
||||
candidate: candidate.to_string(),
|
||||
sdp_mid: sdp_mid.map(|s| s.to_string()),
|
||||
sdp_mline_index,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
session
|
||||
.pc
|
||||
.add_ice_candidate(init)
|
||||
.await
|
||||
.map_err(|e| format!("add ICE: {e}"))
|
||||
}
|
||||
|
||||
pub async fn close_session(&mut self, session_id: &str) -> Result<(), String> {
|
||||
if let Some(session) = self.sessions.remove(session_id) {
|
||||
session.pc.close().await.map_err(|e| format!("close: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser → Mixer audio forwarding loop.
|
||||
/// Reads Opus RTP from the browser track, sends raw Opus payload to the mixer channel.
|
||||
async fn browser_to_mixer_loop(
|
||||
track: Arc<webrtc::track::track_remote::TrackRemote>,
|
||||
mixer_tx: Arc<Mutex<Option<mpsc::Sender<RtpPacket>>>>,
|
||||
out_tx: OutTx,
|
||||
session_id: String,
|
||||
) {
|
||||
let mut buf = vec![0u8; 1500];
|
||||
let mut count = 0u64;
|
||||
|
||||
loop {
|
||||
match track.read(&mut buf).await {
|
||||
Ok((rtp_packet, _attributes)) => {
|
||||
count += 1;
|
||||
|
||||
let payload = &rtp_packet.payload;
|
||||
if payload.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send raw Opus payload to mixer (if linked).
|
||||
let tx = mixer_tx.lock().await;
|
||||
if let Some(ref tx) = *tx {
|
||||
let _ = tx
|
||||
.send(RtpPacket {
|
||||
payload: payload.to_vec(),
|
||||
payload_type: PT_OPUS,
|
||||
marker: rtp_packet.header.marker,
|
||||
seq: rtp_packet.header.sequence_number,
|
||||
timestamp: rtp_packet.header.timestamp,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
if count == 1 || count == 50 || count % 500 == 0 {
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"webrtc_audio_rx",
|
||||
serde_json::json!({
|
||||
"session_id": session_id,
|
||||
"direction": "browser_to_mixer",
|
||||
"packet_count": count,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => break, // Track ended.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixer → Browser audio forwarding loop.
|
||||
/// Reads Opus-encoded RTP packets from the mixer and writes to the WebRTC track.
|
||||
async fn mixer_to_browser_loop(
|
||||
mut outbound_rx: mpsc::Receiver<Vec<u8>>,
|
||||
local_track: Arc<TrackLocalStaticRTP>,
|
||||
) {
|
||||
while let Some(rtp_data) = outbound_rx.recv().await {
|
||||
let _ = local_track.write(&rtp_data).await;
|
||||
}
|
||||
}
|
||||
8
rust/crates/sip-proto/Cargo.toml
Normal file
8
rust/crates/sip-proto/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "sip-proto"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
md-5 = "0.10"
|
||||
rand = "0.8"
|
||||
408
rust/crates/sip-proto/src/dialog.rs
Normal file
408
rust/crates/sip-proto/src/dialog.rs
Normal file
@@ -0,0 +1,408 @@
|
||||
//! SIP dialog state machine (RFC 3261 §12).
|
||||
//!
|
||||
//! Tracks local/remote tags, CSeq counters, route set, and remote target.
|
||||
//! Provides methods to build in-dialog requests (BYE, re-INVITE, ACK, CANCEL).
|
||||
//!
|
||||
//! Ported from ts/sip/dialog.ts.
|
||||
|
||||
use crate::helpers::{generate_branch, generate_tag};
|
||||
use crate::message::SipMessage;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DialogState {
|
||||
Early,
|
||||
Confirmed,
|
||||
Terminated,
|
||||
}
|
||||
|
||||
/// SIP dialog state per RFC 3261 §12.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SipDialog {
|
||||
pub call_id: String,
|
||||
pub local_tag: String,
|
||||
pub remote_tag: Option<String>,
|
||||
pub local_uri: String,
|
||||
pub remote_uri: String,
|
||||
pub local_cseq: u32,
|
||||
pub remote_cseq: u32,
|
||||
pub route_set: Vec<String>,
|
||||
pub remote_target: String,
|
||||
pub state: DialogState,
|
||||
pub local_host: String,
|
||||
pub local_port: u16,
|
||||
}
|
||||
|
||||
impl SipDialog {
|
||||
/// Create a dialog from an INVITE we are sending (UAC side).
|
||||
/// The dialog enters Early state; call `process_response()` when responses arrive.
|
||||
pub fn from_uac_invite(invite: &SipMessage, local_host: &str, local_port: u16) -> Self {
|
||||
let from = invite.get_header("From").unwrap_or("");
|
||||
let to = invite.get_header("To").unwrap_or("");
|
||||
|
||||
let local_cseq = invite
|
||||
.get_header("CSeq")
|
||||
.and_then(|c| c.split_whitespace().next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1);
|
||||
|
||||
Self {
|
||||
call_id: invite.call_id().to_string(),
|
||||
local_tag: SipMessage::extract_tag(from)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(generate_tag),
|
||||
remote_tag: None,
|
||||
local_uri: SipMessage::extract_uri(from)
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
remote_uri: SipMessage::extract_uri(to).unwrap_or("").to_string(),
|
||||
local_cseq,
|
||||
remote_cseq: 0,
|
||||
route_set: Vec::new(),
|
||||
remote_target: invite
|
||||
.request_uri()
|
||||
.or_else(|| SipMessage::extract_uri(to))
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
state: DialogState::Early,
|
||||
local_host: local_host.to_string(),
|
||||
local_port,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a dialog from an INVITE we received (UAS side).
|
||||
pub fn from_uas_invite(
|
||||
invite: &SipMessage,
|
||||
local_tag: &str,
|
||||
local_host: &str,
|
||||
local_port: u16,
|
||||
) -> Self {
|
||||
let from = invite.get_header("From").unwrap_or("");
|
||||
let to = invite.get_header("To").unwrap_or("");
|
||||
let contact = invite.get_header("Contact");
|
||||
|
||||
let remote_target = contact
|
||||
.and_then(SipMessage::extract_uri)
|
||||
.or_else(|| SipMessage::extract_uri(from))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Self {
|
||||
call_id: invite.call_id().to_string(),
|
||||
local_tag: local_tag.to_string(),
|
||||
remote_tag: SipMessage::extract_tag(from).map(|s| s.to_string()),
|
||||
local_uri: SipMessage::extract_uri(to).unwrap_or("").to_string(),
|
||||
remote_uri: SipMessage::extract_uri(from).unwrap_or("").to_string(),
|
||||
local_cseq: 0,
|
||||
remote_cseq: 0,
|
||||
route_set: Vec::new(),
|
||||
remote_target,
|
||||
state: DialogState::Early,
|
||||
local_host: local_host.to_string(),
|
||||
local_port,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update dialog state from a received response.
|
||||
pub fn process_response(&mut self, response: &SipMessage) {
|
||||
let to = response.get_header("To").unwrap_or("");
|
||||
let tag = SipMessage::extract_tag(to).map(|s| s.to_string());
|
||||
let code = response.status_code().unwrap_or(0);
|
||||
|
||||
// Always update remoteTag from 2xx (RFC 3261 §12.1.2).
|
||||
if let Some(ref t) = tag {
|
||||
if code >= 200 && code < 300 {
|
||||
self.remote_tag = Some(t.clone());
|
||||
} else if self.remote_tag.is_none() {
|
||||
self.remote_tag = Some(t.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Update remote target from Contact.
|
||||
if let Some(contact) = response.get_header("Contact") {
|
||||
if let Some(uri) = SipMessage::extract_uri(contact) {
|
||||
self.remote_target = uri.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Record-Route → route set (in reverse for UAC).
|
||||
if self.state == DialogState::Early {
|
||||
let rr: Vec<String> = response
|
||||
.headers
|
||||
.iter()
|
||||
.filter(|(n, _)| n.to_ascii_lowercase() == "record-route")
|
||||
.map(|(_, v)| v.clone())
|
||||
.collect();
|
||||
if !rr.is_empty() {
|
||||
let mut reversed = rr;
|
||||
reversed.reverse();
|
||||
self.route_set = reversed;
|
||||
}
|
||||
}
|
||||
|
||||
if code >= 200 && code < 300 {
|
||||
self.state = DialogState::Confirmed;
|
||||
} else if code >= 300 {
|
||||
self.state = DialogState::Terminated;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an in-dialog request (BYE, re-INVITE, INFO, ...).
|
||||
/// Automatically increments the local CSeq.
|
||||
pub fn create_request(
|
||||
&mut self,
|
||||
method: &str,
|
||||
body: Option<&str>,
|
||||
content_type: Option<&str>,
|
||||
extra_headers: Option<Vec<(String, String)>>,
|
||||
) -> SipMessage {
|
||||
self.local_cseq += 1;
|
||||
let branch = generate_branch();
|
||||
|
||||
let remote_tag_str = self
|
||||
.remote_tag
|
||||
.as_ref()
|
||||
.map(|t| format!(";tag={t}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut headers = vec![
|
||||
(
|
||||
"Via".to_string(),
|
||||
format!(
|
||||
"SIP/2.0/UDP {}:{};branch={branch};rport",
|
||||
self.local_host, self.local_port
|
||||
),
|
||||
),
|
||||
(
|
||||
"From".to_string(),
|
||||
format!("<{}>;tag={}", self.local_uri, self.local_tag),
|
||||
),
|
||||
(
|
||||
"To".to_string(),
|
||||
format!("<{}>{remote_tag_str}", self.remote_uri),
|
||||
),
|
||||
("Call-ID".to_string(), self.call_id.clone()),
|
||||
(
|
||||
"CSeq".to_string(),
|
||||
format!("{} {method}", self.local_cseq),
|
||||
),
|
||||
("Max-Forwards".to_string(), "70".to_string()),
|
||||
];
|
||||
|
||||
for route in &self.route_set {
|
||||
headers.push(("Route".to_string(), route.clone()));
|
||||
}
|
||||
|
||||
headers.push((
|
||||
"Contact".to_string(),
|
||||
format!("<sip:{}:{}>", self.local_host, self.local_port),
|
||||
));
|
||||
|
||||
if let Some(extra) = extra_headers {
|
||||
headers.extend(extra);
|
||||
}
|
||||
|
||||
let body_str = body.unwrap_or("");
|
||||
if !body_str.is_empty() {
|
||||
if let Some(ct) = content_type {
|
||||
headers.push(("Content-Type".to_string(), ct.to_string()));
|
||||
}
|
||||
}
|
||||
headers.push(("Content-Length".to_string(), body_str.len().to_string()));
|
||||
|
||||
let ruri = self.resolve_ruri();
|
||||
SipMessage::new(
|
||||
format!("{method} {ruri} SIP/2.0"),
|
||||
headers,
|
||||
body_str.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build an ACK for a 2xx response to INVITE (RFC 3261 §13.2.2.4).
|
||||
pub fn create_ack(&self) -> SipMessage {
|
||||
let branch = generate_branch();
|
||||
let remote_tag_str = self
|
||||
.remote_tag
|
||||
.as_ref()
|
||||
.map(|t| format!(";tag={t}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut headers = vec![
|
||||
(
|
||||
"Via".to_string(),
|
||||
format!(
|
||||
"SIP/2.0/UDP {}:{};branch={branch};rport",
|
||||
self.local_host, self.local_port
|
||||
),
|
||||
),
|
||||
(
|
||||
"From".to_string(),
|
||||
format!("<{}>;tag={}", self.local_uri, self.local_tag),
|
||||
),
|
||||
(
|
||||
"To".to_string(),
|
||||
format!("<{}>{remote_tag_str}", self.remote_uri),
|
||||
),
|
||||
("Call-ID".to_string(), self.call_id.clone()),
|
||||
(
|
||||
"CSeq".to_string(),
|
||||
format!("{} ACK", self.local_cseq),
|
||||
),
|
||||
("Max-Forwards".to_string(), "70".to_string()),
|
||||
];
|
||||
|
||||
for route in &self.route_set {
|
||||
headers.push(("Route".to_string(), route.clone()));
|
||||
}
|
||||
|
||||
headers.push(("Content-Length".to_string(), "0".to_string()));
|
||||
|
||||
let ruri = self.resolve_ruri();
|
||||
SipMessage::new(format!("ACK {ruri} SIP/2.0"), headers, String::new())
|
||||
}
|
||||
|
||||
/// Build a CANCEL for the original INVITE (same branch, CSeq).
|
||||
pub fn create_cancel(&self, original_invite: &SipMessage) -> SipMessage {
|
||||
let via = original_invite.get_header("Via").unwrap_or("").to_string();
|
||||
let from = original_invite.get_header("From").unwrap_or("").to_string();
|
||||
let to = original_invite.get_header("To").unwrap_or("").to_string();
|
||||
|
||||
let headers = vec![
|
||||
("Via".to_string(), via),
|
||||
("From".to_string(), from),
|
||||
("To".to_string(), to),
|
||||
("Call-ID".to_string(), self.call_id.clone()),
|
||||
(
|
||||
"CSeq".to_string(),
|
||||
format!("{} CANCEL", self.local_cseq),
|
||||
),
|
||||
("Max-Forwards".to_string(), "70".to_string()),
|
||||
("Content-Length".to_string(), "0".to_string()),
|
||||
];
|
||||
|
||||
let ruri = original_invite
|
||||
.request_uri()
|
||||
.unwrap_or(&self.remote_target)
|
||||
.to_string();
|
||||
|
||||
SipMessage::new(
|
||||
format!("CANCEL {ruri} SIP/2.0"),
|
||||
headers,
|
||||
String::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Transition the dialog to terminated state.
|
||||
pub fn terminate(&mut self) {
|
||||
self.state = DialogState::Terminated;
|
||||
}
|
||||
|
||||
/// Resolve Request-URI from route set or remote target.
|
||||
fn resolve_ruri(&self) -> &str {
|
||||
if !self.route_set.is_empty() {
|
||||
if let Some(top_route) = SipMessage::extract_uri(&self.route_set[0]) {
|
||||
if top_route.contains(";lr") {
|
||||
return &self.remote_target; // loose routing
|
||||
}
|
||||
return top_route; // strict routing
|
||||
}
|
||||
}
|
||||
&self.remote_target
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::message::RequestOptions;
|
||||
|
||||
fn make_invite() -> SipMessage {
|
||||
SipMessage::create_request(
|
||||
"INVITE",
|
||||
"sip:callee@host",
|
||||
RequestOptions {
|
||||
via_host: "192.168.1.1".to_string(),
|
||||
via_port: 5070,
|
||||
via_transport: None,
|
||||
via_branch: Some("z9hG4bK-test".to_string()),
|
||||
from_uri: "sip:caller@proxy".to_string(),
|
||||
from_display_name: None,
|
||||
from_tag: Some("from-tag".to_string()),
|
||||
to_uri: "sip:callee@host".to_string(),
|
||||
to_display_name: None,
|
||||
to_tag: None,
|
||||
call_id: Some("test-dialog-call".to_string()),
|
||||
cseq: Some(1),
|
||||
contact: Some("<sip:caller@192.168.1.1:5070>".to_string()),
|
||||
max_forwards: None,
|
||||
body: None,
|
||||
content_type: None,
|
||||
extra_headers: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uac_dialog_lifecycle() {
|
||||
let invite = make_invite();
|
||||
let mut dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070);
|
||||
|
||||
assert_eq!(dialog.state, DialogState::Early);
|
||||
assert_eq!(dialog.call_id, "test-dialog-call");
|
||||
assert_eq!(dialog.local_tag, "from-tag");
|
||||
assert!(dialog.remote_tag.is_none());
|
||||
|
||||
// Simulate 200 OK
|
||||
let response = SipMessage::create_response(
|
||||
200,
|
||||
"OK",
|
||||
&invite,
|
||||
Some(crate::message::ResponseOptions {
|
||||
to_tag: Some("remote-tag".to_string()),
|
||||
contact: Some("<sip:callee@10.0.0.1:5060>".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
|
||||
dialog.process_response(&response);
|
||||
assert_eq!(dialog.state, DialogState::Confirmed);
|
||||
assert_eq!(dialog.remote_tag.as_deref(), Some("remote-tag"));
|
||||
assert_eq!(dialog.remote_target, "sip:callee@10.0.0.1:5060");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bye() {
|
||||
let invite = make_invite();
|
||||
let mut dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070);
|
||||
dialog.remote_tag = Some("remote-tag".to_string());
|
||||
dialog.state = DialogState::Confirmed;
|
||||
|
||||
let bye = dialog.create_request("BYE", None, None, None);
|
||||
assert_eq!(bye.method(), Some("BYE"));
|
||||
assert_eq!(bye.call_id(), "test-dialog-call");
|
||||
assert_eq!(dialog.local_cseq, 2);
|
||||
let to = bye.get_header("To").unwrap();
|
||||
assert!(to.contains("tag=remote-tag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_ack() {
|
||||
let invite = make_invite();
|
||||
let mut dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070);
|
||||
dialog.remote_tag = Some("remote-tag".to_string());
|
||||
|
||||
let ack = dialog.create_ack();
|
||||
assert_eq!(ack.method(), Some("ACK"));
|
||||
assert!(ack.get_header("CSeq").unwrap().contains("ACK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_cancel() {
|
||||
let invite = make_invite();
|
||||
let dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070);
|
||||
|
||||
let cancel = dialog.create_cancel(&invite);
|
||||
assert_eq!(cancel.method(), Some("CANCEL"));
|
||||
assert!(cancel.get_header("CSeq").unwrap().contains("CANCEL"));
|
||||
assert!(cancel.start_line.contains("sip:callee@host"));
|
||||
}
|
||||
}
|
||||
339
rust/crates/sip-proto/src/helpers.rs
Normal file
339
rust/crates/sip-proto/src/helpers.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
//! SIP helper utilities — ID generation, codec registry, SDP builder,
|
||||
//! Digest authentication, SDP parser, and MWI body builder.
|
||||
|
||||
use md5::{Digest, Md5};
|
||||
use rand::Rng;
|
||||
|
||||
// ---- ID generators ---------------------------------------------------------
|
||||
|
||||
/// Generate a random SIP Call-ID (32 hex chars).
|
||||
pub fn generate_call_id(domain: Option<&str>) -> String {
|
||||
let id = random_hex(16);
|
||||
match domain {
|
||||
Some(d) => format!("{id}@{d}"),
|
||||
None => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a random SIP From/To tag (16 hex chars).
|
||||
pub fn generate_tag() -> String {
|
||||
random_hex(8)
|
||||
}
|
||||
|
||||
/// Generate an RFC 3261 compliant Via branch (starts with `z9hG4bK` magic cookie).
|
||||
pub fn generate_branch() -> String {
|
||||
format!("z9hG4bK-{}", random_hex(8))
|
||||
}
|
||||
|
||||
fn random_hex(bytes: usize) -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..bytes).map(|_| format!("{:02x}", rng.gen::<u8>())).collect()
|
||||
}
|
||||
|
||||
// ---- Codec registry --------------------------------------------------------
|
||||
|
||||
/// Look up the rtpmap name for a static payload type.
|
||||
pub fn codec_name(pt: u8) -> &'static str {
|
||||
match pt {
|
||||
0 => "PCMU/8000",
|
||||
3 => "GSM/8000",
|
||||
4 => "G723/8000",
|
||||
8 => "PCMA/8000",
|
||||
9 => "G722/8000",
|
||||
18 => "G729/8000",
|
||||
101 => "telephone-event/8000",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SDP builder -----------------------------------------------------------
|
||||
|
||||
/// Options for building an SDP body.
|
||||
pub struct SdpOptions<'a> {
|
||||
pub ip: &'a str,
|
||||
pub port: u16,
|
||||
pub payload_types: &'a [u8],
|
||||
pub session_id: Option<&'a str>,
|
||||
pub session_name: Option<&'a str>,
|
||||
pub direction: Option<&'a str>,
|
||||
pub attributes: &'a [&'a str],
|
||||
}
|
||||
|
||||
impl<'a> Default for SdpOptions<'a> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ip: "0.0.0.0",
|
||||
port: 0,
|
||||
payload_types: &[9, 0, 8, 101],
|
||||
session_id: None,
|
||||
session_name: None,
|
||||
direction: None,
|
||||
attributes: &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a minimal SDP body suitable for SIP INVITE offers/answers.
|
||||
pub fn build_sdp(opts: &SdpOptions) -> String {
|
||||
let session_id = opts
|
||||
.session_id
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("{}", rand::thread_rng().gen_range(0..1_000_000_000u64)));
|
||||
let session_name = opts.session_name.unwrap_or("-");
|
||||
let direction = opts.direction.unwrap_or("sendrecv");
|
||||
let pts: Vec<String> = opts.payload_types.iter().map(|pt| pt.to_string()).collect();
|
||||
|
||||
let mut lines = vec![
|
||||
"v=0".to_string(),
|
||||
format!("o=- {session_id} {session_id} IN IP4 {}", opts.ip),
|
||||
format!("s={session_name}"),
|
||||
format!("c=IN IP4 {}", opts.ip),
|
||||
"t=0 0".to_string(),
|
||||
format!("m=audio {} RTP/AVP {}", opts.port, pts.join(" ")),
|
||||
];
|
||||
|
||||
for &pt in opts.payload_types {
|
||||
let name = codec_name(pt);
|
||||
if name != "unknown" {
|
||||
lines.push(format!("a=rtpmap:{pt} {name}"));
|
||||
}
|
||||
if pt == 101 {
|
||||
lines.push("a=fmtp:101 0-16".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(format!("a={direction}"));
|
||||
for attr in opts.attributes {
|
||||
lines.push(format!("a={attr}"));
|
||||
}
|
||||
lines.push(String::new()); // trailing CRLF
|
||||
|
||||
lines.join("\r\n")
|
||||
}
|
||||
|
||||
// ---- SIP Digest authentication (RFC 2617) ----------------------------------
|
||||
|
||||
/// Parsed fields from a Proxy-Authenticate or WWW-Authenticate header.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DigestChallenge {
|
||||
pub realm: String,
|
||||
pub nonce: String,
|
||||
pub algorithm: Option<String>,
|
||||
pub opaque: Option<String>,
|
||||
pub qop: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a `Proxy-Authenticate` or `WWW-Authenticate` header value.
|
||||
pub fn parse_digest_challenge(header: &str) -> Option<DigestChallenge> {
|
||||
let lower = header.to_ascii_lowercase();
|
||||
if !lower.starts_with("digest ") {
|
||||
return None;
|
||||
}
|
||||
let params = &header[7..];
|
||||
|
||||
let get = |key: &str| -> Option<String> {
|
||||
// Try quoted value first.
|
||||
let pat = format!("{}=", key);
|
||||
if let Some(pos) = params.to_ascii_lowercase().find(&pat) {
|
||||
let after = ¶ms[pos + pat.len()..];
|
||||
let after = after.trim_start();
|
||||
if after.starts_with('"') {
|
||||
let end = after[1..].find('"')?;
|
||||
return Some(after[1..1 + end].to_string());
|
||||
}
|
||||
// Unquoted value.
|
||||
let end = after.find(|c: char| c == ',' || c.is_whitespace()).unwrap_or(after.len());
|
||||
return Some(after[..end].to_string());
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let realm = get("realm")?;
|
||||
let nonce = get("nonce")?;
|
||||
|
||||
Some(DigestChallenge {
|
||||
realm,
|
||||
nonce,
|
||||
algorithm: get("algorithm"),
|
||||
opaque: get("opaque"),
|
||||
qop: get("qop"),
|
||||
})
|
||||
}
|
||||
|
||||
fn md5_hex(s: &str) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(s.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// Compute a SIP Digest Authorization header value.
|
||||
pub fn compute_digest_auth(
|
||||
username: &str,
|
||||
password: &str,
|
||||
realm: &str,
|
||||
nonce: &str,
|
||||
method: &str,
|
||||
uri: &str,
|
||||
algorithm: Option<&str>,
|
||||
opaque: Option<&str>,
|
||||
) -> String {
|
||||
let ha1 = md5_hex(&format!("{username}:{realm}:{password}"));
|
||||
let ha2 = md5_hex(&format!("{method}:{uri}"));
|
||||
let response = md5_hex(&format!("{ha1}:{nonce}:{ha2}"));
|
||||
let alg = algorithm.unwrap_or("MD5");
|
||||
|
||||
let mut header = format!(
|
||||
"Digest username=\"{username}\", realm=\"{realm}\", \
|
||||
nonce=\"{nonce}\", uri=\"{uri}\", response=\"{response}\", \
|
||||
algorithm={alg}"
|
||||
);
|
||||
if let Some(op) = opaque {
|
||||
header.push_str(&format!(", opaque=\"{op}\""));
|
||||
}
|
||||
header
|
||||
}
|
||||
|
||||
// ---- SDP parser ------------------------------------------------------------
|
||||
|
||||
use crate::Endpoint;
|
||||
|
||||
/// Parse the audio media port, connection address, and preferred codec from an SDP body.
|
||||
pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> {
|
||||
let mut addr: Option<&str> = None;
|
||||
let mut port: Option<u16> = None;
|
||||
let mut codec_pt: Option<u8> = None;
|
||||
|
||||
let normalized = sdp.replace("\r\n", "\n");
|
||||
for raw in normalized.split('\n') {
|
||||
let line = raw.trim();
|
||||
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
|
||||
addr = Some(rest.trim());
|
||||
} else if let Some(rest) = line.strip_prefix("m=audio ") {
|
||||
// m=audio <port> RTP/AVP <pt1> [<pt2> ...]
|
||||
let parts: Vec<&str> = rest.split_whitespace().collect();
|
||||
if !parts.is_empty() {
|
||||
port = parts[0].parse().ok();
|
||||
}
|
||||
// parts[1] is "RTP/AVP" or similar, parts[2..] are payload types.
|
||||
// The first PT is the preferred codec.
|
||||
if parts.len() > 2 {
|
||||
codec_pt = parts[2].parse::<u8>().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (addr, port) {
|
||||
(Some(a), Some(p)) => Some(Endpoint {
|
||||
address: a.to_string(),
|
||||
port: p,
|
||||
codec_pt,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- MWI (RFC 3842) --------------------------------------------------------
|
||||
|
||||
/// Build the body and extra headers for an MWI NOTIFY (RFC 3842 message-summary).
|
||||
pub struct MwiResult {
|
||||
pub body: String,
|
||||
pub content_type: &'static str,
|
||||
pub extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub fn build_mwi_body(
|
||||
new_messages: u32,
|
||||
old_messages: u32,
|
||||
account_uri: &str,
|
||||
) -> MwiResult {
|
||||
let waiting = if new_messages > 0 { "yes" } else { "no" };
|
||||
let body = format!(
|
||||
"Messages-Waiting: {waiting}\r\n\
|
||||
Message-Account: {account_uri}\r\n\
|
||||
Voice-Message: {new_messages}/{old_messages}\r\n"
|
||||
);
|
||||
|
||||
MwiResult {
|
||||
body,
|
||||
content_type: "application/simple-message-summary",
|
||||
extra_headers: vec![
|
||||
("Event".to_string(), "message-summary".to_string()),
|
||||
(
|
||||
"Subscription-State".to_string(),
|
||||
"terminated;reason=noresource".to_string(),
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_branch_has_magic_cookie() {
|
||||
let branch = generate_branch();
|
||||
assert!(branch.starts_with("z9hG4bK-"));
|
||||
assert!(branch.len() > 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codec_name() {
|
||||
assert_eq!(codec_name(0), "PCMU/8000");
|
||||
assert_eq!(codec_name(9), "G722/8000");
|
||||
assert_eq!(codec_name(101), "telephone-event/8000");
|
||||
assert_eq!(codec_name(255), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sdp() {
|
||||
let sdp = build_sdp(&SdpOptions {
|
||||
ip: "192.168.1.1",
|
||||
port: 20000,
|
||||
payload_types: &[9, 0, 101],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(sdp.contains("m=audio 20000 RTP/AVP 9 0 101"));
|
||||
assert!(sdp.contains("c=IN IP4 192.168.1.1"));
|
||||
assert!(sdp.contains("a=rtpmap:9 G722/8000"));
|
||||
assert!(sdp.contains("a=fmtp:101 0-16"));
|
||||
assert!(sdp.contains("a=sendrecv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_digest_challenge() {
|
||||
let header = r#"Digest realm="asterisk", nonce="abc123", algorithm=MD5, opaque="xyz""#;
|
||||
let ch = parse_digest_challenge(header).unwrap();
|
||||
assert_eq!(ch.realm, "asterisk");
|
||||
assert_eq!(ch.nonce, "abc123");
|
||||
assert_eq!(ch.algorithm.as_deref(), Some("MD5"));
|
||||
assert_eq!(ch.opaque.as_deref(), Some("xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_digest_auth() {
|
||||
let auth = compute_digest_auth(
|
||||
"user", "pass", "realm", "nonce", "REGISTER", "sip:host", None, None,
|
||||
);
|
||||
assert!(auth.starts_with("Digest "));
|
||||
assert!(auth.contains("username=\"user\""));
|
||||
assert!(auth.contains("realm=\"realm\""));
|
||||
assert!(auth.contains("response=\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sdp_endpoint() {
|
||||
let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5060 RTP/AVP 0\r\n";
|
||||
let ep = parse_sdp_endpoint(sdp).unwrap();
|
||||
assert_eq!(ep.address, "10.0.0.1");
|
||||
assert_eq!(ep.port, 5060);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_mwi_body() {
|
||||
let mwi = build_mwi_body(3, 5, "sip:user@host");
|
||||
assert!(mwi.body.contains("Messages-Waiting: yes"));
|
||||
assert!(mwi.body.contains("Voice-Message: 3/5"));
|
||||
assert_eq!(mwi.content_type, "application/simple-message-summary");
|
||||
}
|
||||
}
|
||||
19
rust/crates/sip-proto/src/lib.rs
Normal file
19
rust/crates/sip-proto/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! SIP protocol library for the proxy engine.
|
||||
//!
|
||||
//! Provides SIP message parsing/serialization, dialog state management,
|
||||
//! SDP handling, Digest authentication, and URI rewriting.
|
||||
//! Ported from the TypeScript `ts/sip/` library.
|
||||
|
||||
pub mod message;
|
||||
pub mod dialog;
|
||||
pub mod helpers;
|
||||
pub mod rewrite;
|
||||
|
||||
/// Network endpoint (address + port + optional negotiated codec).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Endpoint {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
/// First payload type from the SDP `m=audio` line (the preferred codec).
|
||||
pub codec_pt: Option<u8>,
|
||||
}
|
||||
563
rust/crates/sip-proto/src/message.rs
Normal file
563
rust/crates/sip-proto/src/message.rs
Normal file
@@ -0,0 +1,563 @@
|
||||
//! SIP message parsing, serialization, inspection, mutation, and factory methods.
|
||||
//!
|
||||
//! Ported from ts/sip/message.ts.
|
||||
|
||||
use crate::helpers::{generate_branch, generate_call_id, generate_tag};
|
||||
|
||||
/// A parsed SIP message (request or response).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SipMessage {
|
||||
pub start_line: String,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl SipMessage {
|
||||
pub fn new(start_line: String, headers: Vec<(String, String)>, body: String) -> Self {
|
||||
Self { start_line, headers, body }
|
||||
}
|
||||
|
||||
// ---- Parsing -----------------------------------------------------------
|
||||
|
||||
/// Parse a raw buffer into a SipMessage. Returns None for invalid data.
|
||||
pub fn parse(buf: &[u8]) -> Option<Self> {
|
||||
if buf.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// First byte must be ASCII A-z.
|
||||
if buf[0] < 0x41 || buf[0] > 0x7a {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = std::str::from_utf8(buf).ok()?;
|
||||
|
||||
let (head, body) = if let Some(sep) = text.find("\r\n\r\n") {
|
||||
(&text[..sep], &text[sep + 4..])
|
||||
} else if let Some(sep) = text.find("\n\n") {
|
||||
(&text[..sep], &text[sep + 2..])
|
||||
} else {
|
||||
(text, "")
|
||||
};
|
||||
|
||||
let normalized = head.replace("\r\n", "\n");
|
||||
let lines: Vec<&str> = normalized.split('\n').collect();
|
||||
if lines.is_empty() || lines[0].is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start_line = lines[0];
|
||||
// Validate: must be a SIP request or response start line.
|
||||
if !is_sip_first_line(start_line) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut headers = Vec::new();
|
||||
for &line in &lines[1..] {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(colon) = line.find(':') {
|
||||
let name = line[..colon].trim().to_string();
|
||||
let value = line[colon + 1..].trim().to_string();
|
||||
headers.push((name, value));
|
||||
}
|
||||
}
|
||||
|
||||
Some(SipMessage {
|
||||
start_line: start_line.to_string(),
|
||||
headers,
|
||||
body: body.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Serialization -----------------------------------------------------
|
||||
|
||||
/// Serialize the message to a byte buffer suitable for UDP transmission.
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
let mut head = self.start_line.clone();
|
||||
for (name, value) in &self.headers {
|
||||
head.push_str("\r\n");
|
||||
head.push_str(name);
|
||||
head.push_str(": ");
|
||||
head.push_str(value);
|
||||
}
|
||||
head.push_str("\r\n\r\n");
|
||||
|
||||
let mut buf = head.into_bytes();
|
||||
if !self.body.is_empty() {
|
||||
buf.extend_from_slice(self.body.as_bytes());
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
// ---- Inspectors --------------------------------------------------------
|
||||
|
||||
pub fn is_request(&self) -> bool {
|
||||
!self.start_line.starts_with("SIP/")
|
||||
}
|
||||
|
||||
pub fn is_response(&self) -> bool {
|
||||
self.start_line.starts_with("SIP/")
|
||||
}
|
||||
|
||||
/// Request method (INVITE, REGISTER, ...) or None for responses.
|
||||
pub fn method(&self) -> Option<&str> {
|
||||
if !self.is_request() {
|
||||
return None;
|
||||
}
|
||||
self.start_line.split_whitespace().next()
|
||||
}
|
||||
|
||||
/// Response status code or None for requests.
|
||||
pub fn status_code(&self) -> Option<u16> {
|
||||
if !self.is_response() {
|
||||
return None;
|
||||
}
|
||||
self.start_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
pub fn call_id(&self) -> &str {
|
||||
self.get_header("Call-ID").unwrap_or("noid")
|
||||
}
|
||||
|
||||
/// Method from the CSeq header (e.g. "INVITE").
|
||||
pub fn cseq_method(&self) -> Option<&str> {
|
||||
let cseq = self.get_header("CSeq")?;
|
||||
cseq.split_whitespace().nth(1)
|
||||
}
|
||||
|
||||
/// True for INVITE, SUBSCRIBE, REFER, NOTIFY, UPDATE.
|
||||
pub fn is_dialog_establishing(&self) -> bool {
|
||||
matches!(
|
||||
self.method(),
|
||||
Some("INVITE" | "SUBSCRIBE" | "REFER" | "NOTIFY" | "UPDATE")
|
||||
)
|
||||
}
|
||||
|
||||
/// True when the body carries an SDP payload.
|
||||
pub fn has_sdp_body(&self) -> bool {
|
||||
if self.body.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let ct = self.get_header("Content-Type").unwrap_or("");
|
||||
ct.to_ascii_lowercase().starts_with("application/sdp")
|
||||
}
|
||||
|
||||
// ---- Header accessors --------------------------------------------------
|
||||
|
||||
/// Get the first header value matching `name` (case-insensitive).
|
||||
pub fn get_header(&self, name: &str) -> Option<&str> {
|
||||
let nl = name.to_ascii_lowercase();
|
||||
for (n, v) in &self.headers {
|
||||
if n.to_ascii_lowercase() == nl {
|
||||
return Some(v.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Overwrites the first header with the given name, or appends it.
|
||||
pub fn set_header(&mut self, name: &str, value: &str) -> &mut Self {
|
||||
let nl = name.to_ascii_lowercase();
|
||||
for h in &mut self.headers {
|
||||
if h.0.to_ascii_lowercase() == nl {
|
||||
h.1 = value.to_string();
|
||||
return self;
|
||||
}
|
||||
}
|
||||
self.headers.push((name.to_string(), value.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Inserts a header at the top of the header list.
|
||||
pub fn prepend_header(&mut self, name: &str, value: &str) -> &mut Self {
|
||||
self.headers.insert(0, (name.to_string(), value.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Removes all headers with the given name.
|
||||
pub fn remove_header(&mut self, name: &str) -> &mut Self {
|
||||
let nl = name.to_ascii_lowercase();
|
||||
self.headers.retain(|(n, _)| n.to_ascii_lowercase() != nl);
|
||||
self
|
||||
}
|
||||
|
||||
/// Recalculates Content-Length to match the current body.
|
||||
pub fn update_content_length(&mut self) -> &mut Self {
|
||||
let len = self.body.len();
|
||||
self.set_header("Content-Length", &len.to_string())
|
||||
}
|
||||
|
||||
// ---- Start-line mutation -----------------------------------------------
|
||||
|
||||
/// Replace the Request-URI (second token) of a request start line.
|
||||
pub fn set_request_uri(&mut self, uri: &str) -> &mut Self {
|
||||
if !self.is_request() {
|
||||
return self;
|
||||
}
|
||||
let parts: Vec<&str> = self.start_line.splitn(3, ' ').collect();
|
||||
if parts.len() >= 3 {
|
||||
self.start_line = format!("{} {} {}", parts[0], uri, parts[2]);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the Request-URI (second token) of a request start line.
|
||||
pub fn request_uri(&self) -> Option<&str> {
|
||||
if !self.is_request() {
|
||||
return None;
|
||||
}
|
||||
self.start_line.split_whitespace().nth(1)
|
||||
}
|
||||
|
||||
// ---- Factory methods ---------------------------------------------------
|
||||
|
||||
/// Build a new SIP request.
|
||||
pub fn create_request(method: &str, request_uri: &str, opts: RequestOptions) -> Self {
|
||||
let branch = opts.via_branch.unwrap_or_else(|| generate_branch());
|
||||
let transport = opts.via_transport.unwrap_or_else(|| "UDP".to_string());
|
||||
let from_tag = opts.from_tag.unwrap_or_else(|| generate_tag());
|
||||
let call_id = opts.call_id.unwrap_or_else(|| generate_call_id(None));
|
||||
let cseq = opts.cseq.unwrap_or(1);
|
||||
let max_forwards = opts.max_forwards.unwrap_or(70);
|
||||
|
||||
let from_display = opts
|
||||
.from_display_name
|
||||
.map(|d| format!("\"{d}\" "))
|
||||
.unwrap_or_default();
|
||||
let to_display = opts
|
||||
.to_display_name
|
||||
.map(|d| format!("\"{d}\" "))
|
||||
.unwrap_or_default();
|
||||
let to_tag_str = opts
|
||||
.to_tag
|
||||
.map(|t| format!(";tag={t}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut headers = vec![
|
||||
(
|
||||
"Via".to_string(),
|
||||
format!(
|
||||
"SIP/2.0/{transport} {}:{};branch={branch};rport",
|
||||
opts.via_host, opts.via_port
|
||||
),
|
||||
),
|
||||
(
|
||||
"From".to_string(),
|
||||
format!("{from_display}<{}>;tag={from_tag}", opts.from_uri),
|
||||
),
|
||||
(
|
||||
"To".to_string(),
|
||||
format!("{to_display}<{}>{to_tag_str}", opts.to_uri),
|
||||
),
|
||||
("Call-ID".to_string(), call_id),
|
||||
("CSeq".to_string(), format!("{cseq} {method}")),
|
||||
("Max-Forwards".to_string(), max_forwards.to_string()),
|
||||
];
|
||||
|
||||
if let Some(contact) = &opts.contact {
|
||||
headers.push(("Contact".to_string(), contact.clone()));
|
||||
}
|
||||
|
||||
if let Some(extra) = opts.extra_headers {
|
||||
headers.extend(extra);
|
||||
}
|
||||
|
||||
let body = opts.body.unwrap_or_default();
|
||||
if !body.is_empty() {
|
||||
if let Some(ct) = &opts.content_type {
|
||||
headers.push(("Content-Type".to_string(), ct.clone()));
|
||||
}
|
||||
}
|
||||
headers.push(("Content-Length".to_string(), body.len().to_string()));
|
||||
|
||||
SipMessage {
|
||||
start_line: format!("{method} {request_uri} 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.
|
||||
pub fn create_response(
|
||||
status_code: u16,
|
||||
reason_phrase: &str,
|
||||
request: &SipMessage,
|
||||
opts: Option<ResponseOptions>,
|
||||
) -> Self {
|
||||
let opts = opts.unwrap_or_default();
|
||||
let mut headers: Vec<(String, String)> = Vec::new();
|
||||
|
||||
// Copy all Via headers (order matters).
|
||||
for (n, v) in &request.headers {
|
||||
if n.to_ascii_lowercase() == "via" {
|
||||
headers.push(("Via".to_string(), v.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// From — copied verbatim.
|
||||
if let Some(from) = request.get_header("From") {
|
||||
headers.push(("From".to_string(), from.to_string()));
|
||||
}
|
||||
|
||||
// To — add tag if provided and not already present.
|
||||
let mut to = request.get_header("To").unwrap_or("").to_string();
|
||||
if let Some(tag) = &opts.to_tag {
|
||||
if !to.contains("tag=") {
|
||||
to.push_str(&format!(";tag={tag}"));
|
||||
}
|
||||
}
|
||||
headers.push(("To".to_string(), to));
|
||||
|
||||
headers.push(("Call-ID".to_string(), request.call_id().to_string()));
|
||||
|
||||
if let Some(cseq) = request.get_header("CSeq") {
|
||||
headers.push(("CSeq".to_string(), cseq.to_string()));
|
||||
}
|
||||
|
||||
if let Some(contact) = &opts.contact {
|
||||
headers.push(("Contact".to_string(), contact.clone()));
|
||||
}
|
||||
|
||||
if let Some(extra) = opts.extra_headers {
|
||||
headers.extend(extra);
|
||||
}
|
||||
|
||||
let body = opts.body.unwrap_or_default();
|
||||
if !body.is_empty() {
|
||||
if let Some(ct) = &opts.content_type {
|
||||
headers.push(("Content-Type".to_string(), ct.clone()));
|
||||
}
|
||||
}
|
||||
headers.push(("Content-Length".to_string(), body.len().to_string()));
|
||||
|
||||
SipMessage {
|
||||
start_line: format!("SIP/2.0 {status_code} {reason_phrase}"),
|
||||
headers,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the tag from a From or To header value.
|
||||
pub fn extract_tag(header_value: &str) -> Option<&str> {
|
||||
let idx = header_value.find(";tag=")?;
|
||||
let rest = &header_value[idx + 5..];
|
||||
let end = rest
|
||||
.find(|c: char| c.is_whitespace() || c == ';' || c == '>')
|
||||
.unwrap_or(rest.len());
|
||||
Some(&rest[..end])
|
||||
}
|
||||
|
||||
/// Extract the URI from an addr-spec or name-addr (From/To/Contact).
|
||||
pub fn extract_uri(header_value: &str) -> Option<&str> {
|
||||
if let Some(start) = header_value.find('<') {
|
||||
let end = header_value[start..].find('>')?;
|
||||
Some(&header_value[start + 1..start + end])
|
||||
} else {
|
||||
let trimmed = header_value.trim();
|
||||
let end = trimmed
|
||||
.find(|c: char| c == ';' || c == '>')
|
||||
.unwrap_or(trimmed.len());
|
||||
let result = &trimmed[..end];
|
||||
if result.is_empty() { None } else { Some(result) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for `SipMessage::create_request`.
|
||||
pub struct RequestOptions {
|
||||
pub via_host: String,
|
||||
pub via_port: u16,
|
||||
pub via_transport: Option<String>,
|
||||
pub via_branch: Option<String>,
|
||||
pub from_uri: String,
|
||||
pub from_display_name: Option<String>,
|
||||
pub from_tag: Option<String>,
|
||||
pub to_uri: String,
|
||||
pub to_display_name: Option<String>,
|
||||
pub to_tag: Option<String>,
|
||||
pub call_id: Option<String>,
|
||||
pub cseq: Option<u32>,
|
||||
pub contact: Option<String>,
|
||||
pub max_forwards: Option<u16>,
|
||||
pub body: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
pub extra_headers: Option<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
/// Options for `SipMessage::create_response`.
|
||||
#[derive(Default)]
|
||||
pub struct ResponseOptions {
|
||||
pub to_tag: Option<String>,
|
||||
pub contact: Option<String>,
|
||||
pub body: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
pub extra_headers: Option<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
/// Check if a string matches the SIP first-line pattern.
|
||||
fn is_sip_first_line(line: &str) -> bool {
|
||||
// Request: METHOD SP URI SP SIP/X.Y
|
||||
// Response: SIP/X.Y SP STATUS SP REASON
|
||||
if line.starts_with("SIP/") {
|
||||
// Response: SIP/2.0 200 OK
|
||||
let parts: Vec<&str> = line.splitn(3, ' ').collect();
|
||||
if parts.len() >= 2 {
|
||||
return parts[1].chars().all(|c| c.is_ascii_digit());
|
||||
}
|
||||
} else {
|
||||
// Request: INVITE sip:user@host SIP/2.0
|
||||
let parts: Vec<&str> = line.splitn(3, ' ').collect();
|
||||
if parts.len() >= 3 {
|
||||
return parts[0].chars().all(|c| c.is_ascii_uppercase())
|
||||
&& parts[2].starts_with("SIP/");
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const INVITE_RAW: &str = "INVITE sip:user@host SIP/2.0\r\n\
|
||||
Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK-test\r\n\
|
||||
From: <sip:caller@host>;tag=abc\r\n\
|
||||
To: <sip:user@host>\r\n\
|
||||
Call-ID: test-call-id\r\n\
|
||||
CSeq: 1 INVITE\r\n\
|
||||
Content-Length: 0\r\n\r\n";
|
||||
|
||||
#[test]
|
||||
fn parse_invite() {
|
||||
let msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
|
||||
assert!(msg.is_request());
|
||||
assert!(!msg.is_response());
|
||||
assert_eq!(msg.method(), Some("INVITE"));
|
||||
assert_eq!(msg.call_id(), "test-call-id");
|
||||
assert_eq!(msg.cseq_method(), Some("INVITE"));
|
||||
assert!(msg.is_dialog_establishing());
|
||||
assert_eq!(msg.request_uri(), Some("sip:user@host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response() {
|
||||
let raw = "SIP/2.0 200 OK\r\n\
|
||||
Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK-test\r\n\
|
||||
From: <sip:caller@host>;tag=abc\r\n\
|
||||
To: <sip:user@host>;tag=def\r\n\
|
||||
Call-ID: test-call-id\r\n\
|
||||
CSeq: 1 INVITE\r\n\
|
||||
Content-Length: 0\r\n\r\n";
|
||||
let msg = SipMessage::parse(raw.as_bytes()).unwrap();
|
||||
assert!(msg.is_response());
|
||||
assert_eq!(msg.status_code(), Some(200));
|
||||
assert_eq!(msg.cseq_method(), Some("INVITE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_roundtrip() {
|
||||
let msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
|
||||
let serialized = msg.serialize();
|
||||
let reparsed = SipMessage::parse(&serialized).unwrap();
|
||||
assert_eq!(reparsed.call_id(), "test-call-id");
|
||||
assert_eq!(reparsed.method(), Some("INVITE"));
|
||||
assert_eq!(reparsed.headers.len(), msg.headers.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_mutation() {
|
||||
let mut msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
|
||||
msg.set_header("X-Custom", "value1");
|
||||
assert_eq!(msg.get_header("X-Custom"), Some("value1"));
|
||||
msg.set_header("X-Custom", "value2");
|
||||
assert_eq!(msg.get_header("X-Custom"), Some("value2"));
|
||||
msg.prepend_header("X-First", "first");
|
||||
assert_eq!(msg.headers[0].0, "X-First");
|
||||
msg.remove_header("X-Custom");
|
||||
assert_eq!(msg.get_header("X-Custom"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_request_uri() {
|
||||
let mut msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
|
||||
msg.set_request_uri("sip:new@host");
|
||||
assert_eq!(msg.request_uri(), Some("sip:new@host"));
|
||||
assert!(msg.start_line.starts_with("INVITE sip:new@host SIP/2.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_tag_and_uri() {
|
||||
assert_eq!(
|
||||
SipMessage::extract_tag("<sip:user@host>;tag=abc123"),
|
||||
Some("abc123")
|
||||
);
|
||||
assert_eq!(SipMessage::extract_tag("<sip:user@host>"), None);
|
||||
assert_eq!(
|
||||
SipMessage::extract_uri("<sip:user@host>"),
|
||||
Some("sip:user@host")
|
||||
);
|
||||
assert_eq!(
|
||||
SipMessage::extract_uri("\"Name\" <sip:user@host>;tag=abc"),
|
||||
Some("sip:user@host")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_request_and_response() {
|
||||
let invite = SipMessage::create_request(
|
||||
"INVITE",
|
||||
"sip:user@host",
|
||||
RequestOptions {
|
||||
via_host: "192.168.1.1".to_string(),
|
||||
via_port: 5070,
|
||||
via_transport: None,
|
||||
via_branch: None,
|
||||
from_uri: "sip:caller@proxy".to_string(),
|
||||
from_display_name: None,
|
||||
from_tag: Some("mytag".to_string()),
|
||||
to_uri: "sip:user@host".to_string(),
|
||||
to_display_name: None,
|
||||
to_tag: None,
|
||||
call_id: Some("test-123".to_string()),
|
||||
cseq: Some(1),
|
||||
contact: Some("<sip:caller@192.168.1.1:5070>".to_string()),
|
||||
max_forwards: None,
|
||||
body: None,
|
||||
content_type: None,
|
||||
extra_headers: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(invite.method(), Some("INVITE"));
|
||||
assert_eq!(invite.call_id(), "test-123");
|
||||
assert!(invite.get_header("Via").unwrap().contains("192.168.1.1:5070"));
|
||||
|
||||
let response = SipMessage::create_response(
|
||||
200,
|
||||
"OK",
|
||||
&invite,
|
||||
Some(ResponseOptions {
|
||||
to_tag: Some("remotetag".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(response.is_response());
|
||||
assert_eq!(response.status_code(), Some(200));
|
||||
let to = response.get_header("To").unwrap();
|
||||
assert!(to.contains("tag=remotetag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_sdp_body() {
|
||||
let mut msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
|
||||
assert!(!msg.has_sdp_body());
|
||||
msg.body = "v=0\r\no=- 1 1 IN IP4 0.0.0.0\r\n".to_string();
|
||||
msg.set_header("Content-Type", "application/sdp");
|
||||
assert!(msg.has_sdp_body());
|
||||
}
|
||||
}
|
||||
130
rust/crates/sip-proto/src/rewrite.rs
Normal file
130
rust/crates/sip-proto/src/rewrite.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
//! SIP URI and SDP body rewriting helpers.
|
||||
//!
|
||||
//! Ported from ts/sip/rewrite.ts.
|
||||
|
||||
use crate::Endpoint;
|
||||
|
||||
/// Replaces the host:port in every `sip:` / `sips:` URI found in `value`.
|
||||
pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String {
|
||||
let mut result = String::with_capacity(value.len());
|
||||
let mut i = 0;
|
||||
let bytes = value.as_bytes();
|
||||
|
||||
while i < bytes.len() {
|
||||
// Look for "sip:" or "sips:"
|
||||
let scheme_len = if i + 4 <= bytes.len()
|
||||
&& (bytes[i..].starts_with(b"sip:") || bytes[i..].starts_with(b"SIP:"))
|
||||
{
|
||||
4
|
||||
} else if i + 5 <= bytes.len()
|
||||
&& (bytes[i..].starts_with(b"sips:") || bytes[i..].starts_with(b"SIPS:"))
|
||||
{
|
||||
5
|
||||
} else {
|
||||
result.push(value[i..].chars().next().unwrap());
|
||||
i += value[i..].chars().next().unwrap().len_utf8();
|
||||
continue;
|
||||
};
|
||||
|
||||
let scheme = &value[i..i + scheme_len];
|
||||
let rest = &value[i + scheme_len..];
|
||||
|
||||
// Check for userpart (contains '@')
|
||||
let (userpart, host_start) = if let Some(at) = rest.find('@') {
|
||||
// Make sure @ comes before any delimiters
|
||||
let delim = rest.find(|c: char| c == '>' || c == ';' || c == ',' || c.is_whitespace());
|
||||
if delim.is_none() || at < delim.unwrap() {
|
||||
(&rest[..=at], at + 1)
|
||||
} else {
|
||||
("", 0)
|
||||
}
|
||||
} else {
|
||||
("", 0)
|
||||
};
|
||||
|
||||
// Find the end of the host:port portion
|
||||
let host_rest = &rest[host_start..];
|
||||
let end = host_rest
|
||||
.find(|c: char| c == '>' || c == ';' || c == ',' || c.is_whitespace())
|
||||
.unwrap_or(host_rest.len());
|
||||
|
||||
result.push_str(scheme);
|
||||
result.push_str(userpart);
|
||||
result.push_str(&format!("{host}:{port}"));
|
||||
i += scheme_len + host_start + end;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>) {
|
||||
let mut orig_addr: Option<String> = None;
|
||||
let mut orig_port: Option<u16> = None;
|
||||
|
||||
let lines: Vec<String> = body
|
||||
.replace("\r\n", "\n")
|
||||
.split('\n')
|
||||
.map(|line| {
|
||||
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
|
||||
orig_addr = Some(rest.trim().to_string());
|
||||
format!("c=IN IP4 {ip}")
|
||||
} else if line.starts_with("m=audio ") {
|
||||
let parts: Vec<&str> = line.split(' ').collect();
|
||||
if parts.len() >= 2 {
|
||||
orig_port = parts[1].parse().ok();
|
||||
let mut rebuilt = parts[0].to_string();
|
||||
rebuilt.push(' ');
|
||||
rebuilt.push_str(&port.to_string());
|
||||
for part in &parts[2..] {
|
||||
rebuilt.push(' ');
|
||||
rebuilt.push_str(part);
|
||||
}
|
||||
return rebuilt;
|
||||
}
|
||||
line.to_string()
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let original = match (orig_addr, orig_port) {
|
||||
(Some(a), Some(p)) => Some(Endpoint { address: a, port: p, codec_pt: None }),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
(lines.join("\r\n"), original)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_sip_uri() {
|
||||
let input = "<sip:user@10.0.0.1:5060>";
|
||||
let result = rewrite_sip_uri(input, "192.168.1.1", 5070);
|
||||
assert_eq!(result, "<sip:user@192.168.1.1:5070>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_sip_uri_no_port() {
|
||||
let input = "sip:user@10.0.0.1";
|
||||
let result = rewrite_sip_uri(input, "192.168.1.1", 5070);
|
||||
assert_eq!(result, "sip:user@192.168.1.1:5070");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_sdp() {
|
||||
let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5060 RTP/AVP 0 9\r\na=sendrecv\r\n";
|
||||
let (rewritten, orig) = rewrite_sdp(sdp, "192.168.1.1", 20000);
|
||||
assert!(rewritten.contains("c=IN IP4 192.168.1.1"));
|
||||
assert!(rewritten.contains("m=audio 20000 RTP/AVP 0 9"));
|
||||
let ep = orig.unwrap();
|
||||
assert_eq!(ep.address, "10.0.0.1");
|
||||
assert_eq!(ep.port, 5060);
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/// 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);
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.9.0',
|
||||
version: '1.17.2',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -1,42 +1,22 @@
|
||||
/**
|
||||
* TTS announcement module — pre-generates audio announcements using espeak-ng
|
||||
* and caches them as encoded RTP packets for playback during call setup.
|
||||
* TTS announcement module — generates announcement WAV files at startup.
|
||||
*
|
||||
* On startup, generates the announcement WAV via espeak-ng (formant-based TTS
|
||||
* with highly accurate pronunciation), encodes each 20ms frame to G.722 (for
|
||||
* SIP) and Opus (for WebRTC) via the Rust transcoder, and caches the packets.
|
||||
* Engine priority: espeak-ng (formant TTS, fast) → Kokoro neural TTS via
|
||||
* proxy-engine → disabled.
|
||||
*
|
||||
* Falls back to the Rust tts-engine (Kokoro neural TTS) if espeak-ng is not
|
||||
* installed, and disables announcements if neither is available.
|
||||
* The generated WAV is left on disk for Rust's audio_player / start_interaction
|
||||
* to play during calls. No encoding or RTP playback happens in TypeScript.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { 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;
|
||||
}
|
||||
import { sendProxyCommand, isProxyReady } from './proxybridge.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let cachedAnnouncement: IAnnouncementCache | null = null;
|
||||
|
||||
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
|
||||
const ANNOUNCEMENT_TEXT = "Hello. I'm connecting your call now.";
|
||||
const CACHE_WAV = path.join(TTS_DIR, 'announcement.wav');
|
||||
@@ -47,12 +27,10 @@ const KOKORO_VOICES = 'voices.bin';
|
||||
const KOKORO_VOICE = 'af_bella';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// TTS generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if espeak-ng is available on the system.
|
||||
*/
|
||||
/** Check if espeak-ng is available on the system. */
|
||||
function isEspeakAvailable(): boolean {
|
||||
try {
|
||||
execSync('which espeak-ng', { stdio: 'pipe' });
|
||||
@@ -62,10 +40,7 @@ function isEspeakAvailable(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate announcement WAV via espeak-ng (primary engine).
|
||||
* Returns true on success.
|
||||
*/
|
||||
/** Generate announcement WAV via espeak-ng (primary engine). */
|
||||
function generateViaEspeak(wavPath: string, text: string, log: (msg: string) => void): boolean {
|
||||
log('[tts] generating announcement audio via espeak-ng...');
|
||||
try {
|
||||
@@ -81,11 +56,8 @@ function generateViaEspeak(wavPath: string, text: string, log: (msg: string) =>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate announcement WAV via Kokoro TTS (fallback engine).
|
||||
* Returns true on success.
|
||||
*/
|
||||
function generateViaKokoro(wavPath: string, text: string, log: (msg: string) => void): boolean {
|
||||
/** Generate announcement WAV via Kokoro TTS (fallback, runs inside proxy-engine). */
|
||||
async function generateViaKokoro(wavPath: string, text: string, log: (msg: string) => void): Promise<boolean> {
|
||||
const modelPath = path.join(TTS_DIR, KOKORO_MODEL);
|
||||
const voicesPath = path.join(TTS_DIR, KOKORO_VOICES);
|
||||
|
||||
@@ -94,25 +66,21 @@ function generateViaKokoro(wavPath: string, text: string, log: (msg: string) =>
|
||||
return false;
|
||||
}
|
||||
|
||||
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 — Kokoro fallback unavailable');
|
||||
if (!isProxyReady()) {
|
||||
log('[tts] proxy-engine not ready — Kokoro fallback unavailable');
|
||||
return false;
|
||||
}
|
||||
|
||||
log('[tts] generating announcement audio via Kokoro TTS (fallback)...');
|
||||
try {
|
||||
execSync(
|
||||
`"${ttsBin}" --model "${modelPath}" --voices "${voicesPath}" --voice "${KOKORO_VOICE}" --output "${wavPath}" --text "${text}"`,
|
||||
{ timeout: 120000, stdio: 'pipe' },
|
||||
);
|
||||
log('[tts] Kokoro WAV generated');
|
||||
await sendProxyCommand('generate_tts', {
|
||||
model: modelPath,
|
||||
voices: voicesPath,
|
||||
voice: KOKORO_VOICE,
|
||||
text,
|
||||
output: wavPath,
|
||||
});
|
||||
log('[tts] Kokoro WAV generated (via proxy-engine)');
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
log(`[tts] Kokoro failed: ${e.message}`);
|
||||
@@ -120,40 +88,13 @@ function generateViaKokoro(wavPath: string, text: string, log: (msg: string) =>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a WAV file and detect its sample rate from the fmt chunk.
|
||||
* Returns { pcm, sampleRate } or null on failure.
|
||||
*/
|
||||
function readWavWithRate(wavPath: string): { pcm: Buffer; sampleRate: number } | null {
|
||||
const wav = fs.readFileSync(wavPath);
|
||||
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 sampleRate = 22050; // default
|
||||
let offset = 12;
|
||||
let pcm: Buffer | null = null;
|
||||
|
||||
while (offset < wav.length - 8) {
|
||||
const chunkId = wav.toString('ascii', offset, offset + 4);
|
||||
const chunkSize = wav.readUInt32LE(offset + 4);
|
||||
if (chunkId === 'fmt ') {
|
||||
sampleRate = wav.readUInt32LE(offset + 12);
|
||||
}
|
||||
if (chunkId === 'data') {
|
||||
pcm = wav.subarray(offset + 8, offset + 8 + chunkSize);
|
||||
}
|
||||
offset += 8 + chunkSize;
|
||||
if (offset % 2 !== 0) offset++;
|
||||
}
|
||||
|
||||
if (!pcm) return null;
|
||||
return { pcm, sampleRate };
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pre-generate the announcement audio and encode to G.722 + Opus frames.
|
||||
* Must be called after the codec bridge is initialized.
|
||||
* Pre-generate the announcement WAV file.
|
||||
* Must be called after the proxy engine is initialized.
|
||||
*
|
||||
* Engine priority: espeak-ng → Kokoro → disabled.
|
||||
*/
|
||||
@@ -161,7 +102,6 @@ export async function initAnnouncement(log: (msg: string) => void): Promise<bool
|
||||
fs.mkdirSync(TTS_DIR, { recursive: true });
|
||||
|
||||
try {
|
||||
// Generate WAV if not cached.
|
||||
if (!fs.existsSync(CACHE_WAV)) {
|
||||
let generated = false;
|
||||
|
||||
@@ -172,9 +112,9 @@ export async function initAnnouncement(log: (msg: string) => void): Promise<bool
|
||||
log('[tts] espeak-ng not installed — trying Kokoro fallback');
|
||||
}
|
||||
|
||||
// Fall back to Kokoro.
|
||||
// Fall back to Kokoro (via proxy-engine).
|
||||
if (!generated) {
|
||||
generated = generateViaKokoro(CACHE_WAV, ANNOUNCEMENT_TEXT, log);
|
||||
generated = await generateViaKokoro(CACHE_WAV, ANNOUNCEMENT_TEXT, log);
|
||||
}
|
||||
|
||||
if (!generated) {
|
||||
@@ -183,49 +123,7 @@ export async function initAnnouncement(log: (msg: string) => void): Promise<bool
|
||||
}
|
||||
}
|
||||
|
||||
// Read WAV and extract raw PCM + sample rate.
|
||||
const result = readWavWithRate(CACHE_WAV);
|
||||
if (!result) {
|
||||
log('[tts] failed to parse WAV file');
|
||||
return false;
|
||||
}
|
||||
|
||||
const { pcm, sampleRate } = result;
|
||||
|
||||
// Wait for codec bridge to be ready.
|
||||
if (!isCodecReady()) {
|
||||
log('[tts] codec bridge not ready — will retry');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Encode in 20ms chunks. The Rust encoder resamples to each codec's native rate.
|
||||
const FRAME_SAMPLES = Math.floor(sampleRate * 0.02);
|
||||
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 @ ${sampleRate}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, sampleRate, 9), // G.722 for SIP devices
|
||||
encodePcm(pcmBuf, sampleRate, 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)`);
|
||||
log('[tts] announcement WAV ready');
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
log(`[tts] init error: ${e.message}`);
|
||||
@@ -233,100 +131,7 @@ export async function initAnnouncement(log: (msg: string) => void): Promise<bool
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
/** Get the path to the cached announcement WAV, or null if not generated. */
|
||||
export function getAnnouncementWavPath(): string | null {
|
||||
return fs.existsSync(CACHE_WAV) ? CACHE_WAV : null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
255
ts/call/call.ts
255
ts/call/call.ts
@@ -1,255 +0,0 @@
|
||||
/**
|
||||
* 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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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
104
ts/call/leg.ts
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
}
|
||||
275
ts/call/prompt-cache.ts
Normal file
275
ts/call/prompt-cache.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* PromptCache — manages named audio prompt WAV files for IVR and voicemail.
|
||||
*
|
||||
* Generates WAV files via espeak-ng (primary) or Kokoro TTS through the
|
||||
* proxy-engine (fallback). Also supports loading pre-existing WAV files
|
||||
* and programmatic tone generation.
|
||||
*
|
||||
* All audio playback happens in Rust (audio_player / start_interaction).
|
||||
* This module only manages WAV files on disk.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { sendProxyCommand, isProxyReady } from '../proxybridge.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A cached prompt — just a WAV file path and metadata. */
|
||||
export interface ICachedPrompt {
|
||||
/** Unique prompt identifier. */
|
||||
id: string;
|
||||
/** Path to the WAV file on disk. */
|
||||
wavPath: string;
|
||||
/** Total duration in milliseconds (approximate, from WAV header). */
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TTS helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
|
||||
|
||||
/** Check if espeak-ng is available. */
|
||||
function isEspeakAvailable(): boolean {
|
||||
try {
|
||||
execSync('which espeak-ng', { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate WAV via espeak-ng. */
|
||||
function generateViaEspeak(wavPath: string, text: string): boolean {
|
||||
try {
|
||||
execSync(
|
||||
`espeak-ng -v en-us -s 150 -w "${wavPath}" "${text}"`,
|
||||
{ timeout: 10000, stdio: 'pipe' },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate WAV via Kokoro TTS (runs inside proxy-engine). */
|
||||
async function generateViaKokoro(wavPath: string, text: string, voice: string): Promise<boolean> {
|
||||
const modelPath = path.join(TTS_DIR, 'kokoro-v1.0.onnx');
|
||||
const voicesPath = path.join(TTS_DIR, 'voices.bin');
|
||||
if (!fs.existsSync(modelPath) || !fs.existsSync(voicesPath)) return false;
|
||||
if (!isProxyReady()) return false;
|
||||
|
||||
try {
|
||||
await sendProxyCommand('generate_tts', {
|
||||
model: modelPath,
|
||||
voices: voicesPath,
|
||||
voice,
|
||||
text,
|
||||
output: wavPath,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a WAV file's duration from its header. */
|
||||
function getWavDurationMs(wavPath: string): number {
|
||||
try {
|
||||
const wav = fs.readFileSync(wavPath);
|
||||
if (wav.length < 44) return 0;
|
||||
if (wav.toString('ascii', 0, 4) !== 'RIFF') return 0;
|
||||
|
||||
let sampleRate = 16000;
|
||||
let dataSize = 0;
|
||||
let bitsPerSample = 16;
|
||||
let channels = 1;
|
||||
let offset = 12;
|
||||
|
||||
while (offset < wav.length - 8) {
|
||||
const chunkId = wav.toString('ascii', offset, offset + 4);
|
||||
const chunkSize = wav.readUInt32LE(offset + 4);
|
||||
if (chunkId === 'fmt ') {
|
||||
channels = wav.readUInt16LE(offset + 10);
|
||||
sampleRate = wav.readUInt32LE(offset + 12);
|
||||
bitsPerSample = wav.readUInt16LE(offset + 22);
|
||||
}
|
||||
if (chunkId === 'data') {
|
||||
dataSize = chunkSize;
|
||||
}
|
||||
offset += 8 + chunkSize;
|
||||
if (offset % 2 !== 0) offset++;
|
||||
}
|
||||
|
||||
const bytesPerSample = (bitsPerSample / 8) * channels;
|
||||
const totalSamples = bytesPerSample > 0 ? dataSize / bytesPerSample : 0;
|
||||
return sampleRate > 0 ? Math.round((totalSamples / sampleRate) * 1000) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptCache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class PromptCache {
|
||||
private prompts = new Map<string, ICachedPrompt>();
|
||||
private log: (msg: string) => void;
|
||||
private espeakAvailable: boolean | null = null;
|
||||
|
||||
constructor(log: (msg: string) => void) {
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get a cached prompt by ID. */
|
||||
get(id: string): ICachedPrompt | null {
|
||||
return this.prompts.get(id) ?? null;
|
||||
}
|
||||
|
||||
/** Check if a prompt is cached. */
|
||||
has(id: string): boolean {
|
||||
return this.prompts.has(id);
|
||||
}
|
||||
|
||||
/** List all cached prompt IDs. */
|
||||
listIds(): string[] {
|
||||
return [...this.prompts.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a TTS prompt WAV and cache its path.
|
||||
* Uses espeak-ng (primary) or Kokoro (fallback).
|
||||
*/
|
||||
async generatePrompt(id: string, text: string, voice = 'af_bella'): Promise<ICachedPrompt | null> {
|
||||
fs.mkdirSync(TTS_DIR, { recursive: true });
|
||||
const wavPath = path.join(TTS_DIR, `prompt-${id}.wav`);
|
||||
|
||||
// Check espeak availability once.
|
||||
if (this.espeakAvailable === null) {
|
||||
this.espeakAvailable = isEspeakAvailable();
|
||||
}
|
||||
|
||||
// Generate WAV if not already on disk.
|
||||
if (!fs.existsSync(wavPath)) {
|
||||
let generated = false;
|
||||
if (this.espeakAvailable) {
|
||||
generated = generateViaEspeak(wavPath, text);
|
||||
}
|
||||
if (!generated) {
|
||||
generated = await generateViaKokoro(wavPath, text, voice);
|
||||
}
|
||||
if (!generated) {
|
||||
this.log(`[prompt-cache] failed to generate TTS for "${id}"`);
|
||||
return null;
|
||||
}
|
||||
this.log(`[prompt-cache] generated WAV for "${id}"`);
|
||||
}
|
||||
|
||||
return this.registerWav(id, wavPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a pre-existing WAV file as a prompt.
|
||||
*/
|
||||
async loadWavPrompt(id: string, wavPath: string): Promise<ICachedPrompt | null> {
|
||||
if (!fs.existsSync(wavPath)) {
|
||||
this.log(`[prompt-cache] WAV not found: ${wavPath}`);
|
||||
return null;
|
||||
}
|
||||
return this.registerWav(id, wavPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a beep tone WAV and cache it.
|
||||
*/
|
||||
async generateBeep(
|
||||
id: string,
|
||||
freqHz = 1000,
|
||||
durationMs = 500,
|
||||
amplitude = 8000,
|
||||
): Promise<ICachedPrompt | null> {
|
||||
fs.mkdirSync(TTS_DIR, { recursive: true });
|
||||
const wavPath = path.join(TTS_DIR, `prompt-${id}.wav`);
|
||||
|
||||
if (!fs.existsSync(wavPath)) {
|
||||
// Generate 16kHz 16-bit mono sine wave WAV.
|
||||
const sampleRate = 16000;
|
||||
const totalSamples = Math.floor((sampleRate * durationMs) / 1000);
|
||||
const pcm = Buffer.alloc(totalSamples * 2);
|
||||
|
||||
for (let i = 0; i < totalSamples; i++) {
|
||||
const t = i / sampleRate;
|
||||
const fadeLen = Math.floor(sampleRate * 0.01); // 10ms fade
|
||||
let envelope = 1.0;
|
||||
if (i < fadeLen) envelope = i / fadeLen;
|
||||
else if (i > totalSamples - fadeLen) envelope = (totalSamples - i) / fadeLen;
|
||||
|
||||
const sample = Math.round(Math.sin(2 * Math.PI * freqHz * t) * amplitude * envelope);
|
||||
pcm.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i * 2);
|
||||
}
|
||||
|
||||
// Write WAV file.
|
||||
const headerSize = 44;
|
||||
const dataSize = pcm.length;
|
||||
const wav = Buffer.alloc(headerSize + dataSize);
|
||||
|
||||
// RIFF header
|
||||
wav.write('RIFF', 0);
|
||||
wav.writeUInt32LE(36 + dataSize, 4);
|
||||
wav.write('WAVE', 8);
|
||||
|
||||
// fmt chunk
|
||||
wav.write('fmt ', 12);
|
||||
wav.writeUInt32LE(16, 16); // chunk size
|
||||
wav.writeUInt16LE(1, 20); // PCM format
|
||||
wav.writeUInt16LE(1, 22); // mono
|
||||
wav.writeUInt32LE(sampleRate, 24);
|
||||
wav.writeUInt32LE(sampleRate * 2, 28); // byte rate
|
||||
wav.writeUInt16LE(2, 32); // block align
|
||||
wav.writeUInt16LE(16, 34); // bits per sample
|
||||
|
||||
// data chunk
|
||||
wav.write('data', 36);
|
||||
wav.writeUInt32LE(dataSize, 40);
|
||||
pcm.copy(wav, 44);
|
||||
|
||||
fs.writeFileSync(wavPath, wav);
|
||||
this.log(`[prompt-cache] beep WAV generated for "${id}"`);
|
||||
}
|
||||
|
||||
return this.registerWav(id, wavPath);
|
||||
}
|
||||
|
||||
/** Remove a prompt from the cache. */
|
||||
remove(id: string): void {
|
||||
this.prompts.delete(id);
|
||||
}
|
||||
|
||||
/** Clear all cached prompts. */
|
||||
clear(): void {
|
||||
this.prompts.clear();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private registerWav(id: string, wavPath: string): ICachedPrompt {
|
||||
const durationMs = getWavDurationMs(wavPath);
|
||||
const prompt: ICachedPrompt = { id, wavPath, durationMs };
|
||||
this.prompts.set(id, prompt);
|
||||
this.log(`[prompt-cache] cached "${id}": ${wavPath} (${(durationMs / 1000).toFixed(1)}s)`);
|
||||
return prompt;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -1,622 +0,0 @@
|
||||
/**
|
||||
* 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'],
|
||||
],
|
||||
'',
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
/**
|
||||
* 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
40
ts/codec.ts
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
},
|
||||
};
|
||||
}
|
||||
290
ts/config.ts
290
ts/config.ts
@@ -8,7 +8,15 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { IEndpoint } from './sip/index.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared types (previously in ts/sip/types.ts, now inlined)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IEndpoint {
|
||||
address: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config interfaces
|
||||
@@ -78,6 +86,17 @@ export interface ISipRouteAction {
|
||||
/** Also ring connected browser clients. Default false. */
|
||||
ringBrowsers?: boolean;
|
||||
|
||||
// --- Inbound actions (IVR / voicemail) ---
|
||||
|
||||
/** Route directly to a voicemail box (skip ringing devices). */
|
||||
voicemailBox?: string;
|
||||
|
||||
/** Route to an IVR menu by menu ID (skip ringing devices). */
|
||||
ivrMenuId?: string;
|
||||
|
||||
/** Override no-answer timeout (seconds) before routing to voicemail. */
|
||||
noAnswerTimeout?: number;
|
||||
|
||||
// --- Outbound actions (provider selection) ---
|
||||
|
||||
/** Provider ID to use for outbound. */
|
||||
@@ -137,12 +156,95 @@ export interface IContact {
|
||||
starred?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voicebox configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IVoiceboxConfig {
|
||||
/** Unique ID — typically matches device ID or extension. */
|
||||
id: string;
|
||||
/** Whether this voicebox is active. */
|
||||
enabled: boolean;
|
||||
/** Custom TTS greeting text. */
|
||||
greetingText?: string;
|
||||
/** TTS voice ID (default 'af_bella'). */
|
||||
greetingVoice?: string;
|
||||
/** Path to uploaded WAV greeting (overrides TTS). */
|
||||
greetingWavPath?: string;
|
||||
/** Seconds to wait before routing to voicemail (default 25). */
|
||||
noAnswerTimeoutSec?: number;
|
||||
/** Maximum recording duration in seconds (default 120). */
|
||||
maxRecordingSec?: number;
|
||||
/** Maximum stored messages per box (default 50). */
|
||||
maxMessages?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IVR configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** An action triggered by a digit press in an IVR menu. */
|
||||
export type TIvrAction =
|
||||
| { type: 'route-extension'; extensionId: string }
|
||||
| { type: 'route-voicemail'; boxId: string }
|
||||
| { type: 'submenu'; menuId: string }
|
||||
| { type: 'play-message'; promptId: string }
|
||||
| { type: 'transfer'; number: string; providerId?: string }
|
||||
| { type: 'repeat' }
|
||||
| { type: 'hangup' };
|
||||
|
||||
/** A single digit→action mapping in an IVR menu. */
|
||||
export interface IIvrMenuEntry {
|
||||
/** Digit: '0'-'9', '*', '#'. */
|
||||
digit: string;
|
||||
/** Action to take when this digit is pressed. */
|
||||
action: TIvrAction;
|
||||
}
|
||||
|
||||
/** An IVR menu with a prompt and digit mappings. */
|
||||
export interface IIvrMenu {
|
||||
/** Unique menu ID. */
|
||||
id: string;
|
||||
/** Human-readable name. */
|
||||
name: string;
|
||||
/** TTS text for the menu prompt. */
|
||||
promptText: string;
|
||||
/** TTS voice ID for the prompt. */
|
||||
promptVoice?: string;
|
||||
/** Digit→action entries. */
|
||||
entries: IIvrMenuEntry[];
|
||||
/** Seconds to wait for a digit after prompt finishes (default 5). */
|
||||
timeoutSec?: number;
|
||||
/** Maximum retries before executing timeout action (default 3). */
|
||||
maxRetries?: number;
|
||||
/** Action on timeout (no digit pressed). */
|
||||
timeoutAction: TIvrAction;
|
||||
/** Action on invalid digit. */
|
||||
invalidAction: TIvrAction;
|
||||
}
|
||||
|
||||
/** Top-level IVR configuration. */
|
||||
export interface IIvrConfig {
|
||||
/** Whether the IVR system is active. */
|
||||
enabled: boolean;
|
||||
/** IVR menu definitions. */
|
||||
menus: IIvrMenu[];
|
||||
/** The menu to start with for incoming calls. */
|
||||
entryMenuId: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IAppConfig {
|
||||
proxy: IProxyConfig;
|
||||
providers: IProviderConfig[];
|
||||
devices: IDeviceConfig[];
|
||||
routing: IRoutingConfig;
|
||||
contacts: IContact[];
|
||||
voiceboxes?: IVoiceboxConfig[];
|
||||
ivr?: IIvrConfig;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -201,169 +303,29 @@ export function loadConfig(): IAppConfig {
|
||||
c.starred ??= false;
|
||||
}
|
||||
|
||||
// Voicebox defaults.
|
||||
cfg.voiceboxes ??= [];
|
||||
for (const vb of cfg.voiceboxes) {
|
||||
vb.enabled ??= true;
|
||||
vb.noAnswerTimeoutSec ??= 25;
|
||||
vb.maxRecordingSec ??= 120;
|
||||
vb.maxMessages ??= 50;
|
||||
vb.greetingVoice ??= 'af_bella';
|
||||
}
|
||||
|
||||
// IVR defaults.
|
||||
if (cfg.ivr) {
|
||||
cfg.ivr.enabled ??= false;
|
||||
cfg.ivr.menus ??= [];
|
||||
for (const menu of cfg.ivr.menus) {
|
||||
menu.timeoutSec ??= 5;
|
||||
menu.maxRetries ??= 3;
|
||||
menu.entries ??= [];
|
||||
}
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pattern matching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Test a value against a pattern string.
|
||||
* - undefined/empty pattern: matches everything (wildcard)
|
||||
* - Prefix: "pattern*" matches values starting with "pattern"
|
||||
* - Regex: "/pattern/" or "/pattern/i" compiles as RegExp
|
||||
* - Otherwise: exact match
|
||||
*/
|
||||
export function matchesPattern(pattern: string | undefined, value: string): boolean {
|
||||
if (!pattern) return true;
|
||||
|
||||
// Prefix match: "+49*"
|
||||
if (pattern.endsWith('*')) {
|
||||
return value.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
|
||||
// Regex match: "/^\\+49/" or "/pattern/i"
|
||||
if (pattern.startsWith('/')) {
|
||||
const lastSlash = pattern.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
const re = new RegExp(pattern.slice(1, lastSlash), pattern.slice(lastSlash + 1));
|
||||
return re.test(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Exact match.
|
||||
return value === pattern;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Result of resolving an outbound route. */
|
||||
export interface IOutboundRouteResult {
|
||||
provider: IProviderConfig;
|
||||
transformedNumber: string;
|
||||
}
|
||||
|
||||
/** Result of resolving an inbound route. */
|
||||
export interface IInboundRouteResult {
|
||||
/** Device IDs to ring (empty = all devices). */
|
||||
deviceIds: string[];
|
||||
ringBrowsers: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which provider to use for an outbound call, and transform the number.
|
||||
*
|
||||
* @param cfg - app config
|
||||
* @param dialedNumber - the number being dialed
|
||||
* @param sourceDeviceId - optional device originating the call
|
||||
* @param isProviderRegistered - callback to check if a provider is currently registered
|
||||
*/
|
||||
export function resolveOutboundRoute(
|
||||
cfg: IAppConfig,
|
||||
dialedNumber: string,
|
||||
sourceDeviceId?: string,
|
||||
isProviderRegistered?: (providerId: string) => boolean,
|
||||
): IOutboundRouteResult | null {
|
||||
const routes = cfg.routing.routes
|
||||
.filter((r) => r.enabled && r.match.direction === 'outbound')
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
for (const route of routes) {
|
||||
const m = route.match;
|
||||
|
||||
if (!matchesPattern(m.numberPattern, dialedNumber)) continue;
|
||||
if (m.sourceDevice && m.sourceDevice !== sourceDeviceId) continue;
|
||||
|
||||
// Find a registered provider (primary + failovers).
|
||||
const candidates = [route.action.provider, ...(route.action.failoverProviders || [])].filter(Boolean) as string[];
|
||||
for (const pid of candidates) {
|
||||
const provider = getProvider(cfg, pid);
|
||||
if (!provider) continue;
|
||||
if (isProviderRegistered && !isProviderRegistered(pid)) continue;
|
||||
|
||||
// Apply number transformation.
|
||||
let num = dialedNumber;
|
||||
if (route.action.stripPrefix && num.startsWith(route.action.stripPrefix)) {
|
||||
num = num.slice(route.action.stripPrefix.length);
|
||||
}
|
||||
if (route.action.prependPrefix) {
|
||||
num = route.action.prependPrefix + num;
|
||||
}
|
||||
|
||||
return { provider, transformedNumber: num };
|
||||
}
|
||||
|
||||
// Route matched but no provider is available — continue to next route.
|
||||
}
|
||||
|
||||
// Fallback: first available provider.
|
||||
const fallback = cfg.providers[0];
|
||||
return fallback ? { provider: fallback, transformedNumber: dialedNumber } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which devices/browsers to ring for an inbound call.
|
||||
*
|
||||
* @param cfg - app config
|
||||
* @param providerId - the provider the call is coming from
|
||||
* @param calledNumber - the DID / called number (from Request-URI)
|
||||
* @param callerNumber - the caller ID (from From header)
|
||||
*/
|
||||
export function resolveInboundRoute(
|
||||
cfg: IAppConfig,
|
||||
providerId: string,
|
||||
calledNumber: string,
|
||||
callerNumber: string,
|
||||
): IInboundRouteResult {
|
||||
const routes = cfg.routing.routes
|
||||
.filter((r) => r.enabled && r.match.direction === 'inbound')
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
for (const route of routes) {
|
||||
const m = route.match;
|
||||
|
||||
if (m.sourceProvider && m.sourceProvider !== providerId) continue;
|
||||
if (!matchesPattern(m.numberPattern, calledNumber)) continue;
|
||||
if (!matchesPattern(m.callerPattern, callerNumber)) continue;
|
||||
|
||||
return {
|
||||
deviceIds: route.action.targets || [],
|
||||
ringBrowsers: route.action.ringBrowsers ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: ring all devices + browsers.
|
||||
return { deviceIds: [], ringBrowsers: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use resolveOutboundRoute() instead. Kept for backward compat.
|
||||
*/
|
||||
export function getProviderForOutbound(cfg: IAppConfig): IProviderConfig | null {
|
||||
const result = resolveOutboundRoute(cfg, '');
|
||||
return result?.provider ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use resolveInboundRoute() instead. Kept for backward compat.
|
||||
*/
|
||||
export function getDevicesForInbound(cfg: IAppConfig, providerId: string): IDeviceConfig[] {
|
||||
const result = resolveInboundRoute(cfg, providerId, '', '');
|
||||
if (!result.deviceIds.length) return cfg.devices;
|
||||
return result.deviceIds.map((id) => getDevice(cfg, id)).filter(Boolean) as IDeviceConfig[];
|
||||
}
|
||||
// Route resolution, pattern matching, and provider/device lookup
|
||||
// are now handled entirely by the Rust proxy-engine.
|
||||
|
||||
113
ts/frontend.ts
113
ts/frontend.ts
@@ -11,8 +11,12 @@ 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';
|
||||
import type { VoiceboxManager } from './voicebox.ts';
|
||||
|
||||
// CallManager was previously used for WebRTC call handling. Now replaced by Rust proxy-engine.
|
||||
// Kept as `any` type for backward compat with the function signature until full WebRTC port.
|
||||
type CallManager = any;
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
|
||||
|
||||
@@ -84,6 +88,7 @@ async function handleRequest(
|
||||
onHangupCall: (callId: string) => boolean,
|
||||
onConfigSaved?: () => void,
|
||||
callManager?: CallManager,
|
||||
voiceboxManager?: VoiceboxManager,
|
||||
): Promise<void> {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
const method = req.method || 'GET';
|
||||
@@ -123,14 +128,19 @@ async function handleRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// API: add leg to call.
|
||||
// API: add a SIP device to a call (mid-call INVITE to desk phone).
|
||||
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 });
|
||||
const { addDeviceLeg } = await import('./proxybridge.ts');
|
||||
const legId = await addDeviceLeg(callId, body.deviceId);
|
||||
if (legId) {
|
||||
return sendJson(res, { ok: true, legId });
|
||||
} else {
|
||||
return sendJson(res, { ok: false, error: 'device not registered or call not found' }, 404);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||
}
|
||||
@@ -142,8 +152,9 @@ async function handleRequest(
|
||||
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 });
|
||||
const { addLeg: addLegFn } = await import('./proxybridge.ts');
|
||||
const legId = await addLegFn(callId, body.number, body.providerId);
|
||||
return sendJson(res, { ok: !!legId, legId });
|
||||
} catch (e: any) {
|
||||
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||
}
|
||||
@@ -155,22 +166,22 @@ async function handleRequest(
|
||||
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;
|
||||
const { removeLeg: removeLegFn } = await import('./proxybridge.ts');
|
||||
const ok = await removeLegFn(callId, body.legId);
|
||||
return sendJson(res, { ok });
|
||||
} catch (e: any) {
|
||||
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// API: transfer leg.
|
||||
// API: transfer leg (not yet implemented).
|
||||
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 });
|
||||
return sendJson(res, { ok: false, error: 'not yet implemented' }, 501);
|
||||
} catch (e: any) {
|
||||
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||
}
|
||||
@@ -242,6 +253,8 @@ async function handleRequest(
|
||||
}
|
||||
}
|
||||
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
|
||||
if (updates.voiceboxes !== undefined) cfg.voiceboxes = updates.voiceboxes;
|
||||
if (updates.ivr !== undefined) cfg.ivr = updates.ivr;
|
||||
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
||||
log('[config] updated config.json');
|
||||
@@ -252,6 +265,50 @@ async function handleRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// API: voicemail - list messages.
|
||||
const vmListMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)$/);
|
||||
if (vmListMatch && method === 'GET' && voiceboxManager) {
|
||||
const boxId = vmListMatch[1];
|
||||
return sendJson(res, { ok: true, messages: voiceboxManager.getMessages(boxId) });
|
||||
}
|
||||
|
||||
// API: voicemail - unheard count.
|
||||
const vmUnheardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/unheard$/);
|
||||
if (vmUnheardMatch && method === 'GET' && voiceboxManager) {
|
||||
const boxId = vmUnheardMatch[1];
|
||||
return sendJson(res, { ok: true, count: voiceboxManager.getUnheardCount(boxId) });
|
||||
}
|
||||
|
||||
// API: voicemail - stream audio.
|
||||
const vmAudioMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/audio$/);
|
||||
if (vmAudioMatch && method === 'GET' && voiceboxManager) {
|
||||
const [, boxId, msgId] = vmAudioMatch;
|
||||
const audioPath = voiceboxManager.getMessageAudioPath(boxId, msgId);
|
||||
if (!audioPath) return sendJson(res, { ok: false, error: 'not found' }, 404);
|
||||
const stat = fs.statSync(audioPath);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'audio/wav',
|
||||
'Content-Length': stat.size.toString(),
|
||||
'Accept-Ranges': 'bytes',
|
||||
});
|
||||
fs.createReadStream(audioPath).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// API: voicemail - mark as heard.
|
||||
const vmHeardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/heard$/);
|
||||
if (vmHeardMatch && method === 'POST' && voiceboxManager) {
|
||||
const [, boxId, msgId] = vmHeardMatch;
|
||||
return sendJson(res, { ok: voiceboxManager.markHeard(boxId, msgId) });
|
||||
}
|
||||
|
||||
// API: voicemail - delete message.
|
||||
const vmDeleteMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)$/);
|
||||
if (vmDeleteMatch && method === 'DELETE' && voiceboxManager) {
|
||||
const [, boxId, msgId] = vmDeleteMatch;
|
||||
return sendJson(res, { ok: voiceboxManager.deleteMessage(boxId, msgId) });
|
||||
}
|
||||
|
||||
// Static files.
|
||||
const file = staticFiles.get(url.pathname);
|
||||
if (file) {
|
||||
@@ -288,6 +345,13 @@ export function initWebUi(
|
||||
onHangupCall: (callId: string) => boolean,
|
||||
onConfigSaved?: () => void,
|
||||
callManager?: CallManager,
|
||||
voiceboxManager?: VoiceboxManager,
|
||||
/** WebRTC signaling handlers — forwarded to Rust proxy-engine. */
|
||||
onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise<void>,
|
||||
onWebRtcIce?: (sessionId: string, candidate: any) => Promise<void>,
|
||||
onWebRtcClose?: (sessionId: string) => Promise<void>,
|
||||
/** Called when browser sends webrtc-accept (callId + sessionId linking). */
|
||||
onWebRtcAccept?: (callId: string, sessionId: string) => void,
|
||||
): void {
|
||||
const WEB_PORT = 3060;
|
||||
|
||||
@@ -303,12 +367,12 @@ export function initWebUi(
|
||||
const cert = fs.readFileSync(certPath, 'utf8');
|
||||
const key = fs.readFileSync(keyPath, 'utf8');
|
||||
server = https.createServer({ cert, key }, (req, res) =>
|
||||
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager).catch(() => { res.writeHead(500); res.end(); }),
|
||||
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).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(); }),
|
||||
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -323,17 +387,26 @@ export function initWebUi(
|
||||
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) =>
|
||||
if (msg.type === 'webrtc-offer' && msg.sessionId) {
|
||||
// Forward to Rust proxy-engine for WebRTC handling.
|
||||
if (onWebRtcOffer) {
|
||||
log(`[webrtc-ws] offer msg keys: ${Object.keys(msg).join(',')}, sdp type: ${typeof msg.sdp}, sdp len: ${msg.sdp?.length || 0}`);
|
||||
onWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) =>
|
||||
log(`[webrtc] offer error: ${e.message}`));
|
||||
}
|
||||
} else if (msg.type === 'webrtc-ice' && msg.sessionId) {
|
||||
callManager?.handleWebRtcIce(msg.sessionId, msg.candidate).catch(() => {});
|
||||
if (onWebRtcIce) {
|
||||
onWebRtcIce(msg.sessionId, msg.candidate).catch(() => {});
|
||||
}
|
||||
} else if (msg.type === 'webrtc-hangup' && msg.sessionId) {
|
||||
callManager?.handleWebRtcHangup(msg.sessionId);
|
||||
if (onWebRtcClose) {
|
||||
onWebRtcClose(msg.sessionId).catch(() => {});
|
||||
}
|
||||
} else if (msg.type === 'webrtc-accept' && msg.callId) {
|
||||
log(`[webrtc] accept: call=${msg.callId} session=${msg.sessionId || 'none'}`);
|
||||
if (onWebRtcAccept && msg.sessionId) {
|
||||
onWebRtcAccept(msg.callId, msg.sessionId);
|
||||
}
|
||||
} else if (msg.type?.startsWith('webrtc-')) {
|
||||
msg._remoteIp = remoteIp;
|
||||
handleWebRtcSignaling(socket as any, msg);
|
||||
|
||||
199
ts/opusbridge.ts
199
ts/opusbridge.ts
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
516
ts/proxybridge.ts
Normal file
516
ts/proxybridge.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* Proxy engine bridge — manages the Rust proxy-engine subprocess.
|
||||
*
|
||||
* The proxy-engine handles ALL SIP protocol mechanics. TypeScript only:
|
||||
* - Sends configuration
|
||||
* - Receives high-level events (incoming_call, call_ended, etc.)
|
||||
* - Sends high-level commands (hangup, make_call, play_audio)
|
||||
*
|
||||
* No raw SIP ever touches TypeScript.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { RustBridge } from '@push.rocks/smartrust';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command type map for smartrust
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TProxyCommands = {
|
||||
configure: {
|
||||
params: Record<string, unknown>;
|
||||
result: { bound: string };
|
||||
};
|
||||
hangup: {
|
||||
params: { call_id: string };
|
||||
result: Record<string, never>;
|
||||
};
|
||||
make_call: {
|
||||
params: { number: string; device_id?: string; provider_id?: string };
|
||||
result: { call_id: string };
|
||||
};
|
||||
play_audio: {
|
||||
params: { call_id: string; leg_id?: string; file_path: string; codec?: number };
|
||||
result: Record<string, never>;
|
||||
};
|
||||
start_recording: {
|
||||
params: { call_id: string; file_path: string; max_duration_ms?: number };
|
||||
result: Record<string, never>;
|
||||
};
|
||||
stop_recording: {
|
||||
params: { call_id: string };
|
||||
result: { file_path: string; duration_ms: number };
|
||||
};
|
||||
add_device_leg: {
|
||||
params: { call_id: string; device_id: string };
|
||||
result: { leg_id: string };
|
||||
};
|
||||
transfer_leg: {
|
||||
params: { source_call_id: string; leg_id: string; target_call_id: string };
|
||||
result: Record<string, never>;
|
||||
};
|
||||
replace_leg: {
|
||||
params: { call_id: string; old_leg_id: string; number: string; provider_id?: string };
|
||||
result: { new_leg_id: string };
|
||||
};
|
||||
start_interaction: {
|
||||
params: {
|
||||
call_id: string;
|
||||
leg_id: string;
|
||||
prompt_wav: string;
|
||||
expected_digits: string;
|
||||
timeout_ms: number;
|
||||
};
|
||||
result: { result: 'digit' | 'timeout' | 'cancelled'; digit?: string };
|
||||
};
|
||||
add_tool_leg: {
|
||||
params: {
|
||||
call_id: string;
|
||||
tool_type: 'recording' | 'transcription';
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
result: { tool_leg_id: string };
|
||||
};
|
||||
remove_tool_leg: {
|
||||
params: { call_id: string; tool_leg_id: string };
|
||||
result: Record<string, never>;
|
||||
};
|
||||
set_leg_metadata: {
|
||||
params: { call_id: string; leg_id: string; key: string; value: unknown };
|
||||
result: Record<string, never>;
|
||||
};
|
||||
generate_tts: {
|
||||
params: { model: string; voices: string; voice: string; text: string; output: string };
|
||||
result: { output: string };
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event types from Rust
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IIncomingCallEvent {
|
||||
call_id: string;
|
||||
from_uri: string;
|
||||
to_number: string;
|
||||
provider_id: string;
|
||||
}
|
||||
|
||||
export interface IOutboundCallEvent {
|
||||
call_id: string;
|
||||
from_device: string | null;
|
||||
to_number: string;
|
||||
}
|
||||
|
||||
export interface ICallEndedEvent {
|
||||
call_id: string;
|
||||
reason: string;
|
||||
duration: number;
|
||||
from_side?: string;
|
||||
}
|
||||
|
||||
export interface IProviderRegisteredEvent {
|
||||
provider_id: string;
|
||||
registered: boolean;
|
||||
public_ip: string | null;
|
||||
}
|
||||
|
||||
export interface IDeviceRegisteredEvent {
|
||||
device_id: string;
|
||||
display_name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
aor: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge singleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let bridge: RustBridge<TProxyCommands> | null = null;
|
||||
let initialized = false;
|
||||
let logFn: ((msg: string) => void) | undefined;
|
||||
|
||||
function buildLocalPaths(): string[] {
|
||||
const root = process.cwd();
|
||||
return [
|
||||
path.join(root, 'dist_rust', 'proxy-engine'),
|
||||
path.join(root, 'rust', 'target', 'release', 'proxy-engine'),
|
||||
path.join(root, 'rust', 'target', 'debug', 'proxy-engine'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the proxy engine — spawn the Rust binary.
|
||||
* Call configure() separately to push config and start SIP.
|
||||
*/
|
||||
export async function initProxyEngine(log?: (msg: string) => void): Promise<boolean> {
|
||||
if (initialized && bridge) return true;
|
||||
logFn = log;
|
||||
|
||||
try {
|
||||
bridge = new RustBridge<TProxyCommands>({
|
||||
binaryName: 'proxy-engine',
|
||||
localPaths: buildLocalPaths(),
|
||||
});
|
||||
|
||||
const spawned = await bridge.spawn();
|
||||
if (!spawned) {
|
||||
log?.('[proxy-engine] failed to spawn binary');
|
||||
bridge = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
bridge.on('exit', () => {
|
||||
logFn?.('[proxy-engine] process exited — will need re-init');
|
||||
bridge = null;
|
||||
initialized = false;
|
||||
});
|
||||
|
||||
// Forward stderr for debugging.
|
||||
bridge.on('stderr', (line: string) => {
|
||||
logFn?.(`[proxy-engine:stderr] ${line}`);
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
log?.('[proxy-engine] spawned and ready');
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
log?.(`[proxy-engine] init error: ${e.message}`);
|
||||
bridge = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the full app config to the proxy engine.
|
||||
* This binds the SIP socket, starts provider registrations, etc.
|
||||
*/
|
||||
export async function configureProxyEngine(config: Record<string, unknown>): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
const result = await bridge.sendCommand('configure', config as any);
|
||||
logFn?.(`[proxy-engine] configured, SIP bound on ${(result as any)?.bound || '?'}`);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] configure error: ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate an outbound call via Rust. Returns the call ID or null on failure.
|
||||
*/
|
||||
export async function makeCall(number: string, deviceId?: string, providerId?: string): Promise<string | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('make_call', {
|
||||
number,
|
||||
device_id: deviceId,
|
||||
provider_id: providerId,
|
||||
} as any);
|
||||
return (result as any)?.call_id || null;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] make_call error: ${e?.message || e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a hangup command.
|
||||
*/
|
||||
export async function hangupCall(callId: string): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
await bridge.sendCommand('hangup', { call_id: callId } as any);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a WebRTC offer to the proxy engine. Returns the SDP answer.
|
||||
*/
|
||||
export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp: string } | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('webrtc_offer', { session_id: sessionId, sdp } as any);
|
||||
return result as any;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] webrtc_offer error: ${e?.message || e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward an ICE candidate to the proxy engine.
|
||||
*/
|
||||
export async function webrtcIce(sessionId: string, candidate: any): Promise<void> {
|
||||
if (!bridge || !initialized) return;
|
||||
try {
|
||||
await bridge.sendCommand('webrtc_ice', {
|
||||
session_id: sessionId,
|
||||
candidate: candidate?.candidate || candidate,
|
||||
sdp_mid: candidate?.sdpMid,
|
||||
sdp_mline_index: candidate?.sdpMLineIndex,
|
||||
} as any);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a WebRTC session to a SIP call — enables audio bridging.
|
||||
* The browser's Opus audio will be transcoded and sent to the provider.
|
||||
*/
|
||||
export async function webrtcLink(sessionId: string, callId: string, providerMediaAddr: string, providerMediaPort: number, sipPt: number = 9): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
await bridge.sendCommand('webrtc_link', {
|
||||
session_id: sessionId,
|
||||
call_id: callId,
|
||||
provider_media_addr: providerMediaAddr,
|
||||
provider_media_port: providerMediaPort,
|
||||
sip_pt: sipPt,
|
||||
} as any);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] webrtc_link error: ${e?.message || e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an external SIP leg to an existing call (multiparty).
|
||||
*/
|
||||
export async function addLeg(callId: string, number: string, providerId?: string): Promise<string | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('add_leg', {
|
||||
call_id: callId,
|
||||
number,
|
||||
provider_id: providerId,
|
||||
} as any);
|
||||
return (result as any)?.leg_id || null;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] add_leg error: ${e?.message || e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a leg from a call.
|
||||
*/
|
||||
export async function removeLeg(callId: string, legId: string): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
await bridge.sendCommand('remove_leg', { call_id: callId, leg_id: legId } as any);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] remove_leg error: ${e?.message || e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a WebRTC session.
|
||||
*/
|
||||
export async function webrtcClose(sessionId: string): Promise<void> {
|
||||
if (!bridge || !initialized) return;
|
||||
try {
|
||||
await bridge.sendCommand('webrtc_close', { session_id: sessionId } as any);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device leg & interaction commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add a local SIP device to an existing call (mid-call INVITE to desk phone).
|
||||
*/
|
||||
export async function addDeviceLeg(callId: string, deviceId: string): Promise<string | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('add_device_leg', {
|
||||
call_id: callId,
|
||||
device_id: deviceId,
|
||||
} as any);
|
||||
return (result as any)?.leg_id || null;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] add_device_leg error: ${e?.message || e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer a leg from one call to another (leg stays connected, switches mixer).
|
||||
*/
|
||||
export async function transferLeg(
|
||||
sourceCallId: string,
|
||||
legId: string,
|
||||
targetCallId: string,
|
||||
): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
await bridge.sendCommand('transfer_leg', {
|
||||
source_call_id: sourceCallId,
|
||||
leg_id: legId,
|
||||
target_call_id: targetCallId,
|
||||
} as any);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] transfer_leg error: ${e?.message || e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a leg: terminate the old leg and dial a new number into the same call.
|
||||
*/
|
||||
export async function replaceLeg(
|
||||
callId: string,
|
||||
oldLegId: string,
|
||||
number: string,
|
||||
providerId?: string,
|
||||
): Promise<string | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('replace_leg', {
|
||||
call_id: callId,
|
||||
old_leg_id: oldLegId,
|
||||
number,
|
||||
provider_id: providerId,
|
||||
} as any);
|
||||
return (result as any)?.new_leg_id || null;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] replace_leg error: ${e?.message || e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an interaction on a specific leg — isolate it, play a prompt, collect DTMF.
|
||||
* Blocks until the interaction completes (digit pressed, timeout, or cancelled).
|
||||
*/
|
||||
export async function startInteraction(
|
||||
callId: string,
|
||||
legId: string,
|
||||
promptWav: string,
|
||||
expectedDigits: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('start_interaction', {
|
||||
call_id: callId,
|
||||
leg_id: legId,
|
||||
prompt_wav: promptWav,
|
||||
expected_digits: expectedDigits,
|
||||
timeout_ms: timeoutMs,
|
||||
} as any);
|
||||
return result as any;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] start_interaction error: ${e?.message || e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a tool leg (recording or transcription) to a call.
|
||||
* Tool legs receive per-source unmerged audio from all participants.
|
||||
*/
|
||||
export async function addToolLeg(
|
||||
callId: string,
|
||||
toolType: 'recording' | 'transcription',
|
||||
config?: Record<string, unknown>,
|
||||
): Promise<string | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('add_tool_leg', {
|
||||
call_id: callId,
|
||||
tool_type: toolType,
|
||||
config,
|
||||
} as any);
|
||||
return (result as any)?.tool_leg_id || null;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] add_tool_leg error: ${e?.message || e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tool leg from a call. Triggers finalization (WAV files, metadata).
|
||||
*/
|
||||
export async function removeToolLeg(callId: string, toolLegId: string): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
await bridge.sendCommand('remove_tool_leg', {
|
||||
call_id: callId,
|
||||
tool_leg_id: toolLegId,
|
||||
} as any);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] remove_tool_leg error: ${e?.message || e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a metadata key-value pair on a leg.
|
||||
*/
|
||||
export async function setLegMetadata(
|
||||
callId: string,
|
||||
legId: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
await bridge.sendCommand('set_leg_metadata', {
|
||||
call_id: callId,
|
||||
leg_id: legId,
|
||||
key,
|
||||
value,
|
||||
} as any);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] set_leg_metadata error: ${e?.message || e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event from the proxy engine.
|
||||
* Event names: incoming_call, outbound_device_call, call_ringing,
|
||||
* call_answered, call_ended, provider_registered, device_registered,
|
||||
* dtmf_digit, recording_done, tool_recording_done, tool_transcription_done,
|
||||
* leg_added, leg_removed, sip_unhandled
|
||||
*/
|
||||
export function onProxyEvent(event: string, handler: (data: any) => void): void {
|
||||
if (!bridge) throw new Error('proxy engine not initialized');
|
||||
bridge.on(`management:${event}`, handler);
|
||||
}
|
||||
|
||||
/** Check if the proxy engine is ready. */
|
||||
export function isProxyReady(): boolean {
|
||||
return initialized && bridge !== null;
|
||||
}
|
||||
|
||||
/** Send an arbitrary command to the proxy engine bridge. */
|
||||
export async function sendProxyCommand<K extends keyof TProxyCommands>(
|
||||
method: K,
|
||||
params: TProxyCommands[K]['params'],
|
||||
): Promise<TProxyCommands[K]['result']> {
|
||||
if (!bridge || !initialized) throw new Error('proxy engine not initialized');
|
||||
return bridge.sendCommand(method as string, params as any) as any;
|
||||
}
|
||||
|
||||
/** Shut down the proxy engine. */
|
||||
export function shutdownProxyEngine(): void {
|
||||
if (bridge) {
|
||||
try { bridge.kill(); } catch { /* ignore */ }
|
||||
bridge = null;
|
||||
initialized = false;
|
||||
}
|
||||
}
|
||||
212
ts/registrar.ts
212
ts/registrar.ts
@@ -1,118 +1,27 @@
|
||||
/**
|
||||
* Local SIP registrar — accepts REGISTER from devices and browser clients.
|
||||
* Browser device registration.
|
||||
*
|
||||
* 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.
|
||||
* SIP device registration is now handled entirely by the Rust proxy-engine.
|
||||
* This module only handles browser softphone registration via WebSocket.
|
||||
*/
|
||||
|
||||
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
|
||||
// Browser device registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
}
|
||||
const browserDevices = new Map<string, { deviceId: string; displayName: string; remoteIp: string }>();
|
||||
|
||||
/**
|
||||
* 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';
|
||||
@@ -121,21 +30,11 @@ export function registerBrowserDevice(sessionId: string, userAgent?: string, rem
|
||||
else if (userAgent.includes('Safari/') && !userAgent.includes('Chrome/')) browserName = 'Safari';
|
||||
}
|
||||
|
||||
const entry: IRegisteredDevice = {
|
||||
deviceConfig: {
|
||||
id: `browser-${shortHash(sessionId)}`,
|
||||
browserDevices.set(sessionId, {
|
||||
deviceId: `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);
|
||||
remoteIp: remoteIp || '127.0.0.1',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,96 +43,3 @@ export function registerBrowserDevice(sessionId: string, userAgent?: string, rem
|
||||
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
280
ts/sip/dialog.ts
@@ -1,280 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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';
|
||||
@@ -1,316 +0,0 @@
|
||||
/**
|
||||
* 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
228
ts/sip/readme.md
@@ -1,228 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Shared SIP types.
|
||||
*/
|
||||
|
||||
export interface IEndpoint {
|
||||
address: string;
|
||||
port: number;
|
||||
}
|
||||
769
ts/sipproxy.ts
769
ts/sipproxy.ts
@@ -1,39 +1,22 @@
|
||||
/**
|
||||
* SIP proxy — hub model entry point.
|
||||
* SIP proxy — 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
|
||||
* Spawns the Rust proxy-engine which handles ALL SIP protocol mechanics.
|
||||
* TypeScript is the control plane:
|
||||
* - Loads config and pushes it to Rust
|
||||
* - Receives high-level events (incoming calls, registration, etc.)
|
||||
* - Drives the web dashboard
|
||||
* - Manages IVR, voicemail, announcements
|
||||
* - Handles WebRTC browser signaling (forwarded to Rust in Phase 2)
|
||||
*
|
||||
* All call/media logic lives in ts/call/.
|
||||
* No raw SIP ever touches TypeScript.
|
||||
*/
|
||||
|
||||
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, resolveOutboundRoute } 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 { loadConfig } from './config.ts';
|
||||
import type { IAppConfig } from './config.ts';
|
||||
import { broadcastWs, initWebUi } from './frontend.ts';
|
||||
import {
|
||||
initWebRtcSignaling,
|
||||
@@ -41,19 +24,36 @@ import {
|
||||
getAllBrowserDeviceIds,
|
||||
getBrowserDeviceWs,
|
||||
} from './webrtcbridge.ts';
|
||||
import { initCodecBridge } from './opusbridge.ts';
|
||||
import { initAnnouncement } from './announcement.ts';
|
||||
import { CallManager } from './call/index.ts';
|
||||
import { PromptCache } from './call/prompt-cache.ts';
|
||||
import { VoiceboxManager } from './voicebox.ts';
|
||||
import {
|
||||
initProxyEngine,
|
||||
configureProxyEngine,
|
||||
onProxyEvent,
|
||||
hangupCall,
|
||||
makeCall,
|
||||
shutdownProxyEngine,
|
||||
webrtcOffer,
|
||||
webrtcIce,
|
||||
webrtcLink,
|
||||
webrtcClose,
|
||||
addLeg,
|
||||
removeLeg,
|
||||
} from './proxybridge.ts';
|
||||
import type {
|
||||
IIncomingCallEvent,
|
||||
IOutboundCallEvent,
|
||||
ICallEndedEvent,
|
||||
IProviderRegisteredEvent,
|
||||
IDeviceRegisteredEvent,
|
||||
} from './proxybridge.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');
|
||||
|
||||
@@ -75,35 +75,106 @@ function log(msg: string): void {
|
||||
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');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shadow state — maintained from Rust events for the dashboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface IProviderStatus {
|
||||
id: string;
|
||||
displayName: string;
|
||||
registered: boolean;
|
||||
publicIp: string | null;
|
||||
}
|
||||
|
||||
interface IDeviceStatus {
|
||||
id: string;
|
||||
displayName: string;
|
||||
address: string | null;
|
||||
port: number;
|
||||
connected: boolean;
|
||||
isBrowser: boolean;
|
||||
}
|
||||
|
||||
interface IActiveLeg {
|
||||
id: string;
|
||||
type: 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
|
||||
state: string;
|
||||
codec: string | null;
|
||||
rtpPort: number | null;
|
||||
remoteMedia: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface IActiveCall {
|
||||
id: string;
|
||||
direction: string;
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
providerUsed: string | null;
|
||||
state: string;
|
||||
startedAt: number;
|
||||
legs: Map<string, IActiveLeg>;
|
||||
}
|
||||
|
||||
interface IHistoryLeg {
|
||||
id: string;
|
||||
type: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ICallHistoryEntry {
|
||||
id: string;
|
||||
direction: string;
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
legs: IHistoryLeg[];
|
||||
}
|
||||
|
||||
const providerStatuses = new Map<string, IProviderStatus>();
|
||||
const deviceStatuses = new Map<string, IDeviceStatus>();
|
||||
const activeCalls = new Map<string, IActiveCall>();
|
||||
const callHistory: ICallHistoryEntry[] = [];
|
||||
const MAX_HISTORY = 100;
|
||||
|
||||
// WebRTC session ↔ call linking state.
|
||||
// Both pieces (session accept + call media info) can arrive in any order.
|
||||
const webrtcSessionToCall = new Map<string, string>(); // sessionId → callId
|
||||
const webrtcCallToSession = new Map<string, string>(); // callId → sessionId
|
||||
const pendingCallMedia = new Map<string, { addr: string; port: number; sipPt: number }>(); // callId → provider media info
|
||||
|
||||
// Initialize provider statuses from config (all start as unregistered).
|
||||
for (const p of appConfig.providers) {
|
||||
providerStatuses.set(p.id, {
|
||||
id: p.id,
|
||||
displayName: p.displayName,
|
||||
registered: false,
|
||||
publicIp: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize device statuses from config.
|
||||
for (const d of appConfig.devices) {
|
||||
deviceStatuses.set(d.id, {
|
||||
id: d.id,
|
||||
displayName: d.displayName,
|
||||
address: null,
|
||||
port: 0,
|
||||
connected: false,
|
||||
isBrowser: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize subsystems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const providerStates = initProviderStates(appConfig.providers, proxy.publicIpSeed);
|
||||
const promptCache = new PromptCache(log);
|
||||
const voiceboxManager = new VoiceboxManager(log);
|
||||
voiceboxManager.init(appConfig.voiceboxes ?? []);
|
||||
|
||||
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).
|
||||
// WebRTC signaling (browser device registration).
|
||||
initWebRtcSignaling({ log });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -111,149 +182,372 @@ initWebRtcSignaling({ log });
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
// Merge SIP devices (from Rust) + browser devices (from TS WebSocket).
|
||||
const devices = [...deviceStatuses.values()];
|
||||
for (const bid of getAllBrowserDeviceIds()) {
|
||||
devices.push({
|
||||
id: bid,
|
||||
displayName: 'Browser',
|
||||
address: null,
|
||||
port: 0,
|
||||
connected: true,
|
||||
isBrowser: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
instanceId,
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
lanIp: LAN_IP,
|
||||
providers,
|
||||
devices: getAllDeviceStatuses(),
|
||||
calls: callManager.getStatus(),
|
||||
callHistory: callManager.getHistory(),
|
||||
lanIp: appConfig.proxy.lanIp,
|
||||
providers: [...providerStatuses.values()],
|
||||
devices,
|
||||
calls: [...activeCalls.values()].map((c) => ({
|
||||
...c,
|
||||
duration: Math.floor((Date.now() - c.startedAt) / 1000),
|
||||
legs: [...c.legs.values()].map((l) => ({
|
||||
id: l.id,
|
||||
type: l.type,
|
||||
state: l.state,
|
||||
codec: l.codec,
|
||||
rtpPort: l.rtpPort,
|
||||
remoteMedia: l.remoteMedia,
|
||||
metadata: l.metadata || {},
|
||||
pktSent: 0,
|
||||
pktReceived: 0,
|
||||
transcoding: false,
|
||||
})),
|
||||
})),
|
||||
callHistory,
|
||||
contacts: appConfig.contacts || [],
|
||||
voicemailCounts: voiceboxManager.getAllUnheardCounts(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main UDP socket
|
||||
// Start Rust proxy engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sock = dgram.createSocket('udp4');
|
||||
async function startProxyEngine(): Promise<void> {
|
||||
const ok = await initProxyEngine(log);
|
||||
if (!ok) {
|
||||
log('[FATAL] failed to start proxy engine');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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.
|
||||
// Subscribe to events from Rust BEFORE sending configure.
|
||||
onProxyEvent('provider_registered', (data: IProviderRegisteredEvent) => {
|
||||
const ps = providerStatuses.get(data.provider_id);
|
||||
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 = resolveOutboundRoute(appConfig, '');
|
||||
if (dp) sock.send(data, dp.provider.outboundProxy.port, dp.provider.outboundProxy.address);
|
||||
const wasRegistered = ps.registered;
|
||||
ps.registered = data.registered;
|
||||
ps.publicIp = data.public_ip;
|
||||
if (data.registered && !wasRegistered) {
|
||||
log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`);
|
||||
} else if (!data.registered && wasRegistered) {
|
||||
log(`[provider:${data.provider_id}] registration lost`);
|
||||
}
|
||||
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 dialedNumber = SipMessage.extractUri(msg.requestUri || '') || '';
|
||||
const routeResult = resolveOutboundRoute(
|
||||
appConfig,
|
||||
dialedNumber,
|
||||
undefined,
|
||||
(pid) => !!providerStates.get(pid)?.registeredAor,
|
||||
);
|
||||
if (routeResult) {
|
||||
const provState = providerStates.get(routeResult.provider.id);
|
||||
if (provState) {
|
||||
// Apply number transformation to the INVITE if needed.
|
||||
if (routeResult.transformedNumber !== dialedNumber) {
|
||||
const newUri = msg.requestUri?.replace(dialedNumber, routeResult.transformedNumber);
|
||||
if (newUri) msg.setRequestUri(newUri);
|
||||
}
|
||||
callManager.handlePassthroughOutbound(msg, { address: rinfo.address, port: rinfo.port }, routeResult.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 fallback = resolveOutboundRoute(appConfig, '');
|
||||
if (fallback) sock.send(msg.serialize(), fallback.provider.outboundProxy.port, fallback.provider.outboundProxy.address);
|
||||
}
|
||||
} catch (e: any) {
|
||||
log(`[err] ${e?.stack || e}`);
|
||||
broadcastWs('registration', { providerId: data.provider_id, registered: data.registered });
|
||||
}
|
||||
});
|
||||
|
||||
sock.on('error', (err: Error) => log(`[main] sock err: ${err.message}`));
|
||||
onProxyEvent('device_registered', (data: IDeviceRegisteredEvent) => {
|
||||
const ds = deviceStatuses.get(data.device_id);
|
||||
if (ds) {
|
||||
ds.address = data.address;
|
||||
ds.port = data.port;
|
||||
ds.connected = true;
|
||||
log(`[registrar] ${data.display_name} registered from ${data.address}:${data.port}`);
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('incoming_call', (data: IIncomingCallEvent) => {
|
||||
log(`[call] incoming: ${data.from_uri} → ${data.to_number} via ${data.provider_id} (${data.call_id})`);
|
||||
activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'inbound',
|
||||
callerNumber: data.from_uri,
|
||||
calleeNumber: data.to_number,
|
||||
providerUsed: data.provider_id,
|
||||
state: 'ringing',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
|
||||
// Notify browsers of incoming call.
|
||||
const browserIds = getAllBrowserDeviceIds();
|
||||
for (const bid of browserIds) {
|
||||
sendToBrowserDevice(bid, {
|
||||
type: 'webrtc-incoming',
|
||||
callId: data.call_id,
|
||||
from: data.from_uri,
|
||||
deviceId: bid,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('outbound_device_call', (data: IOutboundCallEvent) => {
|
||||
log(`[call] outbound: device ${data.from_device} → ${data.to_number} (${data.call_id})`);
|
||||
activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'outbound',
|
||||
callerNumber: data.from_device,
|
||||
calleeNumber: data.to_number,
|
||||
providerUsed: null,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
onProxyEvent('outbound_call_started', (data: any) => {
|
||||
log(`[call] outbound started: ${data.call_id} → ${data.number} via ${data.provider_id}`);
|
||||
activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'outbound',
|
||||
callerNumber: null,
|
||||
calleeNumber: data.number,
|
||||
providerUsed: data.provider_id,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
|
||||
// Notify all browser devices — they can connect via WebRTC to listen/talk.
|
||||
const browserIds = getAllBrowserDeviceIds();
|
||||
for (const bid of browserIds) {
|
||||
sendToBrowserDevice(bid, {
|
||||
type: 'webrtc-incoming',
|
||||
callId: data.call_id,
|
||||
from: data.number,
|
||||
deviceId: bid,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('call_ringing', (data: { call_id: string }) => {
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (call) call.state = 'ringing';
|
||||
});
|
||||
|
||||
onProxyEvent('call_answered', (data: { call_id: string; provider_media_addr?: string; provider_media_port?: number; sip_pt?: number }) => {
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (call) {
|
||||
call.state = 'connected';
|
||||
log(`[call] ${data.call_id} connected`);
|
||||
|
||||
// Enrich provider leg with media info from the answered event.
|
||||
if (data.provider_media_addr && data.provider_media_port) {
|
||||
for (const leg of call.legs.values()) {
|
||||
if (leg.type === 'sip-provider') {
|
||||
leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`;
|
||||
if (data.sip_pt !== undefined) {
|
||||
const codecNames: Record<number, string> = { 0: 'PCMU', 8: 'PCMA', 9: 'G.722', 111: 'Opus' };
|
||||
leg.codec = codecNames[data.sip_pt] || `PT${data.sip_pt}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to link WebRTC session to this call for audio bridging.
|
||||
if (data.provider_media_addr && data.provider_media_port) {
|
||||
const sessionId = webrtcCallToSession.get(data.call_id);
|
||||
if (sessionId) {
|
||||
// Both session and media info available — link now.
|
||||
const sipPt = data.sip_pt ?? 9;
|
||||
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${data.call_id} media=${data.provider_media_addr}:${data.provider_media_port} pt=${sipPt}`);
|
||||
webrtcLink(sessionId, data.call_id, data.provider_media_addr, data.provider_media_port, sipPt).then((ok) => {
|
||||
log(`[webrtc] link result: ${ok}`);
|
||||
});
|
||||
} else {
|
||||
// Session not yet accepted — store media info for when it arrives.
|
||||
pendingCallMedia.set(data.call_id, {
|
||||
addr: data.provider_media_addr,
|
||||
port: data.provider_media_port,
|
||||
sipPt: data.sip_pt ?? 9,
|
||||
});
|
||||
log(`[webrtc] media info cached for call=${data.call_id}, waiting for session accept`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('call_ended', (data: ICallEndedEvent) => {
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (call) {
|
||||
log(`[call] ${data.call_id} ended: ${data.reason} (${data.duration}s)`);
|
||||
// Snapshot legs with metadata for history.
|
||||
const historyLegs: IHistoryLeg[] = [];
|
||||
for (const [, leg] of call.legs) {
|
||||
historyLegs.push({
|
||||
id: leg.id,
|
||||
type: leg.type,
|
||||
metadata: leg.metadata || {},
|
||||
});
|
||||
}
|
||||
// Move to history.
|
||||
callHistory.unshift({
|
||||
id: call.id,
|
||||
direction: call.direction,
|
||||
callerNumber: call.callerNumber,
|
||||
calleeNumber: call.calleeNumber,
|
||||
startedAt: call.startedAt,
|
||||
duration: data.duration,
|
||||
legs: historyLegs,
|
||||
});
|
||||
if (callHistory.length > MAX_HISTORY) callHistory.pop();
|
||||
activeCalls.delete(data.call_id);
|
||||
|
||||
// Notify browser(s) that the call ended.
|
||||
broadcastWs('webrtc-call-ended', { callId: data.call_id });
|
||||
|
||||
// Clean up WebRTC session mappings.
|
||||
const sessionId = webrtcCallToSession.get(data.call_id);
|
||||
if (sessionId) {
|
||||
webrtcCallToSession.delete(data.call_id);
|
||||
webrtcSessionToCall.delete(sessionId);
|
||||
webrtcClose(sessionId).catch(() => {});
|
||||
}
|
||||
pendingCallMedia.delete(data.call_id);
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('sip_unhandled', (data: any) => {
|
||||
log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`);
|
||||
});
|
||||
|
||||
// Leg events (multiparty) — update shadow state so the dashboard shows legs.
|
||||
onProxyEvent('leg_added', (data: any) => {
|
||||
log(`[leg] added: call=${data.call_id} leg=${data.leg_id} kind=${data.kind} state=${data.state}`);
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (call) {
|
||||
call.legs.set(data.leg_id, {
|
||||
id: data.leg_id,
|
||||
type: data.kind,
|
||||
state: data.state,
|
||||
codec: data.codec ?? null,
|
||||
rtpPort: data.rtpPort ?? null,
|
||||
remoteMedia: data.remoteMedia ?? null,
|
||||
metadata: data.metadata || {},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('leg_removed', (data: any) => {
|
||||
log(`[leg] removed: call=${data.call_id} leg=${data.leg_id}`);
|
||||
activeCalls.get(data.call_id)?.legs.delete(data.leg_id);
|
||||
});
|
||||
|
||||
onProxyEvent('leg_state_changed', (data: any) => {
|
||||
log(`[leg] state: call=${data.call_id} leg=${data.leg_id} → ${data.state}`);
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (!call) return;
|
||||
const leg = call.legs.get(data.leg_id);
|
||||
if (leg) {
|
||||
leg.state = data.state;
|
||||
if (data.metadata) leg.metadata = data.metadata;
|
||||
} else {
|
||||
// Initial legs (provider/device) don't emit leg_added — create on first state change.
|
||||
const legId: string = data.leg_id;
|
||||
const type = legId.includes('-prov') ? 'sip-provider' : legId.includes('-dev') ? 'sip-device' : 'webrtc';
|
||||
call.legs.set(data.leg_id, {
|
||||
id: data.leg_id,
|
||||
type,
|
||||
state: data.state,
|
||||
codec: null,
|
||||
rtpPort: null,
|
||||
remoteMedia: null,
|
||||
metadata: data.metadata || {},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// WebRTC events from Rust — forward ICE candidates to browser via WebSocket.
|
||||
onProxyEvent('webrtc_ice_candidate', (data: any) => {
|
||||
// Find the browser's WebSocket by session ID and send the ICE candidate.
|
||||
broadcastWs('webrtc-ice', {
|
||||
sessionId: data.session_id,
|
||||
candidate: { candidate: data.candidate, sdpMid: data.sdp_mid, sdpMLineIndex: data.sdp_mline_index },
|
||||
});
|
||||
});
|
||||
|
||||
onProxyEvent('webrtc_state', (data: any) => {
|
||||
log(`[webrtc] session=${data.session_id?.slice(0, 8)} state=${data.state}`);
|
||||
});
|
||||
|
||||
onProxyEvent('webrtc_track', (data: any) => {
|
||||
log(`[webrtc] session=${data.session_id?.slice(0, 8)} track=${data.kind} codec=${data.codec}`);
|
||||
});
|
||||
|
||||
onProxyEvent('webrtc_audio_rx', (data: any) => {
|
||||
if (data.packet_count === 1 || data.packet_count === 50) {
|
||||
log(`[webrtc] session=${data.session_id?.slice(0, 8)} browser audio rx #${data.packet_count}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Voicemail events.
|
||||
onProxyEvent('voicemail_started', (data: any) => {
|
||||
log(`[voicemail] started for call ${data.call_id} caller=${data.caller_number}`);
|
||||
});
|
||||
|
||||
onProxyEvent('recording_done', (data: any) => {
|
||||
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) caller=${data.caller_number}`);
|
||||
// Save voicemail metadata via VoiceboxManager.
|
||||
voiceboxManager.addMessage?.('default', {
|
||||
callerNumber: data.caller_number || 'Unknown',
|
||||
callerName: null,
|
||||
fileName: data.file_path,
|
||||
durationMs: data.duration_ms,
|
||||
});
|
||||
});
|
||||
|
||||
onProxyEvent('voicemail_error', (data: any) => {
|
||||
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
|
||||
});
|
||||
|
||||
// Send full config to Rust — this binds the SIP socket and starts registrations.
|
||||
const configured = await configureProxyEngine({
|
||||
proxy: appConfig.proxy,
|
||||
providers: appConfig.providers,
|
||||
devices: appConfig.devices,
|
||||
routing: appConfig.routing,
|
||||
});
|
||||
|
||||
if (!configured) {
|
||||
log('[FATAL] failed to configure proxy engine');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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}`);
|
||||
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | 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 }),
|
||||
);
|
||||
// Generate TTS audio (WAV files on disk, played by Rust audio_player).
|
||||
try {
|
||||
await initAnnouncement(log);
|
||||
|
||||
// Pre-generate prompts.
|
||||
await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000);
|
||||
for (const vb of appConfig.voiceboxes ?? []) {
|
||||
if (!vb.enabled) continue;
|
||||
const promptId = `voicemail-greeting-${vb.id}`;
|
||||
if (vb.greetingWavPath) {
|
||||
await promptCache.loadWavPrompt(promptId, vb.greetingWavPath);
|
||||
} else {
|
||||
const text = vb.greetingText || 'The person you are trying to reach is not available. Please leave a message after the tone.';
|
||||
await promptCache.generatePrompt(promptId, text, vb.greetingVoice || 'af_bella');
|
||||
}
|
||||
}
|
||||
if (appConfig.ivr?.enabled) {
|
||||
for (const menu of appConfig.ivr.menus) {
|
||||
await promptCache.generatePrompt(`ivr-menu-${menu.id}`, menu.promptText, menu.promptVoice || 'af_bella');
|
||||
}
|
||||
}
|
||||
log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`);
|
||||
} catch (e) {
|
||||
log(`[tts] init failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize audio codec bridge (Rust binary via smartrust).
|
||||
initCodecBridge(log)
|
||||
.then(() => initAnnouncement(log))
|
||||
.catch((e) => log(`[codec] init failed: ${e}`));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Web UI
|
||||
@@ -263,32 +557,121 @@ initWebUi(
|
||||
getStatus,
|
||||
log,
|
||||
(number, deviceId, providerId) => {
|
||||
const call = callManager.createOutboundCall(number, deviceId, providerId);
|
||||
return call ? { id: call.id } : null;
|
||||
// Outbound calls from dashboard — send make_call command to Rust.
|
||||
log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`);
|
||||
// Fire-and-forget — the async result comes via events.
|
||||
makeCall(number, deviceId, providerId).then((callId) => {
|
||||
if (callId) {
|
||||
log(`[dashboard] call started: ${callId}`);
|
||||
activeCalls.set(callId, {
|
||||
id: callId,
|
||||
direction: 'outbound',
|
||||
callerNumber: null,
|
||||
calleeNumber: number,
|
||||
providerUsed: providerId || null,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
} else {
|
||||
log(`[dashboard] call failed for ${number}`);
|
||||
}
|
||||
});
|
||||
// Return a temporary ID so the frontend doesn't show "failed" immediately.
|
||||
return { id: `pending-${Date.now()}` };
|
||||
},
|
||||
(callId) => {
|
||||
hangupCall(callId);
|
||||
return true;
|
||||
},
|
||||
(callId) => callManager.hangup(callId),
|
||||
() => {
|
||||
// Reload config after UI save.
|
||||
// Config saved — reconfigure Rust engine.
|
||||
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');
|
||||
|
||||
// Update shadow state.
|
||||
for (const p of fresh.providers) {
|
||||
if (!providerStatuses.has(p.id)) {
|
||||
providerStatuses.set(p.id, {
|
||||
id: p.id, displayName: p.displayName, registered: false, publicIp: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const d of fresh.devices) {
|
||||
if (!deviceStatuses.has(d.id)) {
|
||||
deviceStatuses.set(d.id, {
|
||||
id: d.id, displayName: d.displayName, address: null, port: 0, connected: false, isBrowser: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Re-send config to Rust.
|
||||
configureProxyEngine({
|
||||
proxy: fresh.proxy,
|
||||
providers: fresh.providers,
|
||||
devices: fresh.devices,
|
||||
routing: fresh.routing,
|
||||
}).then((ok) => {
|
||||
if (ok) log('[config] reloaded — proxy engine reconfigured');
|
||||
else log('[config] reload failed — proxy engine rejected config');
|
||||
});
|
||||
} catch (e: any) {
|
||||
log(`[config] reload failed: ${e.message}`);
|
||||
}
|
||||
},
|
||||
callManager,
|
||||
undefined, // callManager — legacy, replaced by Rust proxy-engine
|
||||
voiceboxManager, // voiceboxManager
|
||||
// WebRTC signaling → forwarded to Rust proxy-engine.
|
||||
async (sessionId, sdp, ws) => {
|
||||
log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`);
|
||||
if (!sdp || typeof sdp !== 'string' || sdp.length < 10) {
|
||||
log(`[webrtc] WARNING: invalid SDP (type=${typeof sdp}), skipping offer`);
|
||||
return;
|
||||
}
|
||||
log(`[webrtc] sending offer to Rust (${sdp.length}b)...`);
|
||||
const result = await webrtcOffer(sessionId, sdp);
|
||||
log(`[webrtc] Rust result: ${JSON.stringify(result)?.slice(0, 200)}`);
|
||||
if (result?.sdp) {
|
||||
ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp }));
|
||||
log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`);
|
||||
} else {
|
||||
log(`[webrtc] ERROR: no answer SDP from Rust`);
|
||||
}
|
||||
},
|
||||
async (sessionId, candidate) => {
|
||||
await webrtcIce(sessionId, candidate);
|
||||
},
|
||||
async (sessionId) => {
|
||||
await webrtcClose(sessionId);
|
||||
},
|
||||
// onWebRtcAccept — browser has accepted a call, linking session to call.
|
||||
(callId: string, sessionId: string) => {
|
||||
log(`[webrtc] accept: callId=${callId} sessionId=${sessionId.slice(0, 8)}`);
|
||||
|
||||
// Store bidirectional mapping.
|
||||
webrtcSessionToCall.set(sessionId, callId);
|
||||
webrtcCallToSession.set(callId, sessionId);
|
||||
|
||||
// Check if we already have media info for this call (provider answered first).
|
||||
const media = pendingCallMedia.get(callId);
|
||||
if (media) {
|
||||
pendingCallMedia.delete(callId);
|
||||
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${callId} media=${media.addr}:${media.port} pt=${media.sipPt}`);
|
||||
webrtcLink(sessionId, callId, media.addr, media.port, media.sipPt).then((ok) => {
|
||||
log(`[webrtc] link result: ${ok}`);
|
||||
});
|
||||
} else {
|
||||
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
process.on('SIGINT', () => { log('SIGINT, exiting'); process.exit(0); });
|
||||
process.on('SIGTERM', () => { log('SIGTERM, exiting'); process.exit(0); });
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
startProxyEngine();
|
||||
|
||||
process.on('SIGINT', () => { log('SIGINT, exiting'); shutdownProxyEngine(); process.exit(0); });
|
||||
process.on('SIGTERM', () => { log('SIGTERM, exiting'); shutdownProxyEngine(); process.exit(0); });
|
||||
|
||||
314
ts/voicebox.ts
Normal file
314
ts/voicebox.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* VoiceboxManager — manages voicemail boxes, message storage, and MWI.
|
||||
*
|
||||
* Each voicebox corresponds to a device/extension. Messages are stored
|
||||
* as WAV files with JSON metadata in .nogit/voicemail/{boxId}/.
|
||||
*
|
||||
* Supports:
|
||||
* - Per-box configurable TTS greetings (text + voice) or uploaded WAV
|
||||
* - Message CRUD: save, list, mark heard, delete
|
||||
* - Unheard count for MWI (Message Waiting Indicator)
|
||||
* - Storage limit (max messages per box)
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IVoiceboxConfig {
|
||||
/** Unique ID — typically matches device ID or extension. */
|
||||
id: string;
|
||||
/** Whether this voicebox is active. */
|
||||
enabled: boolean;
|
||||
/** Custom TTS greeting text (overrides default). */
|
||||
greetingText?: string;
|
||||
/** Kokoro TTS voice ID for the greeting (default 'af_bella'). */
|
||||
greetingVoice?: string;
|
||||
/** Path to uploaded WAV greeting (overrides TTS). */
|
||||
greetingWavPath?: string;
|
||||
/** Seconds to wait before routing to voicemail (default 25). */
|
||||
noAnswerTimeoutSec: number;
|
||||
/** Maximum recording duration in seconds (default 120). */
|
||||
maxRecordingSec: number;
|
||||
/** Maximum stored messages per box (default 50). */
|
||||
maxMessages: number;
|
||||
}
|
||||
|
||||
export interface IVoicemailMessage {
|
||||
/** Unique message ID. */
|
||||
id: string;
|
||||
/** Which voicebox this message belongs to. */
|
||||
boxId: string;
|
||||
/** Caller's phone number. */
|
||||
callerNumber: string;
|
||||
/** Caller's display name (if available from SIP From header). */
|
||||
callerName?: string;
|
||||
/** Unix timestamp (ms) when the message was recorded. */
|
||||
timestamp: number;
|
||||
/** Duration in milliseconds. */
|
||||
durationMs: number;
|
||||
/** Relative path to the WAV file (within the box directory). */
|
||||
fileName: string;
|
||||
/** Whether the message has been listened to. */
|
||||
heard: boolean;
|
||||
}
|
||||
|
||||
// Default greeting text when no custom text is configured.
|
||||
const DEFAULT_GREETING = 'The person you are trying to reach is not available. Please leave a message after the tone.';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoiceboxManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class VoiceboxManager {
|
||||
private boxes = new Map<string, IVoiceboxConfig>();
|
||||
private basePath: string;
|
||||
private log: (msg: string) => void;
|
||||
|
||||
constructor(log: (msg: string) => void) {
|
||||
this.basePath = path.join(process.cwd(), '.nogit', 'voicemail');
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load voicebox configurations from the app config.
|
||||
*/
|
||||
init(voiceboxConfigs: IVoiceboxConfig[]): void {
|
||||
this.boxes.clear();
|
||||
|
||||
for (const cfg of voiceboxConfigs) {
|
||||
// Apply defaults.
|
||||
cfg.noAnswerTimeoutSec ??= 25;
|
||||
cfg.maxRecordingSec ??= 120;
|
||||
cfg.maxMessages ??= 50;
|
||||
cfg.greetingVoice ??= 'af_bella';
|
||||
|
||||
this.boxes.set(cfg.id, cfg);
|
||||
}
|
||||
|
||||
// Ensure base directory exists.
|
||||
fs.mkdirSync(this.basePath, { recursive: true });
|
||||
|
||||
this.log(`[voicebox] initialized ${this.boxes.size} voicebox(es)`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Box management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get config for a specific voicebox. */
|
||||
getBox(boxId: string): IVoiceboxConfig | null {
|
||||
return this.boxes.get(boxId) ?? null;
|
||||
}
|
||||
|
||||
/** Get all configured voicebox IDs. */
|
||||
getBoxIds(): string[] {
|
||||
return [...this.boxes.keys()];
|
||||
}
|
||||
|
||||
/** Get the greeting text for a voicebox. */
|
||||
getGreetingText(boxId: string): string {
|
||||
const box = this.boxes.get(boxId);
|
||||
return box?.greetingText || DEFAULT_GREETING;
|
||||
}
|
||||
|
||||
/** Get the greeting voice for a voicebox. */
|
||||
getGreetingVoice(boxId: string): string {
|
||||
const box = this.boxes.get(boxId);
|
||||
return box?.greetingVoice || 'af_bella';
|
||||
}
|
||||
|
||||
/** Check if a voicebox has a custom WAV greeting. */
|
||||
hasCustomGreetingWav(boxId: string): boolean {
|
||||
const box = this.boxes.get(boxId);
|
||||
if (!box?.greetingWavPath) return false;
|
||||
return fs.existsSync(box.greetingWavPath);
|
||||
}
|
||||
|
||||
/** Get the greeting WAV path (custom or null). */
|
||||
getCustomGreetingWavPath(boxId: string): string | null {
|
||||
const box = this.boxes.get(boxId);
|
||||
if (!box?.greetingWavPath) return null;
|
||||
return fs.existsSync(box.greetingWavPath) ? box.greetingWavPath : null;
|
||||
}
|
||||
|
||||
/** Get the directory path for a voicebox. */
|
||||
getBoxDir(boxId: string): string {
|
||||
return path.join(this.basePath, boxId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Message CRUD
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save a new voicemail message.
|
||||
* The WAV file should already exist at the expected path.
|
||||
*/
|
||||
saveMessage(msg: IVoicemailMessage): void {
|
||||
const boxDir = this.getBoxDir(msg.boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
|
||||
const messages = this.loadMessages(msg.boxId);
|
||||
messages.unshift(msg); // newest first
|
||||
|
||||
// Enforce max messages — delete oldest.
|
||||
const box = this.boxes.get(msg.boxId);
|
||||
const maxMessages = box?.maxMessages ?? 50;
|
||||
while (messages.length > maxMessages) {
|
||||
const old = messages.pop()!;
|
||||
const oldPath = path.join(boxDir, old.fileName);
|
||||
try {
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
this.writeMessages(msg.boxId, messages);
|
||||
this.log(`[voicebox] saved message ${msg.id} in box "${msg.boxId}" (${msg.durationMs}ms from ${msg.callerNumber})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List messages for a voicebox (newest first).
|
||||
*/
|
||||
getMessages(boxId: string): IVoicemailMessage[] {
|
||||
return this.loadMessages(boxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single message by ID.
|
||||
*/
|
||||
getMessage(boxId: string, messageId: string): IVoicemailMessage | null {
|
||||
const messages = this.loadMessages(boxId);
|
||||
return messages.find((m) => m.id === messageId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a message as heard.
|
||||
*/
|
||||
markHeard(boxId: string, messageId: string): boolean {
|
||||
const messages = this.loadMessages(boxId);
|
||||
const msg = messages.find((m) => m.id === messageId);
|
||||
if (!msg) return false;
|
||||
|
||||
msg.heard = true;
|
||||
this.writeMessages(boxId, messages);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message (both metadata and WAV file).
|
||||
*/
|
||||
deleteMessage(boxId: string, messageId: string): boolean {
|
||||
const messages = this.loadMessages(boxId);
|
||||
const idx = messages.findIndex((m) => m.id === messageId);
|
||||
if (idx === -1) return false;
|
||||
|
||||
const msg = messages[idx];
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
const wavPath = path.join(boxDir, msg.fileName);
|
||||
|
||||
// Delete WAV file.
|
||||
try {
|
||||
if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// Remove from list and save.
|
||||
messages.splice(idx, 1);
|
||||
this.writeMessages(boxId, messages);
|
||||
this.log(`[voicebox] deleted message ${messageId} from box "${boxId}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full file path for a message's WAV file.
|
||||
*/
|
||||
getMessageAudioPath(boxId: string, messageId: string): string | null {
|
||||
const msg = this.getMessage(boxId, messageId);
|
||||
if (!msg) return null;
|
||||
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
||||
return fs.existsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Counts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get count of unheard messages for a voicebox. */
|
||||
getUnheardCount(boxId: string): number {
|
||||
const messages = this.loadMessages(boxId);
|
||||
return messages.filter((m) => !m.heard).length;
|
||||
}
|
||||
|
||||
/** Get total message count for a voicebox. */
|
||||
getTotalCount(boxId: string): number {
|
||||
return this.loadMessages(boxId).length;
|
||||
}
|
||||
|
||||
/** Get unheard counts for all voiceboxes. */
|
||||
getAllUnheardCounts(): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const boxId of this.boxes.keys()) {
|
||||
counts[boxId] = this.getUnheardCount(boxId);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Greeting management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save a custom greeting WAV file for a voicebox.
|
||||
*/
|
||||
saveCustomGreeting(boxId: string, wavData: Buffer): string {
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
const greetingPath = path.join(boxDir, 'greeting.wav');
|
||||
fs.writeFileSync(greetingPath, wavData);
|
||||
this.log(`[voicebox] saved custom greeting for box "${boxId}"`);
|
||||
return greetingPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the custom greeting for a voicebox (falls back to TTS).
|
||||
*/
|
||||
deleteCustomGreeting(boxId: string): void {
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
const greetingPath = path.join(boxDir, 'greeting.wav');
|
||||
try {
|
||||
if (fs.existsSync(greetingPath)) fs.unlinkSync(greetingPath);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: JSON persistence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private messagesPath(boxId: string): string {
|
||||
return path.join(this.getBoxDir(boxId), 'messages.json');
|
||||
}
|
||||
|
||||
private loadMessages(boxId: string): IVoicemailMessage[] {
|
||||
const filePath = this.messagesPath(boxId);
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(raw) as IVoicemailMessage[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private writeMessages(boxId: string, messages: IVoicemailMessage[]): void {
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8');
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.9.0',
|
||||
version: '1.17.2',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ 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-voicemail.js';
|
||||
export * from './sipproxy-view-log.js';
|
||||
export * from './sipproxy-view-routes.js';
|
||||
export * from './sipproxy-view-ivr.js';
|
||||
|
||||
// Sub-components (used within views)
|
||||
export * from './sipproxy-devices.js';
|
||||
|
||||
@@ -9,12 +9,16 @@ import { SipproxyViewContacts } from './sipproxy-view-contacts.js';
|
||||
import { SipproxyViewProviders } from './sipproxy-view-providers.js';
|
||||
import { SipproxyViewLog } from './sipproxy-view-log.js';
|
||||
import { SipproxyViewRoutes } from './sipproxy-view-routes.js';
|
||||
import { SipproxyViewVoicemail } from './sipproxy-view-voicemail.js';
|
||||
import { SipproxyViewIvr } from './sipproxy-view-ivr.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: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes },
|
||||
{ name: 'Voicemail', iconName: 'lucide:voicemail', element: SipproxyViewVoicemail },
|
||||
{ name: 'IVR', iconName: 'lucide:list-tree', element: SipproxyViewIvr },
|
||||
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
|
||||
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
||||
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
||||
|
||||
657
ts_web/elements/sipproxy-view-ivr.ts
Normal file
657
ts_web/elements/sipproxy-view-ivr.ts
Normal file
@@ -0,0 +1,657 @@
|
||||
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';
|
||||
import { viewHostCss } from './shared/index.js';
|
||||
import type { IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
const { DeesModal, DeesToast } = deesCatalog;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IVR types (mirrors ts/config.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TIvrAction =
|
||||
| { type: 'route-extension'; extensionId: string }
|
||||
| { type: 'route-voicemail'; boxId: string }
|
||||
| { type: 'submenu'; menuId: string }
|
||||
| { type: 'play-message'; promptId: string }
|
||||
| { type: 'transfer'; number: string; providerId?: string }
|
||||
| { type: 'repeat' }
|
||||
| { type: 'hangup' };
|
||||
|
||||
interface IIvrMenuEntry {
|
||||
digit: string;
|
||||
action: TIvrAction;
|
||||
}
|
||||
|
||||
interface IIvrMenu {
|
||||
id: string;
|
||||
name: string;
|
||||
promptText: string;
|
||||
promptVoice?: string;
|
||||
entries: IIvrMenuEntry[];
|
||||
timeoutSec?: number;
|
||||
maxRetries?: number;
|
||||
timeoutAction: TIvrAction;
|
||||
invalidAction: TIvrAction;
|
||||
}
|
||||
|
||||
interface IIvrConfig {
|
||||
enabled: boolean;
|
||||
menus: IIvrMenu[];
|
||||
entryMenuId: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || `menu-${Date.now()}`;
|
||||
}
|
||||
|
||||
const VOICE_OPTIONS = [
|
||||
{ option: 'af_bella (Female)', key: 'af_bella' },
|
||||
{ option: 'af_sarah (Female)', key: 'af_sarah' },
|
||||
{ option: 'am_adam (Male)', key: 'am_adam' },
|
||||
{ option: 'bf_alice (Female)', key: 'bf_alice' },
|
||||
];
|
||||
|
||||
const ACTION_TYPE_OPTIONS = [
|
||||
{ option: 'Route to Extension', key: 'route-extension' },
|
||||
{ option: 'Route to Voicemail', key: 'route-voicemail' },
|
||||
{ option: 'Submenu', key: 'submenu' },
|
||||
{ option: 'Play Message', key: 'play-message' },
|
||||
{ option: 'Transfer', key: 'transfer' },
|
||||
{ option: 'Repeat', key: 'repeat' },
|
||||
{ option: 'Hangup', key: 'hangup' },
|
||||
];
|
||||
|
||||
const DIGIT_OPTIONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '*', '#'];
|
||||
|
||||
function describeAction(action: TIvrAction): string {
|
||||
switch (action.type) {
|
||||
case 'route-extension': return `Extension: ${action.extensionId}`;
|
||||
case 'route-voicemail': return `Voicemail: ${action.boxId}`;
|
||||
case 'submenu': return `Submenu: ${action.menuId}`;
|
||||
case 'play-message': return `Play: ${action.promptId}`;
|
||||
case 'transfer': return `Transfer: ${action.number}${action.providerId ? ` (${action.providerId})` : ''}`;
|
||||
case 'repeat': return 'Repeat';
|
||||
case 'hangup': return 'Hangup';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function makeDefaultAction(): TIvrAction {
|
||||
return { type: 'hangup' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View element
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@customElement('sipproxy-view-ivr')
|
||||
export class SipproxyViewIvr extends DeesElement {
|
||||
@state() accessor appData: IAppState = appState.getState();
|
||||
@state() accessor config: any = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.view-section { margin-bottom: 24px; }
|
||||
`,
|
||||
];
|
||||
|
||||
// ---- lifecycle -----------------------------------------------------------
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.rxSubscriptions.push({
|
||||
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
||||
} as any);
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
private async loadConfig() {
|
||||
try {
|
||||
this.config = await appState.apiGetConfig();
|
||||
} catch {
|
||||
// Will show empty state.
|
||||
}
|
||||
}
|
||||
|
||||
private getIvrConfig(): IIvrConfig {
|
||||
return this.config?.ivr || { enabled: false, menus: [], entryMenuId: '' };
|
||||
}
|
||||
|
||||
// ---- stats tiles ---------------------------------------------------------
|
||||
|
||||
private getStatsTiles(): IStatsTile[] {
|
||||
const ivr = this.getIvrConfig();
|
||||
const entryMenu = ivr.menus.find((m) => m.id === ivr.entryMenuId);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'total-menus',
|
||||
title: 'Total Menus',
|
||||
value: ivr.menus.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:list-tree',
|
||||
description: 'IVR menu definitions',
|
||||
},
|
||||
{
|
||||
id: 'entry-menu',
|
||||
title: 'Entry Menu',
|
||||
value: entryMenu?.name || '(none)',
|
||||
type: 'text' as any,
|
||||
icon: 'lucide:door-open',
|
||||
description: entryMenu ? `ID: ${entryMenu.id}` : 'No entry menu set',
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
title: 'Status',
|
||||
value: ivr.enabled ? 'Enabled' : 'Disabled',
|
||||
type: 'text' as any,
|
||||
icon: ivr.enabled ? 'lucide:check-circle' : 'lucide:x-circle',
|
||||
color: ivr.enabled ? 'hsl(142.1 76.2% 36.3%)' : 'hsl(0 84.2% 60.2%)',
|
||||
description: ivr.enabled ? 'IVR is active' : 'IVR is inactive',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- table columns -------------------------------------------------------
|
||||
|
||||
private getColumns() {
|
||||
const ivr = this.getIvrConfig();
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
sortable: true,
|
||||
renderer: (val: string, row: IIvrMenu) => {
|
||||
const isEntry = row.id === ivr.entryMenuId;
|
||||
return html`
|
||||
<span>${val}</span>
|
||||
${isEntry ? html`<span style="display:inline-block;margin-left:8px;padding:1px 6px;border-radius:4px;font-size:.65rem;font-weight:600;text-transform:uppercase;background:#1e3a5f;color:#60a5fa">entry</span>` : ''}
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'promptText',
|
||||
header: 'Prompt',
|
||||
renderer: (val: string) => {
|
||||
const truncated = val && val.length > 60 ? val.slice(0, 60) + '...' : val || '--';
|
||||
return html`<span style="font-size:.82rem;color:#94a3b8">${truncated}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'entries',
|
||||
header: 'Digits',
|
||||
renderer: (_val: any, row: IIvrMenu) => {
|
||||
const digits = (row.entries || []).map((e) => e.digit).join(', ');
|
||||
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${digits || '(none)'}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'timeoutAction',
|
||||
header: 'Timeout Action',
|
||||
renderer: (_val: any, row: IIvrMenu) => {
|
||||
return html`<span style="font-size:.82rem;color:#94a3b8">${describeAction(row.timeoutAction)}</span>`;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- table actions -------------------------------------------------------
|
||||
|
||||
private getDataActions() {
|
||||
return [
|
||||
{
|
||||
name: 'Add Menu',
|
||||
iconName: 'lucide:plus' as any,
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.openMenuEditor(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:pencil' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
||||
await this.openMenuEditor(item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Set as Entry',
|
||||
iconName: 'lucide:door-open' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
||||
await this.setEntryMenu(item.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
||||
await this.confirmDeleteMenu(item);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- toggle enabled ------------------------------------------------------
|
||||
|
||||
private async toggleEnabled() {
|
||||
const ivr = this.getIvrConfig();
|
||||
const updated: IIvrConfig = { ...ivr, enabled: !ivr.enabled };
|
||||
const result = await appState.apiSaveConfig({ ivr: updated });
|
||||
if (result.ok) {
|
||||
DeesToast.success(updated.enabled ? 'IVR enabled' : 'IVR disabled');
|
||||
await this.loadConfig();
|
||||
} else {
|
||||
DeesToast.error('Failed to update IVR status');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- set entry menu ------------------------------------------------------
|
||||
|
||||
private async setEntryMenu(menuId: string) {
|
||||
const ivr = this.getIvrConfig();
|
||||
const updated: IIvrConfig = { ...ivr, entryMenuId: menuId };
|
||||
const result = await appState.apiSaveConfig({ ivr: updated });
|
||||
if (result.ok) {
|
||||
DeesToast.success('Entry menu updated');
|
||||
await this.loadConfig();
|
||||
} else {
|
||||
DeesToast.error('Failed to set entry menu');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- delete menu ---------------------------------------------------------
|
||||
|
||||
private async confirmDeleteMenu(menu: IIvrMenu) {
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Delete IVR Menu',
|
||||
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;">${menu.name}</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) => {
|
||||
const ivr = this.getIvrConfig();
|
||||
const menus = ivr.menus.filter((m) => m.id !== menu.id);
|
||||
const updated: IIvrConfig = {
|
||||
...ivr,
|
||||
menus,
|
||||
entryMenuId: ivr.entryMenuId === menu.id ? '' : ivr.entryMenuId,
|
||||
};
|
||||
const result = await appState.apiSaveConfig({ ivr: updated });
|
||||
if (result.ok) {
|
||||
modalRef.destroy();
|
||||
DeesToast.success(`Menu "${menu.name}" deleted`);
|
||||
await this.loadConfig();
|
||||
} else {
|
||||
DeesToast.error('Failed to delete menu');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- action editor helper ------------------------------------------------
|
||||
|
||||
private renderActionEditor(
|
||||
action: TIvrAction,
|
||||
onChange: (a: TIvrAction) => void,
|
||||
label: string,
|
||||
cfg: any,
|
||||
): TemplateResult {
|
||||
const devices = cfg?.devices || [];
|
||||
const menus: IIvrMenu[] = cfg?.ivr?.menus || [];
|
||||
const providers = cfg?.providers || [];
|
||||
|
||||
const currentType = ACTION_TYPE_OPTIONS.find((o) => o.key === action.type) || ACTION_TYPE_OPTIONS[ACTION_TYPE_OPTIONS.length - 1];
|
||||
|
||||
return html`
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px;font-weight:600;">${label}</div>
|
||||
<dees-input-dropdown
|
||||
.label=${'Action Type'}
|
||||
.selectedOption=${currentType}
|
||||
.options=${ACTION_TYPE_OPTIONS}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
const type = e.detail.key;
|
||||
switch (type) {
|
||||
case 'route-extension': onChange({ type, extensionId: devices[0]?.extension || '100' }); break;
|
||||
case 'route-voicemail': onChange({ type, boxId: '' }); break;
|
||||
case 'submenu': onChange({ type, menuId: menus[0]?.id || '' }); break;
|
||||
case 'play-message': onChange({ type, promptId: '' }); break;
|
||||
case 'transfer': onChange({ type, number: '' }); break;
|
||||
case 'repeat': onChange({ type }); break;
|
||||
case 'hangup': onChange({ type }); break;
|
||||
}
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
|
||||
${action.type === 'route-extension' ? html`
|
||||
<dees-input-dropdown
|
||||
.label=${'Extension'}
|
||||
.selectedOption=${{ option: action.extensionId, key: action.extensionId }}
|
||||
.options=${devices.map((d: any) => ({ option: `${d.displayName} (${d.extension})`, key: d.extension }))}
|
||||
@selectedOption=${(e: CustomEvent) => { onChange({ ...action, extensionId: e.detail.key }); }}
|
||||
></dees-input-dropdown>
|
||||
` : ''}
|
||||
|
||||
${action.type === 'route-voicemail' ? html`
|
||||
<dees-input-text
|
||||
.label=${'Voicemail Box ID'}
|
||||
.value=${action.boxId}
|
||||
@input=${(e: Event) => { onChange({ ...action, boxId: (e.target as any).value }); }}
|
||||
></dees-input-text>
|
||||
` : ''}
|
||||
|
||||
${action.type === 'submenu' ? html`
|
||||
<dees-input-dropdown
|
||||
.label=${'Menu'}
|
||||
.selectedOption=${menus.find((m) => m.id === action.menuId)
|
||||
? { option: menus.find((m) => m.id === action.menuId)!.name, key: action.menuId }
|
||||
: { option: '(select)', key: '' }}
|
||||
.options=${menus.map((m) => ({ option: m.name, key: m.id }))}
|
||||
@selectedOption=${(e: CustomEvent) => { onChange({ ...action, menuId: e.detail.key }); }}
|
||||
></dees-input-dropdown>
|
||||
` : ''}
|
||||
|
||||
${action.type === 'play-message' ? html`
|
||||
<dees-input-text
|
||||
.label=${'Prompt ID'}
|
||||
.value=${action.promptId}
|
||||
@input=${(e: Event) => { onChange({ ...action, promptId: (e.target as any).value }); }}
|
||||
></dees-input-text>
|
||||
` : ''}
|
||||
|
||||
${action.type === 'transfer' ? html`
|
||||
<dees-input-text
|
||||
.label=${'Transfer Number'}
|
||||
.value=${action.number}
|
||||
@input=${(e: Event) => { onChange({ ...action, number: (e.target as any).value }); }}
|
||||
></dees-input-text>
|
||||
<dees-input-dropdown
|
||||
.label=${'Provider (optional)'}
|
||||
.selectedOption=${action.providerId
|
||||
? { option: action.providerId, key: action.providerId }
|
||||
: { option: '(default)', key: '' }}
|
||||
.options=${[
|
||||
{ option: '(default)', key: '' },
|
||||
...providers.map((p: any) => ({ option: p.displayName || p.id, key: p.id })),
|
||||
]}
|
||||
@selectedOption=${(e: CustomEvent) => { onChange({ ...action, providerId: e.detail.key || undefined }); }}
|
||||
></dees-input-dropdown>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---- menu editor modal ---------------------------------------------------
|
||||
|
||||
private async openMenuEditor(existing: IIvrMenu | null) {
|
||||
const cfg = this.config;
|
||||
|
||||
const formData: IIvrMenu = existing
|
||||
? JSON.parse(JSON.stringify(existing))
|
||||
: {
|
||||
id: '',
|
||||
name: '',
|
||||
promptText: '',
|
||||
promptVoice: 'af_bella',
|
||||
entries: [],
|
||||
timeoutSec: 5,
|
||||
maxRetries: 3,
|
||||
timeoutAction: { type: 'hangup' as const },
|
||||
invalidAction: { type: 'repeat' as const },
|
||||
};
|
||||
|
||||
// For re-rendering the modal content on state changes we track a version counter.
|
||||
let version = 0;
|
||||
const modalContentId = `ivr-modal-${Date.now()}`;
|
||||
|
||||
const rerenderContent = () => {
|
||||
version++;
|
||||
const container = document.querySelector(`#${modalContentId}`) as HTMLElement
|
||||
|| document.getElementById(modalContentId);
|
||||
if (container) {
|
||||
// Force a re-render by removing and re-adding the modal content.
|
||||
// We can't use lit's render directly here, so we close and reopen.
|
||||
}
|
||||
};
|
||||
|
||||
const buildContent = (): TemplateResult => html`
|
||||
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||
<dees-input-text
|
||||
.key=${'name'} .label=${'Menu Name'} .value=${formData.name}
|
||||
@input=${(e: Event) => {
|
||||
formData.name = (e.target as any).value;
|
||||
if (!existing) {
|
||||
formData.id = slugify(formData.name);
|
||||
}
|
||||
}}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.key=${'id'} .label=${'Menu ID'} .value=${formData.id}
|
||||
.description=${'Auto-generated from name. Editable for custom IDs.'}
|
||||
@input=${(e: Event) => { formData.id = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.key=${'promptText'} .label=${'Prompt Text (TTS)'}
|
||||
.value=${formData.promptText}
|
||||
.description=${'Text that will be read aloud to the caller.'}
|
||||
@input=${(e: Event) => { formData.promptText = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-dropdown
|
||||
.key=${'promptVoice'} .label=${'Voice'}
|
||||
.selectedOption=${VOICE_OPTIONS.find((v) => v.key === formData.promptVoice) || VOICE_OPTIONS[0]}
|
||||
.options=${VOICE_OPTIONS}
|
||||
@selectedOption=${(e: CustomEvent) => { formData.promptVoice = e.detail.key; }}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
||||
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;font-weight:600;">
|
||||
Digit Entries
|
||||
</div>
|
||||
<div
|
||||
style="font-size:.75rem;color:#60a5fa;cursor:pointer;user-select:none;"
|
||||
@click=${() => {
|
||||
const usedDigits = new Set(formData.entries.map((e) => e.digit));
|
||||
const nextDigit = DIGIT_OPTIONS.find((d) => !usedDigits.has(d)) || '1';
|
||||
formData.entries = [...formData.entries, { digit: nextDigit, action: makeDefaultAction() }];
|
||||
rerenderContent();
|
||||
}}
|
||||
>+ Add Digit</div>
|
||||
</div>
|
||||
|
||||
${formData.entries.length === 0
|
||||
? html`<div style="font-size:.82rem;color:#64748b;font-style:italic;margin-bottom:8px;">No digit entries configured.</div>`
|
||||
: formData.entries.map((entry, idx) => html`
|
||||
<div style="padding:8px;margin-bottom:8px;border:1px solid #334155;border-radius:6px;background:#0f172a;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
||||
<dees-input-dropdown
|
||||
.label=${'Digit'}
|
||||
.selectedOption=${{ option: entry.digit, key: entry.digit }}
|
||||
.options=${DIGIT_OPTIONS.map((d) => ({ option: d, key: d }))}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
formData.entries[idx].digit = e.detail.key;
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<div
|
||||
style="font-size:.75rem;color:#f87171;cursor:pointer;user-select:none;margin-left:12px;padding:4px 8px;"
|
||||
@click=${() => {
|
||||
formData.entries = formData.entries.filter((_, i) => i !== idx);
|
||||
rerenderContent();
|
||||
}}
|
||||
>Remove</div>
|
||||
</div>
|
||||
${this.renderActionEditor(
|
||||
entry.action,
|
||||
(a) => { formData.entries[idx].action = a; rerenderContent(); },
|
||||
'Action',
|
||||
cfg,
|
||||
)}
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
Timeout Settings
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;">
|
||||
<dees-input-text
|
||||
.key=${'timeoutSec'} .label=${'Timeout (sec)'}
|
||||
.value=${String(formData.timeoutSec ?? 5)}
|
||||
@input=${(e: Event) => { formData.timeoutSec = parseInt((e.target as any).value, 10) || 5; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'maxRetries'} .label=${'Max Retries'}
|
||||
.value=${String(formData.maxRetries ?? 3)}
|
||||
@input=${(e: Event) => { formData.maxRetries = parseInt((e.target as any).value, 10) || 3; }}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
||||
${this.renderActionEditor(
|
||||
formData.timeoutAction,
|
||||
(a) => { formData.timeoutAction = a; rerenderContent(); },
|
||||
'Timeout Action (no digit pressed)',
|
||||
cfg,
|
||||
)}
|
||||
${this.renderActionEditor(
|
||||
formData.invalidAction,
|
||||
(a) => { formData.invalidAction = a; rerenderContent(); },
|
||||
'Invalid Digit Action',
|
||||
cfg,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: existing ? `Edit Menu: ${existing.name}` : 'New IVR Menu',
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
content: html`<div id="${modalContentId}">${buildContent()}</div>`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => { modalRef.destroy(); },
|
||||
},
|
||||
{
|
||||
name: 'Save',
|
||||
iconName: 'lucide:check',
|
||||
action: async (modalRef: any) => {
|
||||
if (!formData.name.trim()) {
|
||||
DeesToast.error('Menu name is required');
|
||||
return;
|
||||
}
|
||||
if (!formData.id.trim()) {
|
||||
DeesToast.error('Menu ID is required');
|
||||
return;
|
||||
}
|
||||
if (!formData.promptText.trim()) {
|
||||
DeesToast.error('Prompt text is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const ivr = this.getIvrConfig();
|
||||
const menus = [...ivr.menus];
|
||||
const idx = menus.findIndex((m) => m.id === (existing?.id || formData.id));
|
||||
if (idx >= 0) {
|
||||
menus[idx] = formData;
|
||||
} else {
|
||||
menus.push(formData);
|
||||
}
|
||||
|
||||
const updated: IIvrConfig = {
|
||||
...ivr,
|
||||
menus,
|
||||
// Auto-set entry menu if this is the first menu.
|
||||
entryMenuId: ivr.entryMenuId || formData.id,
|
||||
};
|
||||
|
||||
const result = await appState.apiSaveConfig({ ivr: updated });
|
||||
if (result.ok) {
|
||||
modalRef.destroy();
|
||||
DeesToast.success(existing ? 'Menu updated' : 'Menu created');
|
||||
await this.loadConfig();
|
||||
} else {
|
||||
DeesToast.error('Failed to save menu');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- render --------------------------------------------------------------
|
||||
|
||||
public render(): TemplateResult {
|
||||
const ivr = this.getIvrConfig();
|
||||
const menus = ivr.menus || [];
|
||||
|
||||
return html`
|
||||
<div class="view-section">
|
||||
<dees-statsgrid
|
||||
.tiles=${this.getStatsTiles()}
|
||||
.minTileWidth=${220}
|
||||
.gap=${16}
|
||||
></dees-statsgrid>
|
||||
</div>
|
||||
|
||||
<div class="view-section" style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
|
||||
<dees-input-checkbox
|
||||
.key=${'ivr-enabled'}
|
||||
.label=${'Enable IVR System'}
|
||||
.value=${ivr.enabled}
|
||||
@newValue=${() => { this.toggleEnabled(); }}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="view-section">
|
||||
<dees-table
|
||||
heading1="IVR Menus"
|
||||
heading2="${menus.length} configured"
|
||||
dataName="menus"
|
||||
.data=${menus}
|
||||
.rowKey=${'id'}
|
||||
.columns=${this.getColumns()}
|
||||
.dataActions=${this.getDataActions()}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
446
ts_web/elements/sipproxy-view-voicemail.ts
Normal file
446
ts_web/elements/sipproxy-view-voicemail.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
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';
|
||||
import { viewHostCss } from './shared/index.js';
|
||||
import type { IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voicemail message shape (mirrors server IVoicemailMessage)
|
||||
// ---------------------------------------------------------------------------
|
||||
interface IVoicemailMessage {
|
||||
id: string;
|
||||
boxId: string;
|
||||
callerNumber: string;
|
||||
callerName?: string;
|
||||
timestamp: number;
|
||||
durationMs: number;
|
||||
fileName: string;
|
||||
heard: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSec = Math.round(ms / 1000);
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDateTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const date = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View element
|
||||
// ---------------------------------------------------------------------------
|
||||
@customElement('sipproxy-view-voicemail')
|
||||
export class SipproxyViewVoicemail extends DeesElement {
|
||||
@state() accessor appData: IAppState = appState.getState();
|
||||
@state() accessor messages: IVoicemailMessage[] = [];
|
||||
@state() accessor voiceboxIds: string[] = [];
|
||||
@state() accessor selectedBoxId: string = '';
|
||||
@state() accessor playingMessageId: string | null = null;
|
||||
@state() accessor loading: boolean = false;
|
||||
|
||||
private audioElement: HTMLAudioElement | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
}
|
||||
.view-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.box-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.box-selector label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.audio-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #1e293b;
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.audio-player audio {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
}
|
||||
.audio-player .close-btn {
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.audio-player .close-btn:hover {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.empty-state .icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
// ---- lifecycle -----------------------------------------------------------
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.rxSubscriptions.push({
|
||||
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
||||
} as any);
|
||||
this.loadVoiceboxes();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.stopAudio();
|
||||
}
|
||||
|
||||
// ---- data loading --------------------------------------------------------
|
||||
|
||||
private async loadVoiceboxes() {
|
||||
try {
|
||||
const cfg = await appState.apiGetConfig();
|
||||
const boxes: { id: string }[] = cfg.voiceboxes || [];
|
||||
this.voiceboxIds = boxes.map((b) => b.id);
|
||||
if (this.voiceboxIds.length > 0 && !this.selectedBoxId) {
|
||||
this.selectedBoxId = this.voiceboxIds[0];
|
||||
await this.loadMessages();
|
||||
}
|
||||
} catch {
|
||||
// Config unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private async loadMessages() {
|
||||
if (!this.selectedBoxId) {
|
||||
this.messages = [];
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/voicemail/${encodeURIComponent(this.selectedBoxId)}`);
|
||||
const data = await res.json();
|
||||
this.messages = data.messages || [];
|
||||
} catch {
|
||||
this.messages = [];
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private async selectBox(boxId: string) {
|
||||
this.selectedBoxId = boxId;
|
||||
this.stopAudio();
|
||||
await this.loadMessages();
|
||||
}
|
||||
|
||||
// ---- audio playback ------------------------------------------------------
|
||||
|
||||
private playMessage(msg: IVoicemailMessage) {
|
||||
this.stopAudio();
|
||||
const url = `/api/voicemail/${encodeURIComponent(msg.boxId)}/${encodeURIComponent(msg.id)}/audio`;
|
||||
const audio = new Audio(url);
|
||||
this.audioElement = audio;
|
||||
this.playingMessageId = msg.id;
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
this.playingMessageId = null;
|
||||
// Auto-mark as heard after playback completes.
|
||||
if (!msg.heard) {
|
||||
this.markHeard(msg);
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
this.playingMessageId = null;
|
||||
deesCatalog.DeesToast.error('Failed to play audio');
|
||||
});
|
||||
|
||||
audio.play().catch(() => {
|
||||
this.playingMessageId = null;
|
||||
});
|
||||
}
|
||||
|
||||
private stopAudio() {
|
||||
if (this.audioElement) {
|
||||
this.audioElement.pause();
|
||||
this.audioElement.src = '';
|
||||
this.audioElement = null;
|
||||
}
|
||||
this.playingMessageId = null;
|
||||
}
|
||||
|
||||
// ---- message actions -----------------------------------------------------
|
||||
|
||||
private async markHeard(msg: IVoicemailMessage) {
|
||||
try {
|
||||
await fetch(`/api/voicemail/${encodeURIComponent(msg.boxId)}/${encodeURIComponent(msg.id)}/heard`, {
|
||||
method: 'POST',
|
||||
});
|
||||
// Update local state without full reload.
|
||||
this.messages = this.messages.map((m) =>
|
||||
m.id === msg.id ? { ...m, heard: true } : m,
|
||||
);
|
||||
} catch {
|
||||
deesCatalog.DeesToast.error('Failed to mark message as heard');
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteMessage(msg: IVoicemailMessage) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Delete Voicemail',
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
content: html`
|
||||
<div style="padding:8px 0;font-size:.9rem;color:#e2e8f0;">
|
||||
Are you sure you want to delete the voicemail from
|
||||
<strong style="color:#f87171;">${msg.callerName || msg.callerNumber}</strong>
|
||||
(${formatDateTime(msg.timestamp)})?
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => { modalRef.destroy(); },
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
action: async (modalRef: any) => {
|
||||
try {
|
||||
await fetch(
|
||||
`/api/voicemail/${encodeURIComponent(msg.boxId)}/${encodeURIComponent(msg.id)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (this.playingMessageId === msg.id) {
|
||||
this.stopAudio();
|
||||
}
|
||||
this.messages = this.messages.filter((m) => m.id !== msg.id);
|
||||
modalRef.destroy();
|
||||
deesCatalog.DeesToast.success('Voicemail deleted');
|
||||
} catch {
|
||||
deesCatalog.DeesToast.error('Failed to delete voicemail');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- stats tiles ---------------------------------------------------------
|
||||
|
||||
private getStatsTiles(): IStatsTile[] {
|
||||
const total = this.messages.length;
|
||||
const unheard = this.messages.filter((m) => !m.heard).length;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'total',
|
||||
title: 'Total Messages',
|
||||
value: total,
|
||||
type: 'number',
|
||||
icon: 'lucide:voicemail',
|
||||
description: this.selectedBoxId ? `Box: ${this.selectedBoxId}` : 'No box selected',
|
||||
},
|
||||
{
|
||||
id: 'unheard',
|
||||
title: 'Unheard Messages',
|
||||
value: unheard,
|
||||
type: 'number',
|
||||
icon: 'lucide:bell-ring',
|
||||
color: unheard > 0 ? 'hsl(0 84.2% 60.2%)' : 'hsl(142.1 76.2% 36.3%)',
|
||||
description: unheard > 0 ? 'Needs attention' : 'All caught up',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- table columns -------------------------------------------------------
|
||||
|
||||
private getColumns() {
|
||||
return [
|
||||
{
|
||||
key: 'callerNumber',
|
||||
header: 'Caller',
|
||||
sortable: true,
|
||||
renderer: (_val: string, row: IVoicemailMessage) => {
|
||||
const display = row.callerName
|
||||
? html`<span>${row.callerName}</span><br><span style="font-size:.75rem;color:#64748b">${row.callerNumber}</span>`
|
||||
: html`<span style="font-family:'JetBrains Mono',monospace;font-size:.85rem">${row.callerNumber}</span>`;
|
||||
return html`<div>${display}</div>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
header: 'Date/Time',
|
||||
sortable: true,
|
||||
value: (row: IVoicemailMessage) => formatDateTime(row.timestamp),
|
||||
renderer: (val: string) =>
|
||||
html`<span style="font-size:.85rem">${val}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'durationMs',
|
||||
header: 'Duration',
|
||||
sortable: true,
|
||||
value: (row: IVoicemailMessage) => formatDuration(row.durationMs),
|
||||
renderer: (val: string) =>
|
||||
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.85rem">${val}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'heard',
|
||||
header: 'Status',
|
||||
renderer: (val: boolean, row: IVoicemailMessage) => {
|
||||
const isPlaying = this.playingMessageId === row.id;
|
||||
if (isPlaying) {
|
||||
return html`
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:#1e3a5f;color:#60a5fa">Playing</span>
|
||||
`;
|
||||
}
|
||||
const heard = val;
|
||||
const color = heard ? '#71717a' : '#f59e0b';
|
||||
const bg = heard ? '#3f3f46' : '#422006';
|
||||
const label = heard ? 'Heard' : 'New';
|
||||
return html`
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:${bg};color:${color}">${label}</span>
|
||||
`;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- table actions -------------------------------------------------------
|
||||
|
||||
private getDataActions() {
|
||||
return [
|
||||
{
|
||||
name: 'Play',
|
||||
iconName: 'lucide:play',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const msg = actionData.item as IVoicemailMessage;
|
||||
if (this.playingMessageId === msg.id) {
|
||||
this.stopAudio();
|
||||
} else {
|
||||
this.playMessage(msg);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Mark Heard',
|
||||
iconName: 'lucide:check',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const msg = actionData.item as IVoicemailMessage;
|
||||
if (!msg.heard) {
|
||||
await this.markHeard(msg);
|
||||
deesCatalog.DeesToast.success('Marked as heard');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.deleteMessage(actionData.item as IVoicemailMessage);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:refreshCw',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.loadMessages();
|
||||
deesCatalog.DeesToast.success('Messages refreshed');
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- render --------------------------------------------------------------
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
${this.voiceboxIds.length > 1 ? html`
|
||||
<div class="box-selector">
|
||||
<label>Voicebox</label>
|
||||
<dees-input-dropdown
|
||||
.key=${'voicebox'}
|
||||
.selectedOption=${{ option: this.selectedBoxId, key: this.selectedBoxId }}
|
||||
.options=${this.voiceboxIds.map((id) => ({ option: id, key: id }))}
|
||||
@selectedOption=${(e: CustomEvent) => { this.selectBox(e.detail.key); }}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="view-section">
|
||||
<dees-statsgrid
|
||||
.tiles=${this.getStatsTiles()}
|
||||
.minTileWidth=${220}
|
||||
.gap=${16}
|
||||
></dees-statsgrid>
|
||||
</div>
|
||||
|
||||
${this.messages.length === 0 && !this.loading ? html`
|
||||
<div class="empty-state">
|
||||
<div class="icon">✉</div>
|
||||
<div>No voicemail messages${this.selectedBoxId ? ` in box "${this.selectedBoxId}"` : ''}</div>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="view-section">
|
||||
<dees-table
|
||||
heading1="Voicemail"
|
||||
heading2="${this.messages.length} message${this.messages.length !== 1 ? 's' : ''}"
|
||||
dataName="voicemail"
|
||||
.data=${this.messages}
|
||||
.rowKey=${'id'}
|
||||
.searchable=${true}
|
||||
.columns=${this.getColumns()}
|
||||
.dataActions=${this.getDataActions()}
|
||||
></dees-table>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${this.playingMessageId ? html`
|
||||
<div class="audio-player">
|
||||
<span style="color:#60a5fa;font-size:.8rem;font-weight:600;">Now playing</span>
|
||||
<span style="flex:1"></span>
|
||||
<span class="close-btn" @click=${() => this.stopAudio()}>✕</span>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Maps URL paths to views in dees-simple-appdash.
|
||||
*/
|
||||
|
||||
const VIEWS = ['overview', 'calls', 'phone', 'routes', 'contacts', 'providers', 'log'] as const;
|
||||
const VIEWS = ['overview', 'calls', 'phone', 'routes', 'voicemail', 'ivr', 'contacts', 'providers', 'log'] as const;
|
||||
type TViewSlug = (typeof VIEWS)[number];
|
||||
|
||||
class AppRouter {
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface IDeviceStatus {
|
||||
|
||||
export interface ILegStatus {
|
||||
id: string;
|
||||
type: 'sip-device' | 'sip-provider' | 'webrtc';
|
||||
type: 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
|
||||
state: string;
|
||||
remoteMedia: { address: string; port: number } | null;
|
||||
rtpPort: number | null;
|
||||
@@ -28,6 +28,7 @@ export interface ILegStatus {
|
||||
pktReceived: number;
|
||||
codec: string | null;
|
||||
transcoding: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ICallStatus {
|
||||
@@ -42,6 +43,12 @@ export interface ICallStatus {
|
||||
legs: ILegStatus[];
|
||||
}
|
||||
|
||||
export interface IHistoryLeg {
|
||||
id: string;
|
||||
type: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ICallHistoryEntry {
|
||||
id: string;
|
||||
direction: 'inbound' | 'outbound' | 'internal';
|
||||
@@ -50,6 +57,7 @@ export interface ICallHistoryEntry {
|
||||
providerUsed: string | null;
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
legs?: IHistoryLeg[];
|
||||
}
|
||||
|
||||
export interface IContact {
|
||||
@@ -72,6 +80,8 @@ export interface IAppState {
|
||||
contacts: IContact[];
|
||||
selectedContact: IContact | null;
|
||||
logLines: string[];
|
||||
/** Unheard voicemail count per voicebox ID. */
|
||||
voicemailCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
const MAX_LOG = 200;
|
||||
@@ -89,6 +99,7 @@ class AppStateManager {
|
||||
contacts: [],
|
||||
selectedContact: null,
|
||||
logLines: [],
|
||||
voicemailCounts: {},
|
||||
};
|
||||
|
||||
private listeners = new Set<(state: IAppState) => void>();
|
||||
@@ -155,6 +166,7 @@ class AppStateManager {
|
||||
calls: m.data.calls || [],
|
||||
callHistory: m.data.callHistory || [],
|
||||
contacts: m.data.contacts || [],
|
||||
voicemailCounts: m.data.voicemailCounts || {},
|
||||
});
|
||||
} else if (m.type === 'log') {
|
||||
this.addLog(`${m.ts} ${m.data.message}`);
|
||||
|
||||
Reference in New Issue
Block a user