From 2de6e1995608e72c2adc188a36a3e3b8db77efa7 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Mon, 30 Mar 2026 14:53:53 +0400 Subject: [PATCH] =?UTF-8?q?feat:=206=20web=20client=20variants=20=E2=80=94?= =?UTF-8?q?=20all=20wire-compatible=20with=20WZP=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 new WZP-WS variants (speak WZP wire format over WebSocket): - wzp-ws.js (Variant 4): WZP MediaHeader + raw PCM, no WASM - wzp-ws-fec.js (Variant 5): WZP + WASM RaptorQ FEC (block=5, symbol=2048) - wzp-ws-full.js (Variant 6): WZP + FEC + ChaCha20-Poly1305 E2E encryption Wire protocol compliance (verified against wzp-proto/src/packet.rs): - MediaHeader 12-byte bit layout: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1) - FEC ratio 7-bit encoding across byte0-byte1 boundary - All fields big-endian (seq u16, timestamp u32) - Crypto nonce: session_id[4] + seq_be[4] + direction[1] + pad[3] - HKDF info: "warzone-session-key" (matches wzp-crypto) Auth flow (matches wzp-relay/src/ws.rs): - First WS message: {"type":"auth","token":"..."} - Relay responds: {"type":"auth_ok"} or {"type":"auth_error"} - All 6 variants handle auth_ok/auth_error text messages Updated: - wzp-core.js: detectVariant() accepts ws, ws-fec, ws-full - index.html: script map + ClientClass dispatch for all 6 variants - index.html: WASM auto-loading for variants with loadWasm() URL patterns: ?variant=pure Variant 1: Raw PCM over WS (bridge needed) ?variant=hybrid Variant 2: Raw PCM + WASM FEC (bridge needed) ?variant=full Variant 3: WebTransport + FEC + crypto (no bridge) ?variant=ws Variant 4: WZP protocol over WS (relay direct) ?variant=ws-fec Variant 5: WZP + FEC over WS (relay direct) ?variant=ws-full Variant 6: WZP + FEC + E2E crypto over WS (relay direct) Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/wzp-web/static/index.html | 43 +- crates/wzp-web/static/js/wzp-core.js | 3 +- crates/wzp-web/static/js/wzp-ws-fec.js | 592 +++++++++++++++++++ crates/wzp-web/static/js/wzp-ws-full.js | 749 ++++++++++++++++++++++++ crates/wzp-web/static/js/wzp-ws.js | 289 +++++++++ 5 files changed, 1664 insertions(+), 12 deletions(-) create mode 100644 crates/wzp-web/static/js/wzp-ws-fec.js create mode 100644 crates/wzp-web/static/js/wzp-ws-full.js create mode 100644 crates/wzp-web/static/js/wzp-ws.js 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;