diff --git a/crates/wzp-web/static/index.html b/crates/wzp-web/static/index.html
index c6ecdea..2cd8a57 100644
--- a/crates/wzp-web/static/index.html
+++ b/crates/wzp-web/static/index.html
@@ -66,9 +66,12 @@
(function() {
var variant = WZPCore.detectVariant();
var scriptMap = {
- pure: 'js/wzp-pure.js',
- hybrid: 'js/wzp-hybrid.js',
- full: 'js/wzp-full.js',
+ pure: 'js/wzp-pure.js',
+ hybrid: 'js/wzp-hybrid.js',
+ full: 'js/wzp-full.js',
+ 'ws': 'js/wzp-ws.js',
+ 'ws-fec': 'js/wzp-ws-fec.js',
+ 'ws-full': 'js/wzp-ws-full.js',
};
var src = scriptMap[variant] || scriptMap.pure;
var s = document.createElement('script');
@@ -117,8 +120,17 @@ function wzpBoot() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
- // Create client based on selected variant
+ // Create client based on detected variant
var variant = WZPCore.detectVariant();
+ var ClientClass = {
+ pure: window.WZPPureClient,
+ hybrid: window.WZPHybridClient,
+ full: window.WZPFullClient,
+ 'ws': window.WZPWsClient,
+ 'ws-fec': window.WZPWsFecClient,
+ 'ws-full': window.WZPWsFullClient,
+ }[variant] || window.WZPPureClient;
+
var clientOpts = {
wsUrl: wsUrl,
room: room,
@@ -133,14 +145,23 @@ function wzpBoot() {
},
};
- if (variant === 'full' && typeof WZPFullClient !== 'undefined') {
- // Full variant: add WebTransport URL, falls back to WS if WT unavailable
+ // Full variant: add WebTransport URL for direct relay connection
+ if (variant === 'full') {
clientOpts.url = location.origin.replace('http', 'https');
- client = new WZPFullClient(clientOpts);
- } else if (variant === 'hybrid' && typeof WZPHybridClient !== 'undefined') {
- client = new WZPHybridClient(clientOpts);
- } else {
- client = new WZPPureClient(clientOpts);
+ }
+
+ client = new ClientClass(clientOpts);
+
+ // Load WASM for variants that need it
+ if (client.loadWasm) {
+ try {
+ WZPCore.updateStatus('Loading WASM module...');
+ await client.loadWasm();
+ } catch (e) {
+ WZPCore.updateStatus('WASM load failed: ' + e.message);
+ ui.setConnected(false);
+ return;
+ }
}
try {
diff --git a/crates/wzp-web/static/js/wzp-core.js b/crates/wzp-web/static/js/wzp-core.js
index b43261c..de6f288 100644
--- a/crates/wzp-web/static/js/wzp-core.js
+++ b/crates/wzp-web/static/js/wzp-core.js
@@ -13,7 +13,8 @@ const WZP_FRAME_SIZE = 960; // 20ms @ 48kHz
function wzpDetectVariant() {
const params = new URLSearchParams(location.search);
const v = (params.get('variant') || 'pure').toLowerCase();
- if (v === 'hybrid' || v === 'full') return v;
+ const valid = ['pure', 'hybrid', 'full', 'ws', 'ws-fec', 'ws-full'];
+ if (valid.includes(v)) return v;
return 'pure';
}
diff --git a/crates/wzp-web/static/js/wzp-ws-fec.js b/crates/wzp-web/static/js/wzp-ws-fec.js
new file mode 100644
index 0000000..919d697
--- /dev/null
+++ b/crates/wzp-web/static/js/wzp-ws-fec.js
@@ -0,0 +1,592 @@
+// WarzonePhone — WZP-WS-FEC client (Variant 5).
+// WebSocket transport, WZP wire protocol, WASM RaptorQ FEC.
+// Application-layer redundancy even over TCP.
+// Sends MediaPacket-formatted frames with FEC encoding.
+// Ready for direct relay WS support (no bridge translation needed).
+
+'use strict';
+
+// WASM module path (served from /wasm/ by the wzp-web bridge).
+const WZP_WS_FEC_WASM_PATH = '/wasm/wzp_wasm.js';
+
+// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
+const WZP_WS_FEC_HEADER_SIZE = 12;
+
+// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
+const WZP_WS_FEC_FEC_HEADER_SIZE = 3;
+
+// FEC parameters.
+// A 960-sample Int16 PCM frame = 1920 bytes. We use symbol_size = 2048
+// (1920 payload + 2-byte length prefix + 126 bytes padding).
+const WZP_WS_FEC_BLOCK_SIZE = 5;
+const WZP_WS_FEC_SYMBOL_SIZE = 2048;
+
+// Length prefix size within each FEC symbol.
+const WZP_WS_FEC_LENGTH_PREFIX = 2;
+
+class WZPWsFecClient {
+ /**
+ * @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(Object) for UI stats
+ */
+ constructor(options) {
+ this.wsUrl = options.wsUrl;
+ this.room = options.room;
+ this.authToken = options.authToken || null;
+ this.onAudio = options.onAudio || null;
+ this.onStatus = options.onStatus || null;
+ this.onStats = options.onStats || null;
+
+ this.ws = null;
+ this.seq = 0;
+ this.startTimestamp = 0;
+ this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
+ this._startTime = 0;
+ this._statsInterval = null;
+ this._connected = false;
+ this._authenticated = false;
+
+ // WASM FEC instances (loaded in loadWasm() / connect()).
+ this._wasmModule = null;
+ this.fecEncoder = null;
+ this.fecDecoder = null;
+ this.wasmReady = false;
+
+ // Current FEC block counter for outgoing packets.
+ this._fecBlockId = 0;
+ }
+
+ /**
+ * Load the WASM FEC module.
+ * Called automatically by connect(), or can be called early.
+ */
+ async loadWasm() {
+ if (this.wasmReady) return;
+ try {
+ this._wasmModule = await import(WZP_WS_FEC_WASM_PATH);
+ await this._wasmModule.default();
+
+ this.fecEncoder = new this._wasmModule.WzpFecEncoder(
+ WZP_WS_FEC_BLOCK_SIZE,
+ WZP_WS_FEC_SYMBOL_SIZE
+ );
+ this.fecDecoder = new this._wasmModule.WzpFecDecoder(
+ WZP_WS_FEC_BLOCK_SIZE,
+ WZP_WS_FEC_SYMBOL_SIZE
+ );
+ this.wasmReady = true;
+ console.log('[wzp-ws-fec] WASM FEC module loaded successfully');
+ } catch (e) {
+ console.error('[wzp-ws-fec] WASM FEC module failed to load:', e);
+ this.wasmReady = false;
+ throw e;
+ }
+ }
+
+ /**
+ * Build a 12-byte WZP MediaHeader.
+ *
+ * @param {number} seq Sequence number (u16)
+ * @param {number} timestampMs Milliseconds since session start
+ * @param {boolean} isRepair True if this is a FEC repair symbol
+ * @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
+ * @param {number} fecBlock FEC block ID (u8)
+ * @param {number} fecSymbol FEC symbol index (u8)
+ * @param {number} fecRatio FEC ratio (0.0 to ~2.0)
+ * @param {boolean} hasQuality Whether a quality report is attached
+ * @returns {Uint8Array} 12-byte header
+ */
+ _buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
+ const buf = new ArrayBuffer(WZP_WS_FEC_HEADER_SIZE);
+ const view = new DataView(buf);
+
+ const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
+ const byte0 = ((0 & 0x01) << 7) // version=0
+ | ((isRepair ? 1 : 0) << 6) // T bit
+ | ((codecId & 0x0F) << 2) // CodecID
+ | ((hasQuality ? 1 : 0) << 1) // Q bit
+ | ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
+ view.setUint8(0, byte0);
+
+ const byte1 = (fecRatioEncoded & 0x3F) << 2;
+ view.setUint8(1, byte1);
+
+ view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
+ view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
+ view.setUint8(8, fecBlock & 0xFF);
+ view.setUint8(9, fecSymbol & 0xFF);
+ view.setUint8(10, 0); // reserved
+ view.setUint8(11, 0); // csrc_count
+ return new Uint8Array(buf);
+ }
+
+ /**
+ * Parse a 12-byte MediaHeader from received binary data.
+ *
+ * @param {Uint8Array} data At least 12 bytes
+ * @returns {Object|null} Parsed header fields, or null if too short
+ */
+ _parseHeader(data) {
+ if (data.byteLength < WZP_WS_FEC_HEADER_SIZE) return null;
+ const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
+ const byte0 = view.getUint8(0);
+ const byte1 = view.getUint8(1);
+ const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
+ return {
+ version: (byte0 >> 7) & 1,
+ isRepair: !!((byte0 >> 6) & 1),
+ codecId: (byte0 >> 2) & 0x0F,
+ hasQuality: !!((byte0 >> 1) & 1),
+ fecRatio: fecRatioEncoded / 63.5,
+ seq: view.getUint16(2),
+ timestamp: view.getUint32(4),
+ fecBlock: view.getUint8(8),
+ fecSymbol: view.getUint8(9),
+ reserved: view.getUint8(10),
+ csrcCount: view.getUint8(11),
+ };
+ }
+
+ /**
+ * Pad a PCM frame into a FEC symbol with a 2-byte length prefix.
+ * Symbol layout: [len_hi, len_lo, ...pcm_bytes..., ...zero_padding...]
+ *
+ * @param {Uint8Array} pcmBytes Raw PCM bytes
+ * @returns {Uint8Array} Padded symbol of WZP_WS_FEC_SYMBOL_SIZE bytes
+ */
+ _padToSymbol(pcmBytes) {
+ const symbol = new Uint8Array(WZP_WS_FEC_SYMBOL_SIZE);
+ const len = pcmBytes.length;
+ symbol[0] = (len >> 8) & 0xFF;
+ symbol[1] = len & 0xFF;
+ symbol.set(pcmBytes, WZP_WS_FEC_LENGTH_PREFIX);
+ return symbol;
+ }
+
+ /**
+ * Extract the original PCM payload from a FEC symbol (strip prefix + padding).
+ *
+ * @param {Uint8Array} symbol Symbol data (WZP_WS_FEC_SYMBOL_SIZE bytes)
+ * @returns {Uint8Array} Original PCM bytes
+ */
+ _unpadSymbol(symbol) {
+ const len = (symbol[0] << 8) | symbol[1];
+ if (len > WZP_WS_FEC_SYMBOL_SIZE - WZP_WS_FEC_LENGTH_PREFIX) {
+ // Sanity check: if length is bogus, return empty.
+ return new Uint8Array(0);
+ }
+ return symbol.slice(WZP_WS_FEC_LENGTH_PREFIX, WZP_WS_FEC_LENGTH_PREFIX + len);
+ }
+
+ /**
+ * Open WebSocket connection and load the WASM FEC module.
+ * @returns {Promise} 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 (WZP-WS-FEC) to room: ' + this.room + '...');
+
+ this.ws = new WebSocket(this.wsUrl);
+ this.ws.binaryType = 'arraybuffer';
+
+ this.ws.onopen = () => {
+ // Send auth if token provided.
+ if (this.authToken) {
+ this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
+ }
+
+ this._connected = true;
+ this._authenticated = !this.authToken;
+ this.seq = 0;
+ this.startTimestamp = Date.now();
+ this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
+ this._startTime = Date.now();
+ this._fecBlockId = 0;
+ this._startStatsTimer();
+ resolve();
+ };
+
+ this.ws.onmessage = (event) => {
+ // Handle text messages (auth responses).
+ if (typeof event.data === 'string') {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.type === 'auth_ok') {
+ this._authenticated = true;
+ this._status('Authenticated (WZP-WS-FEC) to room: ' + this.room);
+ }
+ if (msg.type === 'auth_error') {
+ this._status('Auth failed: ' + (msg.reason || 'unknown'));
+ this.disconnect();
+ }
+ } catch(e) { /* ignore non-JSON text */ }
+ return;
+ }
+ this._handleMessage(event);
+ };
+
+ this.ws.onclose = () => {
+ const was = this._connected;
+ this._cleanup();
+ if (was) this._status('Disconnected');
+ };
+
+ this.ws.onerror = () => {
+ if (!this._connected) {
+ this._cleanup();
+ reject(new Error('WebSocket connection failed'));
+ } else {
+ this._status('Connection error');
+ }
+ };
+ });
+
+ await Promise.all([wasmPromise, wsPromise]);
+
+ const fecStatus = this.wasmReady ? 'FEC ready' : 'FEC unavailable';
+ this._status('Connected (WZP-WS-FEC) 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), but reset encoder/decoder.
+ if (this.fecEncoder) {
+ try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
+ this.fecEncoder = null;
+ }
+ if (this.fecDecoder) {
+ try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
+ this.fecDecoder = null;
+ }
+ }
+
+ /**
+ * Send a PCM audio frame with FEC encoding over the WebSocket.
+ *
+ * Each PCM frame is padded to a FEC symbol (2048 bytes with length prefix)
+ * and fed to the FEC encoder. When a block of 5 symbols completes, the
+ * encoder outputs source + repair symbols. Each is sent as an individual
+ * WZP MediaPacket with the appropriate fecBlock, fecSymbol, and isRepair
+ * fields in the 12-byte header.
+ *
+ * @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
+ */
+ async sendAudio(pcmBuffer) {
+ if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
+ if (!this.wasmReady || !this.fecEncoder) return;
+
+ const pcmBytes = new Uint8Array(pcmBuffer);
+
+ // Pad PCM frame to FEC symbol size with length prefix.
+ const symbol = this._padToSymbol(pcmBytes);
+
+ // Feed to FEC encoder. Returns wire data when block completes.
+ const fecOutput = this.fecEncoder.add_symbol(symbol);
+
+ if (fecOutput) {
+ // Block completed — send all packets (source + repair).
+ const packetSize = WZP_WS_FEC_FEC_HEADER_SIZE + WZP_WS_FEC_SYMBOL_SIZE;
+ const timestampMs = Date.now() - this.startTimestamp;
+
+ for (let offset = 0; offset + packetSize <= fecOutput.length; offset += packetSize) {
+ const blockId = fecOutput[offset];
+ const symbolIdx = fecOutput[offset + 1];
+ const isRepair = fecOutput[offset + 2] !== 0;
+ const symbolData = fecOutput.slice(
+ offset + WZP_WS_FEC_FEC_HEADER_SIZE,
+ offset + packetSize
+ );
+
+ // Build WZP MediaHeader for this FEC symbol.
+ // fecRatio ~0.5 for 50% repair overhead: encoded = round(0.5 * 63.5) = 32
+ const header = this._buildHeader(
+ this.seq,
+ timestampMs,
+ isRepair,
+ 0, // codecId = RawPcm16
+ blockId,
+ symbolIdx,
+ 0.5, // fecRatio
+ false // hasQuality
+ );
+
+ // Wire frame: header(12) + symbol_data(2048)
+ const packet = new Uint8Array(WZP_WS_FEC_HEADER_SIZE + symbolData.length);
+ packet.set(header, 0);
+ packet.set(symbolData, WZP_WS_FEC_HEADER_SIZE);
+
+ this.ws.send(packet.buffer);
+ this.seq = (this.seq + 1) & 0xFFFF;
+ this.stats.sent++;
+ }
+
+ this._fecBlockId++;
+ }
+ // If block not yet complete, accumulate (no packets sent yet).
+ }
+
+ /**
+ * Test FEC encode -> simulate loss -> decode in the browser.
+ * Demonstrates that the WASM RaptorQ module works correctly
+ * with the WZP wire protocol symbol format.
+ *
+ * @param {Object} [opts]
+ * @param {number} [opts.blockSize=5] Source symbols per block
+ * @param {number} [opts.symbolSize=2048] Padded symbol size
+ * @param {number} [opts.frameSize=1920] PCM frame size in bytes
+ * @param {number} [opts.dropCount=2] Number of packets to drop (simulated 30%+ loss)
+ * @returns {Object} Test results
+ */
+ testFec(opts) {
+ if (!this.wasmReady || !this._wasmModule) {
+ return { success: false, error: 'WASM FEC module not loaded' };
+ }
+
+ const blockSize = (opts && opts.blockSize) || 5;
+ const symbolSize = (opts && opts.symbolSize) || WZP_WS_FEC_SYMBOL_SIZE;
+ const frameSize = (opts && opts.frameSize) || 1920;
+ const dropCount = (opts && opts.dropCount) || 2;
+
+ const FEC_HDR = 3; // block_id + symbol_idx + is_repair
+ const packetSize = FEC_HDR + 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, padded to symbol size with length prefix.
+ const originalFrames = [];
+ const paddedSymbols = [];
+ 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;
+ }
+ originalFrames.push(frame);
+
+ // Pad with length prefix (same as _padToSymbol).
+ const sym = new Uint8Array(symbolSize);
+ sym[0] = (frameSize >> 8) & 0xFF;
+ sym[1] = frameSize & 0xFF;
+ sym.set(frame, 2);
+ paddedSymbols.push(sym);
+ }
+
+ // Encode: feed padded symbols to encoder.
+ let wireData = null;
+ for (const sym of paddedSymbols) {
+ const result = encoder.add_symbol(sym);
+ if (result) wireData = result;
+ }
+
+ if (!wireData) {
+ wireData = encoder.flush();
+ }
+
+ // Parse wire packets.
+ const packets = [];
+ if (wireData) {
+ 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 + FEC_HDR, offset + packetSize),
+ });
+ }
+ }
+
+ const sourcePackets = packets.filter(p => !p.isRepair).length;
+ const repairPackets = packets.filter(p => p.isRepair).length;
+
+ // Simulate packet loss: drop `dropCount` source packets from the front.
+ 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;
+ }
+ }
+
+ // Verify decoded data: extract original frames from decoded symbols.
+ let success = false;
+ if (decoded) {
+ // decoded is the concatenated padded symbols. Extract original frames.
+ const recoveredFrames = [];
+ for (let i = 0; i < blockSize; i++) {
+ const symOffset = i * symbolSize;
+ if (symOffset + symbolSize <= decoded.length) {
+ const sym = decoded.slice(symOffset, symOffset + symbolSize);
+ const len = (sym[0] << 8) | sym[1];
+ recoveredFrames.push(sym.slice(2, 2 + len));
+ }
+ }
+
+ success = recoveredFrames.length === blockSize;
+ if (success) {
+ for (let i = 0; i < blockSize && success; i++) {
+ if (recoveredFrames[i].length !== originalFrames[i].length) {
+ success = false;
+ break;
+ }
+ for (let j = 0; j < originalFrames[i].length; j++) {
+ if (recoveredFrames[i][j] !== originalFrames[i][j]) {
+ success = false;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Free WASM objects.
+ encoder.free();
+ decoder.free();
+
+ const elapsed = performance.now() - t0;
+
+ return {
+ success,
+ sourcePackets,
+ repairPackets,
+ totalPackets: packets.length,
+ dropped: dropCount,
+ recovered: !!decoded,
+ symbolSize: symbolSize,
+ frameSize: frameSize,
+ elapsed: elapsed.toFixed(2) + 'ms',
+ };
+ }
+
+ // -----------------------------------------------------------------------
+ // Internal
+ // -----------------------------------------------------------------------
+
+ _handleMessage(event) {
+ if (!(event.data instanceof ArrayBuffer)) return;
+ const data = new Uint8Array(event.data);
+ if (data.length < WZP_WS_FEC_HEADER_SIZE) return;
+
+ const header = this._parseHeader(data);
+ if (!header) return;
+
+ this.stats.recv++;
+
+ if (!this.wasmReady || !this.fecDecoder) {
+ // No FEC decoder — cannot process FEC-encoded data.
+ return;
+ }
+
+ // Extract symbol data (everything after 12-byte MediaHeader).
+ const symbolData = data.slice(WZP_WS_FEC_HEADER_SIZE);
+
+ // Feed symbol to FEC decoder using header fields.
+ const decoded = this.fecDecoder.add_symbol(
+ header.fecBlock,
+ header.fecSymbol,
+ header.isRepair,
+ symbolData
+ );
+
+ if (decoded) {
+ this.stats.fecRecovered++;
+
+ // decoded is concatenated padded symbols.
+ // Each symbol is WZP_WS_FEC_SYMBOL_SIZE bytes with a 2-byte length prefix.
+ for (let off = 0; off + WZP_WS_FEC_SYMBOL_SIZE <= decoded.length; off += WZP_WS_FEC_SYMBOL_SIZE) {
+ const symbol = decoded.slice(off, off + WZP_WS_FEC_SYMBOL_SIZE);
+ const pcmBytes = this._unpadSymbol(symbol);
+
+ if (pcmBytes.length > 0 && pcmBytes.length % 2 === 0) {
+ const pcm = new Int16Array(
+ pcmBytes.buffer,
+ pcmBytes.byteOffset,
+ pcmBytes.byteLength / 2
+ );
+ 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.wasmReady,
+ });
+ }
+ }, 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.WZPWsFecClient = WZPWsFecClient;
diff --git a/crates/wzp-web/static/js/wzp-ws-full.js b/crates/wzp-web/static/js/wzp-ws-full.js
new file mode 100644
index 0000000..f188b9d
--- /dev/null
+++ b/crates/wzp-web/static/js/wzp-ws-full.js
@@ -0,0 +1,749 @@
+// WarzonePhone — WZP-WS-Full client (Variant 6).
+// WebSocket transport, WZP wire protocol, WASM FEC + ChaCha20-Poly1305 E2E.
+// Full encryption — relay sees only ciphertext.
+// Sends MediaPacket-formatted frames with FEC + encryption.
+// Ready for direct relay WS support (no bridge translation needed).
+
+'use strict';
+
+// WASM module path (served from /wasm/ by the wzp-web bridge).
+const WZP_WS_FULL_WASM_PATH = '/wasm/wzp_wasm.js';
+
+// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
+const WZP_WS_FULL_HEADER_SIZE = 12;
+
+// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
+const WZP_WS_FULL_FEC_HEADER_SIZE = 3;
+
+// FEC parameters.
+// A 960-sample Int16 PCM frame = 1920 bytes. Symbol size = 2048
+// (1920 payload + 2-byte length prefix + 126 bytes padding).
+const WZP_WS_FULL_BLOCK_SIZE = 5;
+const WZP_WS_FULL_SYMBOL_SIZE = 2048;
+
+// Length prefix size within each FEC symbol.
+const WZP_WS_FULL_LENGTH_PREFIX = 2;
+
+// ChaCha20-Poly1305 tag size (16 bytes).
+const WZP_WS_FULL_TAG_SIZE = 16;
+
+// X25519 public key size (32 bytes).
+const WZP_WS_FULL_PUBKEY_SIZE = 32;
+
+class WZPWsFullClient {
+ /**
+ * @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(Object) for UI stats
+ */
+ constructor(options) {
+ this.wsUrl = options.wsUrl;
+ this.room = options.room;
+ this.authToken = options.authToken || null;
+ this.onAudio = options.onAudio || null;
+ this.onStatus = options.onStatus || null;
+ this.onStats = options.onStats || null;
+
+ this.ws = null;
+ this.seq = 0;
+ this.startTimestamp = 0;
+ this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
+ this._startTime = 0;
+ this._statsInterval = null;
+ this._connected = false;
+ this._authenticated = false;
+
+ // WASM instances.
+ this._wasmModule = null;
+ this.fecEncoder = null;
+ this.fecDecoder = null;
+ this.cryptoSession = null;
+ this._keyExchange = null;
+ this.wasmReady = false;
+
+ // Key exchange state.
+ this._keyExchangeComplete = false;
+ this._keyExchangeResolve = null;
+ this._keyExchangeReject = null;
+
+ // Current FEC block counter for outgoing packets.
+ this._fecBlockId = 0;
+ }
+
+ /**
+ * Load the WASM module (FEC + Crypto).
+ * Called automatically by connect(), or can be called early.
+ */
+ async loadWasm() {
+ if (this.wasmReady) return;
+ try {
+ this._wasmModule = await import(WZP_WS_FULL_WASM_PATH);
+ await this._wasmModule.default();
+ this.wasmReady = true;
+ console.log('[wzp-ws-full] WASM module loaded successfully');
+ } catch (e) {
+ console.error('[wzp-ws-full] WASM module failed to load:', e);
+ this.wasmReady = false;
+ throw e;
+ }
+ }
+
+ /**
+ * Build a 12-byte WZP MediaHeader.
+ *
+ * @param {number} seq Sequence number (u16)
+ * @param {number} timestampMs Milliseconds since session start
+ * @param {boolean} isRepair True if this is a FEC repair symbol
+ * @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
+ * @param {number} fecBlock FEC block ID (u8)
+ * @param {number} fecSymbol FEC symbol index (u8)
+ * @param {number} fecRatio FEC ratio (0.0 to ~2.0)
+ * @param {boolean} hasQuality Whether a quality report is attached
+ * @returns {Uint8Array} 12-byte header
+ */
+ _buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
+ const buf = new ArrayBuffer(WZP_WS_FULL_HEADER_SIZE);
+ const view = new DataView(buf);
+
+ const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
+ const byte0 = ((0 & 0x01) << 7) // version=0
+ | ((isRepair ? 1 : 0) << 6) // T bit
+ | ((codecId & 0x0F) << 2) // CodecID
+ | ((hasQuality ? 1 : 0) << 1) // Q bit
+ | ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
+ view.setUint8(0, byte0);
+
+ const byte1 = (fecRatioEncoded & 0x3F) << 2;
+ view.setUint8(1, byte1);
+
+ view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
+ view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
+ view.setUint8(8, fecBlock & 0xFF);
+ view.setUint8(9, fecSymbol & 0xFF);
+ view.setUint8(10, 0); // reserved
+ view.setUint8(11, 0); // csrc_count
+ return new Uint8Array(buf);
+ }
+
+ /**
+ * Parse a 12-byte MediaHeader from received binary data.
+ *
+ * @param {Uint8Array} data At least 12 bytes
+ * @returns {Object|null} Parsed header fields, or null if too short
+ */
+ _parseHeader(data) {
+ if (data.byteLength < WZP_WS_FULL_HEADER_SIZE) return null;
+ const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
+ const byte0 = view.getUint8(0);
+ const byte1 = view.getUint8(1);
+ const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
+ return {
+ version: (byte0 >> 7) & 1,
+ isRepair: !!((byte0 >> 6) & 1),
+ codecId: (byte0 >> 2) & 0x0F,
+ hasQuality: !!((byte0 >> 1) & 1),
+ fecRatio: fecRatioEncoded / 63.5,
+ seq: view.getUint16(2),
+ timestamp: view.getUint32(4),
+ fecBlock: view.getUint8(8),
+ fecSymbol: view.getUint8(9),
+ reserved: view.getUint8(10),
+ csrcCount: view.getUint8(11),
+ };
+ }
+
+ /**
+ * Pad a PCM frame into a FEC symbol with a 2-byte length prefix.
+ *
+ * @param {Uint8Array} pcmBytes Raw PCM bytes
+ * @returns {Uint8Array} Padded symbol of WZP_WS_FULL_SYMBOL_SIZE bytes
+ */
+ _padToSymbol(pcmBytes) {
+ const symbol = new Uint8Array(WZP_WS_FULL_SYMBOL_SIZE);
+ const len = pcmBytes.length;
+ symbol[0] = (len >> 8) & 0xFF;
+ symbol[1] = len & 0xFF;
+ symbol.set(pcmBytes, WZP_WS_FULL_LENGTH_PREFIX);
+ return symbol;
+ }
+
+ /**
+ * Extract the original PCM payload from a FEC symbol (strip prefix + padding).
+ *
+ * @param {Uint8Array} symbol Symbol data
+ * @returns {Uint8Array} Original PCM bytes
+ */
+ _unpadSymbol(symbol) {
+ const len = (symbol[0] << 8) | symbol[1];
+ if (len > WZP_WS_FULL_SYMBOL_SIZE - WZP_WS_FULL_LENGTH_PREFIX) {
+ return new Uint8Array(0);
+ }
+ return symbol.slice(WZP_WS_FULL_LENGTH_PREFIX, WZP_WS_FULL_LENGTH_PREFIX + len);
+ }
+
+ /**
+ * Open WebSocket connection, load WASM, and perform key exchange.
+ *
+ * Key exchange protocol over WebSocket:
+ * 1. After WS open, send our 32-byte X25519 public key as first binary message.
+ * 2. First received binary message of exactly 32 bytes = peer's public key.
+ * 3. Derive shared secret, create WzpCryptoSession.
+ * 4. All subsequent binary messages are encrypted MediaPackets.
+ *
+ * @returns {Promise} resolves when connected and key exchange completes
+ */
+ async connect() {
+ if (this._connected) return;
+
+ // Load WASM first (needed for key exchange).
+ await this.loadWasm();
+
+ // Prepare key exchange.
+ this._keyExchange = new this._wasmModule.WzpKeyExchange();
+ this._keyExchangeComplete = false;
+
+ return new Promise((resolve, reject) => {
+ this._status('Connecting (WZP-WS-Full) to room: ' + this.room + '...');
+
+ this.ws = new WebSocket(this.wsUrl);
+ this.ws.binaryType = 'arraybuffer';
+
+ this.ws.onopen = () => {
+ this.seq = 0;
+ this.startTimestamp = Date.now();
+ this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
+ this._startTime = Date.now();
+ this._fecBlockId = 0;
+
+ // Send auth if token provided.
+ if (this.authToken) {
+ this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
+ this._authenticated = false;
+ } else {
+ this._authenticated = true;
+ // No auth needed — proceed directly to key exchange.
+ this._status('Performing key exchange...');
+ const ourPub = this._keyExchange.public_key();
+ this.ws.send(new Uint8Array(ourPub).buffer);
+ }
+
+ // Store resolve/reject for key exchange completion.
+ this._keyExchangeResolve = resolve;
+ this._keyExchangeReject = reject;
+ };
+
+ this.ws.onmessage = (event) => {
+ // Handle text messages (auth responses).
+ if (typeof event.data === 'string') {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.type === 'auth_ok') {
+ this._authenticated = true;
+ this._status('Authenticated, performing key exchange...');
+ // Auth succeeded — now send public key for key exchange.
+ const ourPub = this._keyExchange.public_key();
+ this.ws.send(new Uint8Array(ourPub).buffer);
+ }
+ if (msg.type === 'auth_error') {
+ this._status('Auth failed: ' + (msg.reason || 'unknown'));
+ if (this._keyExchangeReject) {
+ this._keyExchangeReject(new Error('Auth failed: ' + (msg.reason || 'unknown')));
+ this._keyExchangeResolve = null;
+ this._keyExchangeReject = null;
+ }
+ this._cleanup();
+ }
+ } catch(e) { /* ignore non-JSON text */ }
+ return;
+ }
+ if (!this._keyExchangeComplete) {
+ this._handleKeyExchange(event);
+ } else {
+ this._handleMessage(event);
+ }
+ };
+
+ this.ws.onclose = () => {
+ const was = this._connected;
+ this._cleanup();
+ if (was) {
+ this._status('Disconnected');
+ } else if (this._keyExchangeReject) {
+ this._keyExchangeReject(new Error('Connection closed during key exchange'));
+ this._keyExchangeResolve = null;
+ this._keyExchangeReject = null;
+ }
+ };
+
+ this.ws.onerror = () => {
+ if (!this._connected) {
+ this._cleanup();
+ if (this._keyExchangeReject) {
+ this._keyExchangeReject(new Error('WebSocket connection failed'));
+ this._keyExchangeResolve = null;
+ this._keyExchangeReject = null;
+ } else {
+ reject(new Error('WebSocket connection failed'));
+ }
+ } else {
+ this._status('Connection error');
+ }
+ };
+ });
+ }
+
+ /**
+ * Handle the key exchange: first binary message of 32 bytes = peer's public key.
+ */
+ _handleKeyExchange(event) {
+ if (!(event.data instanceof ArrayBuffer)) return;
+ const data = new Uint8Array(event.data);
+
+ if (data.length === WZP_WS_FULL_PUBKEY_SIZE) {
+ // Received peer's public key — derive shared secret.
+ try {
+ const peerPub = data;
+ const secret = this._keyExchange.derive_shared_secret(peerPub);
+ this.cryptoSession = new this._wasmModule.WzpCryptoSession(secret);
+
+ // Free key exchange object (no longer needed).
+ this._keyExchange.free();
+ this._keyExchange = null;
+
+ // Initialize FEC encoder/decoder.
+ this.fecEncoder = new this._wasmModule.WzpFecEncoder(
+ WZP_WS_FULL_BLOCK_SIZE,
+ WZP_WS_FULL_SYMBOL_SIZE
+ );
+ this.fecDecoder = new this._wasmModule.WzpFecDecoder(
+ WZP_WS_FULL_BLOCK_SIZE,
+ WZP_WS_FULL_SYMBOL_SIZE
+ );
+
+ this._keyExchangeComplete = true;
+ this._connected = true;
+ this._startStatsTimer();
+ this._status('Connected (WZP-WS-Full) to room: ' + this.room + ' (encrypted, FEC active)');
+
+ if (this._keyExchangeResolve) {
+ this._keyExchangeResolve();
+ this._keyExchangeResolve = null;
+ this._keyExchangeReject = null;
+ }
+ } catch (e) {
+ console.error('[wzp-ws-full] Key exchange failed:', e);
+ if (this._keyExchangeReject) {
+ this._keyExchangeReject(new Error('Key exchange failed: ' + e.message));
+ this._keyExchangeResolve = null;
+ this._keyExchangeReject = null;
+ }
+ this._cleanup();
+ }
+ }
+ // Ignore non-32-byte messages during key exchange.
+ }
+
+ /**
+ * Close WebSocket and clean up all resources.
+ */
+ disconnect() {
+ this._connected = false;
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+ this._stopStatsTimer();
+ 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;
+ }
+ if (this._keyExchange) {
+ try { this._keyExchange.free(); } catch (_) { /* ignore */ }
+ this._keyExchange = null;
+ }
+ this._keyExchangeComplete = false;
+ }
+
+ /**
+ * Send a PCM audio frame with FEC encoding + encryption over the WebSocket.
+ *
+ * Pipeline: PCM -> pad to FEC symbol -> FEC encode -> encrypt -> WS send.
+ *
+ * Each FEC symbol is encrypted individually with ChaCha20-Poly1305. The
+ * 12-byte MediaHeader is used as AAD (authenticated but not encrypted),
+ * so the relay can inspect routing fields without decrypting the payload.
+ *
+ * Wire format per packet:
+ * header(12) + ciphertext(symbol_size) + tag(16)
+ *
+ * @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
+ */
+ async sendAudio(pcmBuffer) {
+ if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
+ if (!this.cryptoSession || !this.fecEncoder) return;
+
+ const pcmBytes = new Uint8Array(pcmBuffer);
+
+ // Pad PCM frame to FEC symbol size with length prefix.
+ const symbol = this._padToSymbol(pcmBytes);
+
+ // Feed to FEC encoder. Returns wire data when block completes.
+ const fecOutput = this.fecEncoder.add_symbol(symbol);
+
+ if (fecOutput) {
+ // Block completed — encrypt and send all packets (source + repair).
+ const fecPacketSize = WZP_WS_FULL_FEC_HEADER_SIZE + WZP_WS_FULL_SYMBOL_SIZE;
+ const timestampMs = Date.now() - this.startTimestamp;
+
+ for (let offset = 0; offset + fecPacketSize <= fecOutput.length; offset += fecPacketSize) {
+ const blockId = fecOutput[offset];
+ const symbolIdx = fecOutput[offset + 1];
+ const isRepair = fecOutput[offset + 2] !== 0;
+ const symbolData = fecOutput.slice(
+ offset + WZP_WS_FULL_FEC_HEADER_SIZE,
+ offset + fecPacketSize
+ );
+
+ // Build WZP MediaHeader (used as AAD for encryption).
+ // fecRatio ~0.5 for 50% repair overhead.
+ const header = this._buildHeader(
+ this.seq,
+ timestampMs,
+ isRepair,
+ 0, // codecId = RawPcm16
+ blockId,
+ symbolIdx,
+ 0.5, // fecRatio
+ false // hasQuality
+ );
+
+ // Encrypt: header as AAD, FEC symbol data as plaintext.
+ // Returns ciphertext + tag (symbol_size + 16 bytes).
+ const ciphertext = this.cryptoSession.encrypt(header, symbolData);
+ this.stats.encrypted++;
+
+ // Wire frame: header(12) + ciphertext_with_tag
+ const packet = new Uint8Array(WZP_WS_FULL_HEADER_SIZE + ciphertext.length);
+ packet.set(header, 0);
+ packet.set(ciphertext, WZP_WS_FULL_HEADER_SIZE);
+
+ this.ws.send(packet.buffer);
+ this.seq = (this.seq + 1) & 0xFFFF;
+ this.stats.sent++;
+ }
+
+ this._fecBlockId++;
+ }
+ // If block not yet complete, accumulate (no packets sent yet).
+ }
+
+ /**
+ * Test crypto + FEC roundtrip entirely in WASM (no network).
+ * Simulates: key exchange -> encrypt -> FEC encode -> simulate loss ->
+ * FEC decode -> decrypt -> verify.
+ *
+ * @returns {Object} Test results
+ */
+ testCryptoFec() {
+ if (!this.wasmReady || !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());
+
+ let secretsMatch = aliceSecret.length === bobSecret.length;
+ if (secretsMatch) {
+ for (let i = 0; i < aliceSecret.length; i++) {
+ if (aliceSecret[i] !== bobSecret[i]) { secretsMatch = false; break; }
+ }
+ }
+
+ // --- Crypto sessions ---
+ const aliceSession = new wasm.WzpCryptoSession(aliceSecret);
+ const bobSession = new wasm.WzpCryptoSession(bobSecret);
+
+ // --- Encrypt + FEC encode ---
+ const encoder = new wasm.WzpFecEncoder(WZP_WS_FULL_BLOCK_SIZE, WZP_WS_FULL_SYMBOL_SIZE);
+ const decoder = new wasm.WzpFecDecoder(WZP_WS_FULL_BLOCK_SIZE, WZP_WS_FULL_SYMBOL_SIZE);
+
+ // Generate test PCM frames (known data).
+ const originalFrames = [];
+ for (let i = 0; i < WZP_WS_FULL_BLOCK_SIZE; i++) {
+ const frame = new Uint8Array(1920);
+ for (let j = 0; j < 1920; j++) {
+ frame[j] = ((i * 37 + 7) + j) & 0xFF;
+ }
+ originalFrames.push(frame);
+ }
+
+ // Pad and FEC-encode.
+ const paddedSymbols = [];
+ let wireData = null;
+ for (const frame of originalFrames) {
+ const sym = new Uint8Array(WZP_WS_FULL_SYMBOL_SIZE);
+ sym[0] = (frame.length >> 8) & 0xFF;
+ sym[1] = frame.length & 0xFF;
+ sym.set(frame, 2);
+ paddedSymbols.push(sym);
+
+ const result = encoder.add_symbol(sym);
+ if (result) wireData = result;
+ }
+
+ if (!wireData) wireData = encoder.flush();
+
+ // Parse FEC packets and encrypt each one.
+ const FEC_HDR = WZP_WS_FULL_FEC_HEADER_SIZE;
+ const fecPacketSize = FEC_HDR + WZP_WS_FULL_SYMBOL_SIZE;
+ const encryptedPackets = [];
+
+ if (wireData) {
+ for (let offset = 0; offset + fecPacketSize <= wireData.length; offset += fecPacketSize) {
+ const blockId = wireData[offset];
+ const symbolIdx = wireData[offset + 1];
+ const isRepair = wireData[offset + 2] !== 0;
+ const symbolData = wireData.slice(offset + FEC_HDR, offset + fecPacketSize);
+
+ // Build header for AAD (match wire protocol bit layout).
+ const header = new Uint8Array(WZP_WS_FULL_HEADER_SIZE);
+ const fecRatioEncoded = Math.min(127, Math.round(0.5 * 63.5)); // 50% FEC
+ header[0] = ((isRepair ? 1 : 0) << 6)
+ | ((0 & 0x0F) << 2) // codecId=0
+ | ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
+ header[1] = (fecRatioEncoded & 0x3F) << 2; // FecRatioLo
+ header[8] = blockId;
+ header[9] = symbolIdx;
+
+ // Encrypt with Alice's session.
+ const ciphertext = aliceSession.encrypt(header, symbolData);
+
+ encryptedPackets.push({
+ blockId, symbolIdx, isRepair, header, ciphertext,
+ });
+ }
+ }
+
+ const sourcePackets = encryptedPackets.filter(p => !p.isRepair).length;
+ const repairPackets = encryptedPackets.filter(p => p.isRepair).length;
+
+ // --- Simulate 30% loss (drop 2 of ~7 packets) ---
+ const dropIndices = new Set([1, 3]);
+ const surviving = encryptedPackets.filter((_, i) => !dropIndices.has(i));
+
+ // --- Decrypt + FEC decode on Bob's side ---
+ let fecDecoded = null;
+ let decryptOk = true;
+
+ for (const pkt of surviving) {
+ let symbolData;
+ try {
+ symbolData = bobSession.decrypt(pkt.header, pkt.ciphertext);
+ } catch (e) {
+ decryptOk = false;
+ break;
+ }
+
+ const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, symbolData);
+ if (result) {
+ fecDecoded = result;
+ break;
+ }
+ }
+
+ // --- Verify recovered frames ---
+ let fecOk = false;
+ if (fecDecoded) {
+ fecOk = true;
+ for (let i = 0; i < WZP_WS_FULL_BLOCK_SIZE && fecOk; i++) {
+ const symOffset = i * WZP_WS_FULL_SYMBOL_SIZE;
+ if (symOffset + WZP_WS_FULL_SYMBOL_SIZE > fecDecoded.length) {
+ fecOk = false;
+ break;
+ }
+ const sym = fecDecoded.slice(symOffset, symOffset + WZP_WS_FULL_SYMBOL_SIZE);
+ const len = (sym[0] << 8) | sym[1];
+ const recovered = sym.slice(2, 2 + len);
+
+ if (recovered.length !== originalFrames[i].length) {
+ fecOk = false;
+ break;
+ }
+ for (let j = 0; j < recovered.length; j++) {
+ if (recovered[j] !== originalFrames[i][j]) {
+ 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 && decryptOk && fecOk,
+ secretsMatch,
+ decryptOk,
+ fecOk,
+ sourcePackets,
+ repairPackets,
+ totalPackets: encryptedPackets.length,
+ dropped: dropIndices.size,
+ surviving: surviving.length,
+ elapsed: elapsed.toFixed(2) + 'ms',
+ };
+ }
+
+ // -----------------------------------------------------------------------
+ // Internal
+ // -----------------------------------------------------------------------
+
+ _handleMessage(event) {
+ if (!(event.data instanceof ArrayBuffer)) return;
+ const data = new Uint8Array(event.data);
+ if (data.length < WZP_WS_FULL_HEADER_SIZE) return;
+
+ const header = this._parseHeader(data);
+ if (!header) return;
+
+ this.stats.recv++;
+
+ if (!this.cryptoSession || !this.fecDecoder) return;
+
+ // Extract header bytes (AAD) and ciphertext.
+ const headerBytes = data.slice(0, WZP_WS_FULL_HEADER_SIZE);
+ const ciphertext = data.slice(WZP_WS_FULL_HEADER_SIZE);
+
+ // Decrypt.
+ let symbolData;
+ try {
+ symbolData = this.cryptoSession.decrypt(headerBytes, ciphertext);
+ this.stats.decrypted++;
+ } catch (e) {
+ // Decryption failure — corrupted or replayed packet.
+ console.warn('[wzp-ws-full] decrypt failed:', e);
+ return;
+ }
+
+ // Feed decrypted symbol to FEC decoder.
+ const decoded = this.fecDecoder.add_symbol(
+ header.fecBlock,
+ header.fecSymbol,
+ header.isRepair,
+ symbolData
+ );
+
+ if (decoded) {
+ this.stats.fecRecovered++;
+
+ // decoded is concatenated padded symbols.
+ // Each symbol is WZP_WS_FULL_SYMBOL_SIZE bytes with a 2-byte length prefix.
+ for (let off = 0; off + WZP_WS_FULL_SYMBOL_SIZE <= decoded.length; off += WZP_WS_FULL_SYMBOL_SIZE) {
+ const symbol = decoded.slice(off, off + WZP_WS_FULL_SYMBOL_SIZE);
+ const pcmBytes = this._unpadSymbol(symbol);
+
+ if (pcmBytes.length > 0 && pcmBytes.length % 2 === 0) {
+ const pcm = new Int16Array(
+ pcmBytes.buffer,
+ pcmBytes.byteOffset,
+ pcmBytes.byteLength / 2
+ );
+ 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,
+ 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._keyExchangeComplete = false;
+ this._stopStatsTimer();
+ if (this.ws) {
+ try { this.ws.close(); } catch (_) { /* ignore */ }
+ this.ws = 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;
+ }
+ if (this._keyExchange) {
+ try { this._keyExchange.free(); } catch (_) { /* ignore */ }
+ this._keyExchange = null;
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Export
+// ---------------------------------------------------------------------------
+
+window.WZPWsFullClient = WZPWsFullClient;
diff --git a/crates/wzp-web/static/js/wzp-ws.js b/crates/wzp-web/static/js/wzp-ws.js
new file mode 100644
index 0000000..7452b35
--- /dev/null
+++ b/crates/wzp-web/static/js/wzp-ws.js
@@ -0,0 +1,289 @@
+// WarzonePhone — WZP-WS client (Variant 4).
+// WebSocket transport, WZP wire protocol, no WASM.
+// Sends MediaPacket-formatted frames instead of raw PCM.
+// Ready for direct relay WS support (no bridge translation needed).
+
+'use strict';
+
+// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
+const WZP_WS_HEADER_SIZE = 12;
+
+class WZPWsClient {
+ /**
+ * @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.authToken = options.authToken || null;
+ this.onAudio = options.onAudio || null;
+ this.onStatus = options.onStatus || null;
+ this.onStats = options.onStats || null;
+
+ this.ws = null;
+ this.seq = 0;
+ this.startTimestamp = 0;
+ this.stats = { sent: 0, recv: 0 };
+ this._startTime = 0;
+ this._statsInterval = null;
+ this._connected = false;
+ this._authenticated = false;
+ }
+
+ /**
+ * Build a 12-byte WZP MediaHeader.
+ *
+ * Wire layout (from wzp-proto::packet::MediaHeader):
+ * Byte 0: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
+ * Byte 1: FecRatioLo(6)|Reserved(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)
+ * @param {number} timestampMs Milliseconds since session start
+ * @param {boolean} isRepair True if this is a FEC repair symbol
+ * @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
+ * @param {number} fecBlock FEC block ID (u8)
+ * @param {number} fecSymbol FEC symbol index (u8)
+ * @param {number} fecRatio FEC ratio (0.0 to ~2.0)
+ * @param {boolean} hasQuality Whether a quality report is attached
+ * @returns {Uint8Array} 12-byte header
+ */
+ _buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
+ const buf = new ArrayBuffer(WZP_WS_HEADER_SIZE);
+ const view = new DataView(buf);
+
+ const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
+ const byte0 = ((0 & 0x01) << 7) // version=0
+ | ((isRepair ? 1 : 0) << 6) // T bit
+ | ((codecId & 0x0F) << 2) // CodecID
+ | ((hasQuality ? 1 : 0) << 1) // Q bit
+ | ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
+ view.setUint8(0, byte0);
+
+ const byte1 = (fecRatioEncoded & 0x3F) << 2;
+ view.setUint8(1, byte1);
+
+ view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
+ view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
+ view.setUint8(8, fecBlock & 0xFF);
+ view.setUint8(9, fecSymbol & 0xFF);
+ view.setUint8(10, 0); // reserved
+ view.setUint8(11, 0); // csrc_count
+ return new Uint8Array(buf);
+ }
+
+ /**
+ * Parse a 12-byte MediaHeader from received binary data.
+ *
+ * @param {Uint8Array} data At least 12 bytes
+ * @returns {Object|null} Parsed header fields, or null if too short
+ */
+ _parseHeader(data) {
+ if (data.byteLength < WZP_WS_HEADER_SIZE) return null;
+ const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
+ const byte0 = view.getUint8(0);
+ const byte1 = view.getUint8(1);
+ const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
+ return {
+ version: (byte0 >> 7) & 1,
+ isRepair: !!((byte0 >> 6) & 1),
+ codecId: (byte0 >> 2) & 0x0F,
+ hasQuality: !!((byte0 >> 1) & 1),
+ fecRatio: fecRatioEncoded / 63.5,
+ seq: view.getUint16(2),
+ timestamp: view.getUint32(4),
+ fecBlock: view.getUint8(8),
+ fecSymbol: view.getUint8(9),
+ reserved: view.getUint8(10),
+ csrcCount: view.getUint8(11),
+ };
+ }
+
+ /**
+ * Open WebSocket connection to the wzp-web bridge.
+ * @returns {Promise} resolves when connected
+ */
+ async connect() {
+ if (this._connected) return;
+
+ return new Promise((resolve, reject) => {
+ this._status('Connecting (WZP-WS) to room: ' + this.room + '...');
+
+ this.ws = new WebSocket(this.wsUrl);
+ this.ws.binaryType = 'arraybuffer';
+
+ this.ws.onopen = () => {
+ // Send auth if token provided.
+ if (this.authToken) {
+ this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
+ }
+
+ this._connected = true;
+ this._authenticated = !this.authToken; // authenticated immediately if no token needed
+ this.seq = 0;
+ this.startTimestamp = Date.now();
+ this.stats = { sent: 0, recv: 0 };
+ this._startTime = Date.now();
+ this._status('Connected (WZP-WS) to room: ' + this.room);
+ this._startStatsTimer();
+ resolve();
+ };
+
+ this.ws.onmessage = (event) => {
+ // Handle text messages (auth responses).
+ if (typeof event.data === 'string') {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.type === 'auth_ok') {
+ this._authenticated = true;
+ this._status('Authenticated (WZP-WS) to room: ' + this.room);
+ }
+ if (msg.type === 'auth_error') {
+ this._status('Auth failed: ' + (msg.reason || 'unknown'));
+ this.disconnect();
+ }
+ } catch(e) { /* ignore non-JSON text */ }
+ return;
+ }
+ this._handleMessage(event);
+ };
+
+ this.ws.onclose = () => {
+ const was = this._connected;
+ this._cleanup();
+ if (was) this._status('Disconnected');
+ };
+
+ this.ws.onerror = () => {
+ 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 wrapped in a WZP MediaPacket over the WebSocket.
+ *
+ * Wire format: 12-byte MediaHeader + raw PCM payload.
+ * The relay can parse this natively without bridge translation.
+ *
+ * @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
+ */
+ async sendAudio(pcmBuffer) {
+ if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
+
+ const header = this._buildHeader(
+ this.seq,
+ Date.now() - this.startTimestamp,
+ false, 0, 0, 0, 0, false
+ );
+
+ // Combine header + payload into single binary frame.
+ const pcmBytes = new Uint8Array(pcmBuffer);
+ const packet = new Uint8Array(WZP_WS_HEADER_SIZE + pcmBytes.length);
+ packet.set(header, 0);
+ packet.set(pcmBytes, WZP_WS_HEADER_SIZE);
+
+ this.ws.send(packet.buffer);
+ this.seq = (this.seq + 1) & 0xFFFF;
+ this.stats.sent++;
+ }
+
+ // -----------------------------------------------------------------------
+ // Internal
+ // -----------------------------------------------------------------------
+
+ _handleMessage(event) {
+ if (!(event.data instanceof ArrayBuffer)) return;
+ const data = new Uint8Array(event.data);
+ if (data.length < WZP_WS_HEADER_SIZE) return; // too small for header
+
+ const header = this._parseHeader(data);
+ if (!header) return;
+
+ // Extract payload (everything after 12-byte header).
+ // Payload is raw PCM Int16 samples.
+ const payloadBytes = data.slice(WZP_WS_HEADER_SIZE);
+ const pcm = new Int16Array(
+ payloadBytes.buffer,
+ payloadBytes.byteOffset,
+ payloadBytes.byteLength / 2
+ );
+ 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,
+ });
+ }
+ }, 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.WZPWsClient = WZPWsClient;