From f78639dd192e1ccebe443a9cd54d34d8503cb239 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 10 Apr 2026 16:57:07 +0000 Subject: [PATCH] fix(proxy-engine,codec-lib,sip-proto,ts): preserve negotiated media details and improve RTP audio handling across call legs --- changelog.md | 8 + nogit/voicemail/default/msg-1775840000387.wav | Bin 0 -> 114284 bytes nogit/voicemail/default/msg-1775840014276.wav | Bin 0 -> 29164 bytes rust/crates/codec-lib/src/lib.rs | 56 ++++++- rust/crates/proxy-engine/src/call_manager.rs | 147 ++++++++++++++---- rust/crates/proxy-engine/src/leg_io.rs | 24 ++- rust/crates/proxy-engine/src/main.rs | 9 +- rust/crates/proxy-engine/src/mixer.rs | 66 +++++--- rust/crates/proxy-engine/src/webrtc_engine.rs | 5 +- rust/crates/sip-proto/src/helpers.rs | 10 +- rust/crates/sip-proto/src/lib.rs | 4 +- rust/crates/sip-proto/src/rewrite.rs | 2 +- ts/00_commitinfo_data.ts | 2 +- ts/sipproxy.ts | 6 +- ts_web/00_commitinfo_data.ts | 2 +- 15 files changed, 260 insertions(+), 81 deletions(-) create mode 100644 nogit/voicemail/default/msg-1775840000387.wav create mode 100644 nogit/voicemail/default/msg-1775840014276.wav diff --git a/changelog.md b/changelog.md index 73d1bc1..9e30479 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-10 - 1.17.1 - fix(proxy-engine,codec-lib,sip-proto,ts) +preserve negotiated media details and improve RTP audio handling across call legs + +- Use native Opus float encode/decode to avoid unnecessary i16 quantization in the f32 audio path. +- Parse full RTP headers including extensions and sequence numbers, then sort inbound packets before decoding to keep codec state stable for out-of-order audio. +- Capture negotiated codec payload types from SDP offers and answers and include codec, RTP port, remote media, and metadata in leg_added events. +- Emit leg_state_changed and leg_removed events more consistently so the dashboard reflects leg lifecycle updates accurately. + ## 2026-04-10 - 1.17.0 - feat(proxy-engine) upgrade the internal audio bus to 48kHz f32 with per-leg denoising and improve SIP leg routing diff --git a/nogit/voicemail/default/msg-1775840000387.wav b/nogit/voicemail/default/msg-1775840000387.wav new file mode 100644 index 0000000000000000000000000000000000000000..780f54d4b6838ad2a7324ea51e16107568b20fe8 GIT binary patch literal 114284 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%0D|t(X5}W78s#rCEMc8KJR@e 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 literal 0 HcmV?d00001 diff --git a/rust/crates/codec-lib/src/lib.rs b/rust/crates/codec-lib/src/lib.rs index e23a179..f18ba82 100644 --- a/rust/crates/codec-lib/src/lib.rs +++ b/rust/crates/codec-lib/src/lib.rs @@ -301,19 +301,59 @@ impl TranscodeState { /// Decode an encoded audio payload to f32 PCM samples in [-1.0, 1.0]. /// Returns (samples, sample_rate). + /// + /// For Opus, uses native float decode (no i16 quantization). + /// For G.722/G.711, decodes to i16 then converts (codec is natively i16). pub fn decode_to_f32(&mut self, data: &[u8], pt: u8) -> Result<(Vec, u32), String> { - let (pcm_i16, rate) = self.decode_to_pcm(data, pt)?; - let pcm_f32 = pcm_i16.iter().map(|&s| s as f32 / 32768.0).collect(); - Ok((pcm_f32, rate)) + match pt { + PT_OPUS => { + let mut pcm = vec![0.0f32; 5760]; // up to 120ms at 48kHz + let packet = + OpusPacket::try_from(data).map_err(|e| format!("opus packet: {e}"))?; + let out = + MutSignals::try_from(&mut pcm[..]).map_err(|e| format!("opus signals: {e}"))?; + let n: usize = self + .opus_dec + .decode_float(Some(packet), out, false) + .map_err(|e| format!("opus decode_float: {e}"))? + .into(); + pcm.truncate(n); + Ok((pcm, 48000)) + } + _ => { + // G.722, PCMU, PCMA: natively i16 codecs — decode then convert. + let (pcm_i16, rate) = self.decode_to_pcm(data, pt)?; + let pcm_f32 = pcm_i16.iter().map(|&s| s as f32 / 32768.0).collect(); + Ok((pcm_f32, rate)) + } + } } /// Encode f32 PCM samples ([-1.0, 1.0]) to an audio codec. + /// + /// For Opus, uses native float encode (no i16 quantization). + /// For G.722/G.711, converts to i16 then encodes (codec is natively i16). pub fn encode_from_f32(&mut self, pcm: &[f32], pt: u8) -> Result, String> { - let pcm_i16: Vec = pcm - .iter() - .map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16) - .collect(); - self.encode_from_pcm(&pcm_i16, pt) + match pt { + PT_OPUS => { + let mut buf = vec![0u8; 4000]; + let n: usize = self + .opus_enc + .encode_float(pcm, &mut buf) + .map_err(|e| format!("opus encode_float: {e}"))? + .into(); + buf.truncate(n); + Ok(buf) + } + _ => { + // G.722, PCMU, PCMA: natively i16 codecs. + let pcm_i16: Vec = pcm + .iter() + .map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16) + .collect(); + self.encode_from_pcm(&pcm_i16, pt) + } + } } /// High-quality sample rate conversion for f32 PCM using rubato FFT resampler. diff --git a/rust/crates/proxy-engine/src/call_manager.rs b/rust/crates/proxy-engine/src/call_manager.rs index dda6b07..39827f4 100644 --- a/rust/crates/proxy-engine/src/call_manager.rs +++ b/rust/crates/proxy-engine/src/call_manager.rs @@ -20,6 +20,35 @@ use std::net::SocketAddr; use std::sync::Arc; use tokio::net::UdpSocket; +/// Emit a `leg_added` event with full leg information. +/// Free function (not a method) to avoid `&self` borrow conflicts when `self.calls` is borrowed. +fn emit_leg_added_event(tx: &OutTx, call_id: &str, leg: &LegInfo) { + let metadata: serde_json::Value = if leg.metadata.is_empty() { + serde_json::json!({}) + } else { + serde_json::Value::Object( + leg.metadata + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ) + }; + emit_event( + tx, + "leg_added", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg.id, + "kind": leg.kind.as_str(), + "state": leg.state.as_str(), + "codec": sip_proto::helpers::codec_name(leg.codec_pt), + "rtpPort": leg.rtp_port, + "remoteMedia": leg.remote_media.map(|a| format!("{}:{}", a.ip(), a.port())), + "metadata": metadata, + }), + ); +} + pub struct CallManager { /// All active calls, keyed by internal call ID. pub calls: HashMap, @@ -265,6 +294,11 @@ impl CallManager { dev_leg.state = LegState::Connected; } } + emit_event( + &self.out_tx, + "leg_state_changed", + serde_json::json!({ "call_id": call_id, "leg_id": dev_leg_id, "state": "connected" }), + ); // Wire device leg to mixer. if let Some(dev_remote_addr) = dev_remote { @@ -324,6 +358,8 @@ impl CallManager { leg.state = LegState::Terminated; } } + emit_event(&self.out_tx, "leg_state_changed", + serde_json::json!({ "call_id": call_id, "leg_id": leg_id, "state": "terminated" })); emit_event(&self.out_tx, "call_ended", serde_json::json!({ "call_id": call_id, "reason": reason, "duration": duration })); self.terminate_call(call_id).await; @@ -529,21 +565,30 @@ impl CallManager { if let Some(leg) = call.legs.get_mut(this_leg_id) { leg.state = LegState::Ringing; } + emit_event(&self.out_tx, "leg_state_changed", + serde_json::json!({ "call_id": call_id, "leg_id": this_leg_id, "state": "ringing" })); } else if code >= 200 && code < 300 { let mut needs_wiring = false; if let Some(leg) = call.legs.get_mut(this_leg_id) { leg.state = LegState::Connected; - // Learn remote media from SDP. + // Learn remote media and negotiated codec from SDP answer. if msg.has_sdp_body() { if let Some(ep) = parse_sdp_endpoint(&msg.body) { if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() { leg.remote_media = Some(addr); } + // Use the codec from the SDP answer (what the remote actually selected). + if let Some(pt) = ep.codec_pt { + leg.codec_pt = pt; + } } } needs_wiring = true; } + emit_event(&self.out_tx, "leg_state_changed", + serde_json::json!({ "call_id": call_id, "leg_id": this_leg_id, "state": "connected" })); + if call.state != CallState::Connected { call.state = CallState::Connected; emit_event(&self.out_tx, "call_answered", serde_json::json!({ "call_id": call_id })); @@ -689,15 +734,19 @@ impl CallManager { call.callee_number = Some(called_number); call.state = CallState::Ringing; - let codec_pt = provider_config.codecs.first().copied().unwrap_or(9); + let mut codec_pt = provider_config.codecs.first().copied().unwrap_or(9); - // Provider leg — extract media from SDP. + // Provider leg — extract media and negotiated codec from SDP. let mut provider_media: Option = None; if invite.has_sdp_body() { if let Some(ep) = parse_sdp_endpoint(&invite.body) { if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() { provider_media = Some(addr); } + // Use the codec from the provider's SDP offer (what they actually want to use). + if let Some(pt) = ep.codec_pt { + codec_pt = pt; + } } } @@ -767,6 +816,16 @@ impl CallManager { // Store the call. self.calls.insert(call_id.clone(), call); + // Emit leg_added for both initial legs. + if let Some(call) = self.calls.get(&call_id) { + if let Some(leg) = call.legs.get(&provider_leg_id) { + emit_leg_added_event(&self.out_tx, &call_id, leg); + } + if let Some(leg) = call.legs.get(&device_leg_id) { + emit_leg_added_event(&self.out_tx, &call_id, leg); + } + } + Some(call_id) } @@ -854,6 +913,14 @@ impl CallManager { .insert(sip_call_id, (call_id.clone(), leg_id)); self.calls.insert(call_id.clone(), call); + + // Emit leg_added for the provider leg. + if let Some(call) = self.calls.get(&call_id) { + for leg in call.legs.values() { + emit_leg_added_event(&self.out_tx, &call_id, leg); + } + } + Some(call_id) } @@ -1002,6 +1069,14 @@ impl CallManager { .insert(provider_sip_call_id, (call_id.clone(), provider_leg_id)); self.calls.insert(call_id.clone(), call); + + // Emit leg_added for both initial legs (device + provider). + if let Some(call) = self.calls.get(&call_id) { + for leg in call.legs.values() { + emit_leg_added_event(&self.out_tx, &call_id, leg); + } + } + Some(call_id) } @@ -1069,17 +1144,11 @@ impl CallManager { let call = self.calls.get_mut(call_id).unwrap(); call.legs.insert(leg_id.clone(), leg_info); - emit_event( - &self.out_tx, - "leg_added", - serde_json::json!({ - "call_id": call_id, - "leg_id": leg_id, - "kind": "sip-provider", - "state": "inviting", - "number": number, - }), - ); + if let Some(call) = self.calls.get(call_id) { + if let Some(leg) = call.legs.get(&leg_id) { + emit_leg_added_event(&self.out_tx, call_id, leg); + } + } Some(leg_id) } @@ -1145,17 +1214,11 @@ impl CallManager { let call = self.calls.get_mut(call_id).unwrap(); call.legs.insert(leg_id.clone(), leg_info); - emit_event( - &self.out_tx, - "leg_added", - serde_json::json!({ - "call_id": call_id, - "leg_id": leg_id, - "kind": "sip-device", - "state": "inviting", - "device_id": device_id, - }), - ); + if let Some(call) = self.calls.get(call_id) { + if let Some(leg) = call.legs.get(&leg_id) { + emit_leg_added_event(&self.out_tx, call_id, leg); + } + } Some(leg_id) } @@ -1242,6 +1305,13 @@ impl CallManager { None => return false, }; + // Emit leg_removed for source call. + emit_event( + &self.out_tx, + "leg_removed", + serde_json::json!({ "call_id": source_call_id, "leg_id": leg_id }), + ); + // Update SIP index to point to the target call. if let Some(sip_cid) = &leg_info.sip_call_id { self.sip_index.insert( @@ -1274,15 +1344,12 @@ impl CallManager { let target_call = self.calls.get_mut(target_call_id).unwrap(); target_call.legs.insert(leg_id.to_string(), leg_info); - emit_event( - &self.out_tx, - "leg_transferred", - serde_json::json!({ - "leg_id": leg_id, - "source_call_id": source_call_id, - "target_call_id": target_call_id, - }), - ); + // Emit leg_added for target call. + if let Some(target) = self.calls.get(target_call_id) { + if let Some(leg) = target.legs.get(leg_id) { + emit_leg_added_event(&self.out_tx, target_call_id, leg); + } + } // Check if source call has too few legs remaining. let source_call = self.calls.get(source_call_id).unwrap(); @@ -1385,6 +1452,11 @@ impl CallManager { } } leg.state = LegState::Terminated; + emit_event( + &self.out_tx, + "leg_state_changed", + serde_json::json!({ "call_id": call_id, "leg_id": leg.id, "state": "terminated" }), + ); } emit_event( @@ -1503,6 +1575,13 @@ impl CallManager { ); self.calls.insert(call_id.to_string(), call); + // Emit leg_added for the provider leg. + if let Some(call) = self.calls.get(call_id) { + for leg in call.legs.values() { + emit_leg_added_event(&self.out_tx, call_id, leg); + } + } + // Build recording path. let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/rust/crates/proxy-engine/src/leg_io.rs b/rust/crates/proxy-engine/src/leg_io.rs index ce2e6ff..dc516e4 100644 --- a/rust/crates/proxy-engine/src/leg_io.rs +++ b/rust/crates/proxy-engine/src/leg_io.rs @@ -35,7 +35,8 @@ pub fn create_leg_channels() -> LegChannels { } /// Spawn the inbound I/O task for a SIP leg. -/// Reads RTP from the socket, strips the 12-byte header, sends payload to the mixer. +/// Reads RTP from the socket, parses the variable-length header (RFC 3550), +/// and sends the payload to the mixer. /// Returns the JoinHandle (exits when the inbound_tx channel is dropped). pub fn spawn_sip_inbound( rtp_socket: Arc, @@ -51,12 +52,29 @@ pub fn spawn_sip_inbound( } let pt = buf[1] & 0x7F; let marker = (buf[1] & 0x80) != 0; + let seq = u16::from_be_bytes([buf[2], buf[3]]); let timestamp = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]); - let payload = buf[12..n].to_vec(); + + // RFC 3550: header length = 12 + (CC * 4) + optional extension. + let cc = (buf[0] & 0x0F) as usize; + let has_extension = (buf[0] & 0x10) != 0; + let mut offset = 12 + cc * 4; + if has_extension { + if offset + 4 > n { + continue; // Malformed: extension header truncated. + } + let ext_len = u16::from_be_bytes([buf[offset + 2], buf[offset + 3]]) as usize; + offset += 4 + ext_len * 4; + } + if offset >= n { + continue; // No payload after header. + } + + let payload = buf[offset..n].to_vec(); if payload.is_empty() { continue; } - if inbound_tx.send(RtpPacket { payload, payload_type: pt, marker, timestamp }).await.is_err() { + if inbound_tx.send(RtpPacket { payload, payload_type: pt, marker, seq, timestamp }).await.is_err() { break; // Channel closed — leg removed. } } diff --git a/rust/crates/proxy-engine/src/main.rs b/rust/crates/proxy-engine/src/main.rs index b08b2be..baec084 100644 --- a/rust/crates/proxy-engine/src/main.rs +++ b/rust/crates/proxy-engine/src/main.rs @@ -677,6 +677,10 @@ async fn handle_webrtc_link( "leg_id": session_id, "kind": "webrtc", "state": "connected", + "codec": "Opus", + "rtpPort": 0, + "remoteMedia": null, + "metadata": {}, })); respond_ok(out_tx, &cmd.id, serde_json::json!({ @@ -1125,8 +1129,11 @@ async fn handle_add_tool_leg( "call_id": call_id, "leg_id": tool_leg_id, "kind": "tool", - "tool_type": tool_type_str, "state": "connected", + "codec": null, + "rtpPort": 0, + "remoteMedia": null, + "metadata": { "tool_type": tool_type_str }, }), ); diff --git a/rust/crates/proxy-engine/src/mixer.rs b/rust/crates/proxy-engine/src/mixer.rs index 56a4fb4..ccbbc8c 100644 --- a/rust/crates/proxy-engine/src/mixer.rs +++ b/rust/crates/proxy-engine/src/mixer.rs @@ -35,6 +35,8 @@ pub struct RtpPacket { pub payload_type: u8, /// RTP marker bit (first packet of a DTMF event, etc.). pub marker: bool, + /// RTP sequence number for reordering. + pub seq: u16, /// RTP timestamp from the original packet header. pub timestamp: u32, } @@ -319,16 +321,18 @@ async fn mixer_loop( continue; } - // ── 2. Drain inbound packets, decode to 16kHz PCM. ───────── + // ── 2. Drain inbound packets, decode to 48kHz f32 PCM. ──── // DTMF (PT 101) packets are collected separately. + // Audio packets are sorted by sequence number and decoded + // in order to maintain codec state (critical for G.722 ADPCM). let leg_ids: Vec = legs.keys().cloned().collect(); let mut dtmf_forward: Vec<(String, RtpPacket)> = Vec::new(); for lid in &leg_ids { let slot = legs.get_mut(lid).unwrap(); - // Drain channel — collect DTMF packets separately, keep latest audio. - let mut latest_audio: Option = None; + // Drain channel — collect DTMF separately, collect ALL audio packets. + let mut audio_packets: Vec = Vec::new(); loop { match slot.inbound_rx.try_recv() { Ok(pkt) => { @@ -336,35 +340,47 @@ async fn mixer_loop( // DTMF telephone-event: collect for processing. dtmf_forward.push((lid.clone(), pkt)); } else { - latest_audio = Some(pkt); + audio_packets.push(pkt); } } Err(_) => break, } } - if let Some(pkt) = latest_audio { + if !audio_packets.is_empty() { slot.silent_ticks = 0; - match slot.transcoder.decode_to_f32(&pkt.payload, pkt.payload_type) { - Ok((pcm, rate)) => { - // Resample to 48kHz mixing rate if needed. - let pcm_48k = if rate == MIX_RATE { - pcm - } else { - slot.transcoder - .resample_f32(&pcm, rate, MIX_RATE) - .unwrap_or_else(|_| vec![0.0f32; MIX_FRAME_SIZE]) - }; - // Per-leg inbound denoising at 48kHz. - let denoised = TranscodeState::denoise_f32(&mut slot.denoiser, &pcm_48k); - // Pad or truncate to exactly MIX_FRAME_SIZE. - let mut frame = denoised; - frame.resize(MIX_FRAME_SIZE, 0.0); - slot.last_pcm_frame = frame; - } - Err(_) => { - // Decode failed — use silence. - slot.last_pcm_frame = vec![0.0f32; MIX_FRAME_SIZE]; + + // Sort by sequence number for correct codec state progression. + // This prevents G.722 ADPCM state corruption from out-of-order packets. + audio_packets.sort_by_key(|p| p.seq); + + // Decode ALL packets in order (maintains codec state), + // but only keep the last decoded frame for mixing. + for pkt in &audio_packets { + match slot.transcoder.decode_to_f32(&pkt.payload, pkt.payload_type) { + Ok((pcm, rate)) => { + // Resample to 48kHz mixing rate if needed. + let pcm_48k = if rate == MIX_RATE { + pcm + } else { + slot.transcoder + .resample_f32(&pcm, rate, MIX_RATE) + .unwrap_or_else(|_| vec![0.0f32; MIX_FRAME_SIZE]) + }; + // Per-leg inbound denoising at 48kHz. + // Skip for Opus/WebRTC legs — browsers already apply + // their own noise suppression via getUserMedia. + let processed = if slot.codec_pt != codec_lib::PT_OPUS { + TranscodeState::denoise_f32(&mut slot.denoiser, &pcm_48k) + } else { + pcm_48k + }; + // Pad or truncate to exactly MIX_FRAME_SIZE. + let mut frame = processed; + frame.resize(MIX_FRAME_SIZE, 0.0); + slot.last_pcm_frame = frame; + } + Err(_) => {} } } } else if dtmf_forward.iter().any(|(src, _)| src == lid) { diff --git a/rust/crates/proxy-engine/src/webrtc_engine.rs b/rust/crates/proxy-engine/src/webrtc_engine.rs index a31b130..3c76937 100644 --- a/rust/crates/proxy-engine/src/webrtc_engine.rs +++ b/rust/crates/proxy-engine/src/webrtc_engine.rs @@ -290,8 +290,9 @@ async fn browser_to_mixer_loop( .send(RtpPacket { payload: payload.to_vec(), payload_type: PT_OPUS, - marker: false, - timestamp: 0, + marker: rtp_packet.header.marker, + seq: rtp_packet.header.sequence_number, + timestamp: rtp_packet.header.timestamp, }) .await; } diff --git a/rust/crates/sip-proto/src/helpers.rs b/rust/crates/sip-proto/src/helpers.rs index 225ae41..3bcc455 100644 --- a/rust/crates/sip-proto/src/helpers.rs +++ b/rust/crates/sip-proto/src/helpers.rs @@ -197,10 +197,11 @@ pub fn compute_digest_auth( use crate::Endpoint; -/// Parse the audio media port and connection address from an SDP body. +/// Parse the audio media port, connection address, and preferred codec from an SDP body. pub fn parse_sdp_endpoint(sdp: &str) -> Option { let mut addr: Option<&str> = None; let mut port: Option = None; + let mut codec_pt: Option = None; let normalized = sdp.replace("\r\n", "\n"); for raw in normalized.split('\n') { @@ -208,10 +209,16 @@ pub fn parse_sdp_endpoint(sdp: &str) -> Option { if let Some(rest) = line.strip_prefix("c=IN IP4 ") { addr = Some(rest.trim()); } else if let Some(rest) = line.strip_prefix("m=audio ") { + // m=audio RTP/AVP [ ...] let parts: Vec<&str> = rest.split_whitespace().collect(); if !parts.is_empty() { port = parts[0].parse().ok(); } + // parts[1] is "RTP/AVP" or similar, parts[2..] are payload types. + // The first PT is the preferred codec. + if parts.len() > 2 { + codec_pt = parts[2].parse::().ok(); + } } } @@ -219,6 +226,7 @@ pub fn parse_sdp_endpoint(sdp: &str) -> Option { (Some(a), Some(p)) => Some(Endpoint { address: a.to_string(), port: p, + codec_pt, }), _ => None, } diff --git a/rust/crates/sip-proto/src/lib.rs b/rust/crates/sip-proto/src/lib.rs index 0b71bc3..319c7d9 100644 --- a/rust/crates/sip-proto/src/lib.rs +++ b/rust/crates/sip-proto/src/lib.rs @@ -9,9 +9,11 @@ pub mod dialog; pub mod helpers; pub mod rewrite; -/// Network endpoint (address + port). +/// Network endpoint (address + port + optional negotiated codec). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Endpoint { pub address: String, pub port: u16, + /// First payload type from the SDP `m=audio` line (the preferred codec). + pub codec_pt: Option, } diff --git a/rust/crates/sip-proto/src/rewrite.rs b/rust/crates/sip-proto/src/rewrite.rs index f890dcc..1f60478 100644 --- a/rust/crates/sip-proto/src/rewrite.rs +++ b/rust/crates/sip-proto/src/rewrite.rs @@ -92,7 +92,7 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option .collect(); let original = match (orig_addr, orig_port) { - (Some(a), Some(p)) => Some(Endpoint { address: a, port: p }), + (Some(a), Some(p)) => Some(Endpoint { address: a, port: p, codec_pt: None }), _ => None, }; diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 9124309..68e53fc 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.17.0', + version: '1.17.1', description: 'undefined' } diff --git a/ts/sipproxy.ts b/ts/sipproxy.ts index 02612ec..01f07df 100644 --- a/ts/sipproxy.ts +++ b/ts/sipproxy.ts @@ -425,9 +425,9 @@ async function startProxyEngine(): Promise { id: data.leg_id, type: data.kind, state: data.state, - codec: null, - rtpPort: null, - remoteMedia: null, + codec: data.codec ?? null, + rtpPort: data.rtpPort ?? null, + remoteMedia: data.remoteMedia ?? null, metadata: data.metadata || {}, }); } diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 9124309..68e53fc 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.17.0', + version: '1.17.1', description: 'undefined' }