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