feat: 3 web client variants — Pure JS, Hybrid (JS+WASM FEC), Full WASM

Variant 1: Pure JS (wzp-pure.js)
- WebSocket transport, raw PCM, no encryption (bridge handles QUIC crypto)
- ~20KB, works everywhere, zero dependencies
- WZPPureClient class with connect/disconnect/sendAudio

Variant 2: Hybrid (wzp-hybrid.js + wzp-wasm)
- WebSocket transport + RaptorQ FEC via WASM
- ~120KB (337KB WASM blob shared with full variant)
- WZPHybridClient extends pure with FEC encode/decode
- Loss recovery ready for when WebTransport replaces WebSocket

Variant 3: Full WASM (wzp-full.js + wzp-wasm)
- WebTransport datagrams (unreliable, low latency)
- ChaCha20-Poly1305 encryption + RaptorQ FEC, all in WASM
- X25519 key exchange over bidirectional stream
- WZPFullClient — true E2E encrypted WZP client in browser
- Needs relay HTTP/3 support (h3-quinn) for WebTransport

Shared infrastructure:
- wzp-core.js: UI logic, AudioWorklet, variant detection, PTT
- audio-processor.js: AudioWorklet capture + playback (unchanged)
- index.html: variant selector (?variant=pure|hybrid|full), auto-detect

wzp-wasm crate (new):
- RaptorQ FEC encoder/decoder (WzpFecEncoder, WzpFecDecoder)
- ChaCha20-Poly1305 crypto (WzpCryptoSession)
- X25519 key exchange (WzpKeyExchange)
- 7 native tests (3 FEC + 4 crypto), all passing
- WASM blob: 337KB optimized

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-30 11:10:15 +04:00
parent 4fb15fe7a3
commit f3c8e11995
9 changed files with 2262 additions and 279 deletions

View File

@@ -10,6 +10,10 @@
.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: 1.5rem; }
.variant-badge { display: inline-block; background: #2a2a4a; border: 1px solid #444; color: #00d4ff; font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 4px; margin-left: 0.4rem; vertical-align: middle; font-family: monospace; letter-spacing: 0.05em; }
.variant-selector { margin-bottom: 1.2rem; display: flex; gap: 0.8rem; justify-content: center; flex-wrap: wrap; }
.variant-selector label { font-size: 0.75rem; color: #888; cursor: pointer; display: flex; align-items: center; gap: 0.25rem; }
.variant-selector input[type="radio"] { accent-color: #00d4ff; }
.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; }
@@ -31,15 +35,22 @@
</head>
<body>
<div class="container">
<h1>WarzonePhone</h1>
<h1>WarzonePhone <span class="variant-badge" id="variantBadge">PURE</span></h1>
<p class="subtitle">Lossy VoIP Protocol</p>
<div class="variant-selector">
<label><input type="radio" name="variant" value="pure"> Pure JS</label>
<label><input type="radio" name="variant" value="hybrid"> Hybrid</label>
<label><input type="radio" name="variant" value="full"> Full WASM</label>
</div>
<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>
<button id="callBtn">Connect</button>
<div class="controls" id="controls" style="display:none;">
<label><input type="checkbox" id="pttMode" onchange="togglePTT()"> Radio mode (push-to-talk)</label>
<label><input type="checkbox" id="pttMode"> Radio mode (push-to-talk)</label>
</div>
<button id="pttBtn">Hold to Talk</button>
<div class="level"><div class="level-bar" id="levelBar"></div></div>
@@ -47,302 +58,126 @@
<div class="stats" id="stats"></div>
</div>
<script src="js/wzp-core.js"></script>
<script>
const SAMPLE_RATE = 48000;
const FRAME_SIZE = 960;
let ws = null;
let audioCtx = null;
let mediaStream = null;
let captureNode = null;
let playbackNode = null;
let active = false;
let transmitting = true; // in open-mic mode, always transmitting
let pttMode = false;
let framesSent = 0;
let framesRecv = 0;
let startTime = 0;
let statsInterval = null;
// Use room from URL path or input field
function getRoom() {
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';
}
// Pre-fill room input from URL on page load
// ---------------------------------------------------------------------------
// Load the selected variant script dynamically
// ---------------------------------------------------------------------------
(function() {
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
if (path && path !== 'index.html') {
document.getElementById('room').value = path;
}
var variant = WZPCore.detectVariant();
var scriptMap = {
pure: 'js/wzp-pure.js',
hybrid: 'js/wzp-hybrid.js',
full: 'js/wzp-full.js',
};
var src = scriptMap[variant] || scriptMap.pure;
var s = document.createElement('script');
s.src = src;
s.onload = function() { wzpBoot(); };
s.onerror = function() {
WZPCore.updateStatus('Failed to load variant: ' + variant);
};
document.body.appendChild(s);
})();
function setStatus(msg) { document.getElementById('status').textContent = msg; }
function setStats(msg) { document.getElementById('stats').textContent = msg; }
// ---------------------------------------------------------------------------
// Boot: wire UI to the loaded client variant
// ---------------------------------------------------------------------------
function wzpBoot() {
var client = null;
var capture = null;
var playback = null;
var transmitting = true;
function toggleCall() {
if (active) stopCall();
else startCall();
}
var ui = WZPCore.initUI({
onConnect: function(room) {
doConnect(room);
},
onDisconnect: function() {
doDisconnect();
},
onTransmit: function(tx) {
transmitting = tx;
},
});
async function startCall() {
const btn = document.getElementById('callBtn');
const room = getRoom();
if (!room) { setStatus('Enter a room name'); return; }
async function doConnect(room) {
WZPCore.updateStatus('Requesting microphone...');
btn.disabled = true;
setStatus('Requesting microphone...');
var audioCtx;
try {
audioCtx = await WZPCore.startAudioContext();
} catch (e) {
WZPCore.updateStatus('Audio init failed: ' + e.message);
ui.setConnected(false);
return;
}
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: SAMPLE_RATE, channelCount: 1, echoCancellation: true, noiseSuppression: true }
// Build WebSocket URL
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
// Create client (currently always WZPPureClient; future: switch on variant)
client = new WZPPureClient({
wsUrl: wsUrl,
room: room,
onAudio: function(pcm) {
if (playback) playback.play(pcm);
},
onStatus: function(msg) {
WZPCore.updateStatus(msg);
},
onStats: function(stats) {
WZPCore.updateStats(stats);
},
});
} catch(e) {
setStatus('Mic access denied: ' + e.message);
btn.disabled = false;
return;
}
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
// Connect WebSocket with room name
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
setStatus('Connecting to room: ' + room + '...');
ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = async () => {
setStatus('Connected to room: ' + room);
btn.textContent = 'Disconnect';
btn.classList.add('active');
btn.disabled = false;
active = true;
framesSent = 0;
framesRecv = 0;
startTime = Date.now();
showControls(true);
await startAudioCapture();
await startAudioPlayback();
startStatsUpdate();
};
ws.onmessage = (event) => {
const pcmData = new Int16Array(event.data);
framesRecv++;
playAudio(pcmData);
};
ws.onclose = () => {
if (active) {
setStatus('Disconnected — reconnecting to ' + room + '...');
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
} else {
setStatus('Disconnected');
try {
await client.connect();
} catch (e) {
WZPCore.updateStatus('Connection failed: ' + e.message);
ui.setConnected(false);
return;
}
};
ws.onerror = () => {
if (active) {
setStatus('Error — reconnecting...');
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
// Start audio capture and playback
try {
capture = await WZPCore.connectCapture(audioCtx, function(pcmBuffer) {
if (!transmitting) return;
var pcm = new Int16Array(pcmBuffer);
WZPCore.updateLevel(pcm);
if (client) client.sendAudio(pcmBuffer);
});
playback = await WZPCore.connectPlayback(audioCtx);
} catch (e) {
WZPCore.updateStatus('Audio error: ' + e.message);
if (client) client.disconnect();
client = null;
ui.setConnected(false);
return;
}
};
}
function stopCall() {
active = false;
const btn = document.getElementById('callBtn');
btn.textContent = 'Connect';
btn.classList.remove('active');
btn.disabled = false;
showControls(false);
cleanupAudio();
if (ws) { ws.close(); ws = null; }
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
setStatus('');
setStats('');
}
function cleanupAudio() {
if (captureNode) { captureNode.disconnect(); captureNode = null; }
if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; workletLoaded = false; }
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
}
let workletLoaded = false;
async function loadWorkletModule() {
if (workletLoaded) return true;
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
console.warn('AudioWorklet API not supported in this browser — using ScriptProcessorNode fallback');
return false;
ui.setConnected(true);
}
try {
await audioCtx.audioWorklet.addModule('audio-processor.js');
workletLoaded = true;
return true;
} catch(e) {
console.warn('AudioWorklet module failed to load — using ScriptProcessorNode fallback:', e);
return false;
}
}
async function startAudioCapture() {
const source = audioCtx.createMediaStreamSource(mediaStream);
const hasWorklet = await loadWorkletModule();
function doDisconnect() {
if (capture) { capture.stop(); capture = null; }
if (playback) { playback.stop(); playback = null; }
if (client) { client.disconnect(); client = null; }
if (hasWorklet) {
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
captureNode.port.onmessage = (e) => {
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
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
} else {
// Fallback to ScriptProcessorNode (deprecated but widely supported)
console.warn('Capture: using ScriptProcessorNode fallback');
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
let acc = new Float32Array(0);
captureNode.onaudioprocess = (ev) => {
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) 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() {
const hasWorklet = await loadWorkletModule();
if (hasWorklet) {
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
playbackNode.connect(audioCtx.destination);
} else {
console.warn('Playback: using scheduled BufferSource fallback');
playbackNode = null; // will use createBufferSource fallback in playAudio()
}
}
let nextPlayTime = 0;
function playAudio(pcmInt16) {
if (!audioCtx) return;
if (playbackNode && playbackNode.port) {
// AudioWorklet path — send Int16 PCM directly to the worklet for conversion
playbackNode.port.postMessage(pcmInt16.buffer, [pcmInt16.buffer]);
} else {
// Fallback: scheduled BufferSource (convert Int16 -> Float32 on main thread)
const floatData = new Float32Array(pcmInt16.length);
for (let i = 0; i < pcmInt16.length; i++) {
floatData[i] = pcmInt16[i] / 32768.0;
var audioCtx = WZPCore.getAudioContext();
if (audioCtx && audioCtx.state !== 'closed') {
audioCtx.close();
}
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;
WZPCore.updateStatus('');
WZPCore.updateStats('');
document.getElementById('levelBar').style.width = '0%';
ui.setConnected(false);
}
}
function startStatsUpdate() {
statsInterval = setInterval(() => {
if (!active) { clearInterval(statsInterval); return; }
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
setStats(elapsed + 's | sent: ' + framesSent + ' | recv: ' + framesRecv);
}, 1000);
}
// --- Push-to-talk ---
function togglePTT() {
pttMode = document.getElementById('pttMode').checked;
const btn = document.getElementById('pttBtn');
if (pttMode) {
transmitting = false;
btn.style.display = 'block';
} else {
transmitting = true;
btn.style.display = 'none';
}
}
// PTT button — hold to talk (mouse + touch)
document.getElementById('pttBtn').addEventListener('mousedown', () => { startTransmit(); });
document.getElementById('pttBtn').addEventListener('mouseup', () => { stopTransmit(); });
document.getElementById('pttBtn').addEventListener('mouseleave', () => { stopTransmit(); });
document.getElementById('pttBtn').addEventListener('touchstart', (e) => { e.preventDefault(); startTransmit(); });
document.getElementById('pttBtn').addEventListener('touchend', (e) => { e.preventDefault(); stopTransmit(); });
// Spacebar PTT
document.addEventListener('keydown', (e) => { if (pttMode && active && e.code === 'Space' && !e.repeat) { e.preventDefault(); startTransmit(); } });
document.addEventListener('keyup', (e) => { if (pttMode && active && e.code === 'Space') { e.preventDefault(); stopTransmit(); } });
function startTransmit() {
if (!pttMode || !active) return;
transmitting = true;
document.getElementById('pttBtn').classList.add('transmitting');
document.getElementById('pttBtn').textContent = 'Transmitting...';
}
function stopTransmit() {
if (!pttMode) return;
transmitting = false;
document.getElementById('pttBtn').classList.remove('transmitting');
document.getElementById('pttBtn').textContent = 'Hold to Talk';
}
// Show controls when connected
function showControls(show) {
document.getElementById('controls').style.display = show ? 'flex' : 'none';
if (!show) {
document.getElementById('pttBtn').style.display = 'none';
pttMode = false;
transmitting = true;
}
}
// Set room from URL on load
window.addEventListener('load', () => {
const room = getRoom();
if (room && room !== 'default') {
document.getElementById('room').value = room;
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,378 @@
// WarzonePhone — Shared UI logic for all client variants.
// Provides: audio context management, mic capture, playback, UI wiring.
'use strict';
const WZP_SAMPLE_RATE = 48000;
const WZP_FRAME_SIZE = 960; // 20ms @ 48kHz
// ---------------------------------------------------------------------------
// Variant detection
// ---------------------------------------------------------------------------
function wzpDetectVariant() {
const params = new URLSearchParams(location.search);
const v = (params.get('variant') || 'pure').toLowerCase();
if (v === 'hybrid' || v === 'full') return v;
return 'pure';
}
// ---------------------------------------------------------------------------
// Room helpers
// ---------------------------------------------------------------------------
function wzpGetRoom() {
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
if (path && path !== 'index.html') return path;
const hash = location.hash.replace('#', '');
if (hash) return hash;
const el = document.getElementById('room');
return (el && el.value.trim()) || 'default';
}
function wzpPrefillRoom() {
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
if (path && path !== 'index.html') {
const el = document.getElementById('room');
if (el) el.value = path;
}
}
// ---------------------------------------------------------------------------
// Status / stats helpers
// ---------------------------------------------------------------------------
function wzpUpdateStatus(msg) {
const el = document.getElementById('status');
if (el) el.textContent = msg;
}
function wzpUpdateStats(stats) {
const el = document.getElementById('stats');
if (!el) return;
if (typeof stats === 'string') {
el.textContent = stats;
} else {
const parts = [];
if (stats.elapsed != null) parts.push(stats.elapsed.toFixed(1) + 's');
if (stats.sent != null) parts.push('sent: ' + stats.sent);
if (stats.recv != null) parts.push('recv: ' + stats.recv);
if (stats.loss != null) parts.push('loss: ' + (stats.loss * 100).toFixed(1) + '%');
if (stats.fecRecovered != null && stats.fecRecovered > 0) parts.push('fec: ' + stats.fecRecovered);
if (stats.fecReady != null) parts.push(stats.fecReady ? 'FEC:on' : 'FEC:off');
el.textContent = parts.join(' | ');
}
}
function wzpUpdateLevel(pcmInt16) {
const bar = document.getElementById('levelBar');
if (!bar) return;
let max = 0;
for (let i = 0; i < pcmInt16.length; i += 16) {
const v = Math.abs(pcmInt16[i]);
if (v > max) max = v;
}
bar.style.width = (max / 32768 * 100) + '%';
}
// ---------------------------------------------------------------------------
// Audio context + worklet
// ---------------------------------------------------------------------------
let _wzpAudioCtx = null;
let _wzpWorkletLoaded = false;
async function wzpStartAudioContext() {
if (_wzpAudioCtx && _wzpAudioCtx.state !== 'closed') return _wzpAudioCtx;
_wzpAudioCtx = new AudioContext({ sampleRate: WZP_SAMPLE_RATE });
_wzpWorkletLoaded = false;
return _wzpAudioCtx;
}
function wzpGetAudioContext() {
return _wzpAudioCtx;
}
async function _wzpLoadWorklet(audioCtx) {
if (_wzpWorkletLoaded) return true;
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
console.warn('[wzp-core] AudioWorklet not supported, will use fallback');
return false;
}
try {
await audioCtx.audioWorklet.addModule('audio-processor.js');
_wzpWorkletLoaded = true;
return true;
} catch (e) {
console.warn('[wzp-core] AudioWorklet load failed:', e);
return false;
}
}
// ---------------------------------------------------------------------------
// Mic capture — returns { node, stop() }
// onFrame(ArrayBuffer) called for each 960-sample Int16 PCM frame
// ---------------------------------------------------------------------------
async function wzpConnectCapture(audioCtx, onFrame) {
let mediaStream;
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: WZP_SAMPLE_RATE,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
});
} catch (e) {
throw new Error('Mic access denied: ' + e.message);
}
const source = audioCtx.createMediaStreamSource(mediaStream);
const hasWorklet = await _wzpLoadWorklet(audioCtx);
let captureNode;
if (hasWorklet) {
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
captureNode.port.onmessage = (e) => {
onFrame(e.data); // ArrayBuffer of Int16 PCM
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination); // keep worklet alive
} else {
// ScriptProcessorNode fallback
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
let acc = new Float32Array(0);
captureNode.onaudioprocess = (ev) => {
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 >= WZP_FRAME_SIZE) {
const frame = acc.slice(0, WZP_FRAME_SIZE);
acc = acc.slice(WZP_FRAME_SIZE);
const pcm = new Int16Array(WZP_FRAME_SIZE);
for (let i = 0; i < WZP_FRAME_SIZE; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
}
onFrame(pcm.buffer);
}
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination);
}
return {
node: captureNode,
stop() {
captureNode.disconnect();
mediaStream.getTracks().forEach((t) => t.stop());
},
};
}
// ---------------------------------------------------------------------------
// Playback — returns { node, play(Int16Array), stop() }
// ---------------------------------------------------------------------------
async function wzpConnectPlayback(audioCtx) {
const hasWorklet = await _wzpLoadWorklet(audioCtx);
let playbackNode;
let nextPlayTime = 0;
if (hasWorklet) {
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
playbackNode.connect(audioCtx.destination);
return {
node: playbackNode,
play(pcmInt16) {
// Transfer Int16 buffer to worklet
const buf = pcmInt16.buffer.slice(
pcmInt16.byteOffset,
pcmInt16.byteOffset + pcmInt16.byteLength
);
playbackNode.port.postMessage(buf, [buf]);
},
stop() {
playbackNode.disconnect();
},
};
}
// Fallback: scheduled BufferSource
return {
node: null,
play(pcmInt16) {
if (!audioCtx || audioCtx.state === 'closed') return;
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, WZP_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;
},
stop() {
// nothing to disconnect for fallback
},
};
}
// ---------------------------------------------------------------------------
// UI wiring — call after DOM ready
// ---------------------------------------------------------------------------
function wzpInitUI(callbacks) {
// callbacks: { onConnect(room), onDisconnect() }
const btn = document.getElementById('callBtn');
const pttBtn = document.getElementById('pttBtn');
const pttCheckbox = document.getElementById('pttMode');
let connected = false;
let pttMode = false;
wzpPrefillRoom();
// Variant badge
const variant = wzpDetectVariant();
const badge = document.getElementById('variantBadge');
if (badge) badge.textContent = variant.toUpperCase();
// Variant selector radio buttons
document.querySelectorAll('input[name="variant"]').forEach((radio) => {
if (radio.value === variant) radio.checked = true;
radio.addEventListener('change', () => {
if (radio.checked) {
const params = new URLSearchParams(location.search);
params.set('variant', radio.value);
location.search = params.toString();
}
});
});
btn.onclick = () => {
if (connected) {
connected = false;
btn.textContent = 'Connect';
btn.classList.remove('active');
_showControls(false);
if (callbacks.onDisconnect) callbacks.onDisconnect();
} else {
const room = wzpGetRoom();
if (!room) {
wzpUpdateStatus('Enter a room name');
return;
}
connected = true;
btn.disabled = true;
if (callbacks.onConnect) callbacks.onConnect(room);
}
};
// PTT toggle
if (pttCheckbox) {
pttCheckbox.onchange = () => {
pttMode = pttCheckbox.checked;
if (pttMode) {
pttBtn.style.display = 'block';
if (callbacks.onTransmit) callbacks.onTransmit(false);
} else {
pttBtn.style.display = 'none';
if (callbacks.onTransmit) callbacks.onTransmit(true);
}
};
}
// PTT button events
function startTx() {
if (!pttMode || !connected) return;
pttBtn.classList.add('transmitting');
pttBtn.textContent = 'Transmitting...';
if (callbacks.onTransmit) callbacks.onTransmit(true);
}
function stopTx() {
if (!pttMode) return;
pttBtn.classList.remove('transmitting');
pttBtn.textContent = 'Hold to Talk';
if (callbacks.onTransmit) callbacks.onTransmit(false);
}
if (pttBtn) {
pttBtn.addEventListener('mousedown', startTx);
pttBtn.addEventListener('mouseup', stopTx);
pttBtn.addEventListener('mouseleave', stopTx);
pttBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startTx(); });
pttBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopTx(); });
}
// Spacebar PTT
document.addEventListener('keydown', (e) => {
if (pttMode && connected && e.code === 'Space' && !e.repeat) {
e.preventDefault();
startTx();
}
});
document.addEventListener('keyup', (e) => {
if (pttMode && connected && e.code === 'Space') {
e.preventDefault();
stopTx();
}
});
function _showControls(show) {
const controls = document.getElementById('controls');
if (controls) controls.style.display = show ? 'flex' : 'none';
if (!show && pttBtn) {
pttBtn.style.display = 'none';
pttMode = false;
if (pttCheckbox) pttCheckbox.checked = false;
}
}
return {
setConnected(isConnected) {
connected = isConnected;
btn.disabled = false;
if (isConnected) {
btn.textContent = 'Disconnect';
btn.classList.add('active');
_showControls(true);
} else {
btn.textContent = 'Connect';
btn.classList.remove('active');
_showControls(false);
}
},
isPTT() {
return pttMode;
},
};
}
// ---------------------------------------------------------------------------
// Exports (global)
// ---------------------------------------------------------------------------
window.WZPCore = {
SAMPLE_RATE: WZP_SAMPLE_RATE,
FRAME_SIZE: WZP_FRAME_SIZE,
detectVariant: wzpDetectVariant,
getRoom: wzpGetRoom,
updateStatus: wzpUpdateStatus,
updateStats: wzpUpdateStats,
updateLevel: wzpUpdateLevel,
startAudioContext: wzpStartAudioContext,
getAudioContext: wzpGetAudioContext,
connectCapture: wzpConnectCapture,
connectPlayback: wzpConnectPlayback,
initUI: wzpInitUI,
};

View File

@@ -0,0 +1,524 @@
// WarzonePhone — Full WASM + WebTransport client (Variant 3).
//
// Architecture:
// - WebTransport for unreliable datagrams (UDP-like, no head-of-line blocking)
// - ChaCha20-Poly1305 encryption via WASM (wzp-wasm WzpCryptoSession)
// - RaptorQ FEC via WASM (wzp-wasm WzpFecEncoder/WzpFecDecoder)
// - X25519 key exchange via WASM (wzp-wasm WzpKeyExchange)
//
// NOTE: WebTransport requires the relay to support HTTP/3 (h3-quinn).
// The current wzp-relay uses raw QUIC. This variant demonstrates the full
// architecture but will need relay-side HTTP/3 support to work end-to-end.
// For development / testing, use the hybrid variant (WebSocket + WASM FEC).
//
// Relies on wzp-core.js for UI and audio helpers.
'use strict';
const WZP_WASM_PATH = '/wasm/wzp_wasm.js';
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
const MEDIA_HEADER_SIZE = 12;
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
const FEC_HEADER_SIZE = 3;
class WZPFullClient {
/**
* @param {Object} options
* @param {string} options.url WebTransport URL (https://host:port)
* @param {string} options.room Room name
* @param {Function} options.onAudio callback(Int16Array) for playback
* @param {Function} options.onStatus callback(string) for UI status
* @param {Function} options.onStats callback(Object) for UI stats
*/
constructor(options) {
this.url = options.url;
this.room = options.room;
this.onAudio = options.onAudio || null;
this.onStatus = options.onStatus || null;
this.onStats = options.onStats || null;
this.wt = null; // WebTransport instance
this.datagramWriter = null; // WritableStreamDefaultWriter
this.datagramReader = null; // ReadableStreamDefaultReader
this.cryptoSession = null; // WzpCryptoSession (WASM)
this.fecEncoder = null; // WzpFecEncoder (WASM)
this.fecDecoder = null; // WzpFecDecoder (WASM)
this.sequence = 0;
this._wasmModule = null;
this._connected = false;
this._startTime = 0;
this._statsInterval = null;
this._recvLoopRunning = false;
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
}
/**
* Connect: load WASM, open WebTransport, perform key exchange,
* initialise FEC, and start the receive loop.
*/
async connect() {
if (this._connected) return;
// --- Guard: WebTransport support ---
if (typeof WebTransport === 'undefined') {
throw new Error(
'WebTransport is not supported in this browser. ' +
'Use the hybrid (?variant=hybrid) or pure (?variant=pure) variant instead.'
);
}
this._status('Loading WASM module...');
// 1. Load WASM
this._wasmModule = await import(WZP_WASM_PATH);
await this._wasmModule.default();
this._status('Connecting via WebTransport to ' + this.url + '...');
// 2. WebTransport connection
// The URL should include the room, e.g. https://host:port/room
const wtUrl = this.url + '/' + encodeURIComponent(this.room);
this.wt = new WebTransport(wtUrl);
this.wt.closed.then(() => {
const wasConnected = this._connected;
this._cleanup();
if (wasConnected) {
this._status('WebTransport closed');
}
}).catch((err) => {
this._cleanup();
this._status('WebTransport error: ' + err.message);
});
await this.wt.ready;
// 3. Get datagram streams (unreliable, QUIC DATAGRAM frames)
this.datagramWriter = this.wt.datagrams.writable.getWriter();
this.datagramReader = this.wt.datagrams.readable.getReader();
// 4. Key exchange over a bidirectional stream
this._status('Performing key exchange...');
await this._performKeyExchange();
// 5. Initialise FEC (5 source symbols per block, 256-byte symbols)
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
this._connected = true;
this.sequence = 0;
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
this._startTime = Date.now();
this._startStatsTimer();
// 6. Start receive loop (runs until disconnect)
this._recvLoop();
this._status('Connected to room: ' + this.room + ' (encrypted, FEC active)');
}
/**
* Disconnect and clean up all resources.
*/
disconnect() {
this._connected = false;
if (this.wt) {
try { this.wt.close(); } catch (_) { /* ignore */ }
this.wt = null;
}
this._cleanup();
}
/**
* Send a PCM audio frame.
*
* Pipeline: PCM -> FEC encode -> encrypt -> datagram send.
*
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
*/
async sendAudio(pcmBuffer) {
if (!this._connected || !this.datagramWriter || !this.cryptoSession) return;
const pcmBytes = new Uint8Array(pcmBuffer);
// Build a minimal 12-byte MediaHeader for AAD.
const header = this._buildMediaHeader(this.sequence);
// FEC encode: feed the frame; when a block completes we get wire packets.
const fecOutput = this.fecEncoder.add_symbol(pcmBytes);
if (fecOutput) {
// FEC block completed — send all packets (source + repair).
const packetSize = FEC_HEADER_SIZE + 256; // header + symbol_size
for (let offset = 0; offset + packetSize <= fecOutput.length; offset += packetSize) {
const fecPacket = fecOutput.slice(offset, offset + packetSize);
// Encrypt: header bytes as AAD, FEC packet as plaintext.
const ciphertext = this.cryptoSession.encrypt(header, fecPacket);
this.stats.encrypted++;
// Build wire datagram: header (12) + ciphertext
const datagram = new Uint8Array(MEDIA_HEADER_SIZE + ciphertext.length);
datagram.set(header, 0);
datagram.set(ciphertext, MEDIA_HEADER_SIZE);
try {
await this.datagramWriter.write(datagram);
} catch (e) {
// Datagram send can fail if the transport is closing.
if (this._connected) {
console.warn('[wzp-full] datagram write failed:', e);
}
return;
}
this.stats.sent++;
}
}
// If FEC block not yet complete, accumulate (no packets sent yet).
this.sequence = (this.sequence + 1) & 0xFFFF;
}
/**
* Test crypto + FEC roundtrip entirely in WASM (no network).
* Useful for verifying the WASM module works correctly in the browser.
*
* @returns {Object} test results
*/
testCryptoFec() {
if (!this._wasmModule) {
return { success: false, error: 'WASM module not loaded' };
}
const t0 = performance.now();
const wasm = this._wasmModule;
// Key exchange
const alice = new wasm.WzpKeyExchange();
const bob = new wasm.WzpKeyExchange();
const aliceSecret = alice.derive_shared_secret(bob.public_key());
const bobSecret = bob.derive_shared_secret(alice.public_key());
// Verify secrets match
let secretsMatch = aliceSecret.length === bobSecret.length;
if (secretsMatch) {
for (let i = 0; i < aliceSecret.length; i++) {
if (aliceSecret[i] !== bobSecret[i]) { secretsMatch = false; break; }
}
}
// Encrypt/decrypt
const aliceSession = new wasm.WzpCryptoSession(aliceSecret);
const bobSession = new wasm.WzpCryptoSession(bobSecret);
const header = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]);
const plaintext = new TextEncoder().encode('hello warzone from full variant');
const ciphertext = aliceSession.encrypt(header, plaintext);
const decrypted = bobSession.decrypt(header, ciphertext);
let cryptoOk = decrypted.length === plaintext.length;
if (cryptoOk) {
for (let i = 0; i < plaintext.length; i++) {
if (decrypted[i] !== plaintext[i]) { cryptoOk = false; break; }
}
}
// FEC test (same as hybrid testFec)
const encoder = new wasm.WzpFecEncoder(5, 256);
const decoder = new wasm.WzpFecDecoder(5, 256);
const frames = [];
for (let i = 0; i < 5; i++) {
const frame = new Uint8Array(100);
for (let j = 0; j < 100; j++) frame[j] = ((i * 37 + 7) + j) & 0xFF;
frames.push(frame);
}
let wireData = null;
for (const frame of frames) {
const result = encoder.add_symbol(frame);
if (result) wireData = result;
}
const PACKET_SIZE = FEC_HEADER_SIZE + 256;
const packets = [];
if (wireData) {
for (let off = 0; off + PACKET_SIZE <= wireData.length; off += PACKET_SIZE) {
packets.push({
blockId: wireData[off],
symbolIdx: wireData[off + 1],
isRepair: wireData[off + 2] !== 0,
data: wireData.slice(off + FEC_HEADER_SIZE, off + PACKET_SIZE),
});
}
}
// Drop 2 packets, try to recover
let fecDecoded = null;
for (let i = 0; i < packets.length; i++) {
if (i === 1 || i === 3) continue; // simulate loss
const pkt = packets[i];
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
if (result) { fecDecoded = result; break; }
}
let fecOk = false;
if (fecDecoded) {
const expected = new Uint8Array(5 * 100);
let off = 0;
for (const f of frames) { expected.set(f, off); off += f.length; }
fecOk = fecDecoded.length === expected.length;
if (fecOk) {
for (let i = 0; i < expected.length; i++) {
if (fecDecoded[i] !== expected[i]) { fecOk = false; break; }
}
}
}
// Cleanup WASM objects
alice.free();
bob.free();
aliceSession.free();
bobSession.free();
encoder.free();
decoder.free();
const elapsed = performance.now() - t0;
return {
success: secretsMatch && cryptoOk && fecOk,
secretsMatch,
cryptoOk,
fecOk,
fecPacketsTotal: packets.length,
fecDropped: 2,
elapsed: elapsed.toFixed(2) + 'ms',
};
}
// =========================================================================
// Internal
// =========================================================================
/**
* Perform X25519 key exchange over a WebTransport bidirectional stream.
*
* Protocol (simplified DH, not the full SignalMessage handshake):
* 1. Open a bidirectional stream.
* 2. Send our 32-byte X25519 public key.
* 3. Read the peer's 32-byte public key.
* 4. Derive shared secret via HKDF.
* 5. Create WzpCryptoSession from the shared secret.
*
* In production this would use the full SignalMessage protocol over the
* bidirectional stream (offer/answer/encrypted-session). For now we do
* a simple DH swap to prove the architecture.
*/
async _performKeyExchange() {
const wasm = this._wasmModule;
const kx = new wasm.WzpKeyExchange();
const ourPub = kx.public_key(); // Uint8Array(32)
// Open a bidirectional stream for signaling.
const stream = await this.wt.createBidirectionalStream();
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
// Send our public key.
await writer.write(new Uint8Array(ourPub));
// Read peer's public key (exactly 32 bytes).
// WebTransport streams are byte-oriented; we may get it in chunks.
let peerPub = new Uint8Array(0);
while (peerPub.length < 32) {
const { value, done } = await reader.read();
if (done) {
throw new Error('Key exchange stream closed before receiving peer public key');
}
const combined = new Uint8Array(peerPub.length + value.length);
combined.set(peerPub, 0);
combined.set(value, peerPub.length);
peerPub = combined;
}
peerPub = peerPub.slice(0, 32);
// Derive shared secret and create crypto session.
const secret = kx.derive_shared_secret(peerPub);
this.cryptoSession = new wasm.WzpCryptoSession(secret);
// Close the signaling stream (key exchange complete).
try {
writer.releaseLock();
reader.releaseLock();
await stream.writable.close();
} catch (_) {
// Best-effort close.
}
kx.free();
}
/**
* Receive loop: read datagrams, decrypt, FEC decode, play audio.
*
* Runs until the transport closes or disconnect() is called.
*/
async _recvLoop() {
if (this._recvLoopRunning) return;
this._recvLoopRunning = true;
try {
while (this._connected && this.datagramReader) {
const { value, done } = await this.datagramReader.read();
if (done) break;
this.stats.recv++;
// value is a Uint8Array datagram: header(12) + ciphertext
if (value.length <= MEDIA_HEADER_SIZE) continue; // too short
const headerAad = value.slice(0, MEDIA_HEADER_SIZE);
const ciphertext = value.slice(MEDIA_HEADER_SIZE);
// Decrypt
let fecPacket;
try {
fecPacket = this.cryptoSession.decrypt(headerAad, ciphertext);
this.stats.decrypted++;
} catch (e) {
// Decryption failure — corrupted or out-of-order packet.
// In a real implementation we'd handle sequence number gaps.
console.warn('[wzp-full] decrypt failed:', e);
continue;
}
// FEC decode: parse the FEC wire header and feed to decoder.
if (fecPacket.length < FEC_HEADER_SIZE) continue;
const blockId = fecPacket[0];
const symbolIdx = fecPacket[1];
const isRepair = fecPacket[2] !== 0;
const symbolData = fecPacket.slice(FEC_HEADER_SIZE);
const decoded = this.fecDecoder.add_symbol(blockId, symbolIdx, isRepair, symbolData);
if (decoded) {
this.stats.fecRecovered++;
// decoded is concatenated original PCM frames.
// Each frame is 1920 bytes (960 Int16 samples @ 48kHz mono).
const FRAME_BYTES = 1920;
for (let off = 0; off + FRAME_BYTES <= decoded.length; off += FRAME_BYTES) {
const pcmSlice = decoded.slice(off, off + FRAME_BYTES);
const pcm = new Int16Array(pcmSlice.buffer, pcmSlice.byteOffset, pcmSlice.byteLength / 2);
if (this.onAudio) {
this.onAudio(pcm);
}
}
}
}
} catch (e) {
if (this._connected) {
console.warn('[wzp-full] recv loop error:', e);
}
} finally {
this._recvLoopRunning = false;
}
}
/**
* Build a minimal 12-byte MediaHeader for use as AAD.
*
* Wire layout (from wzp-proto::packet::MediaHeader):
* Byte 0: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
* Byte 1: FecRatioLo(6)|unused(2)
* Bytes 2-3: Sequence number (BE u16)
* Bytes 4-7: Timestamp ms (BE u32)
* Byte 8: FEC block ID
* Byte 9: FEC symbol index
* Byte 10: Reserved
* Byte 11: CSRC count
*
* @param {number} seq Sequence number (u16)
* @returns {Uint8Array} 12-byte header
*/
_buildMediaHeader(seq) {
const buf = new Uint8Array(MEDIA_HEADER_SIZE);
// Byte 0: version=0, is_repair=0, codec=0 (Opus), quality_report=0, fec_ratio_hi=0
buf[0] = 0x00;
// Byte 1: fec_ratio_lo=0
buf[1] = 0x00;
// Bytes 2-3: sequence (BE u16)
buf[2] = (seq >> 8) & 0xFF;
buf[3] = seq & 0xFF;
// Bytes 4-7: timestamp (BE u32) — ms since session start
const ts = Date.now() - this._startTime;
buf[4] = (ts >> 24) & 0xFF;
buf[5] = (ts >> 16) & 0xFF;
buf[6] = (ts >> 8) & 0xFF;
buf[7] = ts & 0xFF;
// Bytes 8-11: FEC block/symbol/reserved/csrc — filled by FEC layer in production
return buf;
}
_startStatsTimer() {
this._stopStatsTimer();
this._statsInterval = setInterval(() => {
if (!this._connected) {
this._stopStatsTimer();
return;
}
const elapsed = (Date.now() - this._startTime) / 1000;
const loss = this.stats.sent > 0
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
: 0;
if (this.onStats) {
this.onStats({
sent: this.stats.sent,
recv: this.stats.recv,
loss,
elapsed,
encrypted: this.stats.encrypted,
decrypted: this.stats.decrypted,
fecRecovered: this.stats.fecRecovered,
});
}
}, 1000);
}
_stopStatsTimer() {
if (this._statsInterval) {
clearInterval(this._statsInterval);
this._statsInterval = null;
}
}
_status(msg) {
if (this.onStatus) this.onStatus(msg);
}
_cleanup() {
this._connected = false;
this._stopStatsTimer();
this.datagramWriter = null;
this.datagramReader = null;
if (this.cryptoSession) {
try { this.cryptoSession.free(); } catch (_) { /* ignore */ }
this.cryptoSession = null;
}
if (this.fecEncoder) {
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
this.fecEncoder = null;
}
if (this.fecDecoder) {
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
this.fecDecoder = null;
}
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
window.WZPFullClient = WZPFullClient;

View File

@@ -0,0 +1,345 @@
// WarzonePhone — Hybrid JS + WASM client (Variant 2).
// WebSocket transport, raw PCM, WASM FEC (RaptorQ) ready for WebTransport.
// Relies on wzp-core.js for UI and audio helpers.
//
// The WASM FEC module is loaded and exposed but not used on the wire yet,
// because WebSocket is TCP (no packet loss). FEC will activate when
// WebTransport (UDP) is added. A testFec() method demonstrates FEC
// encode -> simulate loss -> decode in the browser.
'use strict';
// WASM module path (served from /wasm/ by the wzp-web bridge).
const WZP_WASM_PATH = '/wasm/wzp_wasm.js';
class WZPHybridClient {
/**
* @param {Object} options
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
* @param {string} options.room Room name
* @param {Function} options.onAudio callback(Int16Array) for playback
* @param {Function} options.onStatus callback(string) for UI status
* @param {Function} options.onStats callback({sent, recv, loss, elapsed, fecRecovered}) for UI
*/
constructor(options) {
this.wsUrl = options.wsUrl;
this.room = options.room;
this.onAudio = options.onAudio || null;
this.onStatus = options.onStatus || null;
this.onStats = options.onStats || null;
this.ws = null;
this.sequence = 0;
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
this._startTime = 0;
this._statsInterval = null;
this._connected = false;
// WASM FEC instances (loaded in connect()).
this._wasmModule = null;
this.fecEncoder = null;
this.fecDecoder = null;
this._fecReady = false;
}
/**
* Open WebSocket connection and load the WASM FEC module.
* @returns {Promise<void>} resolves when connected
*/
async connect() {
if (this._connected) return;
// Load WASM module in parallel with WebSocket connect.
const wasmPromise = this._loadWasm();
const wsPromise = new Promise((resolve, reject) => {
this._status('Connecting to room: ' + this.room + '...');
this.ws = new WebSocket(this.wsUrl);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this._connected = true;
this.sequence = 0;
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
this._startTime = Date.now();
this._startStatsTimer();
resolve();
};
this.ws.onmessage = (event) => {
this._handleMessage(event);
};
this.ws.onclose = () => {
const wasConnected = this._connected;
this._cleanup();
if (wasConnected) {
this._status('Disconnected');
}
};
this.ws.onerror = () => {
if (!this._connected) {
this._cleanup();
reject(new Error('WebSocket connection failed'));
} else {
this._status('Connection error');
}
};
});
// Wait for both WASM load and WS connect.
await Promise.all([wasmPromise, wsPromise]);
const fecStatus = this._fecReady ? 'FEC ready' : 'FEC unavailable';
this._status('Connected to room: ' + this.room + ' (' + fecStatus + ')');
}
/**
* Close WebSocket and clean up.
*/
disconnect() {
this._connected = false;
if (this.ws) {
this.ws.close();
this.ws = null;
}
this._stopStatsTimer();
// Keep WASM module loaded (reusable).
this.fecEncoder = null;
this.fecDecoder = null;
}
/**
* Send a PCM audio frame over the WebSocket.
* Currently sends raw PCM (same as pure client) since WebSocket is TCP.
* When WebTransport is added, this will FEC-encode before sending.
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
*/
async sendAudio(pcmBuffer) {
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
// Over WebSocket (TCP): send raw PCM, no FEC needed.
// Over WebTransport (UDP, future): would call this.fecEncoder.add_symbol()
// and send the resulting FEC-protected packets.
this.ws.send(pcmBuffer);
this.sequence++;
this.stats.sent++;
}
/**
* Test FEC encode -> simulate loss -> decode in the browser.
* Demonstrates that the WASM RaptorQ module works correctly.
*
* @param {Object} [opts]
* @param {number} [opts.blockSize=5] Source symbols per block
* @param {number} [opts.symbolSize=256] Padded symbol size
* @param {number} [opts.frameSize=100] Bytes per test frame
* @param {number} [opts.dropCount=2] Number of packets to drop
* @returns {Object} { success, sourcePackets, repairPackets, dropped, recovered, elapsed }
*/
testFec(opts) {
if (!this._fecReady) {
return { success: false, error: 'WASM FEC module not loaded' };
}
const blockSize = (opts && opts.blockSize) || 5;
const symbolSize = (opts && opts.symbolSize) || 256;
const frameSize = (opts && opts.frameSize) || 100;
const dropCount = (opts && opts.dropCount) || 2;
const HEADER_SIZE = 3; // block_id + symbol_idx + is_repair
const packetSize = HEADER_SIZE + symbolSize;
const t0 = performance.now();
// Create fresh encoder/decoder for the test.
const encoder = new this._wasmModule.WzpFecEncoder(blockSize, symbolSize);
const decoder = new this._wasmModule.WzpFecDecoder(blockSize, symbolSize);
// Generate test frames with known data.
const frames = [];
for (let i = 0; i < blockSize; i++) {
const frame = new Uint8Array(frameSize);
for (let j = 0; j < frameSize; j++) {
frame[j] = ((i * 37 + 7) + j) & 0xFF;
}
frames.push(frame);
}
// Encode: feed frames to encoder; last one triggers block output.
let wireData = null;
for (const frame of frames) {
const result = encoder.add_symbol(frame);
if (result) {
wireData = result;
}
}
if (!wireData) {
// Flush if block didn't complete (shouldn't happen with exact blockSize).
wireData = encoder.flush();
}
// Parse wire packets.
const packets = [];
for (let offset = 0; offset + packetSize <= wireData.length; offset += packetSize) {
packets.push({
blockId: wireData[offset],
symbolIdx: wireData[offset + 1],
isRepair: wireData[offset + 2] !== 0,
data: wireData.slice(offset + HEADER_SIZE, offset + packetSize),
});
}
const sourcePackets = packets.filter(p => !p.isRepair).length;
const repairPackets = packets.filter(p => p.isRepair).length;
// Simulate packet loss: drop `dropCount` packets from the front (source symbols).
const dropped = [];
const surviving = [];
for (let i = 0; i < packets.length; i++) {
if (i < dropCount) {
dropped.push(i);
} else {
surviving.push(packets[i]);
}
}
// Decode from surviving packets.
let decoded = null;
for (const pkt of surviving) {
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
if (result) {
decoded = result;
break;
}
}
const elapsed = performance.now() - t0;
// Verify decoded data matches original frames.
let success = false;
if (decoded) {
const expected = new Uint8Array(blockSize * frameSize);
let off = 0;
for (const frame of frames) {
expected.set(frame, off);
off += frame.length;
}
success = decoded.length === expected.length;
if (success) {
for (let i = 0; i < decoded.length; i++) {
if (decoded[i] !== expected[i]) {
success = false;
break;
}
}
}
}
// Free WASM objects.
encoder.free();
decoder.free();
return {
success,
sourcePackets,
repairPackets,
totalPackets: packets.length,
dropped: dropCount,
recovered: success,
decodedBytes: decoded ? decoded.length : 0,
expectedBytes: blockSize * frameSize,
elapsed: elapsed.toFixed(2) + 'ms',
};
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
async _loadWasm() {
try {
// Dynamic import of the wasm-pack generated JS glue.
this._wasmModule = await import(WZP_WASM_PATH);
// Initialize the WASM module (calls __wbg_init).
await this._wasmModule.default();
// Create FEC encoder/decoder instances.
// 5 symbols per block, 256-byte symbols — matches native wzp-fec defaults.
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
this._fecReady = true;
console.log('[wzp-hybrid] WASM FEC module loaded successfully');
} catch (e) {
console.warn('[wzp-hybrid] WASM FEC module failed to load:', e);
this._fecReady = false;
// Non-fatal: client still works without FEC (like pure variant).
}
}
_handleMessage(event) {
if (!(event.data instanceof ArrayBuffer)) return;
const pcm = new Int16Array(event.data);
this.stats.recv++;
if (this.onAudio) {
this.onAudio(pcm);
}
}
_startStatsTimer() {
this._stopStatsTimer();
this._statsInterval = setInterval(() => {
if (!this._connected) {
this._stopStatsTimer();
return;
}
const elapsed = (Date.now() - this._startTime) / 1000;
const loss = this.stats.sent > 0
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
: 0;
if (this.onStats) {
this.onStats({
sent: this.stats.sent,
recv: this.stats.recv,
loss: loss,
elapsed: elapsed,
fecRecovered: this.stats.fecRecovered,
fecReady: this._fecReady,
});
}
}, 1000);
}
_stopStatsTimer() {
if (this._statsInterval) {
clearInterval(this._statsInterval);
this._statsInterval = null;
}
}
_status(msg) {
if (this.onStatus) this.onStatus(msg);
}
_cleanup() {
this._connected = false;
this._stopStatsTimer();
if (this.ws) {
try { this.ws.close(); } catch (_) { /* ignore */ }
this.ws = null;
}
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
window.WZPHybridClient = WZPHybridClient;

View File

@@ -0,0 +1,168 @@
// WarzonePhone — Pure JS client (Variant 1).
// WebSocket transport, raw PCM, no WASM, no FEC.
// Relies on wzp-core.js for UI and audio helpers.
'use strict';
class WZPPureClient {
/**
* @param {Object} options
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
* @param {string} options.room Room name
* @param {Function} options.onAudio callback(Int16Array) for playback
* @param {Function} options.onStatus callback(string) for UI status
* @param {Function} options.onStats callback({sent, recv, loss, elapsed}) for UI
*/
constructor(options) {
this.wsUrl = options.wsUrl;
this.room = options.room;
this.onAudio = options.onAudio || null;
this.onStatus = options.onStatus || null;
this.onStats = options.onStats || null;
this.ws = null;
this.sequence = 0;
this.stats = { sent: 0, recv: 0 };
this._startTime = 0;
this._statsInterval = null;
this._connected = false;
}
/**
* Open WebSocket connection to the wzp-web bridge.
* @returns {Promise<void>} resolves when connected
*/
async connect() {
if (this._connected) return;
return new Promise((resolve, reject) => {
this._status('Connecting to room: ' + this.room + '...');
this.ws = new WebSocket(this.wsUrl);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this._connected = true;
this.sequence = 0;
this.stats = { sent: 0, recv: 0 };
this._startTime = Date.now();
this._status('Connected to room: ' + this.room);
this._startStatsTimer();
resolve();
};
this.ws.onmessage = (event) => {
this._handleMessage(event);
};
this.ws.onclose = () => {
const wasConnected = this._connected;
this._cleanup();
if (wasConnected) {
this._status('Disconnected');
}
};
this.ws.onerror = (err) => {
if (!this._connected) {
this._cleanup();
reject(new Error('WebSocket connection failed'));
} else {
this._status('Connection error');
}
};
});
}
/**
* Close WebSocket and clean up.
*/
disconnect() {
this._connected = false;
if (this.ws) {
this.ws.close();
this.ws = null;
}
this._stopStatsTimer();
}
/**
* Send a PCM audio frame over the WebSocket.
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
*/
async sendAudio(pcmBuffer) {
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
// Pure JS variant: send raw PCM directly (no encryption, no header).
// The wzp-web bridge handles QUIC-side encryption.
this.ws.send(pcmBuffer);
this.sequence++;
this.stats.sent++;
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
_handleMessage(event) {
if (!(event.data instanceof ArrayBuffer)) return;
const pcm = new Int16Array(event.data);
this.stats.recv++;
if (this.onAudio) {
this.onAudio(pcm);
}
}
_startStatsTimer() {
this._stopStatsTimer();
this._statsInterval = setInterval(() => {
if (!this._connected) {
this._stopStatsTimer();
return;
}
const elapsed = (Date.now() - this._startTime) / 1000;
// Simple loss estimate: if we sent frames, the other side should
// receive roughly the same count. Since we only see our own recv,
// we report raw counts and let the UI decide.
const loss = this.stats.sent > 0
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
: 0;
if (this.onStats) {
this.onStats({
sent: this.stats.sent,
recv: this.stats.recv,
loss: loss,
elapsed: elapsed,
});
}
}, 1000);
}
_stopStatsTimer() {
if (this._statsInterval) {
clearInterval(this._statsInterval);
this._statsInterval = null;
}
}
_status(msg) {
if (this.onStatus) this.onStatus(msg);
}
_cleanup() {
this._connected = false;
this._stopStatsTimer();
if (this.ws) {
try { this.ws.close(); } catch (_) { /* ignore */ }
this.ws = null;
}
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
window.WZPPureClient = WZPPureClient;