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) <noreply@anthropic.com>
290 lines
9.0 KiB
JavaScript
290 lines
9.0 KiB
JavaScript
// 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<void>} 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;
|