Files
MetaCrate/crates/libremetaverse-voice-webrtc/src/native.rs
Chili Palmer c9a1170a27
Some checks failed
API and SemVer surface / api-surface (push) Failing after 1m34s
Native code generation / deterministic (push) Has been cancelled
Concurrency and resource soak audit / soak (push) Has been cancelled
Documentation / documentation (push) Has been cancelled
performance evidence / audit (push) Has been cancelled
First release candidate / non-fuzz-release-gate (push) Has been cancelled
Release platform and feature matrix / audit (push) Has been cancelled
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Dependency and supply-chain audit / audit (push) Has been cancelled
Native release artifact audit / audit (push) Has been cancelled
Imaging and meshing gate / native (push) Failing after 53s
JPEG 2000 feature / linux (push) Successful in 2m49s
Native Rust workspace compile / compile (push) Failing after 59s
Skia feature / linux (push) Has been cancelled
Complete first release candidate audit (#107)
2026-08-12 14:44:28 +00:00

1952 lines
70 KiB
Rust

//! Native WebRTC voice transport, signaling, peer protocol, and virtual audio.
#![allow(clippy::cast_possible_truncation)] // Audio normalization clamps before conversion.
#![allow(clippy::cast_precision_loss)] // Sample/rate conversion intentionally uses floating point.
#![allow(clippy::cast_sign_loss)] // Resampler positions are constructed from non-negative indices.
#![allow(clippy::missing_errors_doc)] // The error enum documents the shared failure contract.
#![allow(clippy::too_many_arguments)] // The RTC loop receives independent owned I/O resources.
#![allow(clippy::too_many_lines)] // Linear loops preserve the WebRTC mutation/drain ordering.
use libremetaverse_opus::{Channels, Decoder, Encoder};
use libremetaverse_types::UUID;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
#[cfg(feature = "real-audio")]
use std::collections::VecDeque;
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::future::Future;
use std::io::Cursor;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::Path;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use str0m::change::{SdpAnswer, SdpOffer};
use str0m::channel::ChannelId;
use str0m::format::Codec;
use str0m::media::{Direction, MediaKind, MediaTime, Mid};
use str0m::net::{Protocol, Receive};
use str0m::{Candidate, Event, IceConnectionState, Input, Output, Rtc, RtcConfig};
use tokio::net::UdpSocket;
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
const DATA_CHANNEL_LABEL: &str = "SLData";
const SAMPLE_RATE: u32 = 48_000;
const FRAME_SAMPLES: usize = 960;
const FRAME_DURATION: Duration = Duration::from_millis(20);
const MAX_SIGNALING_BYTES: usize = 1024 * 1024;
const MAX_DATA_BYTES: usize = 64 * 1024;
/// Errors produced by the native WebRTC adapter.
#[derive(Debug)]
#[non_exhaustive]
pub enum WebRtcError {
InvalidInput(&'static str),
Io(std::io::Error),
Signaling(String),
Protocol(&'static str),
Rtc(String),
Audio(String),
Timeout(&'static str),
Closed,
}
impl fmt::Display for WebRtcError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput(message) | Self::Protocol(message) => formatter.write_str(message),
Self::Io(error) => error.fmt(formatter),
Self::Signaling(message) => write!(formatter, "voice signaling failed: {message}"),
Self::Rtc(message) => write!(formatter, "WebRTC transport failed: {message}"),
Self::Audio(message) => write!(formatter, "voice audio failed: {message}"),
Self::Timeout(action) => write!(formatter, "{action} timed out"),
Self::Closed => formatter.write_str("WebRTC voice session is closed"),
}
}
}
impl std::error::Error for WebRtcError {}
impl From<std::io::Error> for WebRtcError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
/// A credential that is deliberately opaque and redacted from diagnostics.
#[derive(Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct VoiceSecret(String);
impl VoiceSecret {
pub fn new(value: impl Into<String>) -> Result<Self, WebRtcError> {
let value = value.into();
if value.len() > 16 * 1024 {
return Err(WebRtcError::InvalidInput("voice credential is too large"));
}
Ok(Self(value))
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for VoiceSecret {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("VoiceSecret(<redacted>)")
}
}
/// Initial `ProvisionVoiceAccountRequest` body.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProvisionRequest {
pub jsep: Jsep,
pub channel_type: String,
pub voice_server_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parcel_local_id: Option<i32>,
}
/// SDP envelope used by the grid voice service.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Jsep {
#[serde(rename = "type")]
pub kind: String,
pub sdp: String,
}
/// Provisioning response with credentials protected from `Debug` output.
#[derive(Clone)]
pub struct ProvisionResponse {
pub answer_sdp: String,
pub viewer_session: UUID,
pub channel: Option<String>,
pub credentials: Option<VoiceSecret>,
}
impl fmt::Debug for ProvisionResponse {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ProvisionResponse")
.field("answer_sdp", &"<redacted-sdp>")
.field("viewer_session", &self.viewer_session)
.field("channel", &self.channel.as_ref().map(|_| "<redacted>"))
.field("credentials", &self.credentials)
.finish()
}
}
/// Candidate-completion body for `VoiceSignalingRequest`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SignalingCompleteRequest {
pub voice_server_type: String,
pub viewer_session: String,
pub candidate: SignalingComplete,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct SignalingComplete {
pub completed: bool,
}
/// Logout body sent to `ProvisionVoiceAccountRequest` during teardown.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CloseRequest {
pub logout: bool,
pub voice_server_type: String,
pub viewer_session: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credentials: Option<VoiceSecret>,
}
type SignalFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, WebRtcError>> + Send + 'a>>;
/// Transport-neutral grid capability boundary. Implementations may use LLSD XML,
/// JSON fixtures, or another native Rust HTTP client without exposing credentials.
pub trait VoiceSignaling: Send + Sync {
fn provision(&self, request: ProvisionRequest) -> SignalFuture<'_, ProvisionResponse>;
fn complete(&self, request: SignalingCompleteRequest) -> SignalFuture<'_, ()>;
fn close(&self, request: CloseRequest) -> SignalFuture<'_, ()>;
}
/// Selectable audio endpoint. Virtual endpoints are always available in CI.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AudioDeviceInfo {
pub id: String,
pub name: String,
pub input: bool,
pub is_default: bool,
pub is_virtual: bool,
}
/// Cross-platform device catalog. Real devices are compiled only with
/// `--features real-audio`; virtual input/output remain deterministic otherwise.
pub fn audio_devices() -> Result<Vec<AudioDeviceInfo>, WebRtcError> {
#[allow(unused_mut)]
let mut devices = vec![
AudioDeviceInfo {
id: "virtual:microphone".into(),
name: "Virtual microphone (48 kHz mono)".into(),
input: true,
is_default: true,
is_virtual: true,
},
AudioDeviceInfo {
id: "virtual:speaker".into(),
name: "Virtual speaker (decoded PCM sink)".into(),
input: false,
is_default: true,
is_virtual: true,
},
];
#[cfg(feature = "real-audio")]
let _ = enumerate_real_devices(&mut devices);
Ok(devices)
}
#[cfg(feature = "real-audio")]
fn enumerate_real_devices(devices: &mut Vec<AudioDeviceInfo>) -> Result<(), WebRtcError> {
use cpal::traits::{DeviceTrait, HostTrait};
let host = cpal::default_host();
let default_input = host
.default_input_device()
.and_then(|device| device.id().ok());
let default_output = host
.default_output_device()
.and_then(|device| device.id().ok());
for device in host
.input_devices()
.map_err(|error| WebRtcError::Audio(error.to_string()))?
{
let name = device.to_string();
let id = device
.id()
.map_err(|error| WebRtcError::Audio(error.to_string()))?;
devices.push(AudioDeviceInfo {
id: format!("cpal:input:{id}"),
is_default: default_input.as_ref() == Some(&id),
name,
input: true,
is_virtual: false,
});
}
for device in host
.output_devices()
.map_err(|error| WebRtcError::Audio(error.to_string()))?
{
let name = device.to_string();
let id = device
.id()
.map_err(|error| WebRtcError::Audio(error.to_string()))?;
devices.push(AudioDeviceInfo {
id: format!("cpal:output:{id}"),
is_default: default_output.as_ref() == Some(&id),
name,
input: false,
is_virtual: false,
});
}
Ok(())
}
/// One 20 ms, 48 kHz mono Opus packet.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OpusFrame(pub Vec<u8>);
/// Encodes PCM into WebRTC Opus frames using native libopus.
pub fn encode_pcm_48k_mono(samples: &[i16]) -> Result<Vec<OpusFrame>, WebRtcError> {
let mut encoder = Encoder::voip(SAMPLE_RATE, Channels::Mono)
.map_err(|error| WebRtcError::Audio(error.to_string()))?;
let mut padded = samples.to_vec();
let remainder = padded.len() % FRAME_SAMPLES;
if remainder != 0 {
padded.resize(padded.len() + FRAME_SAMPLES - remainder, 0);
}
if padded.is_empty() {
padded.resize(FRAME_SAMPLES, 0);
}
let mut frames = Vec::with_capacity(padded.len() / FRAME_SAMPLES);
for pcm in padded.chunks_exact(FRAME_SAMPLES) {
let mut packet = vec![0_u8; 4_000];
let count = encoder
.encode(pcm, &mut packet)
.map_err(|error| WebRtcError::Audio(error.to_string()))?;
packet.truncate(count);
frames.push(OpusFrame(packet));
}
Ok(frames)
}
/// Loads integer or float PCM WAV data, downmixes and linearly resamples it to
/// 48 kHz mono, then encodes it into paced Opus frames.
pub fn encode_wav(path: &Path) -> Result<Vec<OpusFrame>, WebRtcError> {
let bytes = std::fs::read(path)?;
encode_wav_bytes(&bytes)
}
pub fn encode_wav_bytes(bytes: &[u8]) -> Result<Vec<OpusFrame>, WebRtcError> {
if bytes.len() > 128 * 1024 * 1024 {
return Err(WebRtcError::InvalidInput("WAV file exceeds 128 MiB"));
}
let mut reader = hound::WavReader::new(Cursor::new(bytes))
.map_err(|error| WebRtcError::Audio(error.to_string()))?;
let spec = reader.spec();
if spec.channels == 0 || spec.sample_rate == 0 || spec.channels > 32 {
return Err(WebRtcError::InvalidInput(
"unsupported WAV channel/rate metadata",
));
}
let raw: Vec<f32> = match spec.sample_format {
hound::SampleFormat::Float => reader
.samples::<f32>()
.map(|sample| sample.map_err(|error| WebRtcError::Audio(error.to_string())))
.collect::<Result<_, _>>()?,
hound::SampleFormat::Int => {
let scale = 2_f32.powi(i32::from(spec.bits_per_sample.saturating_sub(1)));
reader
.samples::<i32>()
.map(|sample| {
sample
.map(|value| value as f32 / scale)
.map_err(|error| WebRtcError::Audio(error.to_string()))
})
.collect::<Result<_, _>>()?
}
};
let channels = usize::from(spec.channels);
let mono: Vec<f32> = raw
.chunks(channels)
.map(|frame| frame.iter().copied().sum::<f32>() / frame.len() as f32)
.collect();
if mono.is_empty() {
return Err(WebRtcError::InvalidInput(
"WAV file contains no audio samples",
));
}
let output_len = mono
.len()
.saturating_mul(SAMPLE_RATE as usize)
.div_ceil(spec.sample_rate as usize);
let mut pcm = Vec::with_capacity(output_len);
for output_index in 0..output_len {
let position = output_index as f64 * f64::from(spec.sample_rate) / f64::from(SAMPLE_RATE);
let left = position.floor() as usize;
let fraction = (position - left as f64) as f32;
let a = mono[left.min(mono.len() - 1)];
let b = mono[(left + 1).min(mono.len() - 1)];
let value = a + (b - a) * fraction;
pcm.push((value.clamp(-1.0, 1.0) * f32::from(i16::MAX)).round() as i16);
}
encode_pcm_48k_mono(&pcm)
}
/// Peer audio state delivered over `SLData`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PeerAudioState {
pub power: Option<i32>,
pub voice_active: Option<bool>,
pub moderator_muted: Option<bool>,
pub joined_primary: Option<bool>,
pub left: bool,
pub ssrc: Option<u32>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Int3 {
pub x: i32,
pub y: i32,
pub z: i32,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Int4 {
pub x: i32,
pub y: i32,
pub z: i32,
pub w: i32,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct AvatarPosition {
pub sender_position: Option<Int3>,
pub sender_heading: Option<Int4>,
pub listener_position: Option<Int3>,
pub listener_heading: Option<Int4>,
}
/// Observable session events. None contains capability URLs or credentials.
#[derive(Clone, Debug, PartialEq)]
pub enum VoiceEvent {
IceState(IceConnectionState),
Connected,
DataChannelReady,
PeerAudio(UUID, PeerAudioState),
PeerPosition(UUID, AvatarPosition),
MuteMap(HashMap<UUID, bool>),
GainMap(HashMap<UUID, i32>),
AudioFrame { samples: usize, peak: i16 },
Diagnostic(String),
Closed,
}
#[derive(Clone, Debug, Default)]
pub struct SessionSnapshot {
pub connected: bool,
pub data_channel_ready: bool,
pub known_peers: HashSet<UUID>,
pub received_audio_frames: u64,
pub decoded_samples: u64,
pub sent_audio_frames: u64,
pub selected_input: String,
pub selected_output: String,
pub active_tasks: usize,
}
/// Session construction parameters.
#[derive(Clone, Debug)]
pub struct VoiceSessionConfig {
pub bind_ip: IpAddr,
pub parcel_local_id: Option<i32>,
pub timeout: Duration,
pub input_device: String,
pub output_device: String,
}
impl Default for VoiceSessionConfig {
fn default() -> Self {
Self {
bind_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
parcel_local_id: None,
timeout: Duration::from_secs(45),
input_device: "virtual:microphone".into(),
output_device: "virtual:speaker".into(),
}
}
}
enum Command {
Data(String, oneshot::Sender<Result<bool, WebRtcError>>),
Play(
Vec<OpusFrame>,
bool,
oneshot::Sender<Result<(), WebRtcError>>,
),
Stop(oneshot::Sender<()>),
Shutdown(oneshot::Sender<()>),
}
struct Playback {
frames: Vec<OpusFrame>,
index: usize,
looping: bool,
next_frame: Instant,
media_elapsed: Duration,
}
/// Owned native WebRTC voice session.
pub struct WebRtcVoiceSession {
command: mpsc::Sender<Command>,
events: Option<mpsc::Receiver<VoiceEvent>>,
snapshot: Arc<Mutex<SessionSnapshot>>,
signaling: Arc<dyn VoiceSignaling>,
viewer_session: UUID,
channel: Option<String>,
credentials: Option<VoiceSecret>,
task: Option<JoinHandle<()>>,
timeout: Duration,
local_sdp: String,
remote_sdp: String,
#[cfg(feature = "real-audio")]
hardware_audio: Option<RealAudioBridge>,
}
impl WebRtcVoiceSession {
/// Creates an SDP offer with Opus audio and ordered `SLData`, provisions it,
/// applies the answer, and starts the single peer/audio run loop.
pub async fn connect(
signaling: Arc<dyn VoiceSignaling>,
config: VoiceSessionConfig,
) -> Result<Self, WebRtcError> {
validate_device(&config.input_device, true)?;
validate_device(&config.output_device, false)?;
let socket = UdpSocket::bind(SocketAddr::new(config.bind_ip, 0)).await?;
let local_addr = socket.local_addr()?;
if local_addr.ip().is_unspecified() {
return Err(WebRtcError::InvalidInput(
"bind_ip must be a concrete interface address for ICE",
));
}
let now = Instant::now();
let mut rtc = RtcConfig::new().build(now);
let _ = rtc.add_local_candidate(
Candidate::host(local_addr, "udp")
.map_err(|error| WebRtcError::Rtc(error.to_string()))?,
);
let mut change = rtc.sdp_api();
let audio_mid = change.add_media(MediaKind::Audio, Direction::SendRecv, None, None, None);
let channel_id = change.add_channel(DATA_CHANNEL_LABEL.into());
let (offer, pending) = change.apply().ok_or(WebRtcError::Protocol(
"SDP changes did not produce an offer",
))?;
let local_sdp = offer.to_sdp_string();
let request = ProvisionRequest {
jsep: Jsep {
kind: "offer".into(),
sdp: local_sdp.clone(),
},
channel_type: "local".into(),
voice_server_type: "webrtc".into(),
parcel_local_id: config.parcel_local_id,
};
let mut response = tokio::time::timeout(config.timeout, signaling.provision(request))
.await
.map_err(|_| WebRtcError::Timeout("voice provisioning"))??;
if response.answer_sdp.len() > MAX_SIGNALING_BYTES {
abandon_provisioned(&signaling, &mut response, config.timeout).await;
return Err(WebRtcError::Protocol("SDP answer exceeds 1 MiB"));
}
let remote_sdp = sanitize_remote_sdp(&response.answer_sdp);
let answer = match SdpAnswer::from_sdp_string(&remote_sdp) {
Ok(answer) => answer,
Err(error) => {
abandon_provisioned(&signaling, &mut response, config.timeout).await;
return Err(WebRtcError::Rtc(error.to_string()));
}
};
if let Err(error) = rtc.sdp_api().accept_answer(pending, answer) {
abandon_provisioned(&signaling, &mut response, config.timeout).await;
return Err(WebRtcError::Rtc(error.to_string()));
}
let complete = SignalingCompleteRequest {
voice_server_type: "webrtc".into(),
viewer_session: response.viewer_session.to_string(),
candidate: SignalingComplete { completed: true },
};
let complete_result = tokio::time::timeout(config.timeout, signaling.complete(complete))
.await
.map_err(|_| WebRtcError::Timeout("ICE signaling completion"))
.and_then(|result| result);
if let Err(error) = complete_result {
abandon_provisioned(&signaling, &mut response, config.timeout).await;
return Err(error);
}
let (command_tx, command_rx) = mpsc::channel(64);
let (event_tx, event_rx) = mpsc::channel(256);
let (capture_tx, capture_rx) = mpsc::channel(32);
#[cfg(feature = "real-audio")]
let hardware_audio = match RealAudioBridge::open(
&config.input_device,
&config.output_device,
capture_tx,
&event_tx,
) {
Ok(audio) => audio,
Err(error) => {
abandon_provisioned(&signaling, &mut response, config.timeout).await;
return Err(error);
}
};
#[cfg(feature = "real-audio")]
let hardware_output = hardware_audio.output.clone();
#[cfg(not(feature = "real-audio"))]
let hardware_output = ();
#[cfg(not(feature = "real-audio"))]
drop(capture_tx);
let snapshot = Arc::new(Mutex::new(SessionSnapshot {
selected_input: config.input_device,
selected_output: config.output_device,
active_tasks: 1,
..SessionSnapshot::default()
}));
let run_snapshot = Arc::clone(&snapshot);
let task = tokio::spawn(async move {
Box::pin(run_client(
rtc,
socket,
audio_mid,
channel_id,
command_rx,
capture_rx,
event_tx,
run_snapshot,
hardware_output,
))
.await;
});
Ok(Self {
command: command_tx,
events: Some(event_rx),
snapshot,
signaling,
viewer_session: response.viewer_session,
channel: response.channel,
credentials: response.credentials,
task: Some(task),
timeout: config.timeout,
local_sdp,
remote_sdp,
#[cfg(feature = "real-audio")]
hardware_audio: Some(hardware_audio),
})
}
pub async fn next_event(&mut self) -> Option<VoiceEvent> {
self.events.as_mut()?.recv().await
}
/// Transfers the event stream to a facade task while retaining the session's
/// command and teardown handles.
pub fn take_events(&mut self) -> Option<mpsc::Receiver<VoiceEvent>> {
self.events.take()
}
#[must_use]
pub fn snapshot(&self) -> SessionSnapshot {
lock(&self.snapshot).clone()
}
pub async fn send_data(&self, message: impl Into<String>) -> Result<bool, WebRtcError> {
let message = message.into();
if message.len() > MAX_DATA_BYTES {
return Err(WebRtcError::InvalidInput(
"data-channel message exceeds 64 KiB",
));
}
let (reply_tx, reply_rx) = oneshot::channel();
self.command
.send(Command::Data(message, reply_tx))
.await
.map_err(|_| WebRtcError::Closed)?;
reply_rx.await.map_err(|_| WebRtcError::Closed)?
}
/// Queues a bounded data-channel message without blocking the caller.
pub fn try_send_data(&self, message: impl Into<String>) -> Result<bool, WebRtcError> {
let message = message.into();
if message.len() > MAX_DATA_BYTES {
return Err(WebRtcError::InvalidInput(
"data-channel message exceeds 64 KiB",
));
}
let (reply_tx, _reply_rx) = oneshot::channel();
match self.command.try_send(Command::Data(message, reply_tx)) {
Ok(()) => Ok(true),
Err(mpsc::error::TrySendError::Full(_)) => Ok(false),
Err(mpsc::error::TrySendError::Closed(_)) => Err(WebRtcError::Closed),
}
}
#[must_use]
pub fn viewer_session(&self) -> UUID {
self.viewer_session
}
#[must_use]
pub fn channel(&self) -> Option<String> {
self.channel.clone()
}
#[must_use]
pub fn credentials(&self) -> Option<String> {
self.credentials
.as_ref()
.map(|credentials| credentials.expose().to_owned())
}
#[must_use]
pub fn local_sdp(&self) -> String {
self.local_sdp.clone()
}
#[must_use]
pub fn remote_sdp(&self) -> String {
self.remote_sdp.clone()
}
pub async fn set_peer_mute(&self, peer: UUID, mute: bool) -> Result<bool, WebRtcError> {
self.send_data(json!({"m": {peer.to_string(): mute}}).to_string())
.await
}
pub async fn set_peer_gain(&self, peer: UUID, gain: i32) -> Result<bool, WebRtcError> {
self.send_data(json!({"ug": {peer.to_string(): gain.clamp(0, 220)}}).to_string())
.await
}
pub async fn send_position(
&self,
avatar: [f64; 3],
avatar_heading: [f64; 4],
listener: [f64; 3],
listener_heading: [f64; 4],
) -> Result<bool, WebRtcError> {
let scale3 = |v: [f64; 3]| json!({"x": (v[0] * 100.0).round() as i32, "y": (v[1] * 100.0).round() as i32, "z": (v[2] * 100.0).round() as i32});
let scale4 = |v: [f64; 4]| json!({"x": (v[0] * 100.0).round() as i32, "y": (v[1] * 100.0).round() as i32, "z": (v[2] * 100.0).round() as i32, "w": (v[3] * 100.0).round() as i32});
self.send_data(
json!({"sp": scale3(avatar), "sh": scale4(avatar_heading), "lp": scale3(listener), "lh": scale4(listener_heading)}).to_string(),
)
.await
}
pub async fn play_frames(
&self,
frames: Vec<OpusFrame>,
looping: bool,
) -> Result<(), WebRtcError> {
if frames.is_empty()
|| frames
.iter()
.any(|frame| frame.0.is_empty() || frame.0.len() > 4_000)
{
return Err(WebRtcError::InvalidInput("invalid Opus frame sequence"));
}
let (reply_tx, reply_rx) = oneshot::channel();
self.command
.send(Command::Play(frames, looping, reply_tx))
.await
.map_err(|_| WebRtcError::Closed)?;
reply_rx.await.map_err(|_| WebRtcError::Closed)?
}
pub async fn play_wav(&self, path: &Path, looping: bool) -> Result<(), WebRtcError> {
self.play_frames(encode_wav(path)?, looping).await
}
pub async fn stop_wav(&self) -> Result<(), WebRtcError> {
let (reply_tx, reply_rx) = oneshot::channel();
self.command
.send(Command::Stop(reply_tx))
.await
.map_err(|_| WebRtcError::Closed)?;
reply_rx.await.map_err(|_| WebRtcError::Closed)
}
/// Sends leave/logout, drains the peer run loop, and joins its only task.
pub async fn shutdown(&mut self) -> Result<(), WebRtcError> {
let _ = self.send_data("{\"l\":true}").await;
let close = CloseRequest {
logout: true,
voice_server_type: "webrtc".into(),
viewer_session: self.viewer_session.to_string(),
channel: self.channel.take(),
credentials: self.credentials.take(),
};
let signal_result =
match tokio::time::timeout(self.timeout, self.signaling.close(close)).await {
Ok(result) => result,
Err(_) => Err(WebRtcError::Timeout("voice signaling teardown")),
};
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.command.send(Command::Shutdown(reply_tx)).await;
let _ = tokio::time::timeout(self.timeout, reply_rx).await;
let task_result = if let Some(mut task) = self.task.take() {
if let Ok(result) = tokio::time::timeout(self.timeout, &mut task).await {
result.map_err(|error| WebRtcError::Rtc(error.to_string()))
} else {
task.abort();
let _ = task.await;
Err(WebRtcError::Timeout("WebRTC peer teardown"))
}
} else {
Ok(())
};
#[cfg(feature = "real-audio")]
self.hardware_audio.take();
signal_result.and(task_result)
}
}
async fn abandon_provisioned(
signaling: &Arc<dyn VoiceSignaling>,
response: &mut ProvisionResponse,
timeout: Duration,
) {
let close = CloseRequest {
logout: true,
voice_server_type: "webrtc".into(),
viewer_session: response.viewer_session.to_string(),
channel: response.channel.take(),
credentials: response.credentials.take(),
};
let _ = tokio::time::timeout(timeout, signaling.close(close)).await;
}
impl Drop for WebRtcVoiceSession {
fn drop(&mut self) {
if let Some(task) = self.task.take() {
let close = CloseRequest {
logout: true,
voice_server_type: "webrtc".into(),
viewer_session: self.viewer_session.to_string(),
channel: self.channel.take(),
credentials: self.credentials.take(),
};
let signaling = Arc::clone(&self.signaling);
let command = self.command.clone();
let timeout = self.timeout;
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
runtime.spawn(async move {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = command.send(Command::Shutdown(reply_tx)).await;
let _ = tokio::time::timeout(timeout, reply_rx).await;
let _ = tokio::time::timeout(timeout, signaling.close(close)).await;
let mut task = task;
if tokio::time::timeout(timeout, &mut task).await.is_err() {
task.abort();
let _ = task.await;
}
});
} else {
task.abort();
lock(&self.snapshot).active_tasks = 0;
}
}
}
}
fn validate_device(id: &str, input: bool) -> Result<(), WebRtcError> {
let devices = audio_devices()?;
if devices
.iter()
.any(|device| device.id == id && device.input == input)
{
Ok(())
} else {
Err(WebRtcError::InvalidInput(
"selected audio device is unavailable",
))
}
}
async fn run_client(
mut rtc: Rtc,
socket: UdpSocket,
audio_mid: Mid,
channel_id: ChannelId,
mut commands: mpsc::Receiver<Command>,
mut capture: mpsc::Receiver<Vec<i16>>,
events: mpsc::Sender<VoiceEvent>,
snapshot: Arc<Mutex<SessionSnapshot>>,
hardware_output: HardwareOutput,
) {
let mut buffer = vec![0_u8; 65_536];
let mut playback: Option<Playback> = None;
let mut decoder = Decoder::new(SAMPLE_RATE, Channels::Mono).ok();
let mut encoder = Encoder::voip(SAMPLE_RATE, Channels::Mono).ok();
let mut captured_elapsed = Duration::ZERO;
let mut running = true;
while running {
let deadline = match drain_client(
&mut rtc,
&socket,
channel_id,
&events,
&snapshot,
decoder.as_mut(),
&hardware_output,
)
.await
{
Ok(value) => value,
Err(error) => {
let _ = events.send(VoiceEvent::Diagnostic(error.to_string())).await;
break;
}
};
let audio_deadline = playback.as_ref().map(|value| value.next_frame);
let wake = audio_deadline.map_or(deadline, |value| value.min(deadline));
tokio::select! {
command = commands.recv() => match command {
Some(Command::Data(message, reply)) => {
let result = rtc.channel(channel_id)
.ok_or(WebRtcError::Closed)
.and_then(|mut channel| channel.write(false, message.as_bytes()).map_err(|error| WebRtcError::Rtc(error.to_string())));
let _ = reply.send(result);
}
Some(Command::Play(frames, looping, reply)) => {
playback = Some(Playback { frames, index: 0, looping, next_frame: Instant::now(), media_elapsed: Duration::ZERO });
let _ = reply.send(Ok(()));
}
Some(Command::Stop(reply)) => {
playback = None;
let _ = reply.send(());
}
Some(Command::Shutdown(reply)) => {
playback = None;
rtc.disconnect();
let _ = drain_client(&mut rtc, &socket, channel_id, &events, &snapshot, decoder.as_mut(), &hardware_output).await;
let _ = reply.send(());
running = false;
}
None => running = false,
},
captured = capture.recv() => {
if let (Some(pcm), Some(codec)) = (captured, encoder.as_mut())
&& pcm.len() == FRAME_SAMPLES
{
let mut packet = vec![0_u8; 4_000];
if let Ok(count) = codec.encode(&pcm, &mut packet) {
packet.truncate(count);
if write_opus(&mut rtc, audio_mid, &OpusFrame(packet), captured_elapsed).is_ok() {
lock(&snapshot).sent_audio_frames += 1;
}
captured_elapsed += FRAME_DURATION;
}
}
},
receive = socket.recv_from(&mut buffer) => match receive {
Ok((count, source)) => {
let Ok(destination) = socket.local_addr() else { break };
let Ok(contents) = buffer[..count].try_into() else { continue };
let input = Input::Receive(Instant::now(), Receive { proto: Protocol::Udp, source, destination, contents });
if let Err(error) = rtc.handle_input(input) {
let _ = events.send(VoiceEvent::Diagnostic(error.to_string())).await;
break;
}
}
Err(error) => {
let _ = events.send(VoiceEvent::Diagnostic(error.to_string())).await;
break;
}
},
() = tokio::time::sleep_until(tokio::time::Instant::from_std(wake)) => {
let now = Instant::now();
if audio_deadline.is_some_and(|value| value <= now) {
if let Some(active) = playback.as_mut()
&& let Some(frame) = active.frames.get(active.index)
{
let result = write_opus(&mut rtc, audio_mid, frame, active.media_elapsed);
if result.is_ok() {
lock(&snapshot).sent_audio_frames += 1;
}
active.index += 1;
active.media_elapsed += FRAME_DURATION;
active.next_frame += FRAME_DURATION;
if active.index == active.frames.len() {
if active.looping { active.index = 0; } else { playback = None; }
}
}
} else if let Err(error) = rtc.handle_input(Input::Timeout(now)) {
let _ = events.send(VoiceEvent::Diagnostic(error.to_string())).await;
break;
}
}
}
}
{
let mut state = lock(&snapshot);
state.connected = false;
state.data_channel_ready = false;
state.active_tasks = 0;
}
let _ = events.send(VoiceEvent::Closed).await;
}
fn write_opus(
rtc: &mut Rtc,
mid: Mid,
frame: &OpusFrame,
elapsed: Duration,
) -> Result<(), WebRtcError> {
let writer = rtc.writer(mid).ok_or(WebRtcError::Closed)?;
let pt = writer
.payload_params()
.find(|params| params.spec().codec == Codec::Opus)
.map(str0m::format::PayloadParams::pt)
.ok_or(WebRtcError::Protocol("Opus was not negotiated"))?;
writer
.audio_level(-30, true)
.write(
pt,
Instant::now(),
MediaTime::from(elapsed),
frame.0.clone(),
)
.map_err(|error| WebRtcError::Rtc(error.to_string()))
}
async fn drain_client(
rtc: &mut Rtc,
socket: &UdpSocket,
channel_id: ChannelId,
events: &mpsc::Sender<VoiceEvent>,
snapshot: &Arc<Mutex<SessionSnapshot>>,
decoder: Option<&mut Decoder>,
hardware_output: &HardwareOutput,
) -> Result<Instant, WebRtcError> {
#[cfg(not(feature = "real-audio"))]
let () = hardware_output;
let mut decoder = decoder;
loop {
match rtc
.poll_output()
.map_err(|error| WebRtcError::Rtc(error.to_string()))?
{
Output::Timeout(deadline) => return Ok(deadline),
Output::Transmit(transmit) => {
socket
.send_to(&transmit.contents, transmit.destination)
.await?;
}
Output::Event(event) => match event {
Event::Connected => {
lock(snapshot).connected = true;
let _ = events.send(VoiceEvent::Connected).await;
}
Event::IceConnectionStateChange(state) => {
lock(snapshot).connected = matches!(
state,
IceConnectionState::Connected | IceConnectionState::Completed
);
let _ = events.send(VoiceEvent::IceState(state)).await;
}
Event::ChannelOpen(id, label)
if id == channel_id && label == DATA_CHANNEL_LABEL =>
{
lock(snapshot).data_channel_ready = true;
if let Some(mut channel) = rtc.channel(id) {
channel
.write(false, b"{\"j\":{\"p\":true}}")
.map_err(|error| WebRtcError::Rtc(error.to_string()))?;
}
let _ = events.send(VoiceEvent::DataChannelReady).await;
}
Event::ChannelData(data) if !data.binary => {
if data.data.len() <= MAX_DATA_BYTES {
let text = std::str::from_utf8(&data.data).map_err(|_| {
WebRtcError::Protocol("data channel text was not UTF-8")
})?;
let replies = process_peer_message(text, snapshot, events).await?;
for reply in replies {
if let Some(mut channel) = rtc.channel(channel_id) {
let _ = channel.write(false, reply.as_bytes());
}
}
}
}
Event::MediaData(media) if media.params.spec().codec == Codec::Opus => {
if let Some(codec) = decoder.as_deref_mut() {
let mut pcm = vec![0_i16; FRAME_SAMPLES * 6];
if let Ok(count) =
codec.decode(Some(media.data.as_ref()), pcm.as_mut_slice(), false)
{
pcm.truncate(count);
let peak = i16::try_from(
pcm.iter()
.map(|value| value.unsigned_abs())
.max()
.unwrap_or(0)
.min(i16::MAX as u16),
)
.unwrap_or(i16::MAX);
{
let mut state = lock(snapshot);
state.received_audio_frames += 1;
state.decoded_samples += count as u64;
}
let _ = events
.send(VoiceEvent::AudioFrame {
samples: count,
peak,
})
.await;
#[cfg(feature = "real-audio")]
if let Some(output) = hardware_output {
let mut output = lock(output);
let remaining = 96_000_usize.saturating_sub(output.samples.len());
output.samples.extend(pcm.into_iter().take(remaining));
}
}
}
}
Event::ChannelClose(id) if id == channel_id => {
lock(snapshot).data_channel_ready = false;
}
Event::Closed => return Err(WebRtcError::Closed),
_ => {}
},
}
}
}
async fn process_peer_message(
message: &str,
snapshot: &Arc<Mutex<SessionSnapshot>>,
events: &mpsc::Sender<VoiceEvent>,
) -> Result<Vec<String>, WebRtcError> {
let root: Value = serde_json::from_str(message)
.map_err(|_| WebRtcError::Protocol("malformed SLData JSON"))?;
let object = root
.as_object()
.ok_or(WebRtcError::Protocol("SLData JSON must be an object"))?;
let mut replies = Vec::new();
if object.get("ping").and_then(Value::as_bool) == Some(true) {
replies.push("{\"pong\":true}".into());
}
if let Some(map) = object.get("m").and_then(Value::as_object) {
let values = parse_bool_map(map);
if !values.is_empty() {
let _ = events.send(VoiceEvent::MuteMap(values)).await;
}
}
if let Some(map) = object.get("ug").and_then(Value::as_object) {
let values = parse_int_map(map);
if !values.is_empty() {
let _ = events.send(VoiceEvent::GainMap(values)).await;
}
}
let all_peers = !object.is_empty() && object.keys().all(|key| parse_uuid(key).is_some());
if all_peers {
for (key, value) in object {
let Some(peer) = parse_uuid(key) else {
continue;
};
let Some(map) = value.as_object() else {
lock(snapshot).known_peers.remove(&peer);
continue;
};
let state = PeerAudioState {
power: map.get("p").and_then(as_i32),
voice_active: map.get("V").or_else(|| map.get("v")).and_then(as_bool),
moderator_muted: map.get("m").and_then(as_bool),
joined_primary: map
.get("j")
.and_then(Value::as_object)
.and_then(|join| join.get("p"))
.and_then(as_bool),
left: map.get("l").and_then(as_bool).unwrap_or(false),
ssrc: map
.get("s")
.or_else(|| map.get("ssrc"))
.and_then(Value::as_u64)
.and_then(|value| u32::try_from(value).ok()),
};
if state.left {
lock(snapshot).known_peers.remove(&peer);
} else {
lock(snapshot).known_peers.insert(peer);
}
let _ = events.send(VoiceEvent::PeerAudio(peer, state)).await;
if let Some(position) = parse_position(map) {
let _ = events.send(VoiceEvent::PeerPosition(peer, position)).await;
}
}
} else if let Some(join) = object.get("j").and_then(Value::as_object)
&& let Some(peer) = join.get("id").and_then(Value::as_str).and_then(parse_uuid)
{
lock(snapshot).known_peers.insert(peer);
}
Ok(replies)
}
fn parse_uuid(value: &str) -> Option<UUID> {
UUID::new_with_string(value.to_owned()).ok()
}
fn parse_bool_map(map: &Map<String, Value>) -> HashMap<UUID, bool> {
map.iter()
.filter_map(|(key, value)| Some((parse_uuid(key)?, as_bool(value)?)))
.collect()
}
fn parse_int_map(map: &Map<String, Value>) -> HashMap<UUID, i32> {
map.iter()
.filter_map(|(key, value)| Some((parse_uuid(key)?, as_i32(value)?)))
.collect()
}
fn as_bool(value: &Value) -> Option<bool> {
value
.as_bool()
.or_else(|| value.as_i64().map(|number| number != 0))
.or_else(|| value.as_str().and_then(|text| text.parse().ok()))
}
fn as_i32(value: &Value) -> Option<i32> {
value
.as_i64()
.and_then(|number| i32::try_from(number).ok())
.or_else(|| value.as_str().and_then(|text| text.parse().ok()))
}
fn parse_position(map: &Map<String, Value>) -> Option<AvatarPosition> {
let result = AvatarPosition {
sender_position: map.get("sp").and_then(parse_int3),
sender_heading: map.get("sh").and_then(parse_int4),
listener_position: map.get("lp").and_then(parse_int3),
listener_heading: map.get("lh").and_then(parse_int4),
};
(result.sender_position.is_some()
|| result.sender_heading.is_some()
|| result.listener_position.is_some()
|| result.listener_heading.is_some())
.then_some(result)
}
fn parse_int3(value: &Value) -> Option<Int3> {
let map = value.as_object()?;
Some(Int3 {
x: as_i32(map.get("x")?)?,
y: as_i32(map.get("y")?)?,
z: as_i32(map.get("z")?)?,
})
}
fn parse_int4(value: &Value) -> Option<Int4> {
let map = value.as_object()?;
Some(Int4 {
x: as_i32(map.get("x")?)?,
y: as_i32(map.get("y")?)?,
z: as_i32(map.get("z")?)?,
w: as_i32(map.get("w")?)?,
})
}
fn sanitize_remote_sdp(sdp: &str) -> String {
sdp.lines()
.filter(|line| {
if !line.starts_with("a=candidate:") {
return true;
}
let fields: Vec<_> = line.split_whitespace().collect();
fields.get(5).is_none_or(|port| *port != "0")
})
.collect::<Vec<_>>()
.join("\r\n")
+ "\r\n"
}
fn lock<T>(value: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
value
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(feature = "real-audio")]
struct RealAudioBridge {
_input: Option<cpal::Stream>,
_output: Option<cpal::Stream>,
output: Option<Arc<Mutex<RealOutputState>>>,
}
#[cfg(feature = "real-audio")]
struct RealOutputState {
samples: VecDeque<i16>,
current: i16,
phase: u64,
output_rate: u32,
channels: usize,
}
#[cfg(feature = "real-audio")]
type HardwareOutput = Option<Arc<Mutex<RealOutputState>>>;
#[cfg(not(feature = "real-audio"))]
type HardwareOutput = ();
#[cfg(feature = "real-audio")]
impl RealAudioBridge {
fn open(
input_id: &str,
output_id: &str,
capture: mpsc::Sender<Vec<i16>>,
events: &mpsc::Sender<VoiceEvent>,
) -> Result<Self, WebRtcError> {
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
let host = cpal::default_host();
let input = if input_id == "virtual:microphone" {
None
} else {
let id = input_id
.strip_prefix("cpal:input:")
.ok_or(WebRtcError::InvalidInput("invalid CPAL input device ID"))?;
let device = host
.input_devices()
.map_err(|error| WebRtcError::Audio(error.to_string()))?
.find(|device| {
device
.id()
.ok()
.as_ref()
.is_some_and(|value| value.to_string() == id)
})
.ok_or(WebRtcError::InvalidInput(
"selected CPAL input device disappeared",
))?;
let config = device
.default_input_config()
.map_err(|error| WebRtcError::Audio(error.to_string()))?;
let channels = usize::from(config.channels());
let input_rate = config.sample_rate();
let stream_config = config.config();
let stream = match config.sample_format() {
cpal::SampleFormat::I16 => build_input::<i16>(
&device,
&stream_config,
channels,
input_rate,
capture,
events.clone(),
)?,
cpal::SampleFormat::F32 => build_input::<f32>(
&device,
&stream_config,
channels,
input_rate,
capture,
events.clone(),
)?,
cpal::SampleFormat::U16 => build_input::<u16>(
&device,
&stream_config,
channels,
input_rate,
capture,
events.clone(),
)?,
format => {
return Err(WebRtcError::Audio(format!(
"unsupported capture sample format {format}"
)));
}
};
stream
.play()
.map_err(|error| WebRtcError::Audio(error.to_string()))?;
Some(stream)
};
let (output, output_state) = if output_id == "virtual:speaker" {
(None, None)
} else {
let id = output_id
.strip_prefix("cpal:output:")
.ok_or(WebRtcError::InvalidInput("invalid CPAL output device ID"))?;
let device = host
.output_devices()
.map_err(|error| WebRtcError::Audio(error.to_string()))?
.find(|device| {
device
.id()
.ok()
.as_ref()
.is_some_and(|value| value.to_string() == id)
})
.ok_or(WebRtcError::InvalidInput(
"selected CPAL output device disappeared",
))?;
let config = device
.default_output_config()
.map_err(|error| WebRtcError::Audio(error.to_string()))?;
let state = Arc::new(Mutex::new(RealOutputState {
samples: VecDeque::with_capacity(96_000),
current: 0,
phase: 0,
output_rate: config.sample_rate(),
channels: usize::from(config.channels()),
}));
let stream_config = config.config();
let stream = match config.sample_format() {
cpal::SampleFormat::I16 => build_output::<i16>(
&device,
&stream_config,
Arc::clone(&state),
events.clone(),
)?,
cpal::SampleFormat::F32 => build_output::<f32>(
&device,
&stream_config,
Arc::clone(&state),
events.clone(),
)?,
cpal::SampleFormat::U16 => build_output::<u16>(
&device,
&stream_config,
Arc::clone(&state),
events.clone(),
)?,
format => {
return Err(WebRtcError::Audio(format!(
"unsupported playback sample format {format}"
)));
}
};
stream
.play()
.map_err(|error| WebRtcError::Audio(error.to_string()))?;
(Some(stream), Some(state))
};
Ok(Self {
_input: input,
_output: output,
output: output_state,
})
}
}
#[cfg(feature = "real-audio")]
fn build_input<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
channels: usize,
input_rate: u32,
sender: mpsc::Sender<Vec<i16>>,
events: mpsc::Sender<VoiceEvent>,
) -> Result<cpal::Stream, WebRtcError>
where
T: cpal::SizedSample,
f32: cpal::FromSample<T>,
{
use cpal::Sample as _;
use cpal::traits::DeviceTrait;
let mut phase = 0_u64;
let mut frame = Vec::with_capacity(FRAME_SAMPLES);
device
.build_input_stream(
*config,
move |data: &[T], _| {
for input_frame in data.chunks(channels) {
let mono = input_frame
.iter()
.copied()
.map(f32::from_sample)
.sum::<f32>()
/ input_frame.len().max(1) as f32;
phase += u64::from(SAMPLE_RATE);
while phase >= u64::from(input_rate) {
phase -= u64::from(input_rate);
frame.push(i16::from_sample(mono.clamp(-1.0, 1.0)));
if frame.len() == FRAME_SAMPLES {
let _ = sender.try_send(std::mem::replace(
&mut frame,
Vec::with_capacity(FRAME_SAMPLES),
));
}
}
}
},
move |error| {
let _ = events.try_send(VoiceEvent::Diagnostic(format!(
"capture stream failed: {error}"
)));
},
None,
)
.map_err(|error| WebRtcError::Audio(error.to_string()))
}
#[cfg(feature = "real-audio")]
fn build_output<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
state: Arc<Mutex<RealOutputState>>,
events: mpsc::Sender<VoiceEvent>,
) -> Result<cpal::Stream, WebRtcError>
where
T: cpal::SizedSample + cpal::FromSample<i16>,
{
use cpal::traits::DeviceTrait;
device
.build_output_stream(
*config,
move |data: &mut [T], _| {
let mut state = lock(&state);
for output_frame in data.chunks_mut(state.channels) {
let sample = T::from_sample(state.current);
for output in output_frame {
*output = sample;
}
state.phase += u64::from(SAMPLE_RATE);
while state.phase >= u64::from(state.output_rate) {
state.phase -= u64::from(state.output_rate);
state.current = state.samples.pop_front().unwrap_or(0);
}
}
},
move |error| {
let _ = events.try_send(VoiceEvent::Diagnostic(format!(
"playback stream failed: {error}"
)));
},
None,
)
.map_err(|error| WebRtcError::Audio(error.to_string()))
}
/// Deterministic native WebRTC answerer used by CI and offline diagnostics.
pub struct LoopbackSignaling {
state: tokio::sync::Mutex<LoopbackState>,
provisioning: AtomicBool,
active_tasks: Arc<AtomicUsize>,
received_messages: Arc<Mutex<Vec<String>>>,
peer_id: UUID,
}
struct LoopbackState {
shutdown: Option<oneshot::Sender<()>>,
task: Option<JoinHandle<()>>,
completed: bool,
closed: bool,
}
impl LoopbackSignaling {
#[must_use]
pub fn new(peer_id: UUID) -> Arc<Self> {
Arc::new(Self {
state: tokio::sync::Mutex::new(LoopbackState {
shutdown: None,
task: None,
completed: false,
closed: false,
}),
provisioning: AtomicBool::new(false),
active_tasks: Arc::new(AtomicUsize::new(0)),
received_messages: Arc::new(Mutex::new(Vec::new())),
peer_id,
})
}
#[must_use]
pub fn active_tasks(&self) -> usize {
self.active_tasks.load(Ordering::Acquire)
}
#[must_use]
pub fn received_messages(&self) -> Vec<String> {
lock(&self.received_messages).clone()
}
pub async fn wait_closed(&self, timeout: Duration) -> Result<(), WebRtcError> {
tokio::time::timeout(timeout, async {
loop {
if self.active_tasks() == 0 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.map_err(|_| WebRtcError::Timeout("fake signaling teardown"))
}
}
impl VoiceSignaling for LoopbackSignaling {
fn provision(&self, request: ProvisionRequest) -> SignalFuture<'_, ProvisionResponse> {
Box::pin(async move {
struct ProvisionGuard<'a>(&'a AtomicBool);
impl Drop for ProvisionGuard<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
if self
.provisioning
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return Err(WebRtcError::Signaling("duplicate provision request".into()));
}
let _provisioning = ProvisionGuard(&self.provisioning);
if request.jsep.kind != "offer"
|| request.channel_type != "local"
|| request.voice_server_type != "webrtc"
{
return Err(WebRtcError::Protocol("invalid WebRTC provision request"));
}
let offer = SdpOffer::from_sdp_string(&request.jsep.sdp)
.map_err(|error| WebRtcError::Rtc(error.to_string()))?;
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let local_addr = socket.local_addr()?;
let mut rtc = RtcConfig::new().build(Instant::now());
let _ = rtc.add_local_candidate(
Candidate::host(local_addr, "udp")
.map_err(|error| WebRtcError::Rtc(error.to_string()))?,
);
let answer = rtc
.sdp_api()
.accept_offer(offer)
.map_err(|error| WebRtcError::Rtc(error.to_string()))?;
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let active = Arc::clone(&self.active_tasks);
let messages = Arc::clone(&self.received_messages);
let peer_id = self.peer_id;
let viewer_session = UUID::random()
.map_err(|_| WebRtcError::Signaling("could not create session id".into()))?;
let credentials = VoiceSecret::new("offline-secret")?;
let mut state = self.state.lock().await;
if state.task.is_some() {
return Err(WebRtcError::Signaling("duplicate provision request".into()));
}
active.fetch_add(1, Ordering::AcqRel);
let task = tokio::spawn(async move {
Box::pin(run_loopback_peer(
rtc,
socket,
shutdown_rx,
messages,
peer_id,
active,
))
.await;
});
state.shutdown = Some(shutdown_tx);
state.task = Some(task);
Ok(ProvisionResponse {
answer_sdp: answer.to_sdp_string(),
viewer_session,
channel: Some("offline-local".into()),
credentials: Some(credentials),
})
})
}
fn complete(&self, request: SignalingCompleteRequest) -> SignalFuture<'_, ()> {
Box::pin(async move {
if request.voice_server_type != "webrtc"
|| !request.candidate.completed
|| parse_uuid(&request.viewer_session).is_none()
{
return Err(WebRtcError::Protocol("invalid ICE completion request"));
}
self.state.lock().await.completed = true;
Ok(())
})
}
fn close(&self, request: CloseRequest) -> SignalFuture<'_, ()> {
Box::pin(async move {
if !request.logout
|| request.voice_server_type != "webrtc"
|| parse_uuid(&request.viewer_session).is_none()
{
return Err(WebRtcError::Protocol("invalid voice close request"));
}
let (shutdown, task) = {
let mut state = self.state.lock().await;
if !state.completed {
return Err(WebRtcError::Protocol(
"voice session closed before signaling completion",
));
}
state.closed = true;
(state.shutdown.take(), state.task.take())
};
if let Some(shutdown) = shutdown {
let _ = shutdown.send(());
}
if let Some(task) = task {
let _ = task.await;
}
Ok(())
})
}
}
async fn run_loopback_peer(
mut rtc: Rtc,
socket: UdpSocket,
mut shutdown: oneshot::Receiver<()>,
messages: Arc<Mutex<Vec<String>>>,
peer_id: UUID,
active: Arc<AtomicUsize>,
) {
let mut buffer = vec![0_u8; 65_536];
let mut running = true;
let mut channel_id = None;
while running {
let deadline = loop {
match rtc.poll_output() {
Ok(Output::Timeout(deadline)) => break deadline,
Ok(Output::Transmit(transmit)) => {
let _ = socket
.send_to(&transmit.contents, transmit.destination)
.await;
}
Ok(Output::Event(Event::ChannelOpen(id, _))) => {
channel_id = Some(id);
if let Some(mut channel) = rtc.channel(id) {
let body = json!({
peer_id.to_string(): {
"p": 42, "V": true, "m": false, "s": 777_u32,
"j": {"p": true},
"sp": {"x": 100, "y": 200, "z": 300},
"sh": {"x": 0, "y": 0, "z": 0, "w": 100}
}
})
.to_string();
let _ = channel.write(false, body.as_bytes());
let _ = channel.write(
false,
json!({"m": {peer_id.to_string(): false}})
.to_string()
.as_bytes(),
);
let _ = channel.write(
false,
json!({"ug": {peer_id.to_string(): 180}})
.to_string()
.as_bytes(),
);
let _ = channel.write(false, b"{\"ping\":true}");
}
}
Ok(Output::Event(Event::ChannelData(data))) if !data.binary => {
if let Ok(text) = String::from_utf8(data.data) {
lock(&messages).push(text);
}
}
Ok(Output::Event(Event::MediaData(media)))
if media.params.spec().codec == Codec::Opus =>
{
let pt = rtc
.writer(media.mid)
.and_then(|writer| writer.match_params(media.params));
if let Some(pt) = pt
&& let Some(writer) = rtc.writer(media.mid)
{
let _ =
writer.write(pt, media.network_time, media.time, media.data.clone());
}
}
Ok(Output::Event(Event::Closed)) | Err(_) => {
running = false;
break Instant::now();
}
Ok(Output::Event(_)) => {}
}
};
if !running {
break;
}
tokio::select! {
_ = &mut shutdown => { rtc.disconnect(); running = false; }
receive = socket.recv_from(&mut buffer) => if let Ok((count, source)) = receive
&& let (Ok(destination), Ok(contents)) = (socket.local_addr(), buffer[..count].try_into())
{
let _ = rtc.handle_input(Input::Receive(Instant::now(), Receive { proto: Protocol::Udp, source, destination, contents }));
},
() = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => { let _ = rtc.handle_input(Input::Timeout(Instant::now())); }
}
}
if let Some(id) = channel_id.and_then(|id| rtc.channel(id).map(|_| id)) {
let _ = id;
}
active.fetch_sub(1, Ordering::AcqRel);
}
#[cfg(test)]
mod tests {
use super::*;
struct InvalidAnswerSignaling {
close_calls: AtomicUsize,
}
impl VoiceSignaling for InvalidAnswerSignaling {
fn provision(&self, _: ProvisionRequest) -> SignalFuture<'_, ProvisionResponse> {
Box::pin(async {
Ok(ProvisionResponse {
answer_sdp: "not an SDP answer".into(),
viewer_session: UUID::new_with_string(
"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee".into(),
)
.unwrap(),
channel: Some("provisioned-channel".into()),
credentials: Some(VoiceSecret::new("provisioned-secret").unwrap()),
})
})
}
fn complete(&self, _: SignalingCompleteRequest) -> SignalFuture<'_, ()> {
Box::pin(async { Err(WebRtcError::Protocol("completion must not run")) })
}
fn close(&self, request: CloseRequest) -> SignalFuture<'_, ()> {
Box::pin(async move {
assert!(request.logout);
assert!(request.credentials.is_some());
self.close_calls.fetch_add(1, Ordering::AcqRel);
Ok(())
})
}
}
fn wav_fixture() -> Vec<u8> {
let mut cursor = Cursor::new(Vec::new());
{
let spec = hound::WavSpec {
channels: 2,
sample_rate: 24_000,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::new(&mut cursor, spec).unwrap();
for index in 0..2_400 {
let sample = (((index as f32 / 24_000.0) * 440.0 * std::f32::consts::TAU).sin()
* 8_000.0) as i16;
writer.write_sample(sample).unwrap();
writer.write_sample(sample).unwrap();
}
writer.finalize().unwrap();
}
cursor.into_inner()
}
#[test]
fn wav_is_resampled_and_encoded_as_valid_opus() {
let frames = encode_wav_bytes(&wav_fixture()).unwrap();
assert_eq!(frames.len(), 5);
let mut decoder = Decoder::new(SAMPLE_RATE, Channels::Mono).unwrap();
let mut samples = vec![0_i16; FRAME_SAMPLES * 6];
assert_eq!(
decoder
.decode(Some(frames[0].0.as_slice()), samples.as_mut_slice(), false)
.unwrap(),
FRAME_SAMPLES
);
}
#[tokio::test]
async fn loopback_exercises_secure_peer_data_media_and_teardown() {
let peer = UUID::new_with_string("11111111-2222-4333-8444-555555555555".into()).unwrap();
let signaling = LoopbackSignaling::new(peer);
let mut session = WebRtcVoiceSession::connect(
signaling.clone(),
VoiceSessionConfig {
timeout: Duration::from_secs(10),
..VoiceSessionConfig::default()
},
)
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(10), async {
while !session.snapshot().data_channel_ready {
assert!(session.next_event().await.is_some());
}
})
.await
.unwrap();
let frames = encode_wav_bytes(&wav_fixture()).unwrap();
session.play_frames(frames, false).await.unwrap();
session.set_peer_mute(peer, true).await.unwrap();
session.set_peer_gain(peer, 500).await.unwrap();
tokio::time::timeout(Duration::from_secs(10), async {
loop {
let state = session.snapshot();
if state.data_channel_ready
&& state.known_peers.contains(&peer)
&& state.received_audio_frames > 0
{
break;
}
let _ = session.next_event().await;
}
})
.await
.unwrap();
session.shutdown().await.unwrap();
signaling.wait_closed(Duration::from_secs(2)).await.unwrap();
assert_eq!(session.snapshot().active_tasks, 0);
assert!(
signaling
.received_messages()
.iter()
.any(|message| message.contains("\"ug\"") && message.contains("220"))
);
}
#[tokio::test]
async fn dropping_session_cancels_peer_and_signaling_tasks() {
let peer = UUID::new_with_string("11111111-2222-4333-8444-555555555555".into()).unwrap();
let signaling = LoopbackSignaling::new(peer);
let session = WebRtcVoiceSession::connect(
signaling.clone(),
VoiceSessionConfig {
timeout: Duration::from_secs(10),
..VoiceSessionConfig::default()
},
)
.await
.unwrap();
let snapshot = Arc::clone(&session.snapshot);
drop(session);
signaling.wait_closed(Duration::from_secs(2)).await.unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
while lock(&snapshot).active_tasks != 0 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(signaling.active_tasks(), 0);
}
#[tokio::test]
async fn concurrent_provisioning_admits_one_session_without_leaking_the_loser() {
let peer = UUID::new_with_string("11111111-2222-4333-8444-555555555555".into()).unwrap();
let signaling = LoopbackSignaling::new(peer);
let config = VoiceSessionConfig {
timeout: Duration::from_secs(10),
..VoiceSessionConfig::default()
};
let (first, second) = tokio::join!(
WebRtcVoiceSession::connect(signaling.clone(), config.clone()),
WebRtcVoiceSession::connect(signaling.clone(), config),
);
let (mut session, rejected) = match (first, second) {
(Ok(session), Err(error)) | (Err(error), Ok(session)) => (session, error),
(Ok(_), Ok(_)) => panic!("duplicate provisioning created two sessions"),
(Err(first), Err(second)) => {
panic!("both provisioning attempts failed: {first}; {second}")
}
};
assert!(matches!(rejected, WebRtcError::Signaling(_)));
session.shutdown().await.unwrap();
signaling.wait_closed(Duration::from_secs(2)).await.unwrap();
assert_eq!(session.snapshot().active_tasks, 0);
assert_eq!(signaling.active_tasks(), 0);
}
#[tokio::test]
async fn failed_answer_closes_provisioned_server_session() {
let signaling = Arc::new(InvalidAnswerSignaling {
close_calls: AtomicUsize::new(0),
});
let result = WebRtcVoiceSession::connect(
signaling.clone(),
VoiceSessionConfig {
timeout: Duration::from_secs(2),
..VoiceSessionConfig::default()
},
)
.await;
assert!(matches!(result, Err(WebRtcError::Rtc(_))));
assert_eq!(signaling.close_calls.load(Ordering::Acquire), 1);
}
#[test]
fn secrets_and_protocol_shapes_are_safe() {
assert_eq!(
format!("{:?}", VoiceSecret::new("token").unwrap()),
"VoiceSecret(<redacted>)"
);
let request = SignalingCompleteRequest {
voice_server_type: "webrtc".into(),
viewer_session: "id".into(),
candidate: SignalingComplete { completed: true },
};
assert_eq!(
serde_json::to_value(request).unwrap()["candidate"]["completed"],
true
);
}
}