feat(proxy-engine): add Rust-based outbound calling, WebRTC bridging, and voicemail handling
This commit is contained in:
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user