Variant 1: Pure JS (wzp-pure.js) - WebSocket transport, raw PCM, no encryption (bridge handles QUIC crypto) - ~20KB, works everywhere, zero dependencies - WZPPureClient class with connect/disconnect/sendAudio Variant 2: Hybrid (wzp-hybrid.js + wzp-wasm) - WebSocket transport + RaptorQ FEC via WASM - ~120KB (337KB WASM blob shared with full variant) - WZPHybridClient extends pure with FEC encode/decode - Loss recovery ready for when WebTransport replaces WebSocket Variant 3: Full WASM (wzp-full.js + wzp-wasm) - WebTransport datagrams (unreliable, low latency) - ChaCha20-Poly1305 encryption + RaptorQ FEC, all in WASM - X25519 key exchange over bidirectional stream - WZPFullClient — true E2E encrypted WZP client in browser - Needs relay HTTP/3 support (h3-quinn) for WebTransport Shared infrastructure: - wzp-core.js: UI logic, AudioWorklet, variant detection, PTT - audio-processor.js: AudioWorklet capture + playback (unchanged) - index.html: variant selector (?variant=pure|hybrid|full), auto-detect wzp-wasm crate (new): - RaptorQ FEC encoder/decoder (WzpFecEncoder, WzpFecDecoder) - ChaCha20-Poly1305 crypto (WzpCryptoSession) - X25519 key exchange (WzpKeyExchange) - 7 native tests (3 FEC + 4 crypto), all passing - WASM blob: 337KB optimized Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
169 lines
4.5 KiB
JavaScript
169 lines
4.5 KiB
JavaScript
// WarzonePhone — Pure JS client (Variant 1).
|
|
// WebSocket transport, raw PCM, no WASM, no FEC.
|
|
// Relies on wzp-core.js for UI and audio helpers.
|
|
|
|
'use strict';
|
|
|
|
class WZPPureClient {
|
|
/**
|
|
* @param {Object} options
|
|
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
|
* @param {string} options.room Room name
|
|
* @param {Function} options.onAudio callback(Int16Array) for playback
|
|
* @param {Function} options.onStatus callback(string) for UI status
|
|
* @param {Function} options.onStats callback({sent, recv, loss, elapsed}) for UI
|
|
*/
|
|
constructor(options) {
|
|
this.wsUrl = options.wsUrl;
|
|
this.room = options.room;
|
|
this.onAudio = options.onAudio || null;
|
|
this.onStatus = options.onStatus || null;
|
|
this.onStats = options.onStats || null;
|
|
|
|
this.ws = null;
|
|
this.sequence = 0;
|
|
this.stats = { sent: 0, recv: 0 };
|
|
this._startTime = 0;
|
|
this._statsInterval = null;
|
|
this._connected = false;
|
|
}
|
|
|
|
/**
|
|
* Open WebSocket connection to the wzp-web bridge.
|
|
* @returns {Promise<void>} resolves when connected
|
|
*/
|
|
async connect() {
|
|
if (this._connected) return;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
this._status('Connecting to room: ' + this.room + '...');
|
|
|
|
this.ws = new WebSocket(this.wsUrl);
|
|
this.ws.binaryType = 'arraybuffer';
|
|
|
|
this.ws.onopen = () => {
|
|
this._connected = true;
|
|
this.sequence = 0;
|
|
this.stats = { sent: 0, recv: 0 };
|
|
this._startTime = Date.now();
|
|
this._status('Connected to room: ' + this.room);
|
|
this._startStatsTimer();
|
|
resolve();
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
this._handleMessage(event);
|
|
};
|
|
|
|
this.ws.onclose = () => {
|
|
const wasConnected = this._connected;
|
|
this._cleanup();
|
|
if (wasConnected) {
|
|
this._status('Disconnected');
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = (err) => {
|
|
if (!this._connected) {
|
|
this._cleanup();
|
|
reject(new Error('WebSocket connection failed'));
|
|
} else {
|
|
this._status('Connection error');
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Close WebSocket and clean up.
|
|
*/
|
|
disconnect() {
|
|
this._connected = false;
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
this._stopStatsTimer();
|
|
}
|
|
|
|
/**
|
|
* Send a PCM audio frame over the WebSocket.
|
|
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
|
*/
|
|
async sendAudio(pcmBuffer) {
|
|
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
// Pure JS variant: send raw PCM directly (no encryption, no header).
|
|
// The wzp-web bridge handles QUIC-side encryption.
|
|
this.ws.send(pcmBuffer);
|
|
this.sequence++;
|
|
this.stats.sent++;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Internal
|
|
// -----------------------------------------------------------------------
|
|
|
|
_handleMessage(event) {
|
|
if (!(event.data instanceof ArrayBuffer)) return;
|
|
const pcm = new Int16Array(event.data);
|
|
this.stats.recv++;
|
|
if (this.onAudio) {
|
|
this.onAudio(pcm);
|
|
}
|
|
}
|
|
|
|
_startStatsTimer() {
|
|
this._stopStatsTimer();
|
|
this._statsInterval = setInterval(() => {
|
|
if (!this._connected) {
|
|
this._stopStatsTimer();
|
|
return;
|
|
}
|
|
const elapsed = (Date.now() - this._startTime) / 1000;
|
|
// Simple loss estimate: if we sent frames, the other side should
|
|
// receive roughly the same count. Since we only see our own recv,
|
|
// we report raw counts and let the UI decide.
|
|
const loss = this.stats.sent > 0
|
|
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
|
: 0;
|
|
if (this.onStats) {
|
|
this.onStats({
|
|
sent: this.stats.sent,
|
|
recv: this.stats.recv,
|
|
loss: loss,
|
|
elapsed: elapsed,
|
|
});
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
_stopStatsTimer() {
|
|
if (this._statsInterval) {
|
|
clearInterval(this._statsInterval);
|
|
this._statsInterval = null;
|
|
}
|
|
}
|
|
|
|
_status(msg) {
|
|
if (this.onStatus) this.onStatus(msg);
|
|
}
|
|
|
|
_cleanup() {
|
|
this._connected = false;
|
|
this._stopStatsTimer();
|
|
if (this.ws) {
|
|
try { this.ws.close(); } catch (_) { /* ignore */ }
|
|
this.ws = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Export
|
|
// ---------------------------------------------------------------------------
|
|
|
|
window.WZPPureClient = WZPPureClient;
|