Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 03faa78

Browse files
jjlengferosai[bot]
authored andcommitted
feat(webrtc): implement single-port UDP multiplexing
GitOrigin-RevId: 70b9f48beff238ea61da1c9033482931002ccec9
1 parent 8ce2609 commit 03faa78

8 files changed

Lines changed: 317 additions & 61 deletions

File tree

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ services:
6666
WEBRTC__UDP_PORT: 50000
6767
WEBRTC__PUBLIC_IP: 127.0.0.1
6868
TELNYX__PUBLIC_KEY: ${TELNYX__PUBLIC_KEY:-}
69-
VOICE_SERVER__PUBLIC_URL: ${VOICE_SERVER__PUBLIC_URL:-}
69+
VOICE_SERVER__PUBLIC_URL: ${VOICE_SERVER__PUBLIC_URL:-http://127.0.0.1:8300}
7070
RUST_LOG: voice_server=debug,voice_engine=info,agent_kit=info,voice_transport=info
7171
# Write recordings to /recordings (shared with studio-api container via named volume).
7272
# The Python studio-api serves them at /api/recordings/{filename}.

voice/engine/crates/transport/src/webrtc/connection.rs

Lines changed: 112 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,15 @@ impl WebRtcConnection {
7575
/// (e.g. `"stun.cloudflare.com:3478"`)
7676
///
7777
/// The connection spawns a tokio task that:
78-
/// 1. Binds a UDP socket for the ICE agent
78+
/// 1. Obtains the shared UDP socket from the UdpMux
7979
/// 2. Runs the str0m event loop
8080
/// 3. Decodes incoming Opus audio → PCM16 → `audio_tx`
8181
/// 4. Encodes outgoing PCM16 → Opus → RTP via str0m writer
8282
/// 5. Forwards data channel messages bidirectionally
8383
pub async fn from_offer(
8484
offer_json: serde_json::Value,
8585
stun_server: &str,
86+
mux: Arc<super::multiplexer::UdpMux>,
8687
) -> Result<(Self, serde_json::Value), Box<dyn std::error::Error + Send + Sync>> {
8788
let id = uuid::Uuid::new_v4().to_string();
8889
let offer: SdpOffer = serde_json::from_value(offer_json)?;
@@ -95,63 +96,67 @@ impl WebRtcConnection {
9596
// Create str0m Rtc instance
9697
let mut rtc = RtcConfig::new().set_ice_lite(ice_lite).build(Instant::now());
9798

98-
// Bind a UDP socket for WebRTC traffic.
99-
// WEBRTC__BIND_IP controls the bind address (default: 0.0.0.0).
100-
// Some UDP proxy deployments require binding to a specific address
101-
// so that outbound packets are sourced correctly.
102-
let bind_ip = std::env::var("WEBRTC__BIND_IP").unwrap_or_else(|_| "0.0.0.0".to_string());
103-
let bind_port = std::env::var("WEBRTC__UDP_PORT")
104-
.ok()
105-
.and_then(|v| v.parse::<u16>().ok())
106-
.unwrap_or(0);
107-
let bind_addr = format!("{}:{}", bind_ip, bind_port);
108-
109-
let socket = UdpSocket::bind(&bind_addr).await?;
99+
let socket = mux.socket();
110100
let bound_addr = socket.local_addr()?;
111-
info!("[webrtc:{}] Bound UDP socket on {}", &id[..8], bind_addr);
101+
info!("[webrtc:{}] Using shared UDP socket on {}", &id[..8], bound_addr);
112102

113-
let configured_public_ip = std::env::var("WEBRTC__PUBLIC_IP")
103+
let configured_public_ips: Vec<std::net::IpAddr> = std::env::var("WEBRTC__PUBLIC_IP")
114104
.ok()
115-
.and_then(|v| v.parse::<std::net::IpAddr>().ok());
116-
let local_ip = if let Some(ip) = configured_public_ip {
117-
info!("[webrtc:{}] Using WEBRTC__PUBLIC_IP={}", &id[..8], ip);
118-
ip
105+
.map(|s| {
106+
s.split(',')
107+
.filter_map(|p| p.trim().parse::<std::net::IpAddr>().ok())
108+
.collect()
109+
})
110+
.unwrap_or_default();
111+
112+
let local_ips = if !configured_public_ips.is_empty() {
113+
info!("[webrtc:{}] Using WEBRTC__PUBLIC_IP(s)={:?}", &id[..8], configured_public_ips);
114+
configured_public_ips.clone()
119115
} else if !bound_addr.ip().is_unspecified() && !bound_addr.ip().is_loopback() {
120116
info!("[webrtc:{}] Bound to specific IP {}; using as public IP", &id[..8], bound_addr.ip());
121-
bound_addr.ip()
117+
vec![bound_addr.ip()]
122118
} else {
123119
let probe = UdpSocket::bind("0.0.0.0:0").await?;
124120
probe.connect("8.8.8.8:80").await?;
125-
probe.local_addr()?.ip()
121+
vec![probe.local_addr()?.ip()]
126122
};
127-
let host_addr = std::net::SocketAddr::new(local_ip, bound_addr.port());
128123

129-
// Add host candidate (local network address)
130-
let host_candidate = Candidate::host(host_addr, "udp")
131-
.map_err(|e| format!("Failed to create host ICE candidate: {}", e))?;
132-
rtc.add_local_candidate(host_candidate);
133-
info!("[webrtc:{}] Host candidate: {}", &id[..8], host_addr);
124+
let mut primary_host_addr = None;
125+
126+
for ip in &local_ips {
127+
let host_addr = std::net::SocketAddr::new(*ip, bound_addr.port());
128+
if primary_host_addr.is_none() {
129+
primary_host_addr = Some(host_addr);
130+
}
131+
// Add host candidate (local network address)
132+
let host_candidate = Candidate::host(host_addr, "udp")
133+
.map_err(|e| format!("Failed to create host ICE candidate: {}", e))?;
134+
rtc.add_local_candidate(host_candidate);
135+
info!("[webrtc:{}] Host candidate: {}", &id[..8], host_addr);
136+
}
134137

135138
// Resolve public IP via STUN Binding Request.
136139
// Skip srflx in explicit local override mode to avoid advertising
137140
// unreachable candidates that can win pair selection.
138-
if configured_public_ip.is_none() {
141+
if configured_public_ips.is_empty() {
139142
if let Ok(stun_addr) = tokio::net::lookup_host(stun_server)
140143
.await
141144
.map(|mut addrs| addrs.next())
142145
{
143146
if let Some(stun_addr) = stun_addr {
144147
match super::stun::stun_binding(stun_addr).await {
145148
Some(public_addr) => {
146-
let srflx = Candidate::server_reflexive(public_addr, host_addr, "udp")
147-
.map_err(|e| format!("Failed to create srflx candidate: {}", e))?;
148-
rtc.add_local_candidate(srflx);
149-
info!(
150-
"[webrtc:{}] Server-reflexive candidate: {} (via STUN {})",
151-
&id[..8],
152-
public_addr,
153-
stun_server
154-
);
149+
if let Some(host_addr) = primary_host_addr {
150+
let srflx = Candidate::server_reflexive(public_addr, host_addr, "udp")
151+
.map_err(|e| format!("Failed to create srflx candidate: {}", e))?;
152+
rtc.add_local_candidate(srflx);
153+
info!(
154+
"[webrtc:{}] Server-reflexive candidate: {} (via STUN {})",
155+
&id[..8],
156+
public_addr,
157+
stun_server
158+
);
159+
}
155160
}
156161
None => {
157162
warn!(
@@ -178,26 +183,46 @@ impl WebRtcConnection {
178183

179184
let answer_json = serde_json::to_value(&answer)?;
180185

186+
let sdp_str = answer_json.get("sdp").and_then(|v| v.as_str()).unwrap_or("");
187+
let ufrag = sdp_str
188+
.lines()
189+
.find_map(|l| l.strip_prefix("a=ice-ufrag:"))
190+
.unwrap_or("")
191+
.to_string();
192+
if ufrag.is_empty() {
193+
return Err("Failed to extract ice-ufrag from SDP answer — cannot register with UdpMux".into());
194+
}
195+
181196
// Create channels
182197
let (audio_tx, audio_rx) = mpsc::unbounded_channel::<Bytes>();
183198
let (control_event_tx, control_rx) = mpsc::unbounded_channel();
184199
let (control_cmd_tx, control_cmd_rx) = mpsc::unbounded_channel();
185200
let (audio_out_tx, audio_out_rx) = mpsc::unbounded_channel();
201+
let (mux_tx, mux_rx) = mpsc::unbounded_channel();
202+
203+
mux.register(ufrag.clone(), mux_tx).await;
186204

187205
let id_for_task = id.clone();
188206
let control_event_tx_task = control_event_tx.clone();
207+
let mux_for_task = mux.clone();
208+
let ufrag_for_task = ufrag.clone();
189209

190210
// Spawn the str0m event loop as a tokio task
191211
let task_handle = tokio::spawn(async move {
192212
if let Err(e) = run_rtc_loop(
193213
id_for_task.clone(),
194214
rtc,
195215
socket,
196-
host_addr,
216+
local_ips,
217+
bound_addr.port(),
218+
primary_host_addr.expect("primary_host_addr should always be populated by local_ips"),
197219
audio_tx,
198220
control_event_tx_task,
199221
control_cmd_rx,
200222
audio_out_rx,
223+
mux_rx,
224+
mux_for_task,
225+
ufrag_for_task,
201226
)
202227
.await
203228
{
@@ -229,23 +254,47 @@ impl Drop for WebRtcConnection {
229254

230255
// ── str0m Event Loop ────────────────────────────────────────────
231256

257+
struct UdpMuxGuard {
258+
mux: Arc<super::multiplexer::UdpMux>,
259+
ufrag: String,
260+
}
261+
262+
impl Drop for UdpMuxGuard {
263+
fn drop(&mut self) {
264+
let mux = self.mux.clone();
265+
let ufrag = self.ufrag.clone();
266+
tokio::spawn(async move {
267+
mux.unregister(&ufrag).await;
268+
});
269+
}
270+
}
271+
232272
/// The main Sans I/O event loop for a single WebRTC connection.
233273
///
234274
/// Runs until the ICE connection disconnects or an error occurs.
235275
/// Uses tokio's `UdpSocket` for async I/O instead of blocking sockets.
236276
async fn run_rtc_loop(
237277
id: String,
238278
mut rtc: Rtc,
239-
socket: UdpSocket,
240-
local_addr: std::net::SocketAddr,
279+
socket: Arc<UdpSocket>,
280+
local_ips: Vec<std::net::IpAddr>,
281+
bound_port: u16,
282+
primary_host_addr: std::net::SocketAddr,
241283
audio_tx: mpsc::UnboundedSender<Bytes>,
242284
control_tx: mpsc::UnboundedSender<TransportEvent>,
243285
mut control_rx: mpsc::UnboundedReceiver<TransportCommand>,
244286
mut audio_out_rx: mpsc::UnboundedReceiver<RtcInternalCmd>,
287+
mut mux_rx: mpsc::UnboundedReceiver<(Bytes, std::net::SocketAddr)>,
288+
mux: Arc<super::multiplexer::UdpMux>,
289+
ufrag: String,
245290
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
246291
let tag = &id[..8];
247-
let socket = Arc::new(socket);
248-
let mut buf = vec![0u8; 2000];
292+
// socket is already Arc<UdpSocket> (shared from UdpMux); no re-wrapping needed.
293+
294+
let _mux_guard = UdpMuxGuard {
295+
mux: mux.clone(),
296+
ufrag: ufrag.clone(),
297+
};
249298

250299
// Opus decoder for incoming audio (browser → server)
251300
let mut opus_decoder: Option<opus::Decoder> = None;
@@ -333,24 +382,37 @@ async fn run_rtc_loop(
333382
tokio::select! {
334383
biased;
335384

336-
// Incoming UDP packet
337-
result = socket.recv_from(&mut buf) => {
385+
// Incoming UDP packet from multiplexer
386+
result = mux_rx.recv() => {
338387
match result {
339-
Ok((n, source)) => {
340-
let contents = &buf[..n];
388+
Some((packet, source)) => {
389+
let is_stun = packet.len() >= 20 && (packet[0] == 0x00 || packet[0] == 0x01);
390+
if !is_stun {
391+
tracing::trace!("[webrtc:{}] Feeding RTP/other packet (len={}) from {} to str0m", tag, packet.len(), source);
392+
} else {
393+
tracing::debug!("[webrtc:{}] Feeding STUN packet (len={}, type={:02x}{:02x}) from {} to str0m", tag, packet.len(), packet[0], packet[1], source);
394+
}
395+
let dest_ip = if local_ips.contains(&source.ip()) {
396+
source.ip()
397+
} else {
398+
primary_host_addr.ip()
399+
};
400+
let destination = std::net::SocketAddr::new(dest_ip, bound_port);
401+
341402
let input = Input::Receive(
342403
Instant::now(),
343404
Receive {
344405
proto: Protocol::Udp,
345406
source,
346-
destination: local_addr,
347-
contents: contents.try_into()?,
407+
destination,
408+
contents: (&packet[..]).try_into()?,
348409
},
349410
);
350411
rtc.handle_input(input)?;
351412
}
352-
Err(e) => {
353-
warn!("[webrtc:{}] UDP recv error: {}", tag, e);
413+
None => {
414+
warn!("[webrtc:{}] mux_rx closed", tag);
415+
return Ok(());
354416
}
355417
}
356418
}

voice/engine/crates/transport/src/webrtc/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,15 @@
3535
//! - **Outgoing**: Reactor emits `Event::Audio(pcm)` via EventBus →
3636
//! opus encode → `rtc.writer(mid).write()` → str0m → UDP → Browser
3737
38+
pub mod multiplexer;
3839
mod connection;
3940
pub mod ice;
4041
pub mod stun;
4142
mod transport;
4243

4344
pub use connection::WebRtcConnection;
4445
pub use ice::{ice_provider_from_config, IceConfig, IceProvider, IceProviderError, IceServer};
46+
pub use multiplexer::UdpMux;
4547
pub use transport::WebRtcTransport;
4648

4749
/// Opus sample rate is always 48kHz per spec.

0 commit comments

Comments
 (0)