feat: web bridge — browser-based voice calls via WebSocket
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) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ members = [
|
|||||||
"crates/wzp-transport",
|
"crates/wzp-transport",
|
||||||
"crates/wzp-relay",
|
"crates/wzp-relay",
|
||||||
"crates/wzp-client",
|
"crates/wzp-client",
|
||||||
|
"crates/wzp-web",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
27
crates/wzp-web/Cargo.toml
Normal file
27
crates/wzp-web/Cargo.toml
Normal file
@@ -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"
|
||||||
235
crates/wzp-web/src/main.rs
Normal file
235
crates/wzp-web/src/main.rs
Normal file
@@ -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<String> = 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 <port> HTTP/WebSocket port (default: 8080)");
|
||||||
|
eprintln!(" --relay <addr> 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<AppState>,
|
||||||
|
) -> 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<i16> = 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<u8> = 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");
|
||||||
|
}
|
||||||
191
crates/wzp-web/static/index.html
Normal file
191
crates/wzp-web/static/index.html
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WarzonePhone</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #1a1a2e; color: #e0e0e0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||||
|
.container { text-align: center; max-width: 400px; padding: 2rem; }
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
||||||
|
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 2rem; }
|
||||||
|
#callBtn { background: #00d4ff; color: #1a1a2e; border: none; padding: 1rem 3rem; font-size: 1.2rem; border-radius: 50px; cursor: pointer; transition: all 0.2s; }
|
||||||
|
#callBtn:hover { background: #00b8d4; transform: scale(1.05); }
|
||||||
|
#callBtn.active { background: #ff4444; color: white; }
|
||||||
|
#callBtn:disabled { background: #444; color: #888; cursor: not-allowed; }
|
||||||
|
.status { margin-top: 1.5rem; font-size: 0.9rem; color: #888; min-height: 1.5rem; }
|
||||||
|
.stats { margin-top: 1rem; font-size: 0.8rem; color: #666; font-family: monospace; }
|
||||||
|
.level { margin-top: 1rem; height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
|
||||||
|
.level-bar { height: 100%; background: #00d4ff; width: 0%; transition: width 50ms; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>WarzonePhone</h1>
|
||||||
|
<p class="subtitle">Lossy VoIP Protocol</p>
|
||||||
|
<button id="callBtn" onclick="toggleCall()">Connect</button>
|
||||||
|
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
<div class="stats" id="stats"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const SAMPLE_RATE = 48000;
|
||||||
|
const FRAME_SIZE = 960; // 20ms at 48kHz
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let audioCtx = null;
|
||||||
|
let mediaStream = null;
|
||||||
|
let scriptNode = null;
|
||||||
|
let active = false;
|
||||||
|
let framesSent = 0;
|
||||||
|
let framesRecv = 0;
|
||||||
|
let startTime = 0;
|
||||||
|
|
||||||
|
// Playback buffer
|
||||||
|
let playbackQueue = [];
|
||||||
|
let isPlaying = false;
|
||||||
|
|
||||||
|
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||||
|
function setStats(msg) { document.getElementById('stats').textContent = msg; }
|
||||||
|
|
||||||
|
function toggleCall() {
|
||||||
|
if (active) { stopCall(); }
|
||||||
|
else { startCall(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCall() {
|
||||||
|
const btn = document.getElementById('callBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
setStatus('Requesting microphone...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: { sampleRate: SAMPLE_RATE, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
setStatus('Mic access denied: ' + e.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect WebSocket
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = proto + '//' + location.host + '/ws';
|
||||||
|
setStatus('Connecting to relay...');
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setStatus('Connected — speaking...');
|
||||||
|
btn.textContent = 'Disconnect';
|
||||||
|
btn.classList.add('active');
|
||||||
|
btn.disabled = false;
|
||||||
|
active = true;
|
||||||
|
framesSent = 0;
|
||||||
|
framesRecv = 0;
|
||||||
|
startTime = Date.now();
|
||||||
|
startAudioCapture();
|
||||||
|
startStatsUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
// Received PCM audio from server (s16le bytes)
|
||||||
|
const pcmData = new Int16Array(event.data);
|
||||||
|
framesRecv++;
|
||||||
|
playAudio(pcmData);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setStatus('Disconnected');
|
||||||
|
stopCall();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
setStatus('Connection error');
|
||||||
|
stopCall();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCall() {
|
||||||
|
active = false;
|
||||||
|
const btn = document.getElementById('callBtn');
|
||||||
|
btn.textContent = 'Connect';
|
||||||
|
btn.classList.remove('active');
|
||||||
|
btn.disabled = false;
|
||||||
|
|
||||||
|
if (scriptNode) { scriptNode.disconnect(); scriptNode = null; }
|
||||||
|
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
||||||
|
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
|
||||||
|
if (ws) { ws.close(); ws = null; }
|
||||||
|
playbackQueue = [];
|
||||||
|
setStatus('');
|
||||||
|
setStats('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAudioCapture() {
|
||||||
|
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||||
|
const source = audioCtx.createMediaStreamSource(mediaStream);
|
||||||
|
|
||||||
|
// ScriptProcessorNode for capturing raw PCM
|
||||||
|
// (AudioWorklet would be better but this is simpler for a prototype)
|
||||||
|
scriptNode = audioCtx.createScriptProcessor(FRAME_SIZE, 1, 1);
|
||||||
|
|
||||||
|
scriptNode.onaudioprocess = (e) => {
|
||||||
|
if (!active || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const input = e.inputBuffer.getChannelData(0); // Float32 [-1, 1]
|
||||||
|
|
||||||
|
// Convert float32 to int16
|
||||||
|
const pcm = new Int16Array(input.length);
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(input[i] * 32767)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update level meter
|
||||||
|
let maxVal = 0;
|
||||||
|
for (let i = 0; i < pcm.length; i++) maxVal = Math.max(maxVal, Math.abs(pcm[i]));
|
||||||
|
document.getElementById('levelBar').style.width = (maxVal / 32768 * 100) + '%';
|
||||||
|
|
||||||
|
// Send as binary
|
||||||
|
ws.send(pcm.buffer);
|
||||||
|
framesSent++;
|
||||||
|
};
|
||||||
|
|
||||||
|
source.connect(scriptNode);
|
||||||
|
scriptNode.connect(audioCtx.destination); // needed for scriptProcessor to work
|
||||||
|
}
|
||||||
|
|
||||||
|
function playAudio(pcmInt16) {
|
||||||
|
if (!audioCtx) return;
|
||||||
|
|
||||||
|
// Convert int16 to float32
|
||||||
|
const floatData = new Float32Array(pcmInt16.length);
|
||||||
|
for (let i = 0; i < pcmInt16.length; i++) {
|
||||||
|
floatData[i] = pcmInt16[i] / 32768.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = audioCtx.createBuffer(1, floatData.length, SAMPLE_RATE);
|
||||||
|
buffer.getChannelData(0).set(floatData);
|
||||||
|
|
||||||
|
const source = audioCtx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(audioCtx.destination);
|
||||||
|
|
||||||
|
// Schedule playback with small buffer to reduce latency
|
||||||
|
const playTime = audioCtx.currentTime + 0.06; // 60ms buffer
|
||||||
|
source.start(playTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStatsUpdate() {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (!active) { clearInterval(interval); return; }
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
setStats(`${elapsed}s | sent: ${framesSent} | recv: ${framesRecv} | loss: ${framesSent > 0 ? ((1 - framesRecv/framesSent) * 100).toFixed(1) : 0}%`);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user