From 3f128936c4c4f55262ddde66c4fa5d7b86503545 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Fri, 27 Mar 2026 18:23:39 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20web=20bridge=20=E2=80=94=20browser-base?= =?UTF-8?q?d=20voice=20calls=20via=20WebSocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New wzp-web crate serves a web page with: - Browser mic capture via Web Audio API (48kHz mono) - WebSocket transport for raw PCM audio - Server-side Opus encode/decode + FEC through wzp relay - Real-time audio playback in browser - Level meter and connection stats Usage: wzp-relay --listen 0.0.0.0:4433 # start relay wzp-web --port 8080 --relay 127.0.0.1:4433 # start web bridge Open http://localhost:8080 in browser Two browsers connected to the same relay get bridged for a call. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 1 + crates/wzp-web/Cargo.toml | 27 ++++ crates/wzp-web/src/main.rs | 235 +++++++++++++++++++++++++++++++ crates/wzp-web/static/index.html | 191 +++++++++++++++++++++++++ 4 files changed, 454 insertions(+) create mode 100644 crates/wzp-web/Cargo.toml create mode 100644 crates/wzp-web/src/main.rs create mode 100644 crates/wzp-web/static/index.html diff --git a/Cargo.toml b/Cargo.toml index 9fdbdfd..04bfc35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/wzp-transport", "crates/wzp-relay", "crates/wzp-client", + "crates/wzp-web", ] [workspace.package] diff --git a/crates/wzp-web/Cargo.toml b/crates/wzp-web/Cargo.toml new file mode 100644 index 0000000..591f21d --- /dev/null +++ b/crates/wzp-web/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "wzp-web" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "WarzonePhone web bridge — browser audio via WebSocket to wzp relay" + +[dependencies] +wzp-proto = { workspace = true } +wzp-codec = { workspace = true } +wzp-fec = { workspace = true } +wzp-crypto = { workspace = true } +wzp-transport = { workspace = true } +wzp-client = { path = "../wzp-client" } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +bytes = { workspace = true } +anyhow = "1" +axum = { version = "0.8", features = ["ws"] } +tower-http = { version = "0.6", features = ["fs"] } +futures = "0.3" + +[[bin]] +name = "wzp-web" +path = "src/main.rs" diff --git a/crates/wzp-web/src/main.rs b/crates/wzp-web/src/main.rs new file mode 100644 index 0000000..7eaa20b --- /dev/null +++ b/crates/wzp-web/src/main.rs @@ -0,0 +1,235 @@ +//! WarzonePhone Web Bridge +//! +//! Serves a web page for browser-based voice calls and bridges +//! WebSocket audio to the wzp relay protocol. +//! +//! Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::extract::ws::{Message, WebSocket}; +use axum::extract::WebSocketUpgrade; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::Router; +use futures::stream::StreamExt; +use futures::SinkExt; +use tokio::sync::Mutex; +use tower_http::services::ServeDir; +use tracing::{error, info, warn}; + +use wzp_client::call::{CallConfig, CallDecoder, CallEncoder}; +use wzp_proto::MediaTransport; + +const FRAME_SAMPLES: usize = 960; + +#[derive(Clone)] +struct AppState { + relay_addr: SocketAddr, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt().init(); + + let mut port: u16 = 8080; + let mut relay_addr: SocketAddr = "127.0.0.1:4433".parse()?; + + let args: Vec = std::env::args().collect(); + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--port" => { + i += 1; + port = args[i].parse().expect("invalid port"); + } + "--relay" => { + i += 1; + relay_addr = args[i].parse().expect("invalid relay address"); + } + "--help" | "-h" => { + eprintln!("Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433]"); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --port HTTP/WebSocket port (default: 8080)"); + eprintln!(" --relay WZP relay address (default: 127.0.0.1:4433)"); + std::process::exit(0); + } + _ => {} + } + i += 1; + } + + let state = AppState { relay_addr }; + + // Determine static file path (relative to binary or cargo manifest) + let static_dir = if std::path::Path::new("crates/wzp-web/static").exists() { + "crates/wzp-web/static" + } else if std::path::Path::new("static").exists() { + "static" + } else { + // Fallback: look relative to executable + "static" + }; + + let app = Router::new() + .route("/ws", get(ws_handler)) + .fallback_service(ServeDir::new(static_dir)) + .with_state(state); + + let listen: SocketAddr = format!("0.0.0.0:{port}").parse()?; + info!(%listen, %relay_addr, "WarzonePhone web bridge starting"); + info!("Open http://localhost:{port} in your browser"); + + let listener = tokio::net::TcpListener::bind(listen).await?; + axum::serve(listener, app).await?; + Ok(()) +} + +async fn ws_handler( + ws: WebSocketUpgrade, + axum::extract::State(state): axum::extract::State, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_ws(socket, state)) +} + +async fn handle_ws(socket: WebSocket, state: AppState) { + info!("WebSocket client connected"); + + // Connect to wzp relay + let relay_addr = state.relay_addr; + let bind_addr: SocketAddr = if relay_addr.is_ipv6() { + "[::]:0".parse().unwrap() + } else { + "0.0.0.0:0".parse().unwrap() + }; + + let client_config = wzp_transport::client_config(); + let endpoint = match wzp_transport::create_endpoint(bind_addr, None) { + Ok(e) => e, + Err(e) => { + error!("create endpoint: {e}"); + return; + } + }; + + let connection = + match wzp_transport::connect(&endpoint, relay_addr, "localhost", client_config).await { + Ok(c) => c, + Err(e) => { + error!("connect to relay {relay_addr}: {e}"); + return; + } + }; + + info!(%relay_addr, "connected to relay"); + + let transport = Arc::new(wzp_transport::QuinnTransport::new(connection)); + let config = CallConfig::default(); + + let (mut ws_sender, mut ws_receiver) = socket.split(); + let encoder = Arc::new(Mutex::new(CallEncoder::new(&config))); + let decoder = Arc::new(Mutex::new(CallDecoder::new(&config))); + + // --- Browser → Relay: receive PCM from WebSocket, encode, send to relay --- + let send_transport = transport.clone(); + let send_encoder = encoder.clone(); + let send_task = tokio::spawn(async move { + let mut frames_sent = 0u64; + while let Some(Ok(msg)) = ws_receiver.next().await { + match msg { + Message::Binary(data) => { + // data is raw s16le PCM from browser + if data.len() < FRAME_SAMPLES * 2 { + continue; // incomplete frame + } + let pcm: Vec = data + .chunks_exact(2) + .take(FRAME_SAMPLES) + .map(|c| i16::from_le_bytes([c[0], c[1]])) + .collect(); + + let packets = { + let mut enc = send_encoder.lock().await; + match enc.encode_frame(&pcm) { + Ok(p) => p, + Err(e) => { + warn!("encode error: {e}"); + continue; + } + } + }; + + for pkt in &packets { + if let Err(e) = send_transport.send_media(pkt).await { + error!("relay send error: {e}"); + return; + } + } + frames_sent += 1; + if frames_sent % 250 == 0 { + info!(frames_sent, "browser → relay"); + } + } + Message::Close(_) => break, + _ => {} + } + } + info!(frames_sent, "browser send loop ended"); + }); + + // --- Relay → Browser: receive from relay, decode, send PCM to WebSocket --- + let recv_transport = transport.clone(); + let recv_decoder = decoder.clone(); + let recv_task = tokio::spawn(async move { + let mut pcm_buf = vec![0i16; FRAME_SAMPLES]; + let mut frames_recv = 0u64; + loop { + match recv_transport.recv_media().await { + Ok(Some(pkt)) => { + let is_repair = pkt.header.is_repair; + { + let mut dec = recv_decoder.lock().await; + dec.ingest(pkt); + if !is_repair { + if let Some(_n) = dec.decode_next(&mut pcm_buf) { + // Convert i16 PCM to bytes and send to browser + let bytes: Vec = pcm_buf + .iter() + .flat_map(|s| s.to_le_bytes()) + .collect(); + if let Err(e) = ws_sender.send(Message::Binary(bytes.into())).await + { + error!("ws send error: {e}"); + return; + } + frames_recv += 1; + if frames_recv % 250 == 0 { + info!(frames_recv, "relay → browser"); + } + } + } + } + } + Ok(None) => { + info!("relay connection closed"); + break; + } + Err(e) => { + error!("relay recv error: {e}"); + break; + } + } + } + info!(frames_recv, "relay recv loop ended"); + }); + + tokio::select! { + _ = send_task => {} + _ = recv_task => {} + } + + transport.close().await.ok(); + info!("WebSocket session ended"); +} diff --git a/crates/wzp-web/static/index.html b/crates/wzp-web/static/index.html new file mode 100644 index 0000000..a3e1338 --- /dev/null +++ b/crates/wzp-web/static/index.html @@ -0,0 +1,191 @@ + + + + + +WarzonePhone + + + +
+

WarzonePhone

+

Lossy VoIP Protocol

+ +
+
+
+
+ + + +