v0.0.47: integrate 6 WZP audio variants into featherChat calls

startAudio() now dynamically loads the selected WZP client variant:
- /audio-variant [pure|hybrid|full|ws|ws-fec|ws-full]
- Loads variant JS from wzp-web's /audio/js/ path via Caddy
- Falls back to inline pure implementation if variant fails to load
- Variant persisted in localStorage across sessions
- Call bar shows active variant: "In call [ws-fec] with 0x..."

Variants:
  pure     — raw PCM over WS (bridge needed, no WASM)
  hybrid   — raw PCM + WASM FEC over WS (bridge needed)
  full     — WebTransport + FEC + crypto (no bridge, future)
  ws       — WZP protocol over WS (relay direct)
  ws-fec   — WZP + WASM FEC over WS (relay direct)
  ws-full  — WZP + FEC + E2E crypto over WS (relay direct)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-30 16:02:43 +04:00
parent 561f2d6978
commit 8a4f0ef8ee
8 changed files with 189 additions and 57 deletions

10
warzone/Cargo.lock generated
View File

@@ -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",

View File

@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
version = "0.0.46"
version = "0.0.47"
edition = "2021"
license = "MIT"
rust-version = "1.75"

View File

@@ -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)"

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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

View File

@@ -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