feat: room-based calls + AudioWorklet for capture and playback
Rooms: - URL-based: open /myroom to join a room - Two clients in same room get bridged through relay - Input field for room name, also supports URL path and hash - Each room creates independent relay connections AudioWorklet (replaces deprecated ScriptProcessorNode): - capture-processor.js: accumulates mic samples, sends 960-sample frames - playback-processor.js: pull-based output with 200ms buffer cap - Falls back to ScriptProcessor if AudioWorklet unavailable - Eliminates drift: worklet runs on audio thread, not main thread Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,15 @@
|
||||
//! WebSocket audio to the wzp relay protocol.
|
||||
//!
|
||||
//! Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] [--tls]
|
||||
//!
|
||||
//! Rooms: clients connect to /ws/<room-name> and are paired by room.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use axum::extract::WebSocketUpgrade;
|
||||
use axum::extract::{Path, WebSocketUpgrade};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
@@ -27,6 +30,15 @@ const FRAME_SAMPLES: usize = 960;
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
relay_addr: SocketAddr,
|
||||
rooms: Arc<Mutex<HashMap<String, RoomSlot>>>,
|
||||
}
|
||||
|
||||
/// A waiting client in a room.
|
||||
struct RoomSlot {
|
||||
/// Sender half — send audio TO this waiting client's browser.
|
||||
tx: tokio::sync::mpsc::Sender<Vec<u8>>,
|
||||
/// Receiver half — receive audio FROM this waiting client's browser.
|
||||
rx: Arc<Mutex<tokio::sync::mpsc::Receiver<Vec<i16>>>>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -44,25 +56,19 @@ async fn main() -> anyhow::Result<()> {
|
||||
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");
|
||||
}
|
||||
"--tls" => {
|
||||
use_tls = true;
|
||||
}
|
||||
"--port" => { i += 1; port = args[i].parse().expect("invalid port"); }
|
||||
"--relay" => { i += 1; relay_addr = args[i].parse().expect("invalid relay address"); }
|
||||
"--tls" => { use_tls = true; }
|
||||
"--help" | "-h" => {
|
||||
eprintln!("Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] [--tls]");
|
||||
eprintln!();
|
||||
eprintln!("Options:");
|
||||
eprintln!(" --port <port> HTTP/WebSocket port (default: 8080)");
|
||||
eprintln!(" --relay <addr> WZP relay address (default: 127.0.0.1:4433)");
|
||||
eprintln!(" --tls Enable HTTPS with self-signed certificate");
|
||||
eprintln!(" (required for mic access on Android/remote browsers)");
|
||||
eprintln!(" --tls Enable HTTPS (required for mic on Android)");
|
||||
eprintln!();
|
||||
eprintln!("Rooms: open https://host:port/<room-name> to join a room.");
|
||||
eprintln!("Two clients in the same room are connected for a call.");
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {}
|
||||
@@ -70,7 +76,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let state = AppState { relay_addr };
|
||||
let state = AppState {
|
||||
relay_addr,
|
||||
rooms: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
|
||||
let static_dir = if std::path::Path::new("crates/wzp-web/static").exists() {
|
||||
"crates/wzp-web/static"
|
||||
@@ -81,17 +90,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ws", get(ws_handler))
|
||||
.route("/ws/{room}", get(ws_handler))
|
||||
.fallback_service(ServeDir::new(static_dir))
|
||||
.with_state(state);
|
||||
|
||||
let listen: SocketAddr = format!("0.0.0.0:{port}").parse()?;
|
||||
|
||||
if use_tls {
|
||||
// Generate self-signed cert
|
||||
let cert_key = rcgen::generate_simple_self_signed(vec![
|
||||
"localhost".to_string(),
|
||||
"wzp".to_string(),
|
||||
"localhost".to_string(), "wzp".to_string(),
|
||||
])?;
|
||||
let cert_der = rustls_pki_types::CertificateDer::from(cert_key.cert);
|
||||
let key_der = rustls_pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der())
|
||||
@@ -104,17 +111,16 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let tls_config = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(tls_config));
|
||||
|
||||
info!(%listen, %relay_addr, "WarzonePhone web bridge starting (HTTPS)");
|
||||
info!("Open https://localhost:{port} in your browser");
|
||||
info!("NOTE: Accept the self-signed certificate warning in your browser");
|
||||
info!(%listen, %relay_addr, "WarzonePhone web bridge (HTTPS)");
|
||||
info!("Open https://localhost:{port}/<room-name> in your browser");
|
||||
|
||||
axum_server::bind_rustls(listen, tls_config)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
} else {
|
||||
info!(%listen, %relay_addr, "WarzonePhone web bridge starting (HTTP)");
|
||||
info!("Open http://localhost:{port} in your browser");
|
||||
info!("NOTE: Use --tls for mic access on Android/remote browsers");
|
||||
info!(%listen, %relay_addr, "WarzonePhone web bridge (HTTP)");
|
||||
info!("Open http://localhost:{port}/<room-name> in your browser");
|
||||
info!("Use --tls for mic access on Android/remote browsers");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(listen).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
@@ -125,14 +131,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
Path(room): Path<String>,
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_ws(socket, state))
|
||||
info!(room = %room, "WebSocket upgrade request");
|
||||
ws.on_upgrade(move |socket| handle_ws(socket, room, state))
|
||||
}
|
||||
|
||||
async fn handle_ws(socket: WebSocket, state: AppState) {
|
||||
info!("WebSocket client connected");
|
||||
async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
|
||||
info!(room = %room, "client joined room");
|
||||
|
||||
// Connect to relay
|
||||
let relay_addr = state.relay_addr;
|
||||
let bind_addr: SocketAddr = if relay_addr.is_ipv6() {
|
||||
"[::]:0".parse().unwrap()
|
||||
@@ -143,22 +152,16 @@ async fn handle_ws(socket: WebSocket, state: AppState) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
Err(e) => { error!("connect to relay: {e}"); return; }
|
||||
};
|
||||
|
||||
info!(%relay_addr, "connected to relay");
|
||||
info!(room = %room, "connected to relay");
|
||||
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||
let config = CallConfig::default();
|
||||
@@ -167,19 +170,17 @@ async fn handle_ws(socket: WebSocket, state: AppState) {
|
||||
let encoder = Arc::new(Mutex::new(CallEncoder::new(&config)));
|
||||
let decoder = Arc::new(Mutex::new(CallDecoder::new(&config)));
|
||||
|
||||
// Browser -> Relay
|
||||
// Browser → Relay
|
||||
let send_transport = transport.clone();
|
||||
let send_encoder = encoder.clone();
|
||||
let send_room = room.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) => {
|
||||
if data.len() < FRAME_SAMPLES * 2 {
|
||||
continue;
|
||||
}
|
||||
let pcm: Vec<i16> = data
|
||||
.chunks_exact(2)
|
||||
if data.len() < FRAME_SAMPLES * 2 { continue; }
|
||||
let pcm: Vec<i16> = data.chunks_exact(2)
|
||||
.take(FRAME_SAMPLES)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
@@ -188,34 +189,32 @@ async fn handle_ws(socket: WebSocket, state: AppState) {
|
||||
let mut enc = send_encoder.lock().await;
|
||||
match enc.encode_frame(&pcm) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("encode error: {e}");
|
||||
continue;
|
||||
}
|
||||
Err(e) => { warn!("encode: {e}"); continue; }
|
||||
}
|
||||
};
|
||||
|
||||
for pkt in &packets {
|
||||
if let Err(e) = send_transport.send_media(pkt).await {
|
||||
error!("relay send error: {e}");
|
||||
error!("relay send: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
frames_sent += 1;
|
||||
if frames_sent % 250 == 0 {
|
||||
info!(frames_sent, "browser -> relay");
|
||||
if frames_sent % 500 == 0 {
|
||||
info!(room = %send_room, frames_sent, "browser → relay");
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
info!(frames_sent, "browser send loop ended");
|
||||
info!(room = %send_room, frames_sent, "send ended");
|
||||
});
|
||||
|
||||
// Relay -> Browser
|
||||
// Relay → Browser
|
||||
let recv_transport = transport.clone();
|
||||
let recv_decoder = decoder.clone();
|
||||
let recv_room = room.clone();
|
||||
let recv_task = tokio::spawn(async move {
|
||||
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
|
||||
let mut frames_recv = 0u64;
|
||||
@@ -223,40 +222,29 @@ async fn handle_ws(socket: WebSocket, state: AppState) {
|
||||
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) {
|
||||
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");
|
||||
}
|
||||
let mut dec = recv_decoder.lock().await;
|
||||
dec.ingest(pkt);
|
||||
if !is_repair {
|
||||
if let Some(_n) = dec.decode_next(&mut pcm_buf) {
|
||||
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: {e}");
|
||||
return;
|
||||
}
|
||||
frames_recv += 1;
|
||||
if frames_recv % 500 == 0 {
|
||||
info!(room = %recv_room, frames_recv, "relay → browser");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("relay connection closed");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("relay recv error: {e}");
|
||||
break;
|
||||
}
|
||||
Ok(None) => { info!(room = %recv_room, "relay closed"); break; }
|
||||
Err(e) => { error!(room = %recv_room, "relay recv: {e}"); break; }
|
||||
}
|
||||
}
|
||||
info!(frames_recv, "relay recv loop ended");
|
||||
info!(room = %recv_room, frames_recv, "recv ended");
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
@@ -265,5 +253,5 @@ async fn handle_ws(socket: WebSocket, state: AppState) {
|
||||
}
|
||||
|
||||
transport.close().await.ok();
|
||||
info!("WebSocket session ended");
|
||||
info!(room = %room, "session ended");
|
||||
}
|
||||
|
||||
39
crates/wzp-web/static/audio-processor.js
Normal file
39
crates/wzp-web/static/audio-processor.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// AudioWorklet processor for capturing microphone audio.
|
||||
// Accumulates samples and posts 960-sample (20ms @ 48kHz) frames to the main thread.
|
||||
|
||||
class CaptureProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.buffer = new Float32Array(0);
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0];
|
||||
if (!input || !input[0]) return true;
|
||||
|
||||
const samples = input[0]; // Float32Array, typically 128 samples
|
||||
|
||||
// Accumulate
|
||||
const newBuf = new Float32Array(this.buffer.length + samples.length);
|
||||
newBuf.set(this.buffer);
|
||||
newBuf.set(samples, this.buffer.length);
|
||||
this.buffer = newBuf;
|
||||
|
||||
// Send complete 960-sample frames
|
||||
while (this.buffer.length >= 960) {
|
||||
const frame = this.buffer.slice(0, 960);
|
||||
this.buffer = this.buffer.slice(960);
|
||||
|
||||
// Convert to Int16
|
||||
const pcm = new Int16Array(960);
|
||||
for (let i = 0; i < 960; i++) {
|
||||
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
||||
}
|
||||
this.port.postMessage(pcm.buffer, [pcm.buffer]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('capture-processor', CaptureProcessor);
|
||||
@@ -7,15 +7,19 @@
|
||||
<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; }
|
||||
.container { text-align: center; max-width: 420px; padding: 2rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
||||
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 2rem; }
|
||||
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
||||
.room-input { margin-bottom: 1.5rem; }
|
||||
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
|
||||
.room-input input:focus { outline: none; border-color: #00d4ff; }
|
||||
.room-input label { display: block; color: #888; font-size: 0.8rem; margin-bottom: 0.4rem; }
|
||||
#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; }
|
||||
#callBtn:disabled { background: #444; color: #888; cursor: not-allowed; transform: none; }
|
||||
.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; }
|
||||
.stats { margin-top: 0.5rem; font-size: 0.75rem; color: #555; 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>
|
||||
@@ -24,6 +28,10 @@
|
||||
<div class="container">
|
||||
<h1>WarzonePhone</h1>
|
||||
<p class="subtitle">Lossy VoIP Protocol</p>
|
||||
<div class="room-input">
|
||||
<label for="room">Room</label>
|
||||
<input type="text" id="room" placeholder="enter room name" value="">
|
||||
</div>
|
||||
<button id="callBtn" onclick="toggleCall()">Connect</button>
|
||||
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
||||
<div class="status" id="status"></div>
|
||||
@@ -32,31 +40,42 @@
|
||||
|
||||
<script>
|
||||
const SAMPLE_RATE = 48000;
|
||||
const FRAME_SIZE = 960; // 20ms at 48kHz
|
||||
const FRAME_SIZE = 960;
|
||||
|
||||
let ws = null;
|
||||
let audioCtx = null;
|
||||
let mediaStream = null;
|
||||
let scriptNode = null;
|
||||
let captureNode = null;
|
||||
let playbackNode = null;
|
||||
let active = false;
|
||||
let framesSent = 0;
|
||||
let framesRecv = 0;
|
||||
let startTime = 0;
|
||||
let statsInterval = null;
|
||||
|
||||
// Playback buffer
|
||||
let playbackQueue = [];
|
||||
let isPlaying = false;
|
||||
// Use room from URL path or input field
|
||||
function getRoom() {
|
||||
// Check URL: /roomname or /#roomname
|
||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||
if (path && path !== 'index.html') return path;
|
||||
const hash = location.hash.replace('#', '');
|
||||
if (hash) return hash;
|
||||
return document.getElementById('room').value.trim() || 'default';
|
||||
}
|
||||
|
||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||
function setStats(msg) { document.getElementById('stats').textContent = msg; }
|
||||
|
||||
function toggleCall() {
|
||||
if (active) { stopCall(); }
|
||||
else { startCall(); }
|
||||
if (active) stopCall();
|
||||
else startCall();
|
||||
}
|
||||
|
||||
async function startCall() {
|
||||
const btn = document.getElementById('callBtn');
|
||||
const room = getRoom();
|
||||
if (!room) { setStatus('Enter a room name'); return; }
|
||||
|
||||
btn.disabled = true;
|
||||
setStatus('Requesting microphone...');
|
||||
|
||||
@@ -70,16 +89,18 @@ async function startCall() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect WebSocket
|
||||
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||
|
||||
// Connect WebSocket with room name
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = proto + '//' + location.host + '/ws';
|
||||
setStatus('Connecting to relay...');
|
||||
const wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
||||
setStatus('Connecting to room: ' + room + '...');
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus('Connected — speaking...');
|
||||
ws.onopen = async () => {
|
||||
setStatus('Connected to room: ' + room);
|
||||
btn.textContent = 'Disconnect';
|
||||
btn.classList.add('active');
|
||||
btn.disabled = false;
|
||||
@@ -87,12 +108,12 @@ async function startCall() {
|
||||
framesSent = 0;
|
||||
framesRecv = 0;
|
||||
startTime = Date.now();
|
||||
startAudioCapture();
|
||||
await startAudioCapture();
|
||||
await startAudioPlayback();
|
||||
startStatsUpdate();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Received PCM audio from server (s16le bytes)
|
||||
const pcmData = new Int16Array(event.data);
|
||||
framesRecv++;
|
||||
playAudio(pcmData);
|
||||
@@ -100,20 +121,17 @@ async function startCall() {
|
||||
|
||||
ws.onclose = () => {
|
||||
if (active) {
|
||||
setStatus('Disconnected — reconnecting...');
|
||||
setTimeout(() => { if (active) { stopCall(); startCall(); } }, 1000);
|
||||
setStatus('Disconnected — reconnecting to ' + room + '...');
|
||||
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
||||
} else {
|
||||
setStatus('Disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
ws.onerror = () => {
|
||||
if (active) {
|
||||
setStatus('Connection error — reconnecting...');
|
||||
setTimeout(() => { if (active) { stopCall(); startCall(); } }, 1000);
|
||||
} else {
|
||||
setStatus('Connection error');
|
||||
stopCall();
|
||||
setStatus('Error — reconnecting...');
|
||||
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -124,65 +142,77 @@ function stopCall() {
|
||||
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; }
|
||||
cleanupAudio();
|
||||
if (ws) { ws.close(); ws = null; }
|
||||
playbackQueue = [];
|
||||
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
|
||||
setStatus('');
|
||||
setStats('');
|
||||
}
|
||||
|
||||
function startAudioCapture() {
|
||||
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||
console.log('AudioContext sampleRate:', audioCtx.sampleRate);
|
||||
setStatus('Connected — mic active (sample rate: ' + audioCtx.sampleRate + 'Hz)');
|
||||
const source = audioCtx.createMediaStreamSource(mediaStream);
|
||||
|
||||
// ScriptProcessorNode requires power-of-2 buffer. We use 1024 and
|
||||
// accumulate samples, sending exactly FRAME_SIZE (960) chunks.
|
||||
const CAPTURE_BUF = 1024;
|
||||
scriptNode = audioCtx.createScriptProcessor(CAPTURE_BUF, 1, 1);
|
||||
|
||||
let accumulator = new Float32Array(0);
|
||||
|
||||
scriptNode.onaudioprocess = (e) => {
|
||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const input = e.inputBuffer.getChannelData(0);
|
||||
|
||||
// Append to accumulator
|
||||
const newAcc = new Float32Array(accumulator.length + input.length);
|
||||
newAcc.set(accumulator);
|
||||
newAcc.set(input, accumulator.length);
|
||||
accumulator = newAcc;
|
||||
|
||||
// Send complete FRAME_SIZE chunks
|
||||
while (accumulator.length >= FRAME_SIZE) {
|
||||
const frame = accumulator.slice(0, FRAME_SIZE);
|
||||
accumulator = accumulator.slice(FRAME_SIZE);
|
||||
|
||||
const pcm = new Int16Array(FRAME_SIZE);
|
||||
for (let i = 0; i < FRAME_SIZE; i++) {
|
||||
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[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) + '%';
|
||||
|
||||
ws.send(pcm.buffer);
|
||||
framesSent++;
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(scriptNode);
|
||||
scriptNode.connect(audioCtx.destination);
|
||||
function cleanupAudio() {
|
||||
if (captureNode) { captureNode.disconnect(); captureNode = null; }
|
||||
if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
|
||||
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
||||
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
|
||||
}
|
||||
|
||||
async function startAudioCapture() {
|
||||
const source = audioCtx.createMediaStreamSource(mediaStream);
|
||||
|
||||
try {
|
||||
await audioCtx.audioWorklet.addModule('audio-processor.js');
|
||||
captureNode = new AudioWorkletNode(audioCtx, 'capture-processor');
|
||||
captureNode.port.onmessage = (e) => {
|
||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
// e.data is an ArrayBuffer of Int16 PCM
|
||||
ws.send(e.data);
|
||||
framesSent++;
|
||||
|
||||
// Level meter from the PCM data
|
||||
const pcm = new Int16Array(e.data);
|
||||
let max = 0;
|
||||
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
|
||||
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
|
||||
};
|
||||
source.connect(captureNode);
|
||||
captureNode.connect(audioCtx.destination); // needed to keep worklet alive
|
||||
} catch(e) {
|
||||
// Fallback to ScriptProcessor if AudioWorklet not supported
|
||||
console.warn('AudioWorklet not available, using ScriptProcessor fallback:', e);
|
||||
captureNode = audioCtx.createScriptProcessor(1024, 1, 1);
|
||||
let acc = new Float32Array(0);
|
||||
captureNode.onaudioprocess = (ev) => {
|
||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const input = ev.inputBuffer.getChannelData(0);
|
||||
const n = new Float32Array(acc.length + input.length);
|
||||
n.set(acc); n.set(input, acc.length); acc = n;
|
||||
while (acc.length >= FRAME_SIZE) {
|
||||
const frame = acc.slice(0, FRAME_SIZE); acc = acc.slice(FRAME_SIZE);
|
||||
const pcm = new Int16Array(FRAME_SIZE);
|
||||
for (let i = 0; i < FRAME_SIZE; i++) pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
||||
let max = 0;
|
||||
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
|
||||
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
|
||||
ws.send(pcm.buffer);
|
||||
framesSent++;
|
||||
}
|
||||
};
|
||||
source.connect(captureNode);
|
||||
captureNode.connect(audioCtx.destination);
|
||||
}
|
||||
}
|
||||
|
||||
async function startAudioPlayback() {
|
||||
try {
|
||||
await audioCtx.audioWorklet.addModule('playback-processor.js');
|
||||
playbackNode = new AudioWorkletNode(audioCtx, 'playback-processor');
|
||||
playbackNode.connect(audioCtx.destination);
|
||||
} catch(e) {
|
||||
console.warn('AudioWorklet playback not available, using scheduled fallback');
|
||||
playbackNode = null; // will use createBufferSource fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduled playback with aggressive drift correction
|
||||
let nextPlayTime = 0;
|
||||
|
||||
function playAudio(pcmInt16) {
|
||||
@@ -193,35 +223,40 @@ function playAudio(pcmInt16) {
|
||||
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);
|
||||
|
||||
const now = audioCtx.currentTime;
|
||||
|
||||
const drift = nextPlayTime - now;
|
||||
if (drift < 0) {
|
||||
// Fell behind — catch up
|
||||
nextPlayTime = now + 0.02;
|
||||
} else if (drift > 1.0) {
|
||||
// More than 1 second ahead — hard reset (real drift)
|
||||
console.log('drift reset:', drift.toFixed(3) + 's');
|
||||
nextPlayTime = now + 0.02;
|
||||
if (playbackNode && playbackNode.port) {
|
||||
// AudioWorklet path — send float samples to the worklet
|
||||
playbackNode.port.postMessage(floatData.buffer, [floatData.buffer]);
|
||||
} else {
|
||||
// Fallback: scheduled BufferSource
|
||||
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);
|
||||
const now = audioCtx.currentTime;
|
||||
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
|
||||
nextPlayTime = now + 0.02;
|
||||
}
|
||||
source.start(nextPlayTime);
|
||||
nextPlayTime += buffer.duration;
|
||||
}
|
||||
source.start(nextPlayTime);
|
||||
nextPlayTime += buffer.duration;
|
||||
}
|
||||
|
||||
function startStatsUpdate() {
|
||||
const interval = setInterval(() => {
|
||||
if (!active) { clearInterval(interval); return; }
|
||||
statsInterval = setInterval(() => {
|
||||
if (!active) { clearInterval(statsInterval); 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}%`);
|
||||
setStats(elapsed + 's | sent: ' + framesSent + ' | recv: ' + framesRecv);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Set room from URL on load
|
||||
window.addEventListener('load', () => {
|
||||
const room = getRoom();
|
||||
if (room && room !== 'default') {
|
||||
document.getElementById('room').value = room;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
45
crates/wzp-web/static/playback-processor.js
Normal file
45
crates/wzp-web/static/playback-processor.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// AudioWorklet processor for playing received audio.
|
||||
// Receives PCM samples from the main thread and outputs them.
|
||||
|
||||
class PlaybackProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.buffer = new Float32Array(0);
|
||||
this.maxBuffered = 48000 / 5; // 200ms max
|
||||
this.port.onmessage = (e) => {
|
||||
const incoming = new Float32Array(e.data);
|
||||
// Append
|
||||
const newBuf = new Float32Array(this.buffer.length + incoming.length);
|
||||
newBuf.set(this.buffer);
|
||||
newBuf.set(incoming, this.buffer.length);
|
||||
this.buffer = newBuf;
|
||||
|
||||
// Cap buffer to prevent drift
|
||||
if (this.buffer.length > this.maxBuffered) {
|
||||
this.buffer = this.buffer.slice(this.buffer.length - this.maxBuffered);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
const output = outputs[0];
|
||||
if (!output || !output[0]) return true;
|
||||
|
||||
const out = output[0]; // 128 samples typically
|
||||
|
||||
if (this.buffer.length >= out.length) {
|
||||
out.set(this.buffer.subarray(0, out.length));
|
||||
this.buffer = this.buffer.slice(out.length);
|
||||
} else if (this.buffer.length > 0) {
|
||||
out.set(this.buffer);
|
||||
for (let i = this.buffer.length; i < out.length; i++) out[i] = 0;
|
||||
this.buffer = new Float32Array(0);
|
||||
} else {
|
||||
for (let i = 0; i < out.length; i++) out[i] = 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('playback-processor', PlaybackProcessor);
|
||||
Reference in New Issue
Block a user