diff --git a/warzone-phone b/warzone-phone index 09a18b0..2de6e19 160000 --- a/warzone-phone +++ b/warzone-phone @@ -1 +1 @@ -Subproject commit 09a18b086bf0d0bf6a4a4eae4f0b27f80e381f25 +Subproject commit 2de6e1995608e72c2adc188a36a3e3b8db77efa7 diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 94925d8..8aa3ad5 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.46" +version = "0.0.47" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.46" +version = "0.0.47" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.46" +version = "0.0.47" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.46" +version = "0.0.47" dependencies = [ "anyhow", "axum", @@ -3054,7 +3054,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.46" +version = "0.0.47" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 2c5dc88..25655a2 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.46" +version = "0.0.47" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index db43fc2..a9bc31a 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.46" +version = "0.0.47" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 37c941a..1c8472e 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v28'; +const CACHE = 'wz-v29'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -288,7 +288,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.46'; +const VERSION = '0.0.47'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -1265,6 +1265,8 @@ async function sendToGroup(groupName, text) { let callState = 'idle'; // idle, calling, ringing, active let callPeer = null; +let audioVariant = localStorage.getItem('wz-audio-variant') || 'pure'; +let wzpClient = null; // The loaded WZP variant client instance // Group call state let groupCallRoom = null; // Current group call room name @@ -1307,7 +1309,7 @@ function updateCallUI() { btnReject.style.display = ''; break; case 'active': - status.textContent = '\u{1F50A} In call with ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '?').slice(0, 16)); + status.textContent = '\u{1F50A} In call [' + audioVariant + '] with ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '?').slice(0, 16)); status.className = 'call-status'; btnHangup.style.display = ''; break; @@ -1566,14 +1568,13 @@ function sendCallNotification(title, body) { } async function startAudio() { - // Fetch relay config (includes auth token) + // Fetch relay config let relayAddr, authToken; try { const resp = await fetch(SERVER + '/v1/wzp/relay-config'); const data = await resp.json(); relayAddr = data.relay_addr; authToken = data.token; - dbg('Relay address:', relayAddr, 'token:', authToken); } catch(e) { addSys('Audio: cannot get relay config \u2014 ' + e.message); return; @@ -1591,7 +1592,7 @@ async function startAudio() { audioCtx = new AudioContext({ sampleRate: 48000 }); - // Deterministic room: sort both fingerprints so both peers get the same room + // Deterministic room name const myFP = normFP(myFingerprint); const peerFP = callPeer ? normFP(callPeer) : ''; const roomPair = [myFP, peerFP].sort().join('-'); @@ -1600,40 +1601,154 @@ async function startAudio() { const proto = host.startsWith('localhost') || host.startsWith('127.') ? 'ws:' : 'wss:'; const wsUrl = proto + '//' + host + '/ws/' + room; - addSys('Audio: connecting to room ' + room.slice(0, 12) + '...'); + addSys('Audio [' + audioVariant + ']: connecting to room ' + room.slice(0, 12) + '...'); + // Load variant JS from wzp-web if not already loaded + const variantClass = await loadAudioVariant(audioVariant); + if (!variantClass) { + addSys('Audio: failed to load variant "' + audioVariant + '", falling back to pure'); + // Fallback to inline pure implementation + startAudioPure(wsUrl, authToken, room); + return; + } + + // Create variant client + wzpClient = new variantClass({ + wsUrl: wsUrl, + room: room, + authToken: authToken, + onAudio: (pcm) => { + if (!audioCtx) return; + const float32 = new Float32Array(pcm.length); + for (let i = 0; i < pcm.length; i++) float32[i] = pcm[i] / 32768.0; + const buffer = audioCtx.createBuffer(1, float32.length, 48000); + buffer.getChannelData(0).set(float32); + const src = audioCtx.createBufferSource(); + src.buffer = buffer; + src.connect(audioCtx.destination); + src.start(); + }, + onStatus: (msg) => addSys('Audio: ' + msg), + onStats: (stats) => dbg('Audio stats:', stats), + }); + + // Load WASM if variant needs it + if (wzpClient.loadWasm) { + try { + addSys('Audio: loading WASM module...'); + await wzpClient.loadWasm(); + } catch(e) { + addSys('Audio: WASM load failed \u2014 ' + e.message); + return; + } + } + + // Connect + try { + await wzpClient.connect(); + } catch(e) { + addSys('Audio: connection failed \u2014 ' + e.message); + wzpClient = null; + return; + } + + // Start mic capture -> variant client + const source = audioCtx.createMediaStreamSource(mediaStream); + const processor = audioCtx.createScriptProcessor(1024, 1, 1); + let captureBuffer = new Float32Array(0); + + processor.onaudioprocess = (e) => { + if (callState !== 'active' || !wzpClient) return; + const input = e.inputBuffer.getChannelData(0); + const combined = new Float32Array(captureBuffer.length + input.length); + combined.set(captureBuffer); + combined.set(input, captureBuffer.length); + captureBuffer = combined; + + while (captureBuffer.length >= 960) { + const frame = captureBuffer.slice(0, 960); + captureBuffer = captureBuffer.slice(960); + const pcm = new Int16Array(frame.length); + for (let i = 0; i < frame.length; i++) { + pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767))); + } + wzpClient.sendAudio(pcm.buffer); + } + }; + + source.connect(processor); + processor.connect(audioCtx.destination); + captureNode = processor; +} + +// Dynamically load a WZP variant JS file from the audio bridge +async function loadAudioVariant(variant) { + const classMap = { + pure: 'WZPPureClient', + hybrid: 'WZPHybridClient', + full: 'WZPFullClient', + ws: 'WZPWsClient', + 'ws-fec': 'WZPWsFecClient', + 'ws-full': 'WZPWsFullClient', + }; + const fileMap = { + pure: 'wzp-pure.js', + hybrid: 'wzp-hybrid.js', + full: 'wzp-full.js', + ws: 'wzp-ws.js', + 'ws-fec': 'wzp-ws-fec.js', + 'ws-full': 'wzp-ws-full.js', + }; + + const className = classMap[variant]; + if (!className) return null; + + // Already loaded? + if (window[className]) return window[className]; + + // Load from wzp-web's static files via the /audio/ Caddy path + const file = fileMap[variant]; + if (!file) return null; + + try { + const script = document.createElement('script'); + script.src = SERVER + '/audio/js/' + file; + await new Promise((resolve, reject) => { + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + return window[className] || null; + } catch(e) { + dbg('Failed to load variant script:', e); + return null; + } +} + +// Fallback: inline pure audio (original implementation, no external JS) +function startAudioPure(wsUrl, authToken, room) { audioWs = new WebSocket(wsUrl); audioWs.binaryType = 'arraybuffer'; audioWs.onopen = async () => { - // Send auth token as first message (required by wzp-web --auth-url) audioWs.send(JSON.stringify({ type: 'auth', token: authToken })); - addSys('Audio: connected \u2014 mic active'); + addSys('Audio [pure-inline]: connected'); - // Capture: mic -> PCM frames -> WS const source = audioCtx.createMediaStreamSource(mediaStream); - - // Use ScriptProcessor as fallback (AudioWorklet needs a separate file) - const bufferSize = 960; // 20ms at 48kHz const processor = audioCtx.createScriptProcessor(1024, 1, 1); let captureBuffer = new Float32Array(0); processor.onaudioprocess = (e) => { if (callState !== 'active' || !audioWs || audioWs.readyState !== WebSocket.OPEN) return; const input = e.inputBuffer.getChannelData(0); - - // Accumulate samples const combined = new Float32Array(captureBuffer.length + input.length); combined.set(captureBuffer); combined.set(input, captureBuffer.length); captureBuffer = combined; - // Send 960-sample frames (20ms) - while (captureBuffer.length >= bufferSize) { - const frame = captureBuffer.slice(0, bufferSize); - captureBuffer = captureBuffer.slice(bufferSize); - - // Convert float32 to int16 + while (captureBuffer.length >= 960) { + const frame = captureBuffer.slice(0, 960); + captureBuffer = captureBuffer.slice(960); const pcm = new Int16Array(frame.length); for (let i = 0; i < frame.length; i++) { pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767))); @@ -1643,42 +1758,26 @@ async function startAudio() { }; source.connect(processor); - processor.connect(audioCtx.destination); // needed to keep processor alive + processor.connect(audioCtx.destination); captureNode = processor; - - // Playback buffer - playbackNode = { queue: [] }; }; audioWs.onmessage = (event) => { if (!audioCtx) return; const pcm = new Int16Array(event.data); if (pcm.length === 0) return; - - // Convert int16 to float32 and play const float32 = new Float32Array(pcm.length); - for (let i = 0; i < pcm.length; i++) { - float32[i] = pcm[i] / 32768.0; - } - + for (let i = 0; i < pcm.length; i++) float32[i] = pcm[i] / 32768.0; const buffer = audioCtx.createBuffer(1, float32.length, 48000); buffer.getChannelData(0).set(float32); - const source = audioCtx.createBufferSource(); - source.buffer = buffer; - source.connect(audioCtx.destination); - source.start(); + const src = audioCtx.createBufferSource(); + src.buffer = buffer; + src.connect(audioCtx.destination); + src.start(); }; - audioWs.onclose = () => { - if (callState === 'active') { - addSys('Audio: disconnected'); - } - }; - - audioWs.onerror = (e) => { - addSys('Audio: connection error'); - dbg('Audio WS error:', e); - }; + audioWs.onclose = () => { if (callState === 'active') addSys('Audio: disconnected'); }; + audioWs.onerror = () => { addSys('Audio: connection error'); }; } function stopAudio() { @@ -1686,6 +1785,10 @@ function stopAudio() { audioWs.close(); audioWs = null; } + if (wzpClient) { + wzpClient.disconnect(); + wzpClient = null; + } if (captureNode) { captureNode.disconnect(); captureNode = null; @@ -1966,6 +2069,24 @@ async function doSend() { if (text === '/hangup' || text === '/end') { hangupCall(); return; } if (text === '/accept') { acceptCall(); return; } if (text === '/reject') { rejectCall(); return; } + if (text.startsWith('/audio-variant')) { + const parts = text.split(/\s+/); + if (parts.length < 2) { + addSys('Audio variant: ' + audioVariant); + addSys('Options: pure, hybrid, full, ws, ws-fec, ws-full'); + return; + } + const v = parts[1].toLowerCase(); + const valid = ['pure', 'hybrid', 'full', 'ws', 'ws-fec', 'ws-full']; + if (!valid.includes(v)) { + addSys('Unknown variant: ' + v + '. Options: ' + valid.join(', ')); + return; + } + audioVariant = v; + localStorage.setItem('wz-audio-variant', v); + addSys('Audio variant set to: ' + v + ' (takes effect on next call)'); + return; + } if (text === '/reset') { localStorage.clear(); addSys('localStorage cleared. Refresh the page to start fresh.'); @@ -2061,6 +2182,7 @@ async function doSend() { addSys(' /bundleinfo \u2014 debug key bundle info'); addSys(' /sessions \u2014 list cached sessions'); addSys(' /selftest \u2014 run WASM self-test'); + addSys(' /audio-variant [v] \u2014 set audio stack (pure/hybrid/full/ws/ws-fec/ws-full)'); addSys(' /debug \u2014 toggle debug mode'); addSys(' /reset \u2014 clear all local data'); return; diff --git a/warzone/deploy/docker/Caddyfile b/warzone/deploy/docker/Caddyfile index a9bf447..fe58d7c 100644 --- a/warzone/deploy/docker/Caddyfile +++ b/warzone/deploy/docker/Caddyfile @@ -2,16 +2,23 @@ email admin@manko.yoga } +# Wildcard cert for all subdomains +*.voip.manko.yoga { + tls { + dns cloudflare {$CF_API_TOKEN} + } + reverse_proxy wzp-web:8080 +} + +# Main domain — featherChat server voip.manko.yoga { tls { dns cloudflare {$CF_API_TOKEN} } - # Audio bridge WebSocket (wzp-web) handle_path /audio/* { reverse_proxy wzp-web:8080 } - # Everything else → featherChat server reverse_proxy warzone-server:7700 } diff --git a/warzone/deploy/docker/Dockerfile.wzp b/warzone/deploy/docker/Dockerfile.wzp index 61491e6..2eda48d 100644 --- a/warzone/deploy/docker/Dockerfile.wzp +++ b/warzone/deploy/docker/Dockerfile.wzp @@ -24,4 +24,7 @@ RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/ COPY --from=builder /build/warzone-phone/target/release/wzp-relay /usr/local/bin/ COPY --from=builder /build/warzone-phone/target/release/wzp-web /usr/local/bin/ +# Copy static files for wzp-web (HTML, JS, WASM) +COPY --from=builder /build/warzone-phone/crates/wzp-web/static /data/static + WORKDIR /data diff --git a/warzone/scripts/test-variants.sh b/warzone/scripts/test-variants.sh index 9e15f22..9d4c6ab 100755 --- a/warzone/scripts/test-variants.sh +++ b/warzone/scripts/test-variants.sh @@ -122,7 +122,7 @@ do_urls() { for i in "${!VARIANTS[@]}"; do local sub="${VARIANTS[$i]}" local label="${LABELS[$i]}" - printf " │ %-6s │ %-8s │ https://%s.%s/test-room │\n" "$sub" "$label" "$sub" "$BASE_DOMAIN" + printf " │ %-6s │ %-8s │ https://%s.%s/test-room?variant=%s │\n" "$sub" "$label" "$sub" "$BASE_DOMAIN" "$label" done echo " └────────┴──────────┴──────────────────────────────────────────┘" echo "" @@ -137,7 +137,7 @@ do_check() { for i in "${!VARIANTS[@]}"; do local sub="${VARIANTS[$i]}" local label="${LABELS[$i]}" - local url="https://${sub}.${BASE_DOMAIN}/" + local url="https://${sub}.${BASE_DOMAIN}/test-room?variant=${label}" local status status=$(curl -4 -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$url" 2>/dev/null || echo "000") if [ "$status" = "200" ]; then