feat(proxy-engine): add Rust-based outbound calling, WebRTC bridging, and voicemail handling
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
1839
rust/Cargo.lock
generated
1839
rust/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -15,3 +15,6 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
regex-lite = "0.1"
|
||||
webrtc = "0.8"
|
||||
rand = "0.8"
|
||||
hound = "3.5"
|
||||
|
||||
173
rust/crates/proxy-engine/src/audio_player.rs
Normal file
173
rust/crates/proxy-engine/src/audio_player.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! Audio player — reads a WAV file and streams it as RTP packets.
|
||||
|
||||
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};
|
||||
|
||||
/// 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))
|
||||
}
|
||||
@@ -241,15 +241,25 @@ impl CallManager {
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Resolve target device (first registered device for now).
|
||||
// Resolve target device (first registered device).
|
||||
let device_addr = match self.resolve_first_device(config, registrar) {
|
||||
Some(addr) => addr,
|
||||
None => {
|
||||
// No device available — could route to voicemail
|
||||
// For now, send 480 Temporarily Unavailable.
|
||||
let resp = SipMessage::create_response(480, "Temporarily Unavailable", invite, None);
|
||||
let _ = socket.send_to(&resp.serialize(), from_addr).await;
|
||||
return None;
|
||||
// No device registered — route to voicemail.
|
||||
return self
|
||||
.route_to_voicemail(
|
||||
&call_id,
|
||||
invite,
|
||||
from_addr,
|
||||
&caller_number,
|
||||
provider_id,
|
||||
provider_config,
|
||||
config,
|
||||
rtp_pool,
|
||||
socket,
|
||||
public_ip,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -487,6 +497,225 @@ impl CallManager {
|
||||
self.calls.contains_key(sip_call_id)
|
||||
}
|
||||
|
||||
// --- Dashboard outbound call (B2BUA) ---
|
||||
|
||||
/// Initiate an outbound call from the dashboard.
|
||||
/// Builds an INVITE from scratch and sends it to the provider.
|
||||
/// The browser connects separately via WebRTC and gets linked to this call.
|
||||
pub async fn make_outbound_call(
|
||||
&mut self,
|
||||
number: &str,
|
||||
provider_config: &ProviderConfig,
|
||||
config: &AppConfig,
|
||||
rtp_pool: &mut RtpPortPool,
|
||||
socket: &UdpSocket,
|
||||
public_ip: Option<&str>,
|
||||
registered_aor: &str,
|
||||
) -> Option<String> {
|
||||
let call_id = self.next_call_id();
|
||||
let lan_ip = &config.proxy.lan_ip;
|
||||
let lan_port = config.proxy.lan_port;
|
||||
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
|
||||
|
||||
let provider_dest: SocketAddr = match provider_config.outbound_proxy.to_socket_addr() {
|
||||
Some(a) => a,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
// Allocate RTP port for the provider leg.
|
||||
let rtp_alloc = match rtp_pool.allocate().await {
|
||||
Some(a) => a,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
// Build the SIP Call-ID for this new dialog.
|
||||
let sip_call_id = sip_proto::helpers::generate_call_id(None);
|
||||
|
||||
// Build SDP offer.
|
||||
let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions {
|
||||
ip: pub_ip,
|
||||
port: rtp_alloc.port,
|
||||
payload_types: &provider_config.codecs,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Build INVITE.
|
||||
let to_uri = format!("sip:{number}@{}", provider_config.domain);
|
||||
let invite = SipMessage::create_request(
|
||||
"INVITE",
|
||||
&to_uri,
|
||||
sip_proto::message::RequestOptions {
|
||||
via_host: pub_ip.to_string(),
|
||||
via_port: lan_port,
|
||||
via_transport: None,
|
||||
via_branch: Some(sip_proto::helpers::generate_branch()),
|
||||
from_uri: registered_aor.to_string(),
|
||||
from_display_name: None,
|
||||
from_tag: Some(sip_proto::helpers::generate_tag()),
|
||||
to_uri: to_uri.clone(),
|
||||
to_display_name: None,
|
||||
to_tag: None,
|
||||
call_id: Some(sip_call_id.clone()),
|
||||
cseq: Some(1),
|
||||
contact: Some(format!("<sip:{pub_ip}:{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()),
|
||||
("Allow".to_string(), "INVITE, ACK, OPTIONS, CANCEL, BYE, INFO".to_string()),
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
// Send INVITE to provider.
|
||||
let _ = socket.send_to(&invite.serialize(), provider_dest).await;
|
||||
|
||||
// Create call entry — device_addr is a dummy (WebRTC will be linked later).
|
||||
let dummy_addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
let call = PassthroughCall {
|
||||
id: call_id.clone(),
|
||||
sip_call_id: sip_call_id.clone(),
|
||||
state: CallState::SettingUp,
|
||||
direction: CallDirection::Outbound,
|
||||
created_at: Instant::now(),
|
||||
caller_number: Some(registered_aor.to_string()),
|
||||
callee_number: Some(number.to_string()),
|
||||
provider_id: provider_config.id.clone(),
|
||||
provider_addr: provider_dest,
|
||||
provider_media: None,
|
||||
device_addr: dummy_addr,
|
||||
device_media: None,
|
||||
rtp_port: rtp_alloc.port,
|
||||
rtp_socket: rtp_alloc.socket.clone(),
|
||||
pkt_from_device: 0,
|
||||
pkt_from_provider: 0,
|
||||
};
|
||||
self.calls.insert(sip_call_id, call);
|
||||
|
||||
Some(call_id)
|
||||
}
|
||||
|
||||
// --- Voicemail ---
|
||||
|
||||
/// Route a call to voicemail: answer the INVITE, play greeting, record message.
|
||||
async fn route_to_voicemail(
|
||||
&mut self,
|
||||
call_id: &str,
|
||||
invite: &SipMessage,
|
||||
from_addr: SocketAddr,
|
||||
caller_number: &str,
|
||||
provider_id: &str,
|
||||
provider_config: &ProviderConfig,
|
||||
config: &AppConfig,
|
||||
rtp_pool: &mut RtpPortPool,
|
||||
socket: &UdpSocket,
|
||||
public_ip: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let lan_ip = &config.proxy.lan_ip;
|
||||
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
|
||||
|
||||
// Allocate RTP port for the voicemail session.
|
||||
let rtp_alloc = match rtp_pool.allocate().await {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
let resp =
|
||||
SipMessage::create_response(503, "Service Unavailable", invite, None);
|
||||
let _ = socket.send_to(&resp.serialize(), from_addr).await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine provider's preferred codec.
|
||||
let codec_pt = provider_config.codecs.first().copied().unwrap_or(9); // default G.722
|
||||
|
||||
// Build SDP with our RTP port.
|
||||
let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions {
|
||||
ip: pub_ip,
|
||||
port: rtp_alloc.port,
|
||||
payload_types: &provider_config.codecs,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Answer the INVITE with 200 OK.
|
||||
let response = SipMessage::create_response(
|
||||
200,
|
||||
"OK",
|
||||
invite,
|
||||
Some(sip_proto::message::ResponseOptions {
|
||||
to_tag: Some(sip_proto::helpers::generate_tag()),
|
||||
contact: Some(format!("<sip:{}:{}>", lan_ip, config.proxy.lan_port)),
|
||||
body: Some(sdp),
|
||||
content_type: Some("application/sdp".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let _ = socket.send_to(&response.serialize(), from_addr).await;
|
||||
|
||||
// Extract provider media from original SDP.
|
||||
let provider_media = if invite.has_sdp_body() {
|
||||
sip_proto::helpers::parse_sdp_endpoint(&invite.body)
|
||||
.and_then(|ep| format!("{}:{}", ep.address, ep.port).parse().ok())
|
||||
} else {
|
||||
Some(from_addr) // fallback to signaling address
|
||||
};
|
||||
let provider_media = provider_media.unwrap_or(from_addr);
|
||||
|
||||
// Create a voicemail call entry for BYE routing.
|
||||
let call = PassthroughCall {
|
||||
id: call_id.to_string(),
|
||||
sip_call_id: invite.call_id().to_string(),
|
||||
state: CallState::Voicemail,
|
||||
direction: CallDirection::Inbound,
|
||||
created_at: std::time::Instant::now(),
|
||||
caller_number: Some(caller_number.to_string()),
|
||||
callee_number: None,
|
||||
provider_id: provider_id.to_string(),
|
||||
provider_addr: from_addr,
|
||||
provider_media: Some(provider_media),
|
||||
device_addr: from_addr, // no device — just use provider addr as placeholder
|
||||
device_media: None,
|
||||
rtp_port: rtp_alloc.port,
|
||||
rtp_socket: rtp_alloc.socket.clone(),
|
||||
pkt_from_device: 0,
|
||||
pkt_from_provider: 0,
|
||||
};
|
||||
self.calls.insert(invite.call_id().to_string(), call);
|
||||
|
||||
// Build recording file path.
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let recording_dir = format!(".nogit/voicemail/default");
|
||||
let recording_path = format!("{recording_dir}/msg-{timestamp}.wav");
|
||||
|
||||
// Look for a greeting WAV file.
|
||||
let greeting_wav = find_greeting_wav();
|
||||
|
||||
// Spawn the voicemail session.
|
||||
let out_tx = self.out_tx.clone();
|
||||
let call_id_owned = call_id.to_string();
|
||||
let caller_owned = caller_number.to_string();
|
||||
let rtp_socket = rtp_alloc.socket;
|
||||
tokio::spawn(async move {
|
||||
crate::voicemail::run_voicemail_session(
|
||||
rtp_socket,
|
||||
provider_media,
|
||||
codec_pt,
|
||||
greeting_wav,
|
||||
recording_path,
|
||||
120_000, // max 120 seconds
|
||||
call_id_owned,
|
||||
caller_owned,
|
||||
out_tx,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
Some(call_id.to_string())
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
fn resolve_first_device(&self, config: &AppConfig, registrar: &Registrar) -> Option<SocketAddr> {
|
||||
@@ -495,10 +724,25 @@ impl CallManager {
|
||||
return Some(addr);
|
||||
}
|
||||
}
|
||||
None
|
||||
None // No device registered — caller goes to voicemail.
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a voicemail greeting WAV file.
|
||||
fn find_greeting_wav() -> Option<String> {
|
||||
// Check common locations for a pre-generated greeting.
|
||||
let candidates = [
|
||||
".nogit/voicemail/default/greeting.wav",
|
||||
".nogit/voicemail/greeting.wav",
|
||||
];
|
||||
for path in &candidates {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
None // No greeting found — voicemail will just play the beep.
|
||||
}
|
||||
|
||||
/// Rewrite SDP for provider→device direction (use LAN IP).
|
||||
fn rewrite_sdp_for_device(msg: &mut SipMessage, lan_ip: &str, rtp_port: u16) {
|
||||
if msg.has_sdp_body() {
|
||||
|
||||
@@ -14,8 +14,18 @@ pub struct Endpoint {
|
||||
}
|
||||
|
||||
impl Endpoint {
|
||||
/// Resolve to a SocketAddr. Handles both IP addresses and hostnames.
|
||||
pub fn to_socket_addr(&self) -> Option<SocketAddr> {
|
||||
format!("{}:{}", self.address, self.port).parse().ok()
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,19 @@
|
||||
///
|
||||
/// No raw SIP ever touches TypeScript.
|
||||
|
||||
mod audio_player;
|
||||
mod call;
|
||||
mod call_manager;
|
||||
mod config;
|
||||
mod dtmf;
|
||||
mod ipc;
|
||||
mod provider;
|
||||
mod recorder;
|
||||
mod registrar;
|
||||
mod rtp;
|
||||
mod sip_transport;
|
||||
mod voicemail;
|
||||
mod webrtc_engine;
|
||||
|
||||
use crate::call_manager::CallManager;
|
||||
use crate::config::AppConfig;
|
||||
@@ -23,6 +27,7 @@ use crate::provider::ProviderManager;
|
||||
use crate::registrar::Registrar;
|
||||
use crate::rtp::RtpPortPool;
|
||||
use crate::sip_transport::SipTransport;
|
||||
use crate::webrtc_engine::WebRtcEngine;
|
||||
use sip_proto::message::SipMessage;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
@@ -37,6 +42,7 @@ struct ProxyEngine {
|
||||
provider_mgr: ProviderManager,
|
||||
registrar: Registrar,
|
||||
call_mgr: CallManager,
|
||||
webrtc: WebRtcEngine,
|
||||
rtp_pool: Option<RtpPortPool>,
|
||||
out_tx: OutTx,
|
||||
}
|
||||
@@ -49,6 +55,7 @@ impl ProxyEngine {
|
||||
provider_mgr: ProviderManager::new(out_tx.clone()),
|
||||
registrar: Registrar::new(out_tx.clone()),
|
||||
call_mgr: CallManager::new(out_tx.clone()),
|
||||
webrtc: WebRtcEngine::new(out_tx.clone()),
|
||||
rtp_pool: None,
|
||||
out_tx,
|
||||
}
|
||||
@@ -111,7 +118,12 @@ async fn handle_command(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: Co
|
||||
match cmd.method.as_str() {
|
||||
"configure" => handle_configure(engine, out_tx, &cmd).await,
|
||||
"hangup" => handle_hangup(engine, out_tx, &cmd).await,
|
||||
"make_call" => handle_make_call(engine, out_tx, &cmd).await,
|
||||
"get_status" => handle_get_status(engine, out_tx, &cmd).await,
|
||||
"webrtc_offer" => handle_webrtc_offer(engine, out_tx, &cmd).await,
|
||||
"webrtc_ice" => handle_webrtc_ice(engine, out_tx, &cmd).await,
|
||||
"webrtc_link" => handle_webrtc_link(engine, out_tx, &cmd).await,
|
||||
"webrtc_close" => handle_webrtc_close(engine, out_tx, &cmd).await,
|
||||
_ => respond_err(out_tx, &cmd.id, &format!("unknown command: {}", cmd.method)),
|
||||
}
|
||||
}
|
||||
@@ -413,6 +425,78 @@ async fn handle_get_status(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd:
|
||||
respond_ok(out_tx, &cmd.id, serde_json::json!({ "calls": calls }));
|
||||
}
|
||||
|
||||
/// Handle `make_call` — initiate an outbound call to a number via a provider.
|
||||
async fn handle_make_call(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
|
||||
let number = match cmd.params.get("number").and_then(|v| v.as_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => { respond_err(out_tx, &cmd.id, "missing number"); return; }
|
||||
};
|
||||
let provider_id = cmd.params.get("provider_id").and_then(|v| v.as_str());
|
||||
|
||||
let mut eng = engine.lock().await;
|
||||
let config_ref = match &eng.config {
|
||||
Some(c) => c.clone(),
|
||||
None => { respond_err(out_tx, &cmd.id, "not configured"); return; }
|
||||
};
|
||||
|
||||
// Resolve provider.
|
||||
let provider_config = if let Some(pid) = provider_id {
|
||||
config_ref.providers.iter().find(|p| p.id == pid).cloned()
|
||||
} else {
|
||||
// Use route resolution or first provider.
|
||||
let route = config_ref.resolve_outbound_route(&number, None, &|_| true);
|
||||
route.map(|r| r.provider)
|
||||
};
|
||||
|
||||
let provider_config = match provider_config {
|
||||
Some(p) => p,
|
||||
None => { respond_err(out_tx, &cmd.id, "no provider available"); return; }
|
||||
};
|
||||
|
||||
// Get public IP and registered AOR from provider state.
|
||||
let (public_ip, registered_aor) = if let Some(ps_arc) = eng.provider_mgr.find_by_address(
|
||||
&provider_config.outbound_proxy.to_socket_addr().unwrap_or_else(|| "0.0.0.0:0".parse().unwrap())
|
||||
).await {
|
||||
let ps = ps_arc.lock().await;
|
||||
(ps.public_ip.clone(), ps.registered_aor.clone())
|
||||
} else {
|
||||
// Fallback — construct AOR from config.
|
||||
(None, format!("sip:{}@{}", provider_config.username, provider_config.domain))
|
||||
};
|
||||
|
||||
let socket = match &eng.transport {
|
||||
Some(t) => t.socket(),
|
||||
None => { respond_err(out_tx, &cmd.id, "not initialized"); return; }
|
||||
};
|
||||
|
||||
let ProxyEngine { ref mut call_mgr, ref mut rtp_pool, .. } = *eng;
|
||||
let rtp_pool = rtp_pool.as_mut().unwrap();
|
||||
|
||||
let call_id = call_mgr.make_outbound_call(
|
||||
&number,
|
||||
&provider_config,
|
||||
&config_ref,
|
||||
rtp_pool,
|
||||
&socket,
|
||||
public_ip.as_deref(),
|
||||
®istered_aor,
|
||||
).await;
|
||||
|
||||
match call_id {
|
||||
Some(id) => {
|
||||
emit_event(out_tx, "outbound_call_started", serde_json::json!({
|
||||
"call_id": id,
|
||||
"number": number,
|
||||
"provider_id": provider_config.id,
|
||||
}));
|
||||
respond_ok(out_tx, &cmd.id, serde_json::json!({ "call_id": id }));
|
||||
}
|
||||
None => {
|
||||
respond_err(out_tx, &cmd.id, "call origination failed — provider not registered or no ports available");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the `hangup` command.
|
||||
async fn handle_hangup(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
|
||||
let call_id = match cmd.params.get("call_id").and_then(|v| v.as_str()) {
|
||||
@@ -438,3 +522,105 @@ async fn handle_hangup(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Co
|
||||
respond_err(out_tx, &cmd.id, &format!("call {call_id} not found"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `webrtc_offer` — browser sends SDP offer, we create PeerConnection and return answer.
|
||||
async fn handle_webrtc_offer(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
|
||||
let session_id = match cmd.params.get("session_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => { respond_err(out_tx, &cmd.id, "missing session_id"); return; }
|
||||
};
|
||||
let offer_sdp = match cmd.params.get("sdp").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => { respond_err(out_tx, &cmd.id, "missing sdp"); return; }
|
||||
};
|
||||
|
||||
let mut eng = engine.lock().await;
|
||||
match eng.webrtc.handle_offer(&session_id, &offer_sdp).await {
|
||||
Ok(answer_sdp) => {
|
||||
respond_ok(out_tx, &cmd.id, serde_json::json!({
|
||||
"session_id": session_id,
|
||||
"sdp": answer_sdp,
|
||||
}));
|
||||
}
|
||||
Err(e) => respond_err(out_tx, &cmd.id, &e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `webrtc_ice` — forward ICE candidate from browser to Rust PeerConnection.
|
||||
async fn handle_webrtc_ice(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
|
||||
let session_id = match cmd.params.get("session_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => { respond_err(out_tx, &cmd.id, "missing session_id"); return; }
|
||||
};
|
||||
let candidate = cmd.params.get("candidate").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let sdp_mid = cmd.params.get("sdp_mid").and_then(|v| v.as_str());
|
||||
let sdp_mline_index = cmd.params.get("sdp_mline_index").and_then(|v| v.as_u64()).map(|v| v as u16);
|
||||
|
||||
let eng = engine.lock().await;
|
||||
match eng.webrtc.add_ice_candidate(&session_id, candidate, sdp_mid, sdp_mline_index).await {
|
||||
Ok(()) => respond_ok(out_tx, &cmd.id, serde_json::json!({})),
|
||||
Err(e) => respond_err(out_tx, &cmd.id, &e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `webrtc_link` — link a WebRTC session to a SIP call for audio bridging.
|
||||
async fn handle_webrtc_link(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
|
||||
let session_id = match cmd.params.get("session_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => { respond_err(out_tx, &cmd.id, "missing session_id"); return; }
|
||||
};
|
||||
let call_id = match cmd.params.get("call_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => { respond_err(out_tx, &cmd.id, "missing call_id"); return; }
|
||||
};
|
||||
let provider_addr = match cmd.params.get("provider_media_addr").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => { respond_err(out_tx, &cmd.id, "missing provider_media_addr"); return; }
|
||||
};
|
||||
let provider_port = match cmd.params.get("provider_media_port").and_then(|v| v.as_u64()) {
|
||||
Some(p) => p as u16,
|
||||
None => { respond_err(out_tx, &cmd.id, "missing provider_media_port"); return; }
|
||||
};
|
||||
let sip_pt = cmd.params.get("sip_pt").and_then(|v| v.as_u64()).unwrap_or(9) as u8;
|
||||
|
||||
let provider_media: SocketAddr = match format!("{provider_addr}:{provider_port}").parse() {
|
||||
Ok(a) => a,
|
||||
Err(e) => { respond_err(out_tx, &cmd.id, &format!("bad address: {e}")); return; }
|
||||
};
|
||||
|
||||
let mut eng = engine.lock().await;
|
||||
let sip_socket = match &eng.transport {
|
||||
Some(t) => t.socket(),
|
||||
None => { respond_err(out_tx, &cmd.id, "not initialized"); return; }
|
||||
};
|
||||
|
||||
let bridge_info = crate::webrtc_engine::SipBridgeInfo {
|
||||
provider_media,
|
||||
sip_pt,
|
||||
sip_socket,
|
||||
};
|
||||
|
||||
if eng.webrtc.link_to_sip(&session_id, &call_id, bridge_info).await {
|
||||
respond_ok(out_tx, &cmd.id, serde_json::json!({
|
||||
"session_id": session_id,
|
||||
"call_id": call_id,
|
||||
"bridged": true,
|
||||
}));
|
||||
} else {
|
||||
respond_err(out_tx, &cmd.id, &format!("session {session_id} not found"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `webrtc_close` — close a WebRTC session.
|
||||
async fn handle_webrtc_close(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
|
||||
let session_id = match cmd.params.get("session_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => { respond_err(out_tx, &cmd.id, "missing session_id"); return; }
|
||||
};
|
||||
|
||||
let mut eng = engine.lock().await;
|
||||
match eng.webrtc.close_session(&session_id).await {
|
||||
Ok(()) => respond_ok(out_tx, &cmd.id, serde_json::json!({})),
|
||||
Err(e) => respond_err(out_tx, &cmd.id, &e),
|
||||
}
|
||||
}
|
||||
|
||||
132
rust/crates/proxy-engine/src/recorder.rs
Normal file
132
rust/crates/proxy-engine/src/recorder.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
//! 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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
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()
|
||||
}
|
||||
389
rust/crates/proxy-engine/src/webrtc_engine.rs
Normal file
389
rust/crates/proxy-engine/src/webrtc_engine.rs
Normal file
@@ -0,0 +1,389 @@
|
||||
//! WebRTC engine — manages browser PeerConnections with SIP audio bridging.
|
||||
//!
|
||||
//! Browser Opus audio → Rust PeerConnection → transcode via codec-lib → SIP RTP
|
||||
//! SIP RTP → transcode via codec-lib → Rust PeerConnection → Browser Opus
|
||||
|
||||
use crate::ipc::{emit_event, OutTx};
|
||||
use crate::rtp::{build_rtp_header, rtp_clock_increment};
|
||||
use codec_lib::{TranscodeState, PT_G722, PT_OPUS};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::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};
|
||||
|
||||
/// SIP-side bridge info for a WebRTC session.
|
||||
#[derive(Clone)]
|
||||
pub struct SipBridgeInfo {
|
||||
/// Provider's media endpoint (RTP destination).
|
||||
pub provider_media: SocketAddr,
|
||||
/// Provider's codec payload type (e.g. 9 for G.722).
|
||||
pub sip_pt: u8,
|
||||
/// The SIP UDP socket for sending RTP to the provider.
|
||||
pub sip_socket: Arc<UdpSocket>,
|
||||
}
|
||||
|
||||
/// A managed WebRTC session.
|
||||
struct WebRtcSession {
|
||||
pc: Arc<RTCPeerConnection>,
|
||||
local_track: Arc<TrackLocalStaticRTP>,
|
||||
call_id: Option<String>,
|
||||
/// SIP bridge — set when the session is linked to a call.
|
||||
sip_bridge: Arc<Mutex<Option<SipBridgeInfo>>>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 SIP bridge info (populated when linked to a call).
|
||||
let sip_bridge: Arc<Mutex<Option<SipBridgeInfo>>> = 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.
|
||||
// When SIP bridge is set, transcodes and forwards to provider.
|
||||
let out_tx_track = self.out_tx.clone();
|
||||
let sid_track = session_id.to_string();
|
||||
let sip_bridge_for_track = sip_bridge.clone();
|
||||
pc.on_track(Box::new(move |track, _receiver, _transceiver| {
|
||||
let out_tx = out_tx_track.clone();
|
||||
let sid = sid_track.clone();
|
||||
let bridge = sip_bridge_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 the browser→SIP audio forwarding task.
|
||||
tokio::spawn(browser_to_sip_loop(track, bridge, 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,
|
||||
sip_bridge,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(answer_sdp)
|
||||
}
|
||||
|
||||
/// Link a WebRTC session to a SIP call — sets up the audio bridge.
|
||||
pub async fn link_to_sip(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
call_id: &str,
|
||||
bridge_info: SipBridgeInfo,
|
||||
) -> bool {
|
||||
if let Some(session) = self.sessions.get_mut(session_id) {
|
||||
session.call_id = Some(call_id.to_string());
|
||||
let mut bridge = session.sip_bridge.lock().await;
|
||||
*bridge = Some(bridge_info);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Send transcoded audio from the SIP side to the browser.
|
||||
/// Called by the RTP relay when it receives a packet from the provider.
|
||||
pub async fn forward_sip_to_browser(
|
||||
&self,
|
||||
session_id: &str,
|
||||
sip_rtp_payload: &[u8],
|
||||
sip_pt: u8,
|
||||
) -> Result<(), String> {
|
||||
let session = self
|
||||
.sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("session {session_id} not found"))?;
|
||||
|
||||
// Transcode SIP codec → Opus.
|
||||
// We create a temporary TranscodeState per packet for simplicity.
|
||||
// TODO: Use a per-session persistent state for proper codec continuity.
|
||||
let mut transcoder = TranscodeState::new().map_err(|e| format!("codec: {e}"))?;
|
||||
let opus_payload = transcoder
|
||||
.transcode(sip_rtp_payload, sip_pt, PT_OPUS, Some("to_browser"))
|
||||
.map_err(|e| format!("transcode: {e}"))?;
|
||||
|
||||
if opus_payload.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Build RTP header for Opus.
|
||||
// TODO: Track seq/ts/ssrc per session for proper continuity.
|
||||
let header = build_rtp_header(PT_OPUS, 0, 0, 0);
|
||||
let mut packet = header.to_vec();
|
||||
packet.extend_from_slice(&opus_payload);
|
||||
|
||||
session
|
||||
.local_track
|
||||
.write(&packet)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("write: {e}"))
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn has_session(&self, session_id: &str) -> bool {
|
||||
self.sessions.contains_key(session_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser → SIP audio forwarding loop.
|
||||
/// Reads Opus RTP from the browser, transcodes to the SIP codec, sends to provider.
|
||||
async fn browser_to_sip_loop(
|
||||
track: Arc<webrtc::track::track_remote::TrackRemote>,
|
||||
sip_bridge: Arc<Mutex<Option<SipBridgeInfo>>>,
|
||||
out_tx: OutTx,
|
||||
session_id: String,
|
||||
) {
|
||||
// Create a persistent codec state for this direction.
|
||||
let mut transcoder = match TranscodeState::new() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"webrtc_error",
|
||||
serde_json::json!({ "session_id": session_id, "error": format!("codec init: {e}") }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut buf = vec![0u8; 1500];
|
||||
let mut count = 0u64;
|
||||
let mut to_sip_seq: u16 = 0;
|
||||
let mut to_sip_ts: u32 = 0;
|
||||
let to_sip_ssrc: u32 = rand::random();
|
||||
|
||||
loop {
|
||||
match track.read(&mut buf).await {
|
||||
Ok((rtp_packet, _attributes)) => {
|
||||
count += 1;
|
||||
|
||||
// Get the SIP bridge info (may not be set yet if call isn't linked).
|
||||
let bridge = sip_bridge.lock().await;
|
||||
let bridge_info = match bridge.as_ref() {
|
||||
Some(b) => b.clone(),
|
||||
None => continue, // Not linked to a SIP call yet — drop the packet.
|
||||
};
|
||||
drop(bridge); // Release lock before doing I/O.
|
||||
|
||||
// Extract Opus payload from the RTP packet (skip 12-byte header).
|
||||
let payload = &rtp_packet.payload;
|
||||
if payload.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Transcode Opus → SIP codec (e.g. G.722).
|
||||
let sip_payload = match transcoder.transcode(
|
||||
payload,
|
||||
PT_OPUS,
|
||||
bridge_info.sip_pt,
|
||||
Some("to_sip"),
|
||||
) {
|
||||
Ok(p) if !p.is_empty() => p,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Build SIP RTP packet.
|
||||
let header = build_rtp_header(bridge_info.sip_pt, to_sip_seq, to_sip_ts, to_sip_ssrc);
|
||||
let mut sip_rtp = header.to_vec();
|
||||
sip_rtp.extend_from_slice(&sip_payload);
|
||||
|
||||
to_sip_seq = to_sip_seq.wrapping_add(1);
|
||||
to_sip_ts = to_sip_ts.wrapping_add(rtp_clock_increment(bridge_info.sip_pt));
|
||||
|
||||
// Send to provider.
|
||||
let _ = bridge_info
|
||||
.sip_socket
|
||||
.send_to(&sip_rtp, bridge_info.provider_media)
|
||||
.await;
|
||||
|
||||
if count == 1 || count == 50 || count % 500 == 0 {
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"webrtc_audio_tx",
|
||||
serde_json::json!({
|
||||
"session_id": session_id,
|
||||
"direction": "browser_to_sip",
|
||||
"packet_count": count,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => break, // Track ended.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.11.0',
|
||||
version: '1.12.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -14,9 +14,26 @@ 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';
|
||||
|
||||
/** RTP clock increment per 20ms frame for each codec. */
|
||||
function rtpClockIncrement(pt: number): number {
|
||||
if (pt === 111) return 960;
|
||||
if (pt === 9) return 160;
|
||||
return 160;
|
||||
}
|
||||
|
||||
/** Build a fresh RTP header. */
|
||||
function buildRtpHeader(pt: number, seq: number, ts: number, ssrc: number, marker: boolean): Buffer {
|
||||
const hdr = Buffer.alloc(12);
|
||||
hdr[0] = 0x80;
|
||||
hdr[1] = (marker ? 0x80 : 0) | (pt & 0x7f);
|
||||
hdr.writeUInt16BE(seq & 0xffff, 2);
|
||||
hdr.writeUInt32BE(ts >>> 0, 4);
|
||||
hdr.writeUInt32BE(ssrc >>> 0, 8);
|
||||
return hdr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
/**
|
||||
* Audio recorder — captures RTP packets from a single direction,
|
||||
* decodes them to PCM, and writes a WAV file.
|
||||
*
|
||||
* Uses the Rust codec bridge to transcode incoming audio (G.722, Opus,
|
||||
* PCMU, PCMA) to PCMU, then decodes mu-law to 16-bit PCM in TypeScript.
|
||||
* Output: 8kHz 16-bit mono WAV (standard telephony quality).
|
||||
*
|
||||
* Supports:
|
||||
* - Max recording duration limit
|
||||
* - Silence detection (stop after N seconds of silence)
|
||||
* - Manual stop
|
||||
* - DTMF packets (PT 101) are automatically skipped
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { WavWriter } from './wav-writer.ts';
|
||||
import type { IWavWriterResult } from './wav-writer.ts';
|
||||
import { transcode, createSession, destroySession } from '../opusbridge.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IRecordingOptions {
|
||||
/** Output directory for WAV files. */
|
||||
outputDir: string;
|
||||
/** Target sample rate for the WAV output (default 8000). */
|
||||
sampleRate?: number;
|
||||
/** Maximum recording duration in seconds. 0 = unlimited. Default 120. */
|
||||
maxDurationSec?: number;
|
||||
/** Stop after this many consecutive seconds of silence. 0 = disabled. Default 5. */
|
||||
silenceTimeoutSec?: number;
|
||||
/** Silence threshold: max PCM amplitude below this is "silent". Default 200. */
|
||||
silenceThreshold?: number;
|
||||
/** Logging function. */
|
||||
log: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface IRecordingResult {
|
||||
/** Full path to the WAV file. */
|
||||
filePath: string;
|
||||
/** Duration in milliseconds. */
|
||||
durationMs: number;
|
||||
/** Sample rate of the WAV. */
|
||||
sampleRate: number;
|
||||
/** Size of the WAV file in bytes. */
|
||||
fileSize: number;
|
||||
/** Why the recording was stopped. */
|
||||
stopReason: TRecordingStopReason;
|
||||
}
|
||||
|
||||
export type TRecordingStopReason = 'manual' | 'max-duration' | 'silence' | 'cancelled';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mu-law decode table (ITU-T G.711)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Pre-computed mu-law → 16-bit linear PCM lookup table (256 entries). */
|
||||
const MULAW_DECODE: Int16Array = buildMulawDecodeTable();
|
||||
|
||||
function buildMulawDecodeTable(): Int16Array {
|
||||
const table = new Int16Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
// Invert all bits per mu-law standard.
|
||||
let mu = ~i & 0xff;
|
||||
const sign = mu & 0x80;
|
||||
const exponent = (mu >> 4) & 0x07;
|
||||
const mantissa = mu & 0x0f;
|
||||
let magnitude = ((mantissa << 1) + 33) << (exponent + 2);
|
||||
magnitude -= 0x84; // Bias adjustment
|
||||
table[i] = sign ? -magnitude : magnitude;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/** Decode a PCMU payload to 16-bit LE PCM. */
|
||||
function decodeMulaw(mulaw: Buffer): Buffer {
|
||||
const pcm = Buffer.alloc(mulaw.length * 2);
|
||||
for (let i = 0; i < mulaw.length; i++) {
|
||||
pcm.writeInt16LE(MULAW_DECODE[mulaw[i]], i * 2);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AudioRecorder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class AudioRecorder {
|
||||
/** Current state. */
|
||||
state: 'idle' | 'recording' | 'stopped' = 'idle';
|
||||
|
||||
/** Called when recording stops automatically (silence or max duration). */
|
||||
onStopped: ((result: IRecordingResult) => void) | null = null;
|
||||
|
||||
private outputDir: string;
|
||||
private sampleRate: number;
|
||||
private maxDurationSec: number;
|
||||
private silenceTimeoutSec: number;
|
||||
private silenceThreshold: number;
|
||||
private log: (msg: string) => void;
|
||||
|
||||
private wavWriter: WavWriter | null = null;
|
||||
private filePath: string = '';
|
||||
private codecSessionId: string | null = null;
|
||||
private stopReason: TRecordingStopReason = 'manual';
|
||||
|
||||
// Silence detection.
|
||||
private consecutiveSilentFrames = 0;
|
||||
/** Number of 20ms frames that constitute silence timeout. */
|
||||
private silenceFrameThreshold = 0;
|
||||
|
||||
// Max duration timer.
|
||||
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Processing queue to avoid concurrent transcodes.
|
||||
private processQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(options: IRecordingOptions) {
|
||||
this.outputDir = options.outputDir;
|
||||
this.sampleRate = options.sampleRate ?? 8000;
|
||||
this.maxDurationSec = options.maxDurationSec ?? 120;
|
||||
this.silenceTimeoutSec = options.silenceTimeoutSec ?? 5;
|
||||
this.silenceThreshold = options.silenceThreshold ?? 200;
|
||||
this.log = options.log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start recording. Creates the output directory, WAV file, and codec session.
|
||||
* @param fileId - unique ID for the recording file name
|
||||
*/
|
||||
async start(fileId?: string): Promise<void> {
|
||||
if (this.state !== 'idle') return;
|
||||
|
||||
// Ensure output directory exists.
|
||||
if (!fs.existsSync(this.outputDir)) {
|
||||
fs.mkdirSync(this.outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate file path.
|
||||
const id = fileId ?? `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
this.filePath = path.join(this.outputDir, `${id}.wav`);
|
||||
|
||||
// Create a codec session for isolated decoding.
|
||||
this.codecSessionId = `recorder-${id}`;
|
||||
await createSession(this.codecSessionId);
|
||||
|
||||
// Open WAV writer.
|
||||
this.wavWriter = new WavWriter({
|
||||
filePath: this.filePath,
|
||||
sampleRate: this.sampleRate,
|
||||
});
|
||||
this.wavWriter.open();
|
||||
|
||||
// Silence detection threshold: frames in timeout period.
|
||||
this.silenceFrameThreshold = this.silenceTimeoutSec > 0
|
||||
? Math.ceil((this.silenceTimeoutSec * 1000) / 20)
|
||||
: 0;
|
||||
this.consecutiveSilentFrames = 0;
|
||||
|
||||
// Max duration timer.
|
||||
if (this.maxDurationSec > 0) {
|
||||
this.maxDurationTimer = setTimeout(() => {
|
||||
if (this.state === 'recording') {
|
||||
this.stopReason = 'max-duration';
|
||||
this.log(`[recorder] max duration reached (${this.maxDurationSec}s)`);
|
||||
this.stop().then((result) => this.onStopped?.(result));
|
||||
}
|
||||
}, this.maxDurationSec * 1000);
|
||||
}
|
||||
|
||||
this.state = 'recording';
|
||||
this.stopReason = 'manual';
|
||||
this.log(`[recorder] started → ${this.filePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed an RTP packet. Strips the 12-byte header, transcodes the payload
|
||||
* to PCMU via the Rust bridge, decodes to PCM, and writes to WAV.
|
||||
* Skips telephone-event (DTMF) and comfort noise packets.
|
||||
*/
|
||||
processRtp(data: Buffer): void {
|
||||
if (this.state !== 'recording') return;
|
||||
if (data.length < 13) return; // too short
|
||||
|
||||
const pt = data[1] & 0x7f;
|
||||
|
||||
// Skip DTMF (telephone-event) and comfort noise.
|
||||
if (pt === 101 || pt === 13) return;
|
||||
|
||||
const payload = data.subarray(12);
|
||||
if (payload.length === 0) return;
|
||||
|
||||
// Queue processing to avoid concurrent transcodes corrupting codec state.
|
||||
this.processQueue = this.processQueue.then(() => this.decodeAndWrite(payload, pt));
|
||||
}
|
||||
|
||||
/** Decode a single RTP payload to PCM and write to WAV. */
|
||||
private async decodeAndWrite(payload: Buffer, pt: number): Promise<void> {
|
||||
if (this.state !== 'recording' || !this.wavWriter) return;
|
||||
|
||||
let pcm: Buffer;
|
||||
|
||||
if (pt === 0) {
|
||||
// PCMU: decode directly in TypeScript (no Rust round-trip needed).
|
||||
pcm = decodeMulaw(payload);
|
||||
} else {
|
||||
// All other codecs: transcode to PCMU via Rust, then decode mu-law.
|
||||
const mulaw = await transcode(payload, pt, 0, this.codecSessionId ?? undefined);
|
||||
if (!mulaw) return;
|
||||
pcm = decodeMulaw(mulaw);
|
||||
}
|
||||
|
||||
// Silence detection.
|
||||
if (this.silenceFrameThreshold > 0) {
|
||||
if (isSilent(pcm, this.silenceThreshold)) {
|
||||
this.consecutiveSilentFrames++;
|
||||
if (this.consecutiveSilentFrames >= this.silenceFrameThreshold) {
|
||||
this.stopReason = 'silence';
|
||||
this.log(`[recorder] silence detected (${this.silenceTimeoutSec}s)`);
|
||||
this.stop().then((result) => this.onStopped?.(result));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.consecutiveSilentFrames = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.wavWriter.write(pcm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording and finalize the WAV file.
|
||||
*/
|
||||
async stop(): Promise<IRecordingResult> {
|
||||
if (this.state === 'stopped' || this.state === 'idle') {
|
||||
return {
|
||||
filePath: this.filePath,
|
||||
durationMs: 0,
|
||||
sampleRate: this.sampleRate,
|
||||
fileSize: 0,
|
||||
stopReason: this.stopReason,
|
||||
};
|
||||
}
|
||||
|
||||
this.state = 'stopped';
|
||||
|
||||
// Wait for pending decode operations to finish.
|
||||
await this.processQueue;
|
||||
|
||||
// Clear timers.
|
||||
if (this.maxDurationTimer) {
|
||||
clearTimeout(this.maxDurationTimer);
|
||||
this.maxDurationTimer = null;
|
||||
}
|
||||
|
||||
// Finalize WAV.
|
||||
let wavResult: IWavWriterResult | null = null;
|
||||
if (this.wavWriter) {
|
||||
wavResult = this.wavWriter.close();
|
||||
this.wavWriter = null;
|
||||
}
|
||||
|
||||
// Destroy codec session.
|
||||
if (this.codecSessionId) {
|
||||
await destroySession(this.codecSessionId);
|
||||
this.codecSessionId = null;
|
||||
}
|
||||
|
||||
const result: IRecordingResult = {
|
||||
filePath: this.filePath,
|
||||
durationMs: wavResult?.durationMs ?? 0,
|
||||
sampleRate: this.sampleRate,
|
||||
fileSize: wavResult?.fileSize ?? 0,
|
||||
stopReason: this.stopReason,
|
||||
};
|
||||
|
||||
this.log(`[recorder] stopped (${result.stopReason}): ${result.durationMs}ms → ${this.filePath}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Cancel recording — stops and deletes the WAV file. */
|
||||
async cancel(): Promise<void> {
|
||||
this.stopReason = 'cancelled';
|
||||
await this.stop();
|
||||
|
||||
// Delete the incomplete file.
|
||||
try {
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
fs.unlinkSync(this.filePath);
|
||||
this.log(`[recorder] cancelled — deleted ${this.filePath}`);
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
/** Clean up all resources. */
|
||||
destroy(): void {
|
||||
if (this.state === 'recording') {
|
||||
this.cancel();
|
||||
}
|
||||
this.onStopped = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if a PCM buffer is "silent" (max amplitude below threshold). */
|
||||
function isSilent(pcm: Buffer, threshold: number): boolean {
|
||||
let maxAmp = 0;
|
||||
for (let i = 0; i < pcm.length - 1; i += 2) {
|
||||
const sample = pcm.readInt16LE(i);
|
||||
const abs = sample < 0 ? -sample : sample;
|
||||
if (abs > maxAmp) maxAmp = abs;
|
||||
// Early exit: already above threshold.
|
||||
if (maxAmp >= threshold) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
265
ts/call/call.ts
265
ts/call/call.ts
@@ -1,265 +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) {
|
||||
// If a system leg is connected, report voicemail/ivr state for the dashboard.
|
||||
const systemLeg = legs.find((l) => l.type === 'system');
|
||||
if (systemLeg) {
|
||||
// Keep voicemail/ivr state if already set; otherwise set connected.
|
||||
if (this.state !== 'voicemail' && this.state !== 'ivr') {
|
||||
this.state = 'connected';
|
||||
}
|
||||
} else {
|
||||
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 (system legs have no SIP signaling).
|
||||
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) {
|
||||
// Send BYE/CANCEL for SIP legs (system legs just get torn down).
|
||||
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,272 +0,0 @@
|
||||
/**
|
||||
* DTMF detection — parses RFC 2833 telephone-event RTP packets
|
||||
* and SIP INFO (application/dtmf-relay) messages.
|
||||
*
|
||||
* Designed to be attached to any leg or RTP stream. The detector
|
||||
* deduplicates repeated telephone-event packets (same digit is sent
|
||||
* multiple times with increasing duration) and fires a callback
|
||||
* once per detected digit.
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type { SipMessage } from '../sip/index.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single detected DTMF digit. */
|
||||
export interface IDtmfDigit {
|
||||
/** The digit character: '0'-'9', '*', '#', 'A'-'D'. */
|
||||
digit: string;
|
||||
/** Duration in milliseconds. */
|
||||
durationMs: number;
|
||||
/** Detection source. */
|
||||
source: 'rfc2833' | 'sip-info';
|
||||
/** Wall-clock timestamp when the digit was detected. */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Callback fired once per detected DTMF digit. */
|
||||
export type TDtmfCallback = (digit: IDtmfDigit) => void;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** RFC 2833 event ID → character mapping. */
|
||||
const EVENT_CHARS: string[] = [
|
||||
'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 = 200;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DtmfDetector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detects DTMF digits from RFC 2833 RTP packets and SIP INFO messages.
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* const detector = new DtmfDetector(log);
|
||||
* detector.onDigit = (d) => console.log('DTMF:', d.digit);
|
||||
* // Feed every RTP packet (detector checks PT internally):
|
||||
* detector.processRtp(rtpPacket);
|
||||
* // Or feed a SIP INFO message:
|
||||
* detector.processSipInfo(sipMsg);
|
||||
* ```
|
||||
*/
|
||||
export class DtmfDetector {
|
||||
/** Callback fired once per detected digit. */
|
||||
onDigit: TDtmfCallback | null = null;
|
||||
|
||||
/** Negotiated telephone-event payload type (default 101). */
|
||||
private telephoneEventPt: number;
|
||||
|
||||
/** Clock rate for duration calculation (default 8000 Hz). */
|
||||
private clockRate: number;
|
||||
|
||||
// -- Deduplication state for RFC 2833 --
|
||||
/** Event ID of the digit currently being received. */
|
||||
private currentEventId: number | null = null;
|
||||
/** RTP timestamp of the first packet for the current event. */
|
||||
private currentEventTs: number | null = null;
|
||||
/** Whether the current event has already been reported. */
|
||||
private currentEventReported = false;
|
||||
/** Latest duration value seen (in clock ticks). */
|
||||
private currentEventDuration = 0;
|
||||
/** Latest volume value seen (dBm0, 0 = loudest). */
|
||||
private currentEventVolume = 0;
|
||||
/** Safety timer: fires if no End packet arrives. */
|
||||
private safetyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private log: (msg: string) => void;
|
||||
|
||||
constructor(
|
||||
log: (msg: string) => void,
|
||||
telephoneEventPt = 101,
|
||||
clockRate = 8000,
|
||||
) {
|
||||
this.log = log;
|
||||
this.telephoneEventPt = telephoneEventPt;
|
||||
this.clockRate = clockRate;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RFC 2833 RTP processing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Feed an RTP packet. Checks PT; ignores non-DTMF packets.
|
||||
* Expects the full RTP packet (12-byte header + payload).
|
||||
*/
|
||||
processRtp(data: Buffer): void {
|
||||
if (data.length < 16) return; // 12-byte header + 4-byte telephone-event payload minimum
|
||||
|
||||
const pt = data[1] & 0x7f;
|
||||
if (pt !== this.telephoneEventPt) return;
|
||||
|
||||
// Parse RTP header fields we need.
|
||||
const marker = (data[1] & 0x80) !== 0;
|
||||
const rtpTimestamp = data.readUInt32BE(4);
|
||||
|
||||
// Parse telephone-event payload (4 bytes starting at offset 12).
|
||||
const eventId = data[12];
|
||||
const endBit = (data[13] & 0x80) !== 0;
|
||||
const volume = data[13] & 0x3f;
|
||||
const duration = data.readUInt16BE(14);
|
||||
|
||||
// Validate event ID.
|
||||
if (eventId >= EVENT_CHARS.length) return;
|
||||
|
||||
// Detect new event: marker bit, different event ID, or different RTP timestamp.
|
||||
const isNewEvent =
|
||||
marker ||
|
||||
eventId !== this.currentEventId ||
|
||||
rtpTimestamp !== this.currentEventTs;
|
||||
|
||||
if (isNewEvent) {
|
||||
// If there was an unreported previous event, report it now (fallback).
|
||||
this.reportPendingEvent();
|
||||
|
||||
// Start tracking the new event.
|
||||
this.currentEventId = eventId;
|
||||
this.currentEventTs = rtpTimestamp;
|
||||
this.currentEventReported = false;
|
||||
this.currentEventDuration = duration;
|
||||
this.currentEventVolume = volume;
|
||||
|
||||
// Start safety timer.
|
||||
this.clearSafetyTimer();
|
||||
this.safetyTimer = setTimeout(() => {
|
||||
this.reportPendingEvent();
|
||||
}, SAFETY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// Update duration (it increases with each retransmission).
|
||||
if (duration > this.currentEventDuration) {
|
||||
this.currentEventDuration = duration;
|
||||
}
|
||||
|
||||
// Report on End bit (first time only).
|
||||
if (endBit && !this.currentEventReported) {
|
||||
this.currentEventReported = true;
|
||||
this.clearSafetyTimer();
|
||||
|
||||
const digit = EVENT_CHARS[eventId];
|
||||
const durationMs = (this.currentEventDuration / this.clockRate) * 1000;
|
||||
|
||||
this.log(`[dtmf] RFC 2833 digit '${digit}' (${Math.round(durationMs)}ms)`);
|
||||
this.onDigit?.({
|
||||
digit,
|
||||
durationMs,
|
||||
source: 'rfc2833',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Report a pending (unreported) event — called by safety timer or on new event start. */
|
||||
private reportPendingEvent(): void {
|
||||
if (
|
||||
this.currentEventId !== null &&
|
||||
!this.currentEventReported &&
|
||||
this.currentEventId < EVENT_CHARS.length
|
||||
) {
|
||||
this.currentEventReported = true;
|
||||
this.clearSafetyTimer();
|
||||
|
||||
const digit = EVENT_CHARS[this.currentEventId];
|
||||
const durationMs = (this.currentEventDuration / this.clockRate) * 1000;
|
||||
|
||||
this.log(`[dtmf] RFC 2833 digit '${digit}' (${Math.round(durationMs)}ms, safety timeout)`);
|
||||
this.onDigit?.({
|
||||
digit,
|
||||
durationMs,
|
||||
source: 'rfc2833',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private clearSafetyTimer(): void {
|
||||
if (this.safetyTimer) {
|
||||
clearTimeout(this.safetyTimer);
|
||||
this.safetyTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SIP INFO processing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a SIP INFO message carrying DTMF.
|
||||
* Supports Content-Type: application/dtmf-relay (Signal=X / Duration=Y).
|
||||
*/
|
||||
processSipInfo(msg: SipMessage): void {
|
||||
const ct = (msg.getHeader('Content-Type') || '').toLowerCase();
|
||||
if (!ct.includes('application/dtmf-relay') && !ct.includes('application/dtmf')) return;
|
||||
|
||||
const body = msg.body || '';
|
||||
|
||||
if (ct.includes('application/dtmf-relay')) {
|
||||
// Format: "Signal= 5\r\nDuration= 160\r\n"
|
||||
const signalMatch = body.match(/Signal\s*=\s*(\S+)/i);
|
||||
const durationMatch = body.match(/Duration\s*=\s*(\d+)/i);
|
||||
if (!signalMatch) return;
|
||||
|
||||
const signal = signalMatch[1];
|
||||
const durationTicks = durationMatch ? parseInt(durationMatch[1], 10) : 160;
|
||||
|
||||
// Validate digit.
|
||||
if (signal.length !== 1 || !/[0-9*#A-Da-d]/.test(signal)) return;
|
||||
const digit = signal.toUpperCase();
|
||||
const durationMs = (durationTicks / this.clockRate) * 1000;
|
||||
|
||||
this.log(`[dtmf] SIP INFO digit '${digit}' (${Math.round(durationMs)}ms)`);
|
||||
this.onDigit?.({
|
||||
digit,
|
||||
durationMs,
|
||||
source: 'sip-info',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (ct.includes('application/dtmf')) {
|
||||
// Simple format: just the digit character in the body.
|
||||
const digit = body.trim().toUpperCase();
|
||||
if (digit.length !== 1 || !/[0-9*#A-D]/.test(digit)) return;
|
||||
|
||||
this.log(`[dtmf] SIP INFO digit '${digit}' (application/dtmf)`);
|
||||
this.onDigit?.({
|
||||
digit,
|
||||
durationMs: 250, // default duration
|
||||
source: 'sip-info',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Reset detection state (e.g., between calls). */
|
||||
reset(): void {
|
||||
this.currentEventId = null;
|
||||
this.currentEventTs = null;
|
||||
this.currentEventReported = false;
|
||||
this.currentEventDuration = 0;
|
||||
this.currentEventVolume = 0;
|
||||
this.clearSafetyTimer();
|
||||
}
|
||||
|
||||
/** Clean up timers and references. */
|
||||
destroy(): void {
|
||||
this.clearSafetyTimer();
|
||||
this.onDigit = null;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,26 @@ 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 './leg.ts';
|
||||
import { encodePcm, isCodecReady } from '../opusbridge.ts';
|
||||
|
||||
/** RTP clock increment per 20ms frame for each codec. */
|
||||
function rtpClockIncrement(pt: number): number {
|
||||
if (pt === 111) return 960;
|
||||
if (pt === 9) return 160;
|
||||
return 160;
|
||||
}
|
||||
|
||||
/** Build a fresh RTP header. */
|
||||
function buildRtpHeader(pt: number, seq: number, ts: number, ssrc: number, marker: boolean): Buffer {
|
||||
const hdr = Buffer.alloc(12);
|
||||
hdr[0] = 0x80;
|
||||
hdr[1] = (marker ? 0x80 : 0) | (pt & 0x7f);
|
||||
hdr.writeUInt16BE(seq & 0xffff, 2);
|
||||
hdr.writeUInt32BE(ts >>> 0, 4);
|
||||
hdr.writeUInt32BE(ssrc >>> 0, 8);
|
||||
return hdr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,633 +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;
|
||||
|
||||
/** Callback for SIP INFO messages (used for DTMF relay). */
|
||||
onInfoReceived: ((msg: SipMessage) => 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);
|
||||
}
|
||||
if (method === 'INFO') {
|
||||
// Respond 200 OK to the INFO request.
|
||||
const ok = SipMessage.createResponse(200, 'OK', msg);
|
||||
this.config.sendSip(ok.serialize(), { address: rinfo.address, port: rinfo.port });
|
||||
|
||||
// Forward to DTMF handler (if attached).
|
||||
this.onInfoReceived?.(msg);
|
||||
}
|
||||
// Other in-dialog requests (re-INVITE, 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,336 +0,0 @@
|
||||
/**
|
||||
* SystemLeg — virtual ILeg for IVR menus and voicemail.
|
||||
*
|
||||
* Plugs into the Call hub exactly like SipLeg or WebRtcLeg:
|
||||
* - Receives caller audio via sendRtp() (called by Call.forwardRtp)
|
||||
* - Plays prompts by firing onRtpReceived (picked up by Call.forwardRtp → caller's leg)
|
||||
* - Detects DTMF from caller's audio (RFC 2833 telephone-event)
|
||||
* - Records caller's audio to WAV files (for voicemail)
|
||||
*
|
||||
* No UDP socket or SIP dialog needed — purely virtual.
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type dgram from 'node:dgram';
|
||||
import type { IEndpoint } from '../sip/index.ts';
|
||||
import type { SipMessage } from '../sip/index.ts';
|
||||
import type { SipDialog } from '../sip/index.ts';
|
||||
import type { IRtpTranscoder } from '../codec.ts';
|
||||
import type { ILeg } from './leg.ts';
|
||||
import type { TLegState, TLegType, ILegStatus } from './types.ts';
|
||||
import { DtmfDetector } from './dtmf-detector.ts';
|
||||
import type { IDtmfDigit } from './dtmf-detector.ts';
|
||||
import { AudioRecorder } from './audio-recorder.ts';
|
||||
import type { IRecordingResult } from './audio-recorder.ts';
|
||||
import { PromptCache, playPromptG722, playPromptOpus } from './prompt-cache.ts';
|
||||
import type { ICachedPrompt } from './prompt-cache.ts';
|
||||
import { buildRtpHeader, rtpClockIncrement } from './leg.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TSystemLegMode = 'ivr' | 'voicemail-greeting' | 'voicemail-recording' | 'idle';
|
||||
|
||||
export interface ISystemLegConfig {
|
||||
/** Logging function. */
|
||||
log: (msg: string) => void;
|
||||
/** The prompt cache for TTS playback. */
|
||||
promptCache: PromptCache;
|
||||
/**
|
||||
* Codec payload type used by the caller's leg.
|
||||
* Determines whether G.722 (9) or Opus (111) frames are played.
|
||||
* Default: 9 (G.722, typical for SIP callers).
|
||||
*/
|
||||
callerCodecPt?: number;
|
||||
/** Called when a DTMF digit is detected. */
|
||||
onDtmfDigit?: (digit: IDtmfDigit) => void;
|
||||
/** Called when a voicemail recording is complete. */
|
||||
onRecordingComplete?: (result: IRecordingResult) => void;
|
||||
/** Called when the SystemLeg wants to signal an IVR action. */
|
||||
onAction?: (action: string, data?: any) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SystemLeg
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class SystemLeg implements ILeg {
|
||||
readonly id: string;
|
||||
readonly type: TLegType = 'system';
|
||||
state: TLegState = 'connected'; // Immediately "connected" — no setup phase.
|
||||
|
||||
/** Current operating mode. */
|
||||
mode: TSystemLegMode = 'idle';
|
||||
|
||||
// --- ILeg required fields (virtual — no real network resources) ---
|
||||
readonly sipCallId: string;
|
||||
readonly rtpPort: number | null = null;
|
||||
readonly rtpSock: dgram.Socket | null = null;
|
||||
remoteMedia: IEndpoint | null = null;
|
||||
codec: number | null = null;
|
||||
transcoder: IRtpTranscoder | null = null;
|
||||
pktSent = 0;
|
||||
pktReceived = 0;
|
||||
readonly dialog: SipDialog | null = null;
|
||||
|
||||
/**
|
||||
* Set by Call.addLeg() — firing this injects audio into the Call hub,
|
||||
* which forwards it to the caller's leg.
|
||||
*/
|
||||
onRtpReceived: ((data: Buffer) => void) | null = null;
|
||||
|
||||
// --- Internal components ---
|
||||
private dtmfDetector: DtmfDetector;
|
||||
private recorder: AudioRecorder | null = null;
|
||||
private promptCache: PromptCache;
|
||||
private promptCancel: (() => void) | null = null;
|
||||
private callerCodecPt: number;
|
||||
private log: (msg: string) => void;
|
||||
readonly config: ISystemLegConfig;
|
||||
|
||||
/** Stable SSRC for all prompt playback (random, stays constant for the leg's lifetime). */
|
||||
private ssrc: number;
|
||||
|
||||
/** Sequence/timestamp counters for Opus prompt playback (shared for seamless transitions). */
|
||||
private opusCounters = { seq: 0, ts: 0 };
|
||||
|
||||
constructor(id: string, config: ISystemLegConfig) {
|
||||
this.id = id;
|
||||
this.sipCallId = `system-${id}`; // Virtual Call-ID — not a real SIP dialog.
|
||||
this.config = config;
|
||||
this.log = config.log;
|
||||
this.promptCache = config.promptCache;
|
||||
this.callerCodecPt = config.callerCodecPt ?? 9; // Default G.722
|
||||
|
||||
this.ssrc = (Math.random() * 0xffffffff) >>> 0;
|
||||
this.opusCounters.seq = Math.floor(Math.random() * 0xffff);
|
||||
this.opusCounters.ts = Math.floor(Math.random() * 0xffffffff);
|
||||
|
||||
// Initialize DTMF detector.
|
||||
this.dtmfDetector = new DtmfDetector(this.log);
|
||||
this.dtmfDetector.onDigit = (digit) => {
|
||||
this.log(`[system-leg:${this.id}] DTMF '${digit.digit}' (${digit.source})`);
|
||||
this.config.onDtmfDigit?.(digit);
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ILeg: sendRtp — receives caller's audio from the Call hub
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called by the Call hub (via forwardRtp) to deliver the caller's audio
|
||||
* to this leg. We use this for DTMF detection and recording.
|
||||
*/
|
||||
sendRtp(data: Buffer): void {
|
||||
this.pktReceived++;
|
||||
|
||||
// Feed DTMF detector (it checks PT internally, ignores non-101 packets).
|
||||
this.dtmfDetector.processRtp(data);
|
||||
|
||||
// Feed recorder if active.
|
||||
if (this.mode === 'voicemail-recording' && this.recorder) {
|
||||
this.recorder.processRtp(data);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ILeg: handleSipMessage — handles SIP INFO for DTMF
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle a SIP message routed to this leg. Only SIP INFO (DTMF) is relevant.
|
||||
*/
|
||||
handleSipMessage(msg: SipMessage, _rinfo: IEndpoint): void {
|
||||
if (msg.method === 'INFO') {
|
||||
this.dtmfDetector.processSipInfo(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Prompt playback
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Play a cached prompt by ID.
|
||||
* The audio is injected into the Call hub via onRtpReceived.
|
||||
*
|
||||
* @param promptId - ID of the prompt in the PromptCache
|
||||
* @param onDone - called when playback completes (not on cancel)
|
||||
* @returns true if playback started, false if prompt not found
|
||||
*/
|
||||
playPrompt(promptId: string, onDone?: () => void): boolean {
|
||||
const prompt = this.promptCache.get(promptId);
|
||||
if (!prompt) {
|
||||
this.log(`[system-leg:${this.id}] prompt "${promptId}" not found`);
|
||||
onDone?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel any in-progress playback.
|
||||
this.cancelPrompt();
|
||||
|
||||
this.log(`[system-leg:${this.id}] playing prompt "${promptId}" (${prompt.durationMs}ms)`);
|
||||
|
||||
// Select G.722 or Opus frames based on caller codec.
|
||||
if (this.callerCodecPt === 111) {
|
||||
// WebRTC caller: play Opus frames.
|
||||
this.promptCancel = playPromptOpus(
|
||||
prompt,
|
||||
(pkt) => this.injectPacket(pkt),
|
||||
this.ssrc,
|
||||
this.opusCounters,
|
||||
() => {
|
||||
this.promptCancel = null;
|
||||
onDone?.();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// SIP caller: play G.722 frames (works for all SIP codecs since the
|
||||
// SipLeg's RTP socket sends whatever we give it — the provider's
|
||||
// media endpoint accepts the codec negotiated in the SDP).
|
||||
this.promptCancel = playPromptG722(
|
||||
prompt,
|
||||
(pkt) => this.injectPacket(pkt),
|
||||
this.ssrc,
|
||||
() => {
|
||||
this.promptCancel = null;
|
||||
onDone?.();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return this.promptCancel !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sequence of prompts, one after another.
|
||||
*/
|
||||
playPromptSequence(promptIds: string[], onDone?: () => void): void {
|
||||
let index = 0;
|
||||
const playNext = () => {
|
||||
if (index >= promptIds.length) {
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
const id = promptIds[index++];
|
||||
if (!this.playPrompt(id, playNext)) {
|
||||
// Prompt not found — skip and play next.
|
||||
playNext();
|
||||
}
|
||||
};
|
||||
playNext();
|
||||
}
|
||||
|
||||
/** Cancel any in-progress prompt playback. */
|
||||
cancelPrompt(): void {
|
||||
if (this.promptCancel) {
|
||||
this.promptCancel();
|
||||
this.promptCancel = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a prompt is currently playing. */
|
||||
get isPlaying(): boolean {
|
||||
return this.promptCancel !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject an RTP packet into the Call hub.
|
||||
* This simulates "receiving" audio on this leg — the hub
|
||||
* will forward it to the caller's leg.
|
||||
*/
|
||||
private injectPacket(pkt: Buffer): void {
|
||||
this.pktSent++;
|
||||
this.onRtpReceived?.(pkt);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Recording
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start recording the caller's audio.
|
||||
* @param outputDir - directory to write the WAV file
|
||||
* @param fileId - unique ID for the file name
|
||||
*/
|
||||
async startRecording(outputDir: string, fileId?: string): Promise<void> {
|
||||
if (this.recorder) {
|
||||
await this.recorder.stop();
|
||||
}
|
||||
|
||||
this.recorder = new AudioRecorder({
|
||||
outputDir,
|
||||
log: this.log,
|
||||
maxDurationSec: 120,
|
||||
silenceTimeoutSec: 5,
|
||||
});
|
||||
|
||||
this.recorder.onStopped = (result) => {
|
||||
this.log(`[system-leg:${this.id}] recording auto-stopped (${result.stopReason})`);
|
||||
this.config.onRecordingComplete?.(result);
|
||||
};
|
||||
|
||||
this.mode = 'voicemail-recording';
|
||||
await this.recorder.start(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording and finalize the WAV file.
|
||||
*/
|
||||
async stopRecording(): Promise<IRecordingResult | null> {
|
||||
if (!this.recorder) return null;
|
||||
|
||||
const result = await this.recorder.stop();
|
||||
this.recorder = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Cancel recording — stops and deletes the file. */
|
||||
async cancelRecording(): Promise<void> {
|
||||
if (this.recorder) {
|
||||
await this.recorder.cancel();
|
||||
this.recorder = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Release all resources. */
|
||||
teardown(): void {
|
||||
this.cancelPrompt();
|
||||
|
||||
// Stop recording gracefully.
|
||||
if (this.recorder && this.recorder.state === 'recording') {
|
||||
this.recorder.stop().then((result) => {
|
||||
this.config.onRecordingComplete?.(result);
|
||||
});
|
||||
this.recorder = null;
|
||||
}
|
||||
|
||||
this.dtmfDetector.destroy();
|
||||
this.state = 'terminated';
|
||||
this.mode = 'idle';
|
||||
this.onRtpReceived = null;
|
||||
|
||||
this.log(`[system-leg:${this.id}] torn down`);
|
||||
}
|
||||
|
||||
/** Status snapshot for the dashboard. */
|
||||
getStatus(): ILegStatus {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
state: this.state,
|
||||
remoteMedia: null,
|
||||
rtpPort: null,
|
||||
pktSent: this.pktSent,
|
||||
pktReceived: this.pktReceived,
|
||||
codec: this.callerCodecPt === 111 ? 'Opus' : 'G.722',
|
||||
transcoding: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,70 +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'
|
||||
| 'voicemail'
|
||||
| 'ivr'
|
||||
| 'transferring'
|
||||
| 'terminating'
|
||||
| 'terminated';
|
||||
|
||||
export type TLegState =
|
||||
| 'inviting'
|
||||
| 'ringing'
|
||||
| 'connected'
|
||||
| 'on-hold'
|
||||
| 'terminating'
|
||||
| 'terminated';
|
||||
|
||||
export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc' | 'system';
|
||||
|
||||
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,163 +0,0 @@
|
||||
/**
|
||||
* Streaming WAV file writer — opens a file, writes a placeholder header,
|
||||
* appends raw PCM data in chunks, and finalizes (patches sizes) on close.
|
||||
*
|
||||
* Produces standard RIFF/WAVE format compatible with the WAV parser
|
||||
* in announcement.ts (extractPcmFromWav).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IWavWriterOptions {
|
||||
/** Full path to the output WAV file. */
|
||||
filePath: string;
|
||||
/** Sample rate in Hz (e.g. 16000). */
|
||||
sampleRate: number;
|
||||
/** Number of channels (default 1 = mono). */
|
||||
channels?: number;
|
||||
/** Bits per sample (default 16). */
|
||||
bitsPerSample?: number;
|
||||
}
|
||||
|
||||
export interface IWavWriterResult {
|
||||
/** Full path to the WAV file. */
|
||||
filePath: string;
|
||||
/** Total duration in milliseconds. */
|
||||
durationMs: number;
|
||||
/** Sample rate of the output file. */
|
||||
sampleRate: number;
|
||||
/** Total number of audio samples written. */
|
||||
totalSamples: number;
|
||||
/** File size in bytes. */
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WAV header constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Standard WAV header size: RIFF(12) + fmt(24) + data-header(8) = 44 bytes. */
|
||||
const HEADER_SIZE = 44;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WavWriter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class WavWriter {
|
||||
private fd: number | null = null;
|
||||
private totalDataBytes = 0;
|
||||
private closed = false;
|
||||
|
||||
private filePath: string;
|
||||
private sampleRate: number;
|
||||
private channels: number;
|
||||
private bitsPerSample: number;
|
||||
|
||||
constructor(options: IWavWriterOptions) {
|
||||
this.filePath = options.filePath;
|
||||
this.sampleRate = options.sampleRate;
|
||||
this.channels = options.channels ?? 1;
|
||||
this.bitsPerSample = options.bitsPerSample ?? 16;
|
||||
}
|
||||
|
||||
/** Open the file and write a placeholder 44-byte WAV header. */
|
||||
open(): void {
|
||||
if (this.fd !== null) throw new Error('WavWriter already open');
|
||||
|
||||
this.fd = fs.openSync(this.filePath, 'w');
|
||||
this.totalDataBytes = 0;
|
||||
this.closed = false;
|
||||
|
||||
// Write 44 bytes of zeros as placeholder — patched in close().
|
||||
const placeholder = Buffer.alloc(HEADER_SIZE);
|
||||
fs.writeSync(this.fd, placeholder, 0, HEADER_SIZE, 0);
|
||||
}
|
||||
|
||||
/** Append raw 16-bit little-endian PCM samples. */
|
||||
write(pcm: Buffer): void {
|
||||
if (this.fd === null || this.closed) return;
|
||||
if (pcm.length === 0) return;
|
||||
|
||||
fs.writeSync(this.fd, pcm, 0, pcm.length);
|
||||
this.totalDataBytes += pcm.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize: rewrite the RIFF and data chunk sizes in the header, close the file.
|
||||
* Returns metadata about the written WAV.
|
||||
*/
|
||||
close(): IWavWriterResult {
|
||||
if (this.fd === null || this.closed) {
|
||||
return {
|
||||
filePath: this.filePath,
|
||||
durationMs: 0,
|
||||
sampleRate: this.sampleRate,
|
||||
totalSamples: 0,
|
||||
fileSize: HEADER_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
|
||||
const blockAlign = this.channels * (this.bitsPerSample / 8);
|
||||
const byteRate = this.sampleRate * blockAlign;
|
||||
const fileSize = HEADER_SIZE + this.totalDataBytes;
|
||||
|
||||
// Build the complete 44-byte header.
|
||||
const hdr = Buffer.alloc(HEADER_SIZE);
|
||||
let offset = 0;
|
||||
|
||||
// RIFF chunk descriptor.
|
||||
hdr.write('RIFF', offset); offset += 4;
|
||||
hdr.writeUInt32LE(fileSize - 8, offset); offset += 4; // ChunkSize = fileSize - 8
|
||||
hdr.write('WAVE', offset); offset += 4;
|
||||
|
||||
// fmt sub-chunk.
|
||||
hdr.write('fmt ', offset); offset += 4;
|
||||
hdr.writeUInt32LE(16, offset); offset += 4; // Subchunk1Size (PCM = 16)
|
||||
hdr.writeUInt16LE(1, offset); offset += 2; // AudioFormat (1 = PCM)
|
||||
hdr.writeUInt16LE(this.channels, offset); offset += 2;
|
||||
hdr.writeUInt32LE(this.sampleRate, offset); offset += 4;
|
||||
hdr.writeUInt32LE(byteRate, offset); offset += 4;
|
||||
hdr.writeUInt16LE(blockAlign, offset); offset += 2;
|
||||
hdr.writeUInt16LE(this.bitsPerSample, offset); offset += 2;
|
||||
|
||||
// data sub-chunk.
|
||||
hdr.write('data', offset); offset += 4;
|
||||
hdr.writeUInt32LE(this.totalDataBytes, offset); offset += 4;
|
||||
|
||||
// Patch the header at the beginning of the file.
|
||||
fs.writeSync(this.fd, hdr, 0, HEADER_SIZE, 0);
|
||||
fs.closeSync(this.fd);
|
||||
this.fd = null;
|
||||
|
||||
const bytesPerSample = this.bitsPerSample / 8;
|
||||
const totalSamples = Math.floor(this.totalDataBytes / (bytesPerSample * this.channels));
|
||||
const durationMs = (totalSamples / this.sampleRate) * 1000;
|
||||
|
||||
return {
|
||||
filePath: this.filePath,
|
||||
durationMs: Math.round(durationMs),
|
||||
sampleRate: this.sampleRate,
|
||||
totalSamples,
|
||||
fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** Current recording duration in milliseconds. */
|
||||
get durationMs(): number {
|
||||
const bytesPerSample = this.bitsPerSample / 8;
|
||||
const totalSamples = Math.floor(this.totalDataBytes / (bytesPerSample * this.channels));
|
||||
return (totalSamples / this.sampleRate) * 1000;
|
||||
}
|
||||
|
||||
/** Whether the writer is still open and accepting data. */
|
||||
get isOpen(): boolean {
|
||||
return this.fd !== null && !this.closed;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
};
|
||||
}
|
||||
184
ts/config.ts
184
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
|
||||
@@ -319,175 +327,5 @@ export function loadConfig(): IAppConfig {
|
||||
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;
|
||||
/** If set, route directly to this voicemail box (skip ringing). */
|
||||
voicemailBox?: string;
|
||||
/** If set, route to this IVR menu (skip ringing). */
|
||||
ivrMenuId?: string;
|
||||
/** Override for no-answer timeout in seconds. */
|
||||
noAnswerTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
voicemailBox: route.action.voicemailBox,
|
||||
ivrMenuId: route.action.ivrMenuId,
|
||||
noAnswerTimeout: route.action.noAnswerTimeout,
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -11,10 +11,13 @@ 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');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +339,10 @@ export function initWebUi(
|
||||
onHangupCall: (callId: string) => boolean,
|
||||
onConfigSaved?: () => void,
|
||||
callManager?: CallManager,
|
||||
/** 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>,
|
||||
voiceboxManager?: VoiceboxManager,
|
||||
): void {
|
||||
const WEB_PORT = 3060;
|
||||
@@ -372,17 +379,23 @@ 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) =>
|
||||
log(`[webrtc] offer error: ${e.message}`));
|
||||
if (msg.type === 'webrtc-offer' && msg.sessionId) {
|
||||
// Forward to Rust proxy-engine for WebRTC handling.
|
||||
if (onWebRtcOffer) {
|
||||
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) {
|
||||
// TODO: Wire to Rust call linking.
|
||||
log(`[webrtc] accept: call=${msg.callId} session=${msg.sessionId || 'none'}`);
|
||||
} else if (msg.type?.startsWith('webrtc-')) {
|
||||
msg._remoteIp = remoteIp;
|
||||
handleWebRtcSignaling(socket as any, msg);
|
||||
|
||||
209
ts/ivr.ts
209
ts/ivr.ts
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* IVR engine — state machine that navigates callers through menus
|
||||
* based on DTMF digit input.
|
||||
*
|
||||
* The IvrEngine is instantiated per-call and drives a SystemLeg:
|
||||
* - Plays menu prompts via the SystemLeg's prompt playback
|
||||
* - Receives DTMF digits and resolves them to actions
|
||||
* - Fires an onAction callback for the CallManager to execute
|
||||
* (route to extension, voicemail, transfer, etc.)
|
||||
*/
|
||||
|
||||
import type { IIvrConfig, IIvrMenu, TIvrAction } from './config.ts';
|
||||
import type { SystemLeg } from './call/system-leg.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IVR Engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class IvrEngine {
|
||||
private config: IIvrConfig;
|
||||
private systemLeg: SystemLeg;
|
||||
private onAction: (action: TIvrAction) => void;
|
||||
private log: (msg: string) => void;
|
||||
|
||||
/** The currently active menu. */
|
||||
private currentMenu: IIvrMenu | null = null;
|
||||
|
||||
/** How many times the current menu has been replayed (for retry limit). */
|
||||
private retryCount = 0;
|
||||
|
||||
/** Timer for digit input timeout. */
|
||||
private digitTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Whether the engine is waiting for a digit (prompt finished playing). */
|
||||
private waitingForDigit = false;
|
||||
|
||||
/** Whether the engine has been destroyed. */
|
||||
private destroyed = false;
|
||||
|
||||
constructor(
|
||||
config: IIvrConfig,
|
||||
systemLeg: SystemLeg,
|
||||
onAction: (action: TIvrAction) => void,
|
||||
log: (msg: string) => void,
|
||||
) {
|
||||
this.config = config;
|
||||
this.systemLeg = systemLeg;
|
||||
this.onAction = onAction;
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start the IVR — navigates to the entry menu and plays its prompt.
|
||||
*/
|
||||
start(): void {
|
||||
const entryMenu = this.getMenu(this.config.entryMenuId);
|
||||
if (!entryMenu) {
|
||||
this.log(`[ivr] entry menu "${this.config.entryMenuId}" not found — hanging up`);
|
||||
this.onAction({ type: 'hangup' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.navigateToMenu(entryMenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a DTMF digit from the caller.
|
||||
*/
|
||||
handleDigit(digit: string): void {
|
||||
if (this.destroyed || !this.currentMenu) return;
|
||||
|
||||
// Clear the timeout — caller pressed something.
|
||||
this.clearDigitTimeout();
|
||||
|
||||
// Cancel any playing prompt (caller interrupted it).
|
||||
this.systemLeg.cancelPrompt();
|
||||
this.waitingForDigit = false;
|
||||
|
||||
this.log(`[ivr] digit '${digit}' in menu "${this.currentMenu.id}"`);
|
||||
|
||||
// Look up the digit in the current menu.
|
||||
const entry = this.currentMenu.entries.find((e) => e.digit === digit);
|
||||
if (entry) {
|
||||
this.executeAction(entry.action);
|
||||
} else {
|
||||
this.log(`[ivr] invalid digit '${digit}' in menu "${this.currentMenu.id}"`);
|
||||
this.executeAction(this.currentMenu.invalidAction);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up timers and state.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.clearDigitTimeout();
|
||||
this.currentMenu = null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Navigate to a menu: play its prompt, then wait for digit. */
|
||||
private navigateToMenu(menu: IIvrMenu): void {
|
||||
if (this.destroyed) return;
|
||||
|
||||
this.currentMenu = menu;
|
||||
this.waitingForDigit = false;
|
||||
this.clearDigitTimeout();
|
||||
|
||||
const promptId = `ivr-menu-${menu.id}`;
|
||||
this.log(`[ivr] playing menu "${menu.id}" prompt`);
|
||||
|
||||
this.systemLeg.playPrompt(promptId, () => {
|
||||
if (this.destroyed) return;
|
||||
// Prompt finished — start digit timeout.
|
||||
this.waitingForDigit = true;
|
||||
this.startDigitTimeout();
|
||||
});
|
||||
}
|
||||
|
||||
/** Start the timeout timer for digit input. */
|
||||
private startDigitTimeout(): void {
|
||||
const timeoutSec = this.currentMenu?.timeoutSec ?? 5;
|
||||
|
||||
this.digitTimeout = setTimeout(() => {
|
||||
if (this.destroyed || !this.currentMenu) return;
|
||||
this.log(`[ivr] digit timeout in menu "${this.currentMenu.id}"`);
|
||||
this.handleTimeout();
|
||||
}, timeoutSec * 1000);
|
||||
}
|
||||
|
||||
/** Handle timeout (no digit pressed). */
|
||||
private handleTimeout(): void {
|
||||
if (!this.currentMenu) return;
|
||||
|
||||
this.retryCount++;
|
||||
const maxRetries = this.currentMenu.maxRetries ?? 3;
|
||||
|
||||
if (this.retryCount >= maxRetries) {
|
||||
this.log(`[ivr] max retries (${maxRetries}) reached in menu "${this.currentMenu.id}"`);
|
||||
this.executeAction(this.currentMenu.timeoutAction);
|
||||
} else {
|
||||
this.log(`[ivr] retry ${this.retryCount}/${maxRetries} in menu "${this.currentMenu.id}"`);
|
||||
// Replay the current menu.
|
||||
this.navigateToMenu(this.currentMenu);
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute an IVR action. */
|
||||
private executeAction(action: TIvrAction): void {
|
||||
if (this.destroyed) return;
|
||||
|
||||
switch (action.type) {
|
||||
case 'submenu': {
|
||||
const submenu = this.getMenu(action.menuId);
|
||||
if (submenu) {
|
||||
this.retryCount = 0;
|
||||
this.navigateToMenu(submenu);
|
||||
} else {
|
||||
this.log(`[ivr] submenu "${action.menuId}" not found — hanging up`);
|
||||
this.onAction({ type: 'hangup' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'repeat': {
|
||||
if (this.currentMenu) {
|
||||
this.navigateToMenu(this.currentMenu);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'play-message': {
|
||||
// Play a message prompt, then return to the current menu.
|
||||
this.systemLeg.playPrompt(action.promptId, () => {
|
||||
if (this.destroyed || !this.currentMenu) return;
|
||||
this.navigateToMenu(this.currentMenu);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// All other actions (route-extension, route-voicemail, transfer, hangup)
|
||||
// are handled by the CallManager via the onAction callback.
|
||||
this.log(`[ivr] action: ${action.type}`);
|
||||
this.onAction(action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up a menu by ID. */
|
||||
private getMenu(menuId: string): IIvrMenu | null {
|
||||
return this.config.menus.find((m) => m.id === menuId) ?? null;
|
||||
}
|
||||
|
||||
/** Clear the digit timeout timer. */
|
||||
private clearDigitTimeout(): void {
|
||||
if (this.digitTimeout) {
|
||||
clearTimeout(this.digitTimeout);
|
||||
this.digitTimeout = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -157,6 +157,24 @@ export async function configureProxyEngine(config: Record<string, unknown>): Pro
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -170,6 +188,66 @@ export async function hangupCall(callId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event from the proxy engine.
|
||||
* Event names: incoming_call, outbound_device_call, call_ringing,
|
||||
|
||||
214
ts/registrar.ts
214
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)}`,
|
||||
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);
|
||||
browserDevices.set(sessionId, {
|
||||
deviceId: `browser-${shortHash(sessionId)}`,
|
||||
displayName: browserName,
|
||||
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,241 +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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MWI (Message Waiting Indicator) — RFC 3842
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a SIP NOTIFY request for Message Waiting Indicator.
|
||||
*
|
||||
* Sent out-of-dialog to notify a device about voicemail message counts.
|
||||
* Uses the message-summary event package per RFC 3842.
|
||||
*/
|
||||
export interface IMwiOptions {
|
||||
/** Proxy LAN IP and port (Via / From / Contact). */
|
||||
proxyHost: string;
|
||||
proxyPort: number;
|
||||
/** Target device URI (e.g. "sip:user@192.168.5.100:5060"). */
|
||||
targetUri: string;
|
||||
/** Account URI for the voicebox (used in the From header). */
|
||||
accountUri: string;
|
||||
/** Number of new (unheard) voice messages. */
|
||||
newMessages: number;
|
||||
/** Number of old (heard) voice messages. */
|
||||
oldMessages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the body and headers for an MWI NOTIFY (RFC 3842 message-summary).
|
||||
*
|
||||
* Returns the body string and extra headers needed. The caller builds
|
||||
* the SipMessage via SipMessage.createRequest('NOTIFY', ...).
|
||||
*/
|
||||
export function buildMwiBody(newMessages: number, oldMessages: number, accountUri: string): {
|
||||
body: string;
|
||||
contentType: string;
|
||||
extraHeaders: [string, string][];
|
||||
} {
|
||||
const hasNew = newMessages > 0;
|
||||
const body =
|
||||
`Messages-Waiting: ${hasNew ? 'yes' : 'no'}\r\n` +
|
||||
`Message-Account: ${accountUri}\r\n` +
|
||||
`Voice-Message: ${newMessages}/${oldMessages}\r\n`;
|
||||
|
||||
return {
|
||||
body,
|
||||
contentType: 'application/simple-message-summary',
|
||||
extraHeaders: [
|
||||
['Event', 'message-summary'],
|
||||
['Subscription-State', 'terminated;reason=noresource'],
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,17 +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,
|
||||
buildMwiBody,
|
||||
} from './helpers.ts';
|
||||
export type { ISdpOptions, IDigestChallenge, IMwiOptions } 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;
|
||||
}
|
||||
118
ts/sipproxy.ts
118
ts/sipproxy.ts
@@ -33,7 +33,11 @@ import {
|
||||
configureProxyEngine,
|
||||
onProxyEvent,
|
||||
hangupCall,
|
||||
makeCall,
|
||||
shutdownProxyEngine,
|
||||
webrtcOffer,
|
||||
webrtcIce,
|
||||
webrtcClose,
|
||||
} from './proxybridge.ts';
|
||||
import type {
|
||||
IIncomingCallEvent,
|
||||
@@ -152,12 +156,25 @@ initWebRtcSignaling({ log });
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getStatus() {
|
||||
// Merge SIP devices (from Rust) + browser devices (from TS WebSocket).
|
||||
const devices = [...deviceStatuses.values()];
|
||||
for (const bid of getAllBrowserDeviceIds()) {
|
||||
devices.push({
|
||||
id: bid,
|
||||
displayName: 'Browser',
|
||||
address: null,
|
||||
port: 0,
|
||||
connected: true,
|
||||
isBrowser: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
instanceId,
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
lanIp: appConfig.proxy.lanIp,
|
||||
providers: [...providerStatuses.values()],
|
||||
devices: [...deviceStatuses.values()],
|
||||
devices,
|
||||
calls: [...activeCalls.values()].map((c) => ({
|
||||
...c,
|
||||
duration: Math.floor((Date.now() - c.startedAt) / 1000),
|
||||
@@ -243,6 +260,19 @@ async function startProxyEngine(): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
});
|
||||
|
||||
onProxyEvent('call_ringing', (data: { call_id: string }) => {
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (call) call.state = 'ringing';
|
||||
@@ -278,6 +308,49 @@ async function startProxyEngine(): Promise<void> {
|
||||
log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`);
|
||||
});
|
||||
|
||||
// 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,
|
||||
@@ -330,12 +403,28 @@ async function startProxyEngine(): Promise<void> {
|
||||
initWebUi(
|
||||
getStatus,
|
||||
log,
|
||||
(number, _deviceId, _providerId) => {
|
||||
(number, deviceId, providerId) => {
|
||||
// Outbound calls from dashboard — send make_call command to Rust.
|
||||
// For now, log only. Full implementation needs make_call in Rust.
|
||||
log(`[dashboard] start call requested: ${number}`);
|
||||
// TODO: send make_call command when implemented in Rust
|
||||
return null;
|
||||
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(),
|
||||
});
|
||||
} 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);
|
||||
@@ -377,8 +466,23 @@ initWebUi(
|
||||
log(`[config] reload failed: ${e.message}`);
|
||||
}
|
||||
},
|
||||
undefined, // callManager — WebRTC calls handled separately in Phase 2
|
||||
undefined, // callManager — legacy, replaced by Rust proxy-engine
|
||||
voiceboxManager,
|
||||
// WebRTC signaling → forwarded to Rust proxy-engine.
|
||||
async (sessionId, sdp, ws) => {
|
||||
log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)}`);
|
||||
const result = await webrtcOffer(sessionId, sdp);
|
||||
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)}`);
|
||||
}
|
||||
},
|
||||
async (sessionId, candidate) => {
|
||||
await webrtcIce(sessionId, candidate);
|
||||
},
|
||||
async (sessionId) => {
|
||||
await webrtcClose(sessionId);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.11.0',
|
||||
version: '1.12.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user