feat(proxy-engine): add Rust-based outbound calling, WebRTC bridging, and voicemail handling

This commit is contained in:
2026-04-10 11:36:18 +00:00
parent ad253f823f
commit 239e2ac81d
42 changed files with 3360 additions and 6444 deletions

View 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))
}

View File

@@ -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() {

View File

@@ -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())
}
}

View File

@@ -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(),
&registered_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),
}
}

View 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,
}

View 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()
}

View 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.
}
}
}