Implement native WebRtcTest validation (#96)
Some checks failed
Native code generation / deterministic (push) Failing after 2m5s
Imaging and meshing gate / native (push) Failing after 5m37s
JPEG 2000 feature / linux (push) Successful in 2m49s
Native Rust workspace compile / compile (push) Failing after 17m32s
Skia feature / linux (push) Successful in 31m32s

This commit is contained in:
2026-08-11 15:46:30 +00:00
parent da4afec708
commit 66f10baa1b
14 changed files with 4280 additions and 17 deletions

View File

@@ -68,7 +68,7 @@ jobs:
- name: Install audited OpenJPEG 2.5.4 - name: Install audited OpenJPEG 2.5.4
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install --yes build-essential cmake pkg-config sudo apt-get install --yes build-essential cmake pkg-config libopus-dev libasound2-dev
tools/install_openjpeg_2_5_4.sh "$OPENJPEG_PREFIX" tools/install_openjpeg_2_5_4.sh "$OPENJPEG_PREFIX"
- name: Check formatting - name: Check formatting
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
@@ -99,6 +99,10 @@ jobs:
run: python3 tools/test_milestone_10.py run: python3 tools/test_milestone_10.py
- name: Compile every workspace target with bounded memory - name: Compile every workspace target with bounded memory
run: cargo check --workspace --all-targets --locked -j 1 run: cargo check --workspace --all-targets --locked -j 1
- name: Check optional cross-platform real audio backend
run: |
cargo check -p libremetaverse-voice-webrtc --all-targets --features real-audio --locked -j 1
cargo check -p libremetaverse-programs --bin webrtc-test --features real-audio --locked -j 1
- name: Lint every native extension target with bounded memory - name: Lint every native extension target with bounded memory
run: cargo clippy -p libremetaverse-rendering-simple -p libremetaverse-rendering-mesh-foundry -p libremetaverse-rlv -p libremetaverse-lsl-tools --all-targets --locked -j 1 -- -D warnings run: cargo clippy -p libremetaverse-rendering-simple -p libremetaverse-rendering-mesh-foundry -p libremetaverse-rlv -p libremetaverse-lsl-tools --all-targets --locked -j 1 -- -D warnings
- name: Document every native extension crate - name: Document every native extension crate

1335
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,23 @@ repository.workspace = true
description = "WebRTC voice shims for the MetaCrate LibreMetaverse rewrite" description = "WebRTC voice shims for the MetaCrate LibreMetaverse rewrite"
[dependencies] [dependencies]
audiopus = "0.2"
hound = "3.5"
libremetaverse = { path = "../libremetaverse" } libremetaverse = { path = "../libremetaverse" }
libremetaverse-structured-data = { path = "../libremetaverse-structured-data" } libremetaverse-structured-data = { path = "../libremetaverse-structured-data" }
libremetaverse-types = { path = "../libremetaverse-types" } libremetaverse-types = { path = "../libremetaverse-types" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
str0m = { version = "0.22", default-features = false, features = ["rust-crypto"] }
tokio = { version = "1.47.1", features = ["macros", "net", "rt", "sync", "time"] }
[features]
default = []
real-audio = ["dep:cpal"]
[dependencies.cpal]
version = "0.18"
optional = true
[lints] [lints]
workspace = true workspace = true

View File

@@ -3,11 +3,13 @@
extern crate self as libremetaverse_voice_webrtc; extern crate self as libremetaverse_voice_webrtc;
mod generated; mod generated;
mod native;
pub use generated::*; pub use generated::*;
pub use libremetaverse as core; pub use libremetaverse as core;
pub use libremetaverse_structured_data as structured_data; pub use libremetaverse_structured_data as structured_data;
pub use libremetaverse_types::Error; pub use libremetaverse_types::Error;
pub use native::*;
/// Project-owned boundary types for external JSON signatures. /// Project-owned boundary types for external JSON signatures.
pub mod signaling { pub mod signaling {

File diff suppressed because it is too large Load Diff

80
docs/webrtc.md Normal file
View File

@@ -0,0 +1,80 @@
# Native WebRTC voice validation
The `libremetaverse-voice-webrtc` native API owns the complete WebRTC voice
path. `WebRtcVoiceSession::connect` binds a caller-selected interface, creates
an Opus send/receive m-line and ordered `SLData` channel, posts the grid's
provision body through a `VoiceSignaling` implementation, applies the SDP
answer, and drives ICE, DTLS, SRTP, RTP, and SCTP from a single async run loop.
There is no CLR, C# process, RPC bridge, proprietary SDK, or platform-specific
fallback.
The grid wire shapes match the viewer protocol:
- Initial provisioning uses `jsep.type=offer`, `channel_type=local`, and
`voice_server_type=webrtc`, with an optional top-level `parcel_local_id`.
- ICE completion uses the singular `candidate: { completed: true }` field and
the opaque viewer-session ID.
- Teardown sends `logout=true`, then disconnects and joins the local peer loop.
Capability URLs, SDP, channel credentials, login secrets, and viewer-session
credentials are not printed. `VoiceSecret` always formats as `<redacted>`.
Incoming SDP and `SLData` messages are bounded and validated before use.
## Deterministic validation
`webrtc-test --fake` is the CI path. `LoopbackSignaling` accepts the real offer,
creates a second native peer, and exchanges UDP ICE checks, DTLS, encrypted
Opus RTP, and SCTP `SLData` on loopback. It publishes peer audio/position and
mute/gain maps; the client replies with join, mute, clamped gain, position,
ping/pong, and leave. A generated virtual 48 kHz tone is encoded with libopus,
echoed by the remote peer, decoded into the virtual sink, and counted. Shutdown
awaits both peers and reports zero peer/audio tasks.
WAV playback accepts bounded integer or float PCM, downmixes all channels,
linearly resamples to 48 kHz mono, encodes 20 ms Opus frames, and paces them on
the same peer loop. This keeps virtual-audio tests deterministic and prevents
orphan playback tasks.
## Native prerequisites
The default virtual path requires native libopus development files at build
time. Typical packages are:
- Ubuntu/Debian: `libopus-dev` (and `pkg-config`).
- Fedora: `opus-devel`.
- Windows MSVC: `audiopus` supplies supported prebuilt Opus libraries; a custom
libopus can be selected with `OPUS_LIB_DIR`/`LIBOPUS_LIB_DIR`.
- macOS: install `opus` with the system package manager when it is not already
discoverable by `pkg-config`.
Real hardware is separately opt-in. Build the program with
`--features real-audio`; CPAL uses ALSA on Linux, WASAPI on Windows, and
CoreAudio on macOS. Linux builders need ALSA development headers (for example
`libasound2-dev`). List stable endpoint IDs with `webrtc-test --list-devices`,
then pass `--input-device cpal:input:...` and/or
`--output-device cpal:output:...`. Selected capture and render streams are
opened only during the session and dropped after the peer loop is joined.
## Live grid gate
Credential-only capability validation:
```sh
webrtc-test --allow-live-login --confirm-live-login LOGIN
```
The command reads `GRID_USER="First Last"`, `GRID_PASSWORD`, and
`GRID_LOGIN_URL` from the process environment or the workspace `.env`, matching
the compatibility-test convention. Positional names/password and the legacy
`GRID_FIRST_NAME`/`GRID_LAST_NAME` pair remain supported. Merely having a
credential file never permits a login: both command-line confirmations above
are still mandatory.
Creating a live voice session and sending/receiving media requires the separate
`--allow-session-audio` flag. `--bind-ip` can select the concrete interface
advertised in the host ICE candidate; otherwise the program discovers the
preferred route without sending a packet. `--wav FILE` starts looping microphone
playback. Interactive commands preserve the upstream tool's `peers`, `mute`,
`gain`, `playwav`, `stopwav`, and `quit` controls; `--commands FILE` supplies the
same commands non-interactively. Grid logout and voice teardown run even when
the validation body fails.

View File

@@ -13,6 +13,7 @@ libremetaverse-imaging = { path = "../crates/libremetaverse-imaging", features =
libremetaverse-imaging-skia = { path = "../crates/libremetaverse-imaging-skia", features = ["skia"] } libremetaverse-imaging-skia = { path = "../crates/libremetaverse-imaging-skia", features = ["skia"] }
libremetaverse-structured-data = { path = "../crates/libremetaverse-structured-data" } libremetaverse-structured-data = { path = "../crates/libremetaverse-structured-data" }
libremetaverse-voice-vivox = { path = "../crates/libremetaverse-voice-vivox" } libremetaverse-voice-vivox = { path = "../crates/libremetaverse-voice-vivox" }
libremetaverse-voice-webrtc = { path = "../crates/libremetaverse-voice-webrtc" }
regex = "1.12" regex = "1.12"
roxmltree = "0.21.1" roxmltree = "0.21.1"
tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
@@ -20,6 +21,10 @@ tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "r
[lints] [lints]
workspace = true workspace = true
[features]
default = []
real-audio = ["libremetaverse-voice-webrtc/real-audio"]
[[bin]] [[bin]]
name = "irc-gateway" name = "irc-gateway"
path = "src/bin/irc_gateway.rs" path = "src/bin/irc_gateway.rs"

View File

@@ -15,7 +15,43 @@ here so a source entry is never mistaken for a completed port.
| `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts | | `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts |
| `test-client` | TestClient | All non-voice command groups implemented with live and deterministic fake-grid backends; voice adapters tracked by #95 and #96 | | `test-client` | TestClient | All non-voice command groups implemented with live and deterministic fake-grid backends; voice adapters tracked by #95 and #96 |
| `vivox-test` | VivoxTest | Implemented with gated live validation and a scripted fake TCP/control service | | `vivox-test` | VivoxTest | Implemented with gated live validation and a scripted fake TCP/control service |
| `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 | | `webrtc-test` | WebRtcTest | Implemented with gated live WebRTC and a deterministic secure loopback peer |
## WebRtcTest
`webrtc-test` uses the native `libremetaverse-voice-webrtc` adapter. Its
`str0m` peer performs ICE, DTLS, SRTP, ordered SCTP `SLData`, SDP offer/answer,
Opus RTP, peer mute/gain/position messages, WAV microphone playback, diagnostics,
and joined teardown. It does not invoke the CLR or upstream C# program.
The hardware-independent gate is:
```sh
webrtc-test --fake --timeout-seconds 10
```
This creates two native peers on IPv4 loopback, exchanges an actual encrypted
WebRTC session, sends and decodes native-libopus audio, validates peer maps and
controls, and proves both peer/audio task counts return to zero. `--list-devices`
always reports the virtual endpoints. A build with `--features real-audio`
also reports CPAL endpoints and `--input-device`/`--output-device` opens the
selected hardware streams; capture and playback are converted to/from 48 kHz
mono without changing the WebRTC protocol.
Live login requires `--allow-live-login --confirm-live-login LOGIN`. It only
checks the redacted voice capabilities unless `--allow-session-audio` is also
present. It reads `GRID_USER="First Last"`, `GRID_PASSWORD`, and
`GRID_LOGIN_URL` from the environment or workspace `.env`; positional values
and `GRID_FIRST_NAME`/`GRID_LAST_NAME` remain supported. See `docs/webrtc.md`
for native prerequisites and the full command/gate matrix.
Run the isolated checks with:
```sh
cargo test -p libremetaverse-voice-webrtc --lib native::tests
cargo test -p libremetaverse-programs --test webrtc_test_cli
cargo test --manifest-path tests/compat/Cargo.toml --test webrtc_protocol_semantics
```
## VivoxTest ## VivoxTest

View File

@@ -1,3 +1,3 @@
fn main() -> std::process::ExitCode { fn main() -> std::process::ExitCode {
libremetaverse_programs::pending_program("WebRtcTest") libremetaverse_programs::webrtc_test::main_entry()
} }

View File

@@ -9,5 +9,6 @@ pub mod prim_inspector;
pub mod simple_bot; pub mod simple_bot;
pub mod test_client; pub mod test_client;
pub mod vivox_test; pub mod vivox_test;
pub mod webrtc_test;
pub use libremetaverse::shim::pending_program; pub use libremetaverse::shim::pending_program;

801
programs/src/webrtc_test.rs Normal file
View File

@@ -0,0 +1,801 @@
//! Credential-safe native port of the `WebRtcTest` validation program.
#![allow(clippy::cast_possible_truncation)] // The bounded test tone fits exactly in i16.
#![allow(clippy::cast_precision_loss)] // Tone synthesis intentionally uses f32 sample positions.
#![allow(clippy::struct_excessive_bools)] // Independent CLI safety gates are clearer as flags.
use clap::Parser;
use libremetaverse::types::compat::{CancellationToken, Uri};
use libremetaverse::{GridClient, NetworkManager, Simulator};
use libremetaverse_structured_data::{OSD, OSDFormat, OSDParser};
use libremetaverse_voice_webrtc::{
CloseRequest, LoopbackSignaling, ProvisionRequest, ProvisionResponse, SignalingCompleteRequest,
VoiceEvent, VoiceSecret, VoiceSessionConfig, VoiceSignaling, WebRtcError, WebRtcVoiceSession,
audio_devices, encode_pcm_48k_mono,
};
use std::collections::HashMap;
use std::fmt;
use std::future::Future;
use std::io::{self, BufRead, IsTerminal, Write};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncBufReadExt as _;
pub const EXIT_SUCCESS: u8 = 0;
pub const EXIT_USAGE: u8 = 2;
pub const EXIT_INPUT: u8 = 3;
pub const EXIT_SERVICE: u8 = 4;
const LIVE_CONFIRMATION: &str = "LOGIN";
const MAX_CAPABILITY_BYTES: usize = 1024 * 1024;
#[derive(Parser)]
#[command(
name = "webrtc-test",
version,
about = "Validate native LibreMetaverse WebRTC voice, Opus audio, and SLData behavior",
arg_required_else_help = true,
after_help = "CI/offline: --fake runs a real in-process ICE/DTLS/SRTP/SCTP peer with virtual 48 kHz audio. Live grid login requires --allow-live-login --confirm-live-login LOGIN; joining voice additionally requires --allow-session-audio. Live credentials use GRID_USER, GRID_PASSWORD, and GRID_LOGIN_URL from the environment or workspace .env. Real hardware enumeration requires building with --features real-audio and remains opt-in through --input-device/--output-device. Native libopus development files are required. Prefer GRID_PASSWORD over a positional password."
)]
struct Cli {
/// Avatar first name; `GRID_FIRST_NAME` is the fallback.
#[arg(value_name = "FIRSTNAME")]
first_name: Option<String>,
/// Avatar last name; `GRID_LAST_NAME` is the fallback.
#[arg(value_name = "LASTNAME")]
last_name: Option<String>,
/// Avatar password; prefer `GRID_PASSWORD` to avoid process-list exposure.
#[arg(value_name = "PASSWORD")]
password: Option<String>,
/// Run the full deterministic loopback signaling, peer, media, and data-channel flow.
#[arg(long)]
fake: bool,
/// List selectable virtual and compiled-in real audio endpoints.
#[arg(long)]
list_devices: bool,
/// Explicitly permit a live grid login.
#[arg(long)]
allow_live_login: bool,
/// Required literal confirmation for live login.
#[arg(long, value_name = "LOGIN")]
confirm_live_login: Option<String>,
/// Join live parcel voice and transmit/receive media.
#[arg(long, requires = "allow_live_login")]
allow_session_audio: bool,
/// Override the grid login endpoint; `GRID_LOGIN_URL` is the fallback.
#[arg(long, value_name = "URL")]
login_uri: Option<String>,
/// Concrete local interface address advertised as the host ICE candidate.
#[arg(long, value_name = "IP")]
bind_ip: Option<IpAddr>,
/// Optional parcel local ID included in WebRTC provisioning.
#[arg(long, value_name = "ID")]
parcel_local_id: Option<i32>,
/// Selected capture endpoint ID.
#[arg(long, default_value = "virtual:microphone")]
input_device: String,
/// Selected playback endpoint ID.
#[arg(long, default_value = "virtual:speaker")]
output_device: String,
/// WAV file to resample, Opus-encode, and send as microphone input.
#[arg(long, value_name = "FILE")]
wav: Option<PathBuf>,
/// Read interactive commands from a file instead of stdin.
#[arg(long, value_name = "FILE")]
commands: Option<PathBuf>,
/// Maximum duration for login, capabilities, connection, and teardown.
#[arg(long, default_value_t = 45, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
timeout_seconds: u64,
}
#[derive(Debug)]
enum ProgramError {
Usage(&'static str),
Input(String),
Grid(&'static str),
Voice(WebRtcError),
Io(io::Error),
Timeout(&'static str),
}
impl ProgramError {
const fn exit_code(&self) -> u8 {
match self {
Self::Usage(_) => EXIT_USAGE,
Self::Input(_) | Self::Io(_) => EXIT_INPUT,
Self::Grid(_) | Self::Voice(_) | Self::Timeout(_) => EXIT_SERVICE,
}
}
}
impl fmt::Display for ProgramError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Usage(message) | Self::Grid(message) => formatter.write_str(message),
Self::Input(message) => write!(formatter, "invalid input: {message}"),
Self::Voice(error) => error.fmt(formatter),
Self::Io(error) => error.fmt(formatter),
Self::Timeout(action) => write!(formatter, "{action} timed out"),
}
}
}
impl From<WebRtcError> for ProgramError {
fn from(error: WebRtcError) -> Self {
Self::Voice(error)
}
}
impl From<io::Error> for ProgramError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
/// Runs the `WebRtcTest` command.
#[must_use]
pub fn main_entry() -> ExitCode {
let cli = Cli::parse();
let Ok(runtime) = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
else {
eprintln!("webrtc-test: could not initialize the async runtime");
return ExitCode::from(EXIT_SERVICE);
};
match runtime.block_on(run(cli)) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("webrtc-test: {error}");
ExitCode::from(error.exit_code())
}
}
}
async fn run(cli: Cli) -> Result<(), ProgramError> {
if cli.list_devices {
if cli.fake
|| cli.first_name.is_some()
|| cli.last_name.is_some()
|| cli.password.is_some()
|| cli.allow_live_login
{
return Err(ProgramError::Usage(
"--list-devices cannot be combined with fake or live mode",
));
}
print_devices()?;
return Ok(());
}
if cli.fake {
if cli.first_name.is_some()
|| cli.last_name.is_some()
|| cli.password.is_some()
|| cli.allow_live_login
|| cli.confirm_live_login.is_some()
|| cli.allow_session_audio
|| cli.login_uri.is_some()
|| cli.commands.is_some()
{
return Err(ProgramError::Usage(
"credentials and live/interactive options cannot be combined with --fake",
));
}
return run_fake(Duration::from_secs(cli.timeout_seconds)).await;
}
run_live(cli).await
}
fn print_devices() -> Result<(), ProgramError> {
for device in audio_devices()? {
println!(
"{}\t{}\t{}{}",
if device.input { "input" } else { "output" },
device.id,
device.name,
if device.is_default { " [default]" } else { "" }
);
}
Ok(())
}
async fn run_fake(timeout: Duration) -> Result<(), ProgramError> {
let peer =
libremetaverse::types::UUID::new_with_string("11111111-2222-4333-8444-555555555555".into())
.map_err(|_| ProgramError::Grid("could not build deterministic peer ID"))?;
let signaling = LoopbackSignaling::new(peer);
let mut session = WebRtcVoiceSession::connect(
signaling.clone(),
VoiceSessionConfig {
timeout,
..VoiceSessionConfig::default()
},
)
.await?;
let mut saw_connected = false;
let mut saw_channel = false;
let mut saw_audio_state = false;
let mut saw_position = false;
let mut saw_mute = false;
let mut saw_gain = false;
tokio::time::timeout(timeout, async {
while !(saw_connected
&& saw_channel
&& saw_audio_state
&& saw_position
&& saw_mute
&& saw_gain)
{
match session.next_event().await {
Some(VoiceEvent::Connected) => saw_connected = true,
Some(VoiceEvent::DataChannelReady) => saw_channel = true,
Some(VoiceEvent::PeerAudio(id, _)) if id == peer => saw_audio_state = true,
Some(VoiceEvent::PeerPosition(id, _)) if id == peer => saw_position = true,
Some(VoiceEvent::MuteMap(map)) if map.contains_key(&peer) => saw_mute = true,
Some(VoiceEvent::GainMap(map)) if map.contains_key(&peer) => saw_gain = true,
Some(VoiceEvent::Diagnostic(message)) => {
return Err(ProgramError::Input(message));
}
None | Some(VoiceEvent::Closed) => {
return Err(ProgramError::Grid("fake WebRTC peer closed prematurely"));
}
_ => {}
}
}
Ok(())
})
.await
.map_err(|_| ProgramError::Timeout("fake WebRTC connection"))??;
session.set_peer_mute(peer, true).await?;
session.set_peer_gain(peer, 500).await?;
session
.send_position(
[1.0, 2.0, 3.0],
[0.0, 0.0, 0.0, 1.0],
[4.0, 5.0, 6.0],
[0.0, 0.0, 0.0, 1.0],
)
.await?;
let pcm: Vec<i16> = (0..4 * 960)
.map(|index| {
((index as f32 / 48_000.0 * 440.0 * std::f32::consts::TAU).sin() * 8_000.0) as i16
})
.collect();
session
.play_frames(encode_pcm_48k_mono(&pcm)?, false)
.await?;
tokio::time::timeout(timeout, async {
while session.snapshot().received_audio_frames < 4 {
if session.next_event().await.is_none() {
return Err(ProgramError::Grid("fake audio echo stopped prematurely"));
}
}
Ok(())
})
.await
.map_err(|_| ProgramError::Timeout("virtual audio echo"))??;
session.shutdown().await?;
signaling.wait_closed(timeout).await?;
let snapshot = session.snapshot();
let sent = signaling.received_messages();
if snapshot.active_tasks != 0
|| signaling.active_tasks() != 0
|| !sent.iter().any(|value| value.contains("\"m\""))
|| !sent
.iter()
.any(|value| value.contains("\"ug\"") && value.contains("220"))
|| !sent.iter().any(|value| value.contains("\"sp\""))
|| !sent.iter().any(|value| value.contains("\"pong\""))
{
return Err(ProgramError::Grid(
"fake peer did not observe every required SLData action or clean teardown",
));
}
println!(
"Fake WebRTC validation complete; ice=connected dtls=connected srtp=opus sctp=SLData peer-events=4 sent-audio={} received-audio={} active-peer-tasks=0 active-audio-tasks=0",
snapshot.sent_audio_frames, snapshot.received_audio_frames
);
Ok(())
}
async fn run_live(mut cli: Cli) -> Result<(), ProgramError> {
if !cli.allow_live_login || cli.confirm_live_login.as_deref() != Some(LIVE_CONFIRMATION) {
return Err(ProgramError::Usage(
"live mode requires --allow-live-login --confirm-live-login LOGIN",
));
}
let (first_name, last_name) = grid_names(cli.first_name.take(), cli.last_name.take())?;
let password = required(cli.password.take(), "GRID_PASSWORD", "PASSWORD is required")?;
let timeout = Duration::from_secs(cli.timeout_seconds);
let client =
GridClient::new().map_err(|_| ProgramError::Grid("could not construct GridClient"))?;
let network = client.network();
let mut login = network
.default_login_params(
first_name,
last_name,
password,
"WebRtcTest".into(),
env!("CARGO_PKG_VERSION").into(),
)
.map_err(|_| ProgramError::Grid("could not build grid login parameters"))?;
login.login_location = "WebRTC Voice 1/128/128/50".into();
if let Some(uri) = cli
.login_uri
.take()
.or_else(|| credential("GRID_LOGIN_URL"))
{
login.uri = uri;
}
println!("Logging into the grid with redacted credentials...");
let logged_in = tokio::time::timeout(
timeout,
network.login_with_login_params_cancellation_token(login, None),
)
.await
.map_err(|_| ProgramError::Timeout("grid login"))?
.unwrap_or(false);
if !logged_in {
return Err(ProgramError::Grid("grid login was rejected"));
}
let operation = async {
let simulator = wait_for_event_queue(&network, timeout).await?;
let provision_uri = capability(&simulator, "ProvisionVoiceAccountRequest")?;
let signaling_uri = capability(&simulator, "VoiceSignalingRequest")?;
println!("WebRTC voice capabilities are present; capability URLs are redacted.");
if !cli.allow_session_audio {
println!("Live voice not joined; pass --allow-session-audio to create a peer and transmit/receive audio.");
return Ok(());
}
let bind_ip = cli.bind_ip.unwrap_or_else(discover_bind_ip);
let signaling: Arc<dyn VoiceSignaling> = Arc::new(GridCapabilitySignaling {
client: client.clone(),
provision_uri,
signaling_uri,
timeout,
});
let mut session = WebRtcVoiceSession::connect(
signaling,
VoiceSessionConfig {
bind_ip,
parcel_local_id: cli.parcel_local_id,
timeout,
input_device: cli.input_device,
output_device: cli.output_device,
},
)
.await?;
let session_result = async {
wait_ready(&mut session, timeout).await?;
println!("WebRTC voice connected; ICE/DTLS/SRTP and ordered SLData are ready.");
if let Some(wav) = cli.wav.as_deref() {
session.play_wav(wav, true).await?;
println!("WAV microphone playback started (48 kHz mono Opus).");
}
run_commands(&mut session, cli.commands.as_deref()).await
}
.await;
let shutdown_result = session.shutdown().await;
if session.snapshot().active_tasks != 0 {
return Err(ProgramError::Grid("peer/audio tasks remained after shutdown"));
}
session_result?;
shutdown_result?;
println!("WebRTC voice disconnected; peer, audio, and signaling resources closed.");
Ok(())
}
.await;
let _ = network.logout_with_method();
operation
}
fn required(
value: Option<String>,
environment: &str,
message: &'static str,
) -> Result<String, ProgramError> {
value
.or_else(|| credential(environment))
.ok_or(ProgramError::Usage(message))
}
fn credential(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
let dotenv = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env");
let contents = std::fs::read_to_string(dotenv).ok()?;
contents.lines().find_map(|line| {
let line = line.trim().strip_prefix("export ").unwrap_or(line.trim());
let (key, value) = line.split_once('=')?;
(key.trim() == name)
.then(|| value.trim().trim_matches(['\'', '"']).to_owned())
.filter(|value| !value.is_empty())
})
})
}
fn grid_names(
first_name: Option<String>,
last_name: Option<String>,
) -> Result<(String, String), ProgramError> {
let first_name = first_name.or_else(|| credential("GRID_FIRST_NAME"));
let last_name = last_name.or_else(|| credential("GRID_LAST_NAME"));
match (first_name, last_name) {
(Some(first), Some(last)) if !first.trim().is_empty() && !last.trim().is_empty() => {
Ok((first, last))
}
(Some(_), None) | (None, Some(_)) => Err(ProgramError::Usage(
"FIRSTNAME and LASTNAME must be provided together",
)),
_ => split_grid_user(&credential("GRID_USER").ok_or(ProgramError::Usage(
"FIRSTNAME/LASTNAME or GRID_USER is required",
))?),
}
}
fn split_grid_user(value: &str) -> Result<(String, String), ProgramError> {
let mut names = value.split_whitespace();
let (Some(first), Some(last), None) = (names.next(), names.next(), names.next()) else {
return Err(ProgramError::Usage(
"GRID_USER must contain exactly FIRSTNAME LASTNAME",
));
};
Ok((first.to_owned(), last.to_owned()))
}
async fn wait_for_event_queue(
network: &NetworkManager,
timeout: Duration,
) -> Result<Simulator, ProgramError> {
tokio::time::timeout(timeout, async {
loop {
if let Some(simulator) = network.current_sim()
&& simulator
.is_event_queue_running(Some(false))
.unwrap_or(false)
{
return simulator;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
.await
.map_err(|_| ProgramError::Timeout("EventQueueRunning"))
}
fn capability(simulator: &Simulator, name: &'static str) -> Result<Uri, ProgramError> {
simulator
.native_capability_uri(name)
.map_err(|_| ProgramError::Grid("voice capability lookup failed"))?
.ok_or(ProgramError::Grid(
"required WebRTC voice capability is missing",
))
}
fn discover_bind_ip() -> IpAddr {
let socket = StdUdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0));
if let Ok(socket) = socket
&& socket
.connect(SocketAddr::from(([192, 0, 2, 1], 9)))
.is_ok()
&& let Ok(address) = socket.local_addr()
&& !address.ip().is_unspecified()
{
return address.ip();
}
IpAddr::V4(Ipv4Addr::LOCALHOST)
}
async fn wait_ready(
session: &mut WebRtcVoiceSession,
timeout: Duration,
) -> Result<(), ProgramError> {
tokio::time::timeout(timeout, async {
while !session.snapshot().data_channel_ready {
match session.next_event().await {
Some(VoiceEvent::Diagnostic(message)) => {
return Err(ProgramError::Input(message));
}
Some(VoiceEvent::Closed) | None => {
return Err(ProgramError::Grid(
"WebRTC peer closed before SLData opened",
));
}
Some(event) => print_event(&event),
}
}
Ok(())
})
.await
.map_err(|_| ProgramError::Timeout("WebRTC peer connection"))?
}
fn print_event(event: &VoiceEvent) {
match event {
VoiceEvent::PeerAudio(id, state) => println!(
"[Voice] PeerAudio {id} Power={:?} VAD={:?} JoinedPrimary={:?} Left={}",
state.power, state.voice_active, state.joined_primary, state.left
),
VoiceEvent::PeerPosition(id, position) => {
println!(
"[Voice] PeerPosition {id} sender={:?}",
position.sender_position
);
}
VoiceEvent::MuteMap(map) => {
for (id, value) in map {
println!("[Voice] Mute {id} = {value}");
}
}
VoiceEvent::GainMap(map) => {
for (id, value) in map {
println!("[Voice] Gain {id} = {value}");
}
}
_ => {}
}
}
async fn run_commands(
session: &mut WebRtcVoiceSession,
path: Option<&Path>,
) -> Result<(), ProgramError> {
if let Some(path) = path {
let file = io::BufReader::new(std::fs::File::open(path)?);
for line in file.lines().take(1_024) {
if !execute_command(session, &line?).await? {
break;
}
}
return Ok(());
}
if !io::stdin().is_terminal() {
return Ok(());
}
println!(
"Commands: peers, mute <uuid> <bool>, gain <uuid> <0-220>, playwav <path>, stopwav, quit"
);
let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
loop {
print!("> ");
io::stdout().flush()?;
tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal?;
println!("\nCancellation requested; closing WebRTC voice.");
break;
}
line = lines.next_line() => {
let Some(line) = line? else { break };
if !execute_command(session, &line).await? {
break;
}
}
}
}
Ok(())
}
async fn execute_command(
session: &mut WebRtcVoiceSession,
line: &str,
) -> Result<bool, ProgramError> {
let fields: Vec<_> = line.split_whitespace().collect();
let Some(command) = fields.first().map(|value| value.to_ascii_lowercase()) else {
return Ok(true);
};
match command.as_str() {
"quit" | "exit" => return Ok(false),
"peers" | "list" => {
for peer in session.snapshot().known_peers {
println!("{peer}");
}
}
"mute" if fields.len() == 3 => {
let peer = parse_peer(fields[1])?;
let mute = fields[2]
.parse()
.map_err(|_| ProgramError::Input("mute value must be true or false".into()))?;
session.set_peer_mute(peer, mute).await?;
}
"gain" if fields.len() == 3 => {
let peer = parse_peer(fields[1])?;
let gain = fields[2]
.parse()
.map_err(|_| ProgramError::Input("gain must be an integer".into()))?;
session.set_peer_gain(peer, gain).await?;
}
"playwav" if fields.len() == 2 => {
session.play_wav(Path::new(fields[1]), true).await?;
}
"stopwav" if fields.len() == 1 => session.stop_wav().await?,
_ => {
return Err(ProgramError::Input(
"unknown command or incorrect command arguments".into(),
));
}
}
Ok(true)
}
fn parse_peer(value: &str) -> Result<libremetaverse::types::UUID, ProgramError> {
libremetaverse::types::UUID::new_with_string(value.to_owned())
.map_err(|_| ProgramError::Input("peer ID must be a UUID".into()))
}
struct GridCapabilitySignaling {
client: GridClient,
provision_uri: Uri,
signaling_uri: Uri,
timeout: Duration,
}
impl GridCapabilitySignaling {
async fn post(&self, uri: &Uri, body: OSD) -> Result<OSD, WebRtcError> {
let http = self.client.http_caps_client();
let request = http.post_with_uri_osd_format_osd_cancellation_token_i_progress(
uri.clone(),
OSDFormat::Xml,
body,
CancellationToken::default(),
None,
);
let (response, bytes) = tokio::time::timeout(self.timeout, request)
.await
.map_err(|_| WebRtcError::Timeout("grid voice capability"))?
.map_err(|_| WebRtcError::Signaling("capability request failed".into()))?;
if !response.is_success_status_code() || bytes.len() > MAX_CAPABILITY_BYTES {
return Err(WebRtcError::Signaling(format!(
"capability returned HTTP {}",
response.status_code
)));
}
OSDParser::deserialize_with_bytes(bytes)
.map_err(|_| WebRtcError::Signaling("capability returned malformed LLSD".into()))
}
}
impl VoiceSignaling for GridCapabilitySignaling {
fn provision(
&self,
request: ProvisionRequest,
) -> Pin<Box<dyn Future<Output = Result<ProvisionResponse, WebRtcError>> + Send + '_>> {
Box::pin(async move {
let mut jsep = HashMap::new();
jsep.insert("type".into(), OSD::String(request.jsep.kind));
jsep.insert("sdp".into(), OSD::String(request.jsep.sdp));
let mut body = HashMap::new();
body.insert("jsep".into(), OSD::Map(jsep));
body.insert("channel_type".into(), OSD::String(request.channel_type));
body.insert(
"voice_server_type".into(),
OSD::String(request.voice_server_type),
);
if let Some(id) = request.parcel_local_id {
body.insert("parcel_local_id".into(), OSD::Integer(id));
}
let OSD::Map(response) = self.post(&self.provision_uri, OSD::Map(body)).await? else {
return Err(WebRtcError::Signaling(
"provision response was not a map".into(),
));
};
let answer_sdp = match response.get("jsep") {
Some(OSD::Map(jsep)) => jsep
.get("sdp")
.and_then(|value| value.as_string().ok())
.filter(|value| !value.is_empty()),
_ => None,
}
.ok_or_else(|| WebRtcError::Signaling("provision response omitted SDP".into()))?;
let viewer_session = response
.get("viewer_session")
.and_then(|value| value.as_uuid().ok())
.filter(|value| *value != libremetaverse::types::UUID::zero())
.ok_or_else(|| {
WebRtcError::Signaling("provision response omitted session ID".into())
})?;
let channel = response
.get("channel")
.and_then(|value| value.as_string().ok())
.filter(|value| !value.is_empty());
let credentials = response
.get("credentials")
.and_then(|value| value.as_string().ok())
.filter(|value| !value.is_empty())
.map(VoiceSecret::new)
.transpose()?;
Ok(ProvisionResponse {
answer_sdp,
viewer_session,
channel,
credentials,
})
})
}
fn complete(
&self,
request: SignalingCompleteRequest,
) -> Pin<Box<dyn Future<Output = Result<(), WebRtcError>> + Send + '_>> {
Box::pin(async move {
let mut complete = HashMap::new();
complete.insert(
"completed".into(),
OSD::Boolean(request.candidate.completed),
);
let mut body = HashMap::new();
body.insert(
"voice_server_type".into(),
OSD::String(request.voice_server_type),
);
body.insert("viewer_session".into(), OSD::String(request.viewer_session));
body.insert("candidate".into(), OSD::Map(complete));
let _ = self.post(&self.signaling_uri, OSD::Map(body)).await?;
Ok(())
})
}
fn close(
&self,
request: CloseRequest,
) -> Pin<Box<dyn Future<Output = Result<(), WebRtcError>> + Send + '_>> {
Box::pin(async move {
let mut body = HashMap::new();
body.insert("logout".into(), OSD::Boolean(request.logout));
body.insert(
"voice_server_type".into(),
OSD::String(request.voice_server_type),
);
body.insert("viewer_session".into(), OSD::String(request.viewer_session));
if let Some(channel) = request.channel {
body.insert("channel".into(), OSD::String(channel));
}
if let Some(credentials) = request.credentials {
body.insert(
"credentials".into(),
OSD::String(credentials.expose().to_owned()),
);
}
let _ = self.post(&self.provision_uri, OSD::Map(body)).await?;
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn live_mode_is_doubly_gated_and_does_not_echo_password() {
let cli = Cli::try_parse_from(["webrtc-test", "First", "Last", "secret"]).unwrap();
let error = tokio::runtime::Runtime::new()
.unwrap()
.block_on(run(cli))
.unwrap_err();
assert!(matches!(error, ProgramError::Usage(_)));
assert!(!error.to_string().contains("secret"));
}
#[test]
fn bind_discovery_returns_a_concrete_address() {
assert!(!discover_bind_ip().is_unspecified());
}
#[test]
fn grid_user_requires_the_repository_first_last_shape() {
assert_eq!(
split_grid_user("First Last").unwrap(),
("First".to_owned(), "Last".to_owned())
);
assert!(split_grid_user("First").is_err());
assert!(split_grid_user("First Middle Last").is_err());
}
}

View File

@@ -0,0 +1,103 @@
use std::process::{Command, Output, Stdio};
const EXIT_USAGE: i32 = 2;
fn run(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_webrtc-test"))
.args(args)
.env_remove("GRID_FIRST_NAME")
.env_remove("GRID_LAST_NAME")
.env_remove("GRID_PASSWORD")
.env_remove("GRID_LOGIN_URL")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("run webrtc-test")
}
fn text(bytes: &[u8]) -> &str {
std::str::from_utf8(bytes).expect("UTF-8 process output")
}
#[test]
fn help_documents_native_prerequisites_fake_flow_and_live_gates() {
let output = run(&["--help"]);
assert!(output.status.success());
let help = text(&output.stdout);
for marker in [
"--fake",
"--list-devices",
"--allow-live-login",
"--confirm-live-login",
"--allow-session-audio",
"--input-device",
"--output-device",
"--wav",
"ICE/DTLS/SRTP/SCTP",
"Native libopus",
"--features real-audio",
] {
assert!(help.contains(marker), "help omitted {marker}:\n{help}");
}
assert_eq!(run(&[]).status.code(), Some(EXIT_USAGE));
}
#[test]
fn fake_peer_exercises_media_data_channel_and_clean_teardown() {
let output = run(&["--fake", "--timeout-seconds", "10"]);
assert!(
output.status.success(),
"stdout:\n{}\nstderr:\n{}",
text(&output.stdout),
text(&output.stderr)
);
let stdout = text(&output.stdout);
for marker in [
"ice=connected",
"dtls=connected",
"srtp=opus",
"sctp=SLData",
"sent-audio=4",
"active-peer-tasks=0",
"active-audio-tasks=0",
] {
assert!(
stdout.contains(marker),
"output omitted {marker}:\n{stdout}"
);
}
assert!(output.stderr.is_empty(), "{}", text(&output.stderr));
}
#[test]
fn virtual_devices_are_available_without_physical_hardware() {
let output = run(&["--list-devices"]);
assert!(output.status.success(), "{}", text(&output.stderr));
let stdout = text(&output.stdout);
assert!(stdout.contains("input\tvirtual:microphone"));
assert!(stdout.contains("output\tvirtual:speaker"));
}
#[test]
fn live_inputs_are_rejected_before_network_without_both_opt_ins_or_secret_echo() {
let output = run(&["First", "Last", "do-not-echo"]);
assert_eq!(output.status.code(), Some(EXIT_USAGE));
assert!(text(&output.stderr).contains("--allow-live-login"));
assert!(!text(&output.stderr).contains("do-not-echo"));
let output = run(&[
"First",
"Last",
"do-not-echo",
"--allow-live-login",
"--confirm-live-login",
"WRONG",
]);
assert_eq!(output.status.code(), Some(EXIT_USAGE));
assert!(!text(&output.stderr).contains("do-not-echo"));
let output = run(&["First", "Last", "do-not-echo", "--fake"]);
assert_eq!(output.status.code(), Some(EXIT_USAGE));
assert!(!text(&output.stderr).contains("do-not-echo"));
}

View File

@@ -20,6 +20,7 @@ libremetaverse-types = { path = "../../crates/libremetaverse-types" }
libremetaverse-utilities = { path = "../../crates/libremetaverse-utilities" } libremetaverse-utilities = { path = "../../crates/libremetaverse-utilities" }
libremetaverse-voice-vivox = { path = "../../crates/libremetaverse-voice-vivox" } libremetaverse-voice-vivox = { path = "../../crates/libremetaverse-voice-vivox" }
libremetaverse-voice-webrtc = { path = "../../crates/libremetaverse-voice-webrtc" } libremetaverse-voice-webrtc = { path = "../../crates/libremetaverse-voice-webrtc" }
serde_json = "1"
tokio = { version = "1.47.1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] } tokio = { version = "1.47.1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
[lints] [lints]

View File

@@ -0,0 +1,57 @@
use libremetaverse::types::UUID;
use libremetaverse_voice_webrtc::{
CloseRequest, Jsep, ProvisionRequest, SignalingComplete, SignalingCompleteRequest, VoiceSecret,
encode_pcm_48k_mono,
};
#[test]
fn local_provision_matches_web_rtc_capability_wire_shape() {
let request = ProvisionRequest {
jsep: Jsep {
kind: "offer".into(),
sdp: "v=0\r\n".into(),
},
channel_type: "local".into(),
voice_server_type: "webrtc".into(),
parcel_local_id: Some(42),
};
let value = serde_json::to_value(request).unwrap();
assert_eq!(value["jsep"]["type"], "offer");
assert_eq!(value["parcel_local_id"], 42);
assert_eq!(value["channel_type"], "local");
assert_eq!(value["voice_server_type"], "webrtc");
}
#[test]
fn completion_uses_singular_candidate_and_close_is_explicit() {
let session = UUID::new_with_string("11111111-2222-4333-8444-555555555555".into()).unwrap();
let completion = serde_json::to_value(SignalingCompleteRequest {
voice_server_type: "webrtc".into(),
viewer_session: session.to_string(),
candidate: SignalingComplete { completed: true },
})
.unwrap();
assert_eq!(completion["candidate"]["completed"], true);
assert!(completion.get("candidates").is_none());
let close = serde_json::to_value(CloseRequest {
logout: true,
voice_server_type: "webrtc".into(),
viewer_session: session.to_string(),
channel: None,
credentials: None,
})
.unwrap();
assert_eq!(close["logout"], true);
}
#[test]
fn native_opus_frames_are_nonempty_and_credentials_are_redacted() {
let frames = encode_pcm_48k_mono(&vec![0_i16; 1_920]).unwrap();
assert_eq!(frames.len(), 2);
assert!(frames.iter().all(|frame| !frame.0.is_empty()));
assert_eq!(
format!("{:?}", VoiceSecret::new("capability-token").unwrap()),
"VoiceSecret(<redacted>)"
);
}