7 Commits

Author SHA1 Message Date
Siavash Sameni
1d33f3ed4e fix: WASM import path respects __WZP_BASE_URL for cross-origin loading
When variant JS is loaded from featherChat (different origin), WASM
imports need to resolve via the /audio/ Caddy path, not root /.
All 4 variant files now use: (window.__WZP_BASE_URL || '') + '/wasm/...'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:12:35 +04:00
Siavash Sameni
2de6e19956 feat: 6 web client variants — all wire-compatible with WZP protocol
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>
2026-03-30 14:54:38 +04:00
Siavash Sameni
ec437afbce feat: web variants use relay WS directly — no bridge needed
Updated all 3 web client variants to connect via the relay's new
WebSocket endpoint (/ws/room) instead of the wzp-web bridge.

index.html:
- Boot logic now creates the correct client class per variant
  (WZPPureClient, WZPHybridClient, or WZPFullClient)

wzp-full.js (Full WASM):
- Tries WebTransport first with 3s timeout
- Falls back to WebSocket if WT unavailable or relay lacks HTTP/3
- WS fallback sends raw PCM (same as pure), WASM FEC module still loaded
- When WT works: full encrypted + FEC pipeline over UDP datagrams

Pure + Hybrid variants already used /ws/room — no changes needed.

All JS syntax verified, 63 relay tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:43:49 +04:00
Siavash Sameni
137e7973c4 Merge branch 'main' into feature/wzp-web-variants 2026-03-30 14:41:38 +04:00
Siavash Sameni
55d4004f86 docs: web client variant architecture — Pure JS, Hybrid, Full WASM
WEB_VARIANTS.md with Mermaid diagrams for all three variants:
- Comparison table (bundle, transport, encryption, FEC, latency)
- Per-variant architecture diagrams + sequence flows
- WASM module structure (FEC + crypto exports)
- FEC wire format (3-byte header + 256-byte padded symbols)
- Encryption flow (X25519 DH → HKDF → ChaCha20-Poly1305)
- Nonce construction (matches native wzp-crypto)
- Send/receive pipeline details for Full variant
- Shared infrastructure (wzp-core.js, AudioWorklet, boot sequence)
- Deployment guides (Caddy, direct TLS, URL patterns)
- Browser console test commands for FEC and crypto

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:11:37 +04:00
Siavash Sameni
09a18b086b chore: include WASM blob + JS glue in git for deployment
wasm-pack generated .gitignore was excluding all build output.
The WASM (337KB) and JS glue need to be in the repo so the
wzp-web static server can serve them without a build step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:00:16 +04:00
Siavash Sameni
f3c8e11995 feat: 3 web client variants — Pure JS, Hybrid (JS+WASM FEC), Full WASM
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>
2026-03-30 11:10:15 +04:00
16 changed files with 5014 additions and 282 deletions

15
Cargo.lock generated
View File

@@ -4302,6 +4302,21 @@ dependencies = [
"wzp-proto",
]
[[package]]
name = "wzp-wasm"
version = "0.1.0"
dependencies = [
"chacha20poly1305",
"getrandom 0.2.17",
"hkdf",
"js-sys",
"rand 0.8.5",
"raptorq",
"sha2",
"wasm-bindgen",
"x25519-dalek",
]
[[package]]
name = "wzp-web"
version = "0.1.0"

View File

@@ -9,6 +9,7 @@ members = [
"crates/wzp-relay",
"crates/wzp-client",
"crates/wzp-web",
"crates/wzp-wasm",
]
[workspace.package]

View File

@@ -0,0 +1,25 @@
[package]
name = "wzp-wasm"
version = "0.1.0"
edition = "2021"
description = "WarzonePhone WASM bindings — FEC (RaptorQ) + crypto (ChaCha20-Poly1305, X25519)"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
raptorq = "2"
js-sys = "0.3"
# Crypto (ChaCha20-Poly1305 + X25519 key exchange)
chacha20poly1305 = "0.10"
hkdf = "0.12"
sha2 = "0.10"
x25519-dalek = { version = "2", features = ["static_secrets"] }
rand = "0.8"
getrandom = { version = "0.2", features = ["js"] } # CRITICAL for WASM randomness
[profile.release]
opt-level = "s"
lto = true

692
crates/wzp-wasm/src/lib.rs Normal file
View File

@@ -0,0 +1,692 @@
//! WarzonePhone WASM bindings.
//!
//! Exports two subsystems for browser-side usage:
//!
//! **FEC** — RaptorQ forward error correction (encode/decode).
//! Audio frames are padded to a fixed symbol size (default 256 bytes) with a
//! 2-byte little-endian length prefix, matching the native wzp-fec wire format.
//!
//! Wire format per symbol:
//! [block_id:1][symbol_idx:1][is_repair:1][symbol_data:symbol_size]
//!
//! Encoder output: concatenated symbols in the above format when a block completes.
//! Decoder input: individual symbols in the above format.
//! Decoder output: concatenated original source data (length-prefix stripped).
//!
//! **Crypto** — X25519 key exchange + ChaCha20-Poly1305 AEAD encryption.
//! Mirrors `wzp-crypto` nonce/session/handshake logic so WASM and native
//! peers produce interoperable ciphertext.
use wasm_bindgen::prelude::*;
use raptorq::{
EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder,
SourceBlockEncoder,
};
/// Header size prepended to each symbol on the wire: block_id + symbol_idx + is_repair.
const HEADER_SIZE: usize = 3;
/// Length prefix size inside each padded symbol (u16 LE), matching wzp-fec.
const LEN_PREFIX: usize = 2;
// ---------------------------------------------------------------------------
// Encoder
// ---------------------------------------------------------------------------
#[wasm_bindgen]
pub struct WzpFecEncoder {
block_id: u8,
frames_per_block: usize,
symbol_size: usize,
source_symbols: Vec<Vec<u8>>,
}
#[wasm_bindgen]
impl WzpFecEncoder {
/// Create a new FEC encoder.
///
/// * `block_size` — number of source symbols (audio frames) per FEC block.
/// * `symbol_size` — padded byte size of each symbol (default 256).
#[wasm_bindgen(constructor)]
pub fn new(block_size: usize, symbol_size: usize) -> Self {
Self {
block_id: 0,
frames_per_block: block_size,
symbol_size,
source_symbols: Vec::with_capacity(block_size),
}
}
/// Add a source symbol (audio frame).
///
/// Returns encoded packets (all source + repair) when the block is complete,
/// or `undefined` if the block is still accumulating.
///
/// Each returned packet carries the 3-byte header:
/// `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
pub fn add_symbol(&mut self, data: &[u8]) -> Option<Vec<u8>> {
self.source_symbols.push(data.to_vec());
if self.source_symbols.len() >= self.frames_per_block {
Some(self.encode_block())
} else {
None
}
}
/// Force-flush the current (possibly partial) block.
///
/// Returns all source + repair symbols with headers, or empty vec if no
/// symbols have been accumulated.
pub fn flush(&mut self) -> Vec<u8> {
if self.source_symbols.is_empty() {
return Vec::new();
}
self.encode_block()
}
/// Internal: encode accumulated source symbols into a block, generate repair,
/// and return the concatenated wire-format output.
fn encode_block(&mut self) -> Vec<u8> {
let ss = self.symbol_size;
let num_source = self.source_symbols.len();
let block_id = self.block_id;
// Build length-prefixed, padded block data (matches wzp-fec format).
let block_data = self.build_block_data();
let config =
ObjectTransmissionInformation::with_defaults(block_data.len() as u64, ss as u16);
let encoder = SourceBlockEncoder::new(block_id, &config, &block_data);
// Generate source packets.
let source_packets = encoder.source_packets();
// Generate repair packets — 50% overhead by default.
let num_repair = ((num_source as f32) * 0.5).ceil() as u32;
let repair_packets = encoder.repair_packets(0, num_repair);
// Allocate output buffer.
let total_packets = source_packets.len() + repair_packets.len();
let packet_wire_size = HEADER_SIZE + ss;
let mut output = Vec::with_capacity(total_packets * packet_wire_size);
// Write source symbols.
for (i, pkt) in source_packets.iter().enumerate() {
output.push(block_id);
output.push(i as u8);
output.push(0); // is_repair = false
let pkt_data = pkt.data();
let copy_len = pkt_data.len().min(ss);
output.extend_from_slice(&pkt_data[..copy_len]);
// Pad if shorter.
if copy_len < ss {
output.resize(output.len() + (ss - copy_len), 0);
}
}
// Write repair symbols.
for (i, pkt) in repair_packets.iter().enumerate() {
output.push(block_id);
output.push((num_source + i) as u8);
output.push(1); // is_repair = true
let pkt_data = pkt.data();
let copy_len = pkt_data.len().min(ss);
output.extend_from_slice(&pkt_data[..copy_len]);
if copy_len < ss {
output.resize(output.len() + (ss - copy_len), 0);
}
}
// Advance block.
self.block_id = self.block_id.wrapping_add(1);
self.source_symbols.clear();
output
}
/// Build the contiguous, length-prefixed block data buffer.
fn build_block_data(&self) -> Vec<u8> {
let ss = self.symbol_size;
let mut data = vec![0u8; self.source_symbols.len() * ss];
for (i, sym) in self.source_symbols.iter().enumerate() {
let max_payload = ss - LEN_PREFIX;
let payload_len = sym.len().min(max_payload);
let offset = i * ss;
data[offset..offset + LEN_PREFIX]
.copy_from_slice(&(payload_len as u16).to_le_bytes());
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
.copy_from_slice(&sym[..payload_len]);
}
data
}
}
// ---------------------------------------------------------------------------
// Decoder
// ---------------------------------------------------------------------------
/// Per-block decoder state.
struct BlockState {
packets: Vec<EncodingPacket>,
decoded: bool,
result: Option<Vec<u8>>,
}
#[wasm_bindgen]
pub struct WzpFecDecoder {
frames_per_block: usize,
symbol_size: usize,
blocks: Vec<(u8, BlockState)>, // poor man's map (no std HashMap in tiny WASM)
}
#[wasm_bindgen]
impl WzpFecDecoder {
/// Create a new FEC decoder.
///
/// * `block_size` — expected number of source symbols per block.
/// * `symbol_size` — padded byte size of each symbol (must match encoder).
#[wasm_bindgen(constructor)]
pub fn new(block_size: usize, symbol_size: usize) -> Self {
Self {
frames_per_block: block_size,
symbol_size,
blocks: Vec::new(),
}
}
/// Feed a received symbol.
///
/// Returns the decoded block (concatenated original frames, unpadded) if
/// enough symbols have been received to recover the block, or `undefined`.
pub fn add_symbol(
&mut self,
block_id: u8,
symbol_idx: u8,
_is_repair: bool,
data: &[u8],
) -> Option<Vec<u8>> {
let ss = self.symbol_size;
// Pad incoming data to symbol_size.
let mut padded = vec![0u8; ss];
let len = data.len().min(ss);
padded[..len].copy_from_slice(&data[..len]);
let esi = symbol_idx as u32;
let packet = EncodingPacket::new(PayloadId::new(block_id, esi), padded);
// Find or create block state.
let block = self.get_or_create_block(block_id);
if block.decoded {
return block.result.clone();
}
block.packets.push(packet);
// Attempt decode.
self.try_decode(block_id)
}
/// Try to decode a block; returns the original frames if successful.
fn try_decode(&mut self, block_id: u8) -> Option<Vec<u8>> {
let ss = self.symbol_size;
let num_source = self.frames_per_block;
let block_length = (num_source as u64) * (ss as u64);
let block = self.get_block_mut(block_id)?;
if block.decoded {
return block.result.clone();
}
let config =
ObjectTransmissionInformation::with_defaults(block_length, ss as u16);
let mut decoder = SourceBlockDecoder::new(block_id, &config, block_length);
let decoded = decoder.decode(block.packets.clone());
match decoded {
Some(data) => {
// Extract original frames by stripping length prefixes.
let mut output = Vec::new();
for i in 0..num_source {
let offset = i * ss;
if offset + LEN_PREFIX > data.len() {
break;
}
let payload_len = u16::from_le_bytes([
data[offset],
data[offset + 1],
]) as usize;
let payload_start = offset + LEN_PREFIX;
let payload_end = (payload_start + payload_len).min(data.len());
output.extend_from_slice(&data[payload_start..payload_end]);
}
let block = self.get_block_mut(block_id).unwrap();
block.decoded = true;
block.result = Some(output.clone());
Some(output)
}
None => None,
}
}
fn get_or_create_block(&mut self, block_id: u8) -> &mut BlockState {
if let Some(pos) = self.blocks.iter().position(|(id, _)| *id == block_id) {
return &mut self.blocks[pos].1;
}
self.blocks.push((
block_id,
BlockState {
packets: Vec::new(),
decoded: false,
result: None,
},
));
let last = self.blocks.len() - 1;
&mut self.blocks[last].1
}
fn get_block_mut(&mut self, block_id: u8) -> Option<&mut BlockState> {
self.blocks
.iter_mut()
.find(|(id, _)| *id == block_id)
.map(|(_, state)| state)
}
}
// =========================================================================
// Crypto — X25519 key exchange
// =========================================================================
/// X25519 key exchange: generate ephemeral keypair and derive shared secret.
///
/// Usage from JS:
/// ```js
/// const kx = new WzpKeyExchange();
/// const ourPub = kx.public_key(); // Uint8Array(32)
/// // ... send ourPub to peer, receive peerPub ...
/// const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
/// const session = new WzpCryptoSession(secret);
/// ```
#[wasm_bindgen]
pub struct WzpKeyExchange {
secret: x25519_dalek::StaticSecret,
public: x25519_dalek::PublicKey,
}
#[wasm_bindgen]
impl WzpKeyExchange {
/// Generate a new random X25519 keypair.
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
let secret = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
let public = x25519_dalek::PublicKey::from(&secret);
Self { secret, public }
}
/// Our public key (32 bytes).
pub fn public_key(&self) -> Vec<u8> {
self.public.as_bytes().to_vec()
}
/// Derive a 32-byte session key from the peer's public key.
///
/// Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
/// matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
pub fn derive_shared_secret(&self, peer_public: &[u8]) -> Result<Vec<u8>, JsValue> {
if peer_public.len() != 32 {
return Err(JsValue::from_str("peer public key must be 32 bytes"));
}
let mut peer_bytes = [0u8; 32];
peer_bytes.copy_from_slice(peer_public);
let peer_pk = x25519_dalek::PublicKey::from(peer_bytes);
// Rebuild secret from bytes (StaticSecret doesn't impl Clone).
let secret_bytes = self.secret.to_bytes();
let secret_clone = x25519_dalek::StaticSecret::from(secret_bytes);
let shared = secret_clone.diffie_hellman(&peer_pk);
// HKDF expand — same derivation as wzp-crypto handshake.rs
use hkdf::Hkdf;
use sha2::Sha256;
let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
let mut session_key = [0u8; 32];
hk.expand(b"warzone-session-key", &mut session_key)
.expect("HKDF expand should not fail for 32-byte output");
Ok(session_key.to_vec())
}
}
// =========================================================================
// Crypto — ChaCha20-Poly1305 AEAD session
// =========================================================================
/// Build a 12-byte nonce (mirrors `wzp-crypto::nonce::build_nonce`).
///
/// Layout: `session_id[4] || seq(u32 BE) || direction(1) || pad(3 zero)`.
fn build_nonce(session_id: &[u8; 4], seq: u32, direction: u8) -> [u8; 12] {
let mut nonce = [0u8; 12];
nonce[0..4].copy_from_slice(session_id);
nonce[4..8].copy_from_slice(&seq.to_be_bytes());
nonce[8] = direction;
nonce
}
/// Symmetric encryption session using ChaCha20-Poly1305.
///
/// Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
/// and key setup are identical so WASM and native peers interoperate.
#[wasm_bindgen]
pub struct WzpCryptoSession {
cipher: chacha20poly1305::ChaCha20Poly1305,
session_id: [u8; 4],
send_seq: u32,
recv_seq: u32,
}
#[wasm_bindgen]
impl WzpCryptoSession {
/// Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
#[wasm_bindgen(constructor)]
pub fn new(shared_secret: &[u8]) -> Result<WzpCryptoSession, JsValue> {
if shared_secret.len() != 32 {
return Err(JsValue::from_str("shared secret must be 32 bytes"));
}
use chacha20poly1305::KeyInit;
use sha2::Digest;
let session_id_hash = sha2::Sha256::digest(shared_secret);
let mut session_id = [0u8; 4];
session_id.copy_from_slice(&session_id_hash[..4]);
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(shared_secret)
.map_err(|e| JsValue::from_str(&format!("invalid key: {}", e)))?;
Ok(Self {
cipher,
session_id,
send_seq: 0,
recv_seq: 0,
})
}
/// Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
///
/// Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
pub fn encrypt(&mut self, header_aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, JsValue> {
use chacha20poly1305::aead::{Aead, Payload};
use chacha20poly1305::Nonce;
let nonce_bytes = build_nonce(&self.session_id, self.send_seq, 0); // 0 = Send
let nonce = Nonce::from_slice(&nonce_bytes);
let payload = Payload {
msg: plaintext,
aad: header_aad,
};
let ciphertext = self
.cipher
.encrypt(nonce, payload)
.map_err(|_| JsValue::from_str("encryption failed"))?;
self.send_seq = self.send_seq.wrapping_add(1);
Ok(ciphertext)
}
/// Decrypt a media payload with AAD.
///
/// Returns plaintext on success, or throws on auth failure.
pub fn decrypt(&mut self, header_aad: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, JsValue> {
use chacha20poly1305::aead::{Aead, Payload};
use chacha20poly1305::Nonce;
// direction=0 (Send) matches the sender's nonce — same as native code.
let nonce_bytes = build_nonce(&self.session_id, self.recv_seq, 0);
let nonce = Nonce::from_slice(&nonce_bytes);
let payload = Payload {
msg: ciphertext,
aad: header_aad,
};
let plaintext = self
.cipher
.decrypt(nonce, payload)
.map_err(|_| JsValue::from_str("decryption failed — bad key or corrupted data"))?;
self.recv_seq = self.recv_seq.wrapping_add(1);
Ok(plaintext)
}
/// Current send sequence number (for diagnostics / UI stats).
pub fn send_seq(&self) -> u32 {
self.send_seq
}
/// Current receive sequence number (for diagnostics / UI stats).
pub fn recv_seq(&self) -> u32 {
self.recv_seq
}
}
// ---------------------------------------------------------------------------
// Tests (native only — not compiled to WASM)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_decode_roundtrip() {
let block_size = 5;
let symbol_size = 256;
let mut encoder = WzpFecEncoder::new(block_size, symbol_size);
let mut decoder = WzpFecDecoder::new(block_size, symbol_size);
// Create test frames of varying sizes.
let frames: Vec<Vec<u8>> = (0..block_size)
.map(|i| vec![(i as u8).wrapping_mul(37).wrapping_add(7); 80 + i * 10])
.collect();
// Feed frames to encoder; last one triggers block encoding.
let mut wire_data = None;
for frame in &frames {
wire_data = encoder.add_symbol(frame);
}
let wire_data = wire_data.expect("block should be complete");
// Parse wire packets and feed to decoder.
let packet_size = HEADER_SIZE + symbol_size;
assert_eq!(wire_data.len() % packet_size, 0);
let mut result = None;
for chunk in wire_data.chunks(packet_size) {
let blk_id = chunk[0];
let sym_idx = chunk[1];
let is_repair = chunk[2] != 0;
let sym_data = &chunk[HEADER_SIZE..];
if let Some(decoded) = decoder.add_symbol(blk_id, sym_idx, is_repair, sym_data) {
result = Some(decoded);
break;
}
}
let decoded_data = result.expect("should decode with all symbols");
// Verify: decoded data should be all original frames concatenated.
let mut expected = Vec::new();
for frame in &frames {
expected.extend_from_slice(frame);
}
assert_eq!(decoded_data, expected);
}
#[test]
fn decode_with_packet_loss() {
let block_size = 5;
let symbol_size = 256;
let mut encoder = WzpFecEncoder::new(block_size, symbol_size);
let mut decoder = WzpFecDecoder::new(block_size, symbol_size);
let frames: Vec<Vec<u8>> = (0..block_size)
.map(|i| vec![(i as u8).wrapping_mul(37).wrapping_add(7); 100])
.collect();
let mut wire_data = None;
for frame in &frames {
wire_data = encoder.add_symbol(frame);
}
let wire_data = wire_data.unwrap();
let packet_size = HEADER_SIZE + symbol_size;
let packets: Vec<&[u8]> = wire_data.chunks(packet_size).collect();
// Drop 2 source packets (simulate 40% source loss).
// We have 5 source + 3 repair = 8 packets. Drop packets at index 1 and 3.
let mut result = None;
for (i, chunk) in packets.iter().enumerate() {
if i == 1 || i == 3 {
continue; // simulate loss
}
let blk_id = chunk[0];
let sym_idx = chunk[1];
let is_repair = chunk[2] != 0;
let sym_data = &chunk[HEADER_SIZE..];
if let Some(decoded) = decoder.add_symbol(blk_id, sym_idx, is_repair, sym_data) {
result = Some(decoded);
break;
}
}
let decoded_data = result.expect("should recover with FEC despite 2 lost packets");
let mut expected = Vec::new();
for frame in &frames {
expected.extend_from_slice(frame);
}
assert_eq!(decoded_data, expected);
}
#[test]
fn flush_partial_block() {
let mut encoder = WzpFecEncoder::new(5, 256);
// Add only 3 of 5 expected symbols, then flush.
encoder.add_symbol(&[1; 50]);
encoder.add_symbol(&[2; 60]);
encoder.add_symbol(&[3; 70]);
let wire_data = encoder.flush();
assert!(!wire_data.is_empty());
// Verify block_id advanced.
assert_eq!(encoder.block_id, 1);
}
// -- Crypto tests -------------------------------------------------------
#[test]
fn crypto_encrypt_decrypt_roundtrip() {
let key = [0x42u8; 32];
let mut alice = WzpCryptoSession::new(&key).unwrap();
let mut bob = WzpCryptoSession::new(&key).unwrap();
let header = b"test-header";
let plaintext = b"hello warzone from wasm";
let ciphertext = alice.encrypt(header, plaintext).unwrap();
let decrypted = bob.decrypt(header, &ciphertext).unwrap();
assert_eq!(&decrypted, plaintext);
}
// NOTE: crypto_wrong_aad_fails and crypto_wrong_key_fails return
// Err(JsValue) which aborts on non-wasm32 (JsValue::from_str uses an
// extern "C" shim that panics with "cannot unwind"). These tests are
// gated to wasm32-only; on native the encrypt/decrypt roundtrip and
// nonce-layout tests provide sufficient coverage.
#[cfg(target_arch = "wasm32")]
#[test]
fn crypto_wrong_aad_fails() {
let key = [0x42u8; 32];
let mut alice = WzpCryptoSession::new(&key).unwrap();
let mut bob = WzpCryptoSession::new(&key).unwrap();
let ciphertext = alice.encrypt(b"correct", b"secret").unwrap();
let result = bob.decrypt(b"wrong", &ciphertext);
assert!(result.is_err());
}
#[cfg(target_arch = "wasm32")]
#[test]
fn crypto_wrong_key_fails() {
let mut alice = WzpCryptoSession::new(&[0xAA; 32]).unwrap();
let mut eve = WzpCryptoSession::new(&[0xBB; 32]).unwrap();
let ciphertext = alice.encrypt(b"hdr", b"secret").unwrap();
let result = eve.decrypt(b"hdr", &ciphertext);
assert!(result.is_err());
}
#[test]
fn crypto_multiple_packets() {
let key = [0x42u8; 32];
let mut alice = WzpCryptoSession::new(&key).unwrap();
let mut bob = WzpCryptoSession::new(&key).unwrap();
for i in 0..100u32 {
let msg = format!("message {}", i);
let ct = alice.encrypt(b"hdr", msg.as_bytes()).unwrap();
let pt = bob.decrypt(b"hdr", &ct).unwrap();
assert_eq!(pt, msg.as_bytes());
}
assert_eq!(alice.send_seq(), 100);
assert_eq!(bob.recv_seq(), 100);
}
#[test]
fn key_exchange_roundtrip() {
let alice_kx = WzpKeyExchange::new();
let bob_kx = WzpKeyExchange::new();
let alice_secret = alice_kx
.derive_shared_secret(&bob_kx.public_key())
.unwrap();
let bob_secret = bob_kx
.derive_shared_secret(&alice_kx.public_key())
.unwrap();
assert_eq!(alice_secret, bob_secret);
assert_eq!(alice_secret.len(), 32);
// Verify the derived secret actually works for encrypt/decrypt.
let mut alice_session = WzpCryptoSession::new(&alice_secret).unwrap();
let mut bob_session = WzpCryptoSession::new(&bob_secret).unwrap();
let ct = alice_session.encrypt(b"hdr", b"hello").unwrap();
let pt = bob_session.decrypt(b"hdr", &ct).unwrap();
assert_eq!(&pt, b"hello");
}
#[test]
fn nonce_layout_matches_native() {
// Verify our build_nonce matches wzp-crypto::nonce::build_nonce layout.
let sid = [0xAA, 0xBB, 0xCC, 0xDD];
let seq: u32 = 0x00000100;
let nonce = build_nonce(&sid, seq, 1); // 1 = Recv direction
assert_eq!(&nonce[0..4], &[0xAA, 0xBB, 0xCC, 0xDD]);
assert_eq!(&nonce[4..8], &[0x00, 0x00, 0x01, 0x00]);
assert_eq!(nonce[8], 1);
assert_eq!(&nonce[9..12], &[0, 0, 0]);
}
}

View File

@@ -10,6 +10,10 @@
.container { text-align: center; max-width: 420px; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
.variant-badge { display: inline-block; background: #2a2a4a; border: 1px solid #444; color: #00d4ff; font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 4px; margin-left: 0.4rem; vertical-align: middle; font-family: monospace; letter-spacing: 0.05em; }
.variant-selector { margin-bottom: 1.2rem; display: flex; gap: 0.8rem; justify-content: center; flex-wrap: wrap; }
.variant-selector label { font-size: 0.75rem; color: #888; cursor: pointer; display: flex; align-items: center; gap: 0.25rem; }
.variant-selector input[type="radio"] { accent-color: #00d4ff; }
.room-input { margin-bottom: 1.5rem; }
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
.room-input input:focus { outline: none; border-color: #00d4ff; }
@@ -31,15 +35,22 @@
</head>
<body>
<div class="container">
<h1>WarzonePhone</h1>
<h1>WarzonePhone <span class="variant-badge" id="variantBadge">PURE</span></h1>
<p class="subtitle">Lossy VoIP Protocol</p>
<div class="variant-selector">
<label><input type="radio" name="variant" value="pure"> Pure JS</label>
<label><input type="radio" name="variant" value="hybrid"> Hybrid</label>
<label><input type="radio" name="variant" value="full"> Full WASM</label>
</div>
<div class="room-input">
<label for="room">Room</label>
<input type="text" id="room" placeholder="enter room name" value="">
</div>
<button id="callBtn" onclick="toggleCall()">Connect</button>
<button id="callBtn">Connect</button>
<div class="controls" id="controls" style="display:none;">
<label><input type="checkbox" id="pttMode" onchange="togglePTT()"> Radio mode (push-to-talk)</label>
<label><input type="checkbox" id="pttMode"> Radio mode (push-to-talk)</label>
</div>
<button id="pttBtn">Hold to Talk</button>
<div class="level"><div class="level-bar" id="levelBar"></div></div>
@@ -47,302 +58,158 @@
<div class="stats" id="stats"></div>
</div>
<script src="js/wzp-core.js"></script>
<script>
const SAMPLE_RATE = 48000;
const FRAME_SIZE = 960;
let ws = null;
let audioCtx = null;
let mediaStream = null;
let captureNode = null;
let playbackNode = null;
let active = false;
let transmitting = true; // in open-mic mode, always transmitting
let pttMode = false;
let framesSent = 0;
let framesRecv = 0;
let startTime = 0;
let statsInterval = null;
// Use room from URL path or input field
function getRoom() {
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
if (path && path !== 'index.html') return path;
const hash = location.hash.replace('#', '');
if (hash) return hash;
return document.getElementById('room').value.trim() || 'default';
}
// Pre-fill room input from URL on page load
// ---------------------------------------------------------------------------
// Load the selected variant script dynamically
// ---------------------------------------------------------------------------
(function() {
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
if (path && path !== 'index.html') {
document.getElementById('room').value = path;
}
var variant = WZPCore.detectVariant();
var scriptMap = {
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');
s.src = src;
s.onload = function() { wzpBoot(); };
s.onerror = function() {
WZPCore.updateStatus('Failed to load variant: ' + variant);
};
document.body.appendChild(s);
})();
function setStatus(msg) { document.getElementById('status').textContent = msg; }
function setStats(msg) { document.getElementById('stats').textContent = msg; }
// ---------------------------------------------------------------------------
// Boot: wire UI to the loaded client variant
// ---------------------------------------------------------------------------
function wzpBoot() {
var client = null;
var capture = null;
var playback = null;
var transmitting = true;
function toggleCall() {
if (active) stopCall();
else startCall();
}
var ui = WZPCore.initUI({
onConnect: function(room) {
doConnect(room);
},
onDisconnect: function() {
doDisconnect();
},
onTransmit: function(tx) {
transmitting = tx;
},
});
async function startCall() {
const btn = document.getElementById('callBtn');
const room = getRoom();
if (!room) { setStatus('Enter a room name'); return; }
async function doConnect(room) {
WZPCore.updateStatus('Requesting microphone...');
btn.disabled = true;
setStatus('Requesting microphone...');
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: SAMPLE_RATE, channelCount: 1, echoCancellation: true, noiseSuppression: true }
});
} catch(e) {
setStatus('Mic access denied: ' + e.message);
btn.disabled = false;
return;
}
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
// Connect WebSocket with room name
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
setStatus('Connecting to room: ' + room + '...');
ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = async () => {
setStatus('Connected to room: ' + room);
btn.textContent = 'Disconnect';
btn.classList.add('active');
btn.disabled = false;
active = true;
framesSent = 0;
framesRecv = 0;
startTime = Date.now();
showControls(true);
await startAudioCapture();
await startAudioPlayback();
startStatsUpdate();
};
ws.onmessage = (event) => {
const pcmData = new Int16Array(event.data);
framesRecv++;
playAudio(pcmData);
};
ws.onclose = () => {
if (active) {
setStatus('Disconnected — reconnecting to ' + room + '...');
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
} else {
setStatus('Disconnected');
var audioCtx;
try {
audioCtx = await WZPCore.startAudioContext();
} catch (e) {
WZPCore.updateStatus('Audio init failed: ' + e.message);
ui.setConnected(false);
return;
}
};
ws.onerror = () => {
if (active) {
setStatus('Error — reconnecting...');
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
}
};
}
// Build WebSocket URL
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
function stopCall() {
active = false;
const btn = document.getElementById('callBtn');
btn.textContent = 'Connect';
btn.classList.remove('active');
btn.disabled = false;
showControls(false);
cleanupAudio();
if (ws) { ws.close(); ws = null; }
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
setStatus('');
setStats('');
}
// 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;
function cleanupAudio() {
if (captureNode) { captureNode.disconnect(); captureNode = null; }
if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; workletLoaded = false; }
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
}
let workletLoaded = false;
async function loadWorkletModule() {
if (workletLoaded) return true;
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
console.warn('AudioWorklet API not supported in this browser — using ScriptProcessorNode fallback');
return false;
}
try {
await audioCtx.audioWorklet.addModule('audio-processor.js');
workletLoaded = true;
return true;
} catch(e) {
console.warn('AudioWorklet module failed to load — using ScriptProcessorNode fallback:', e);
return false;
}
}
async function startAudioCapture() {
const source = audioCtx.createMediaStreamSource(mediaStream);
const hasWorklet = await loadWorkletModule();
if (hasWorklet) {
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
captureNode.port.onmessage = (e) => {
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
ws.send(e.data);
framesSent++;
// Level meter from the PCM data
const pcm = new Int16Array(e.data);
let max = 0;
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
var clientOpts = {
wsUrl: wsUrl,
room: room,
onAudio: function(pcm) {
if (playback) playback.play(pcm);
},
onStatus: function(msg) {
WZPCore.updateStatus(msg);
},
onStats: function(stats) {
WZPCore.updateStats(stats);
},
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination); // needed to keep worklet alive
} else {
// Fallback to ScriptProcessorNode (deprecated but widely supported)
console.warn('Capture: using ScriptProcessorNode fallback');
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
let acc = new Float32Array(0);
captureNode.onaudioprocess = (ev) => {
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
const input = ev.inputBuffer.getChannelData(0);
const n = new Float32Array(acc.length + input.length);
n.set(acc); n.set(input, acc.length); acc = n;
while (acc.length >= FRAME_SIZE) {
const frame = acc.slice(0, FRAME_SIZE); acc = acc.slice(FRAME_SIZE);
const pcm = new Int16Array(FRAME_SIZE);
for (let i = 0; i < FRAME_SIZE; i++) pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
let max = 0;
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
ws.send(pcm.buffer);
framesSent++;
// Full variant: add WebTransport URL for direct relay connection
if (variant === 'full') {
clientOpts.url = location.origin.replace('http', 'https');
}
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;
}
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination);
}
}
async function startAudioPlayback() {
const hasWorklet = await loadWorkletModule();
if (hasWorklet) {
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
playbackNode.connect(audioCtx.destination);
} else {
console.warn('Playback: using scheduled BufferSource fallback');
playbackNode = null; // will use createBufferSource fallback in playAudio()
}
}
let nextPlayTime = 0;
function playAudio(pcmInt16) {
if (!audioCtx) return;
if (playbackNode && playbackNode.port) {
// AudioWorklet path — send Int16 PCM directly to the worklet for conversion
playbackNode.port.postMessage(pcmInt16.buffer, [pcmInt16.buffer]);
} else {
// Fallback: scheduled BufferSource (convert Int16 -> Float32 on main thread)
const floatData = new Float32Array(pcmInt16.length);
for (let i = 0; i < pcmInt16.length; i++) {
floatData[i] = pcmInt16[i] / 32768.0;
}
const buffer = audioCtx.createBuffer(1, floatData.length, SAMPLE_RATE);
buffer.getChannelData(0).set(floatData);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
const now = audioCtx.currentTime;
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
nextPlayTime = now + 0.02;
try {
await client.connect();
} catch (e) {
WZPCore.updateStatus('Connection failed: ' + e.message);
ui.setConnected(false);
return;
}
source.start(nextPlayTime);
nextPlayTime += buffer.duration;
// Start audio capture and playback
try {
capture = await WZPCore.connectCapture(audioCtx, function(pcmBuffer) {
if (!transmitting) return;
var pcm = new Int16Array(pcmBuffer);
WZPCore.updateLevel(pcm);
if (client) client.sendAudio(pcmBuffer);
});
playback = await WZPCore.connectPlayback(audioCtx);
} catch (e) {
WZPCore.updateStatus('Audio error: ' + e.message);
if (client) client.disconnect();
client = null;
ui.setConnected(false);
return;
}
ui.setConnected(true);
}
function doDisconnect() {
if (capture) { capture.stop(); capture = null; }
if (playback) { playback.stop(); playback = null; }
if (client) { client.disconnect(); client = null; }
var audioCtx = WZPCore.getAudioContext();
if (audioCtx && audioCtx.state !== 'closed') {
audioCtx.close();
}
WZPCore.updateStatus('');
WZPCore.updateStats('');
document.getElementById('levelBar').style.width = '0%';
ui.setConnected(false);
}
}
function startStatsUpdate() {
statsInterval = setInterval(() => {
if (!active) { clearInterval(statsInterval); return; }
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
setStats(elapsed + 's | sent: ' + framesSent + ' | recv: ' + framesRecv);
}, 1000);
}
// --- Push-to-talk ---
function togglePTT() {
pttMode = document.getElementById('pttMode').checked;
const btn = document.getElementById('pttBtn');
if (pttMode) {
transmitting = false;
btn.style.display = 'block';
} else {
transmitting = true;
btn.style.display = 'none';
}
}
// PTT button — hold to talk (mouse + touch)
document.getElementById('pttBtn').addEventListener('mousedown', () => { startTransmit(); });
document.getElementById('pttBtn').addEventListener('mouseup', () => { stopTransmit(); });
document.getElementById('pttBtn').addEventListener('mouseleave', () => { stopTransmit(); });
document.getElementById('pttBtn').addEventListener('touchstart', (e) => { e.preventDefault(); startTransmit(); });
document.getElementById('pttBtn').addEventListener('touchend', (e) => { e.preventDefault(); stopTransmit(); });
// Spacebar PTT
document.addEventListener('keydown', (e) => { if (pttMode && active && e.code === 'Space' && !e.repeat) { e.preventDefault(); startTransmit(); } });
document.addEventListener('keyup', (e) => { if (pttMode && active && e.code === 'Space') { e.preventDefault(); stopTransmit(); } });
function startTransmit() {
if (!pttMode || !active) return;
transmitting = true;
document.getElementById('pttBtn').classList.add('transmitting');
document.getElementById('pttBtn').textContent = 'Transmitting...';
}
function stopTransmit() {
if (!pttMode) return;
transmitting = false;
document.getElementById('pttBtn').classList.remove('transmitting');
document.getElementById('pttBtn').textContent = 'Hold to Talk';
}
// Show controls when connected
function showControls(show) {
document.getElementById('controls').style.display = show ? 'flex' : 'none';
if (!show) {
document.getElementById('pttBtn').style.display = 'none';
pttMode = false;
transmitting = true;
}
}
// Set room from URL on load
window.addEventListener('load', () => {
const room = getRoom();
if (room && room !== 'default') {
document.getElementById('room').value = room;
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,379 @@
// WarzonePhone — Shared UI logic for all client variants.
// Provides: audio context management, mic capture, playback, UI wiring.
'use strict';
const WZP_SAMPLE_RATE = 48000;
const WZP_FRAME_SIZE = 960; // 20ms @ 48kHz
// ---------------------------------------------------------------------------
// Variant detection
// ---------------------------------------------------------------------------
function wzpDetectVariant() {
const params = new URLSearchParams(location.search);
const v = (params.get('variant') || 'pure').toLowerCase();
const valid = ['pure', 'hybrid', 'full', 'ws', 'ws-fec', 'ws-full'];
if (valid.includes(v)) return v;
return 'pure';
}
// ---------------------------------------------------------------------------
// Room helpers
// ---------------------------------------------------------------------------
function wzpGetRoom() {
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
if (path && path !== 'index.html') return path;
const hash = location.hash.replace('#', '');
if (hash) return hash;
const el = document.getElementById('room');
return (el && el.value.trim()) || 'default';
}
function wzpPrefillRoom() {
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
if (path && path !== 'index.html') {
const el = document.getElementById('room');
if (el) el.value = path;
}
}
// ---------------------------------------------------------------------------
// Status / stats helpers
// ---------------------------------------------------------------------------
function wzpUpdateStatus(msg) {
const el = document.getElementById('status');
if (el) el.textContent = msg;
}
function wzpUpdateStats(stats) {
const el = document.getElementById('stats');
if (!el) return;
if (typeof stats === 'string') {
el.textContent = stats;
} else {
const parts = [];
if (stats.elapsed != null) parts.push(stats.elapsed.toFixed(1) + 's');
if (stats.sent != null) parts.push('sent: ' + stats.sent);
if (stats.recv != null) parts.push('recv: ' + stats.recv);
if (stats.loss != null) parts.push('loss: ' + (stats.loss * 100).toFixed(1) + '%');
if (stats.fecRecovered != null && stats.fecRecovered > 0) parts.push('fec: ' + stats.fecRecovered);
if (stats.fecReady != null) parts.push(stats.fecReady ? 'FEC:on' : 'FEC:off');
el.textContent = parts.join(' | ');
}
}
function wzpUpdateLevel(pcmInt16) {
const bar = document.getElementById('levelBar');
if (!bar) return;
let max = 0;
for (let i = 0; i < pcmInt16.length; i += 16) {
const v = Math.abs(pcmInt16[i]);
if (v > max) max = v;
}
bar.style.width = (max / 32768 * 100) + '%';
}
// ---------------------------------------------------------------------------
// Audio context + worklet
// ---------------------------------------------------------------------------
let _wzpAudioCtx = null;
let _wzpWorkletLoaded = false;
async function wzpStartAudioContext() {
if (_wzpAudioCtx && _wzpAudioCtx.state !== 'closed') return _wzpAudioCtx;
_wzpAudioCtx = new AudioContext({ sampleRate: WZP_SAMPLE_RATE });
_wzpWorkletLoaded = false;
return _wzpAudioCtx;
}
function wzpGetAudioContext() {
return _wzpAudioCtx;
}
async function _wzpLoadWorklet(audioCtx) {
if (_wzpWorkletLoaded) return true;
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
console.warn('[wzp-core] AudioWorklet not supported, will use fallback');
return false;
}
try {
await audioCtx.audioWorklet.addModule('audio-processor.js');
_wzpWorkletLoaded = true;
return true;
} catch (e) {
console.warn('[wzp-core] AudioWorklet load failed:', e);
return false;
}
}
// ---------------------------------------------------------------------------
// Mic capture — returns { node, stop() }
// onFrame(ArrayBuffer) called for each 960-sample Int16 PCM frame
// ---------------------------------------------------------------------------
async function wzpConnectCapture(audioCtx, onFrame) {
let mediaStream;
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: WZP_SAMPLE_RATE,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
});
} catch (e) {
throw new Error('Mic access denied: ' + e.message);
}
const source = audioCtx.createMediaStreamSource(mediaStream);
const hasWorklet = await _wzpLoadWorklet(audioCtx);
let captureNode;
if (hasWorklet) {
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
captureNode.port.onmessage = (e) => {
onFrame(e.data); // ArrayBuffer of Int16 PCM
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination); // keep worklet alive
} else {
// ScriptProcessorNode fallback
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
let acc = new Float32Array(0);
captureNode.onaudioprocess = (ev) => {
const input = ev.inputBuffer.getChannelData(0);
const n = new Float32Array(acc.length + input.length);
n.set(acc);
n.set(input, acc.length);
acc = n;
while (acc.length >= WZP_FRAME_SIZE) {
const frame = acc.slice(0, WZP_FRAME_SIZE);
acc = acc.slice(WZP_FRAME_SIZE);
const pcm = new Int16Array(WZP_FRAME_SIZE);
for (let i = 0; i < WZP_FRAME_SIZE; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
}
onFrame(pcm.buffer);
}
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination);
}
return {
node: captureNode,
stop() {
captureNode.disconnect();
mediaStream.getTracks().forEach((t) => t.stop());
},
};
}
// ---------------------------------------------------------------------------
// Playback — returns { node, play(Int16Array), stop() }
// ---------------------------------------------------------------------------
async function wzpConnectPlayback(audioCtx) {
const hasWorklet = await _wzpLoadWorklet(audioCtx);
let playbackNode;
let nextPlayTime = 0;
if (hasWorklet) {
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
playbackNode.connect(audioCtx.destination);
return {
node: playbackNode,
play(pcmInt16) {
// Transfer Int16 buffer to worklet
const buf = pcmInt16.buffer.slice(
pcmInt16.byteOffset,
pcmInt16.byteOffset + pcmInt16.byteLength
);
playbackNode.port.postMessage(buf, [buf]);
},
stop() {
playbackNode.disconnect();
},
};
}
// Fallback: scheduled BufferSource
return {
node: null,
play(pcmInt16) {
if (!audioCtx || audioCtx.state === 'closed') return;
const floatData = new Float32Array(pcmInt16.length);
for (let i = 0; i < pcmInt16.length; i++) {
floatData[i] = pcmInt16[i] / 32768.0;
}
const buffer = audioCtx.createBuffer(1, floatData.length, WZP_SAMPLE_RATE);
buffer.getChannelData(0).set(floatData);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
const now = audioCtx.currentTime;
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
nextPlayTime = now + 0.02;
}
source.start(nextPlayTime);
nextPlayTime += buffer.duration;
},
stop() {
// nothing to disconnect for fallback
},
};
}
// ---------------------------------------------------------------------------
// UI wiring — call after DOM ready
// ---------------------------------------------------------------------------
function wzpInitUI(callbacks) {
// callbacks: { onConnect(room), onDisconnect() }
const btn = document.getElementById('callBtn');
const pttBtn = document.getElementById('pttBtn');
const pttCheckbox = document.getElementById('pttMode');
let connected = false;
let pttMode = false;
wzpPrefillRoom();
// Variant badge
const variant = wzpDetectVariant();
const badge = document.getElementById('variantBadge');
if (badge) badge.textContent = variant.toUpperCase();
// Variant selector radio buttons
document.querySelectorAll('input[name="variant"]').forEach((radio) => {
if (radio.value === variant) radio.checked = true;
radio.addEventListener('change', () => {
if (radio.checked) {
const params = new URLSearchParams(location.search);
params.set('variant', radio.value);
location.search = params.toString();
}
});
});
btn.onclick = () => {
if (connected) {
connected = false;
btn.textContent = 'Connect';
btn.classList.remove('active');
_showControls(false);
if (callbacks.onDisconnect) callbacks.onDisconnect();
} else {
const room = wzpGetRoom();
if (!room) {
wzpUpdateStatus('Enter a room name');
return;
}
connected = true;
btn.disabled = true;
if (callbacks.onConnect) callbacks.onConnect(room);
}
};
// PTT toggle
if (pttCheckbox) {
pttCheckbox.onchange = () => {
pttMode = pttCheckbox.checked;
if (pttMode) {
pttBtn.style.display = 'block';
if (callbacks.onTransmit) callbacks.onTransmit(false);
} else {
pttBtn.style.display = 'none';
if (callbacks.onTransmit) callbacks.onTransmit(true);
}
};
}
// PTT button events
function startTx() {
if (!pttMode || !connected) return;
pttBtn.classList.add('transmitting');
pttBtn.textContent = 'Transmitting...';
if (callbacks.onTransmit) callbacks.onTransmit(true);
}
function stopTx() {
if (!pttMode) return;
pttBtn.classList.remove('transmitting');
pttBtn.textContent = 'Hold to Talk';
if (callbacks.onTransmit) callbacks.onTransmit(false);
}
if (pttBtn) {
pttBtn.addEventListener('mousedown', startTx);
pttBtn.addEventListener('mouseup', stopTx);
pttBtn.addEventListener('mouseleave', stopTx);
pttBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startTx(); });
pttBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopTx(); });
}
// Spacebar PTT
document.addEventListener('keydown', (e) => {
if (pttMode && connected && e.code === 'Space' && !e.repeat) {
e.preventDefault();
startTx();
}
});
document.addEventListener('keyup', (e) => {
if (pttMode && connected && e.code === 'Space') {
e.preventDefault();
stopTx();
}
});
function _showControls(show) {
const controls = document.getElementById('controls');
if (controls) controls.style.display = show ? 'flex' : 'none';
if (!show && pttBtn) {
pttBtn.style.display = 'none';
pttMode = false;
if (pttCheckbox) pttCheckbox.checked = false;
}
}
return {
setConnected(isConnected) {
connected = isConnected;
btn.disabled = false;
if (isConnected) {
btn.textContent = 'Disconnect';
btn.classList.add('active');
_showControls(true);
} else {
btn.textContent = 'Connect';
btn.classList.remove('active');
_showControls(false);
}
},
isPTT() {
return pttMode;
},
};
}
// ---------------------------------------------------------------------------
// Exports (global)
// ---------------------------------------------------------------------------
window.WZPCore = {
SAMPLE_RATE: WZP_SAMPLE_RATE,
FRAME_SIZE: WZP_FRAME_SIZE,
detectVariant: wzpDetectVariant,
getRoom: wzpGetRoom,
updateStatus: wzpUpdateStatus,
updateStats: wzpUpdateStats,
updateLevel: wzpUpdateLevel,
startAudioContext: wzpStartAudioContext,
getAudioContext: wzpGetAudioContext,
connectCapture: wzpConnectCapture,
connectPlayback: wzpConnectPlayback,
initUI: wzpInitUI,
};

View File

@@ -0,0 +1,579 @@
// WarzonePhone — Full WASM + WebTransport client (Variant 3).
//
// Architecture:
// - WebTransport for unreliable datagrams (UDP-like, no head-of-line blocking)
// - ChaCha20-Poly1305 encryption via WASM (wzp-wasm WzpCryptoSession)
// - RaptorQ FEC via WASM (wzp-wasm WzpFecEncoder/WzpFecDecoder)
// - X25519 key exchange via WASM (wzp-wasm WzpKeyExchange)
//
// NOTE: WebTransport requires the relay to support HTTP/3 (h3-quinn).
// The current wzp-relay uses raw QUIC. This variant demonstrates the full
// architecture but will need relay-side HTTP/3 support to work end-to-end.
// For development / testing, use the hybrid variant (WebSocket + WASM FEC).
//
// Relies on wzp-core.js for UI and audio helpers.
'use strict';
const WZP_WASM_PATH = (window.__WZP_BASE_URL || '') + '/wasm/wzp_wasm.js';
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
const MEDIA_HEADER_SIZE = 12;
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
const FEC_HEADER_SIZE = 3;
class WZPFullClient {
/**
* @param {Object} options
* @param {string} options.url WebTransport URL (https://host:port)
* @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.url = options.url;
this.wsUrl = options.wsUrl; // WS fallback URL
this.room = options.room;
this.onAudio = options.onAudio || null;
this.onStatus = options.onStatus || null;
this.onStats = options.onStats || null;
this.wt = null; // WebTransport instance
this.ws = null; // WebSocket fallback
this.datagramWriter = null; // WritableStreamDefaultWriter
this.datagramReader = null; // ReadableStreamDefaultReader
this.cryptoSession = null; // WzpCryptoSession (WASM)
this.fecEncoder = null; // WzpFecEncoder (WASM)
this.fecDecoder = null; // WzpFecDecoder (WASM)
this.sequence = 0;
this._wasmModule = null;
this._connected = false;
this._useWebTransport = false; // true if WT connected, false = WS fallback
this._startTime = 0;
this._statsInterval = null;
this._recvLoopRunning = false;
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
}
/**
* Connect: load WASM, open WebTransport, perform key exchange,
* initialise FEC, and start the receive loop.
*/
async connect() {
if (this._connected) return;
this._status('Loading WASM module...');
// 1. Load WASM (FEC + crypto)
this._wasmModule = await import(WZP_WASM_PATH);
await this._wasmModule.default();
// 2. Try WebTransport first, fall back to WebSocket
let wtSuccess = false;
if (typeof WebTransport !== 'undefined' && this.url) {
try {
this._status('Trying WebTransport...');
const wtUrl = this.url + '/' + encodeURIComponent(this.room);
this.wt = new WebTransport(wtUrl);
await Promise.race([
this.wt.ready,
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
]);
this.datagramWriter = this.wt.datagrams.writable.getWriter();
this.datagramReader = this.wt.datagrams.readable.getReader();
this._status('Performing key exchange...');
await this._performKeyExchange();
wtSuccess = true;
this._useWebTransport = true;
} catch (e) {
console.warn('[wzp-full] WebTransport failed, falling back to WebSocket:', e.message);
if (this.wt) { try { this.wt.close(); } catch (_) {} }
this.wt = null;
this.datagramWriter = null;
this.datagramReader = null;
}
}
if (!wtSuccess) {
// WebSocket fallback (same as hybrid — WASM loaded but uses WS transport)
this._useWebTransport = false;
await this._connectWebSocket();
}
// 3. Initialise FEC
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
this._connected = true;
this.sequence = 0;
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
this._startTime = Date.now();
this._startStatsTimer();
// 4. Start receive loop (WebTransport only — WS uses onmessage)
if (this._useWebTransport) {
this._recvLoop();
this._status('Connected to room: ' + this.room + ' (WebTransport, encrypted, FEC active)');
} else {
this._status('Connected to room: ' + this.room + ' (WebSocket fallback, WASM FEC loaded)');
}
}
/**
* WebSocket fallback connection (used when WebTransport unavailable).
*/
async _connectWebSocket() {
return new Promise((resolve, reject) => {
this._status('Connecting via WebSocket (fallback)...');
this.ws = new WebSocket(this.wsUrl);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this._status('WebSocket connected to room: ' + this.room);
resolve();
};
this.ws.onmessage = (event) => {
if (!(event.data instanceof ArrayBuffer)) return;
const pcm = new Int16Array(event.data);
this.stats.recv++;
if (this.onAudio) this.onAudio(pcm);
};
this.ws.onclose = () => {
if (this._connected) {
this._cleanup();
this._status('Disconnected');
}
};
this.ws.onerror = () => {
if (!this._connected) {
this._cleanup();
reject(new Error('WebSocket connection failed'));
}
};
});
}
/**
* Disconnect and clean up all resources.
*/
disconnect() {
this._connected = false;
if (this.wt) {
try { this.wt.close(); } catch (_) { /* ignore */ }
this.wt = null;
}
if (this.ws) {
try { this.ws.close(); } catch (_) { /* ignore */ }
this.ws = null;
}
this._cleanup();
}
/**
* Send a PCM audio frame.
*
* Pipeline: PCM -> FEC encode -> encrypt -> datagram send.
*
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
*/
async sendAudio(pcmBuffer) {
if (!this._connected) return;
// WebSocket fallback: send raw PCM like pure/hybrid
if (!this._useWebTransport) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(pcmBuffer);
this.sequence++;
this.stats.sent++;
}
return;
}
if (!this.datagramWriter || !this.cryptoSession) return;
const pcmBytes = new Uint8Array(pcmBuffer);
// Build a minimal 12-byte MediaHeader for AAD.
const header = this._buildMediaHeader(this.sequence);
// FEC encode: feed the frame; when a block completes we get wire packets.
const fecOutput = this.fecEncoder.add_symbol(pcmBytes);
if (fecOutput) {
// FEC block completed — send all packets (source + repair).
const packetSize = FEC_HEADER_SIZE + 256; // header + symbol_size
for (let offset = 0; offset + packetSize <= fecOutput.length; offset += packetSize) {
const fecPacket = fecOutput.slice(offset, offset + packetSize);
// Encrypt: header bytes as AAD, FEC packet as plaintext.
const ciphertext = this.cryptoSession.encrypt(header, fecPacket);
this.stats.encrypted++;
// Build wire datagram: header (12) + ciphertext
const datagram = new Uint8Array(MEDIA_HEADER_SIZE + ciphertext.length);
datagram.set(header, 0);
datagram.set(ciphertext, MEDIA_HEADER_SIZE);
try {
await this.datagramWriter.write(datagram);
} catch (e) {
// Datagram send can fail if the transport is closing.
if (this._connected) {
console.warn('[wzp-full] datagram write failed:', e);
}
return;
}
this.stats.sent++;
}
}
// If FEC block not yet complete, accumulate (no packets sent yet).
this.sequence = (this.sequence + 1) & 0xFFFF;
}
/**
* Test crypto + FEC roundtrip entirely in WASM (no network).
* Useful for verifying the WASM module works correctly in the browser.
*
* @returns {Object} test results
*/
testCryptoFec() {
if (!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());
// Verify secrets match
let secretsMatch = aliceSecret.length === bobSecret.length;
if (secretsMatch) {
for (let i = 0; i < aliceSecret.length; i++) {
if (aliceSecret[i] !== bobSecret[i]) { secretsMatch = false; break; }
}
}
// Encrypt/decrypt
const aliceSession = new wasm.WzpCryptoSession(aliceSecret);
const bobSession = new wasm.WzpCryptoSession(bobSecret);
const header = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]);
const plaintext = new TextEncoder().encode('hello warzone from full variant');
const ciphertext = aliceSession.encrypt(header, plaintext);
const decrypted = bobSession.decrypt(header, ciphertext);
let cryptoOk = decrypted.length === plaintext.length;
if (cryptoOk) {
for (let i = 0; i < plaintext.length; i++) {
if (decrypted[i] !== plaintext[i]) { cryptoOk = false; break; }
}
}
// FEC test (same as hybrid testFec)
const encoder = new wasm.WzpFecEncoder(5, 256);
const decoder = new wasm.WzpFecDecoder(5, 256);
const frames = [];
for (let i = 0; i < 5; i++) {
const frame = new Uint8Array(100);
for (let j = 0; j < 100; j++) frame[j] = ((i * 37 + 7) + j) & 0xFF;
frames.push(frame);
}
let wireData = null;
for (const frame of frames) {
const result = encoder.add_symbol(frame);
if (result) wireData = result;
}
const PACKET_SIZE = FEC_HEADER_SIZE + 256;
const packets = [];
if (wireData) {
for (let off = 0; off + PACKET_SIZE <= wireData.length; off += PACKET_SIZE) {
packets.push({
blockId: wireData[off],
symbolIdx: wireData[off + 1],
isRepair: wireData[off + 2] !== 0,
data: wireData.slice(off + FEC_HEADER_SIZE, off + PACKET_SIZE),
});
}
}
// Drop 2 packets, try to recover
let fecDecoded = null;
for (let i = 0; i < packets.length; i++) {
if (i === 1 || i === 3) continue; // simulate loss
const pkt = packets[i];
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
if (result) { fecDecoded = result; break; }
}
let fecOk = false;
if (fecDecoded) {
const expected = new Uint8Array(5 * 100);
let off = 0;
for (const f of frames) { expected.set(f, off); off += f.length; }
fecOk = fecDecoded.length === expected.length;
if (fecOk) {
for (let i = 0; i < expected.length; i++) {
if (fecDecoded[i] !== expected[i]) { 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 && cryptoOk && fecOk,
secretsMatch,
cryptoOk,
fecOk,
fecPacketsTotal: packets.length,
fecDropped: 2,
elapsed: elapsed.toFixed(2) + 'ms',
};
}
// =========================================================================
// Internal
// =========================================================================
/**
* Perform X25519 key exchange over a WebTransport bidirectional stream.
*
* Protocol (simplified DH, not the full SignalMessage handshake):
* 1. Open a bidirectional stream.
* 2. Send our 32-byte X25519 public key.
* 3. Read the peer's 32-byte public key.
* 4. Derive shared secret via HKDF.
* 5. Create WzpCryptoSession from the shared secret.
*
* In production this would use the full SignalMessage protocol over the
* bidirectional stream (offer/answer/encrypted-session). For now we do
* a simple DH swap to prove the architecture.
*/
async _performKeyExchange() {
const wasm = this._wasmModule;
const kx = new wasm.WzpKeyExchange();
const ourPub = kx.public_key(); // Uint8Array(32)
// Open a bidirectional stream for signaling.
const stream = await this.wt.createBidirectionalStream();
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
// Send our public key.
await writer.write(new Uint8Array(ourPub));
// Read peer's public key (exactly 32 bytes).
// WebTransport streams are byte-oriented; we may get it in chunks.
let peerPub = new Uint8Array(0);
while (peerPub.length < 32) {
const { value, done } = await reader.read();
if (done) {
throw new Error('Key exchange stream closed before receiving peer public key');
}
const combined = new Uint8Array(peerPub.length + value.length);
combined.set(peerPub, 0);
combined.set(value, peerPub.length);
peerPub = combined;
}
peerPub = peerPub.slice(0, 32);
// Derive shared secret and create crypto session.
const secret = kx.derive_shared_secret(peerPub);
this.cryptoSession = new wasm.WzpCryptoSession(secret);
// Close the signaling stream (key exchange complete).
try {
writer.releaseLock();
reader.releaseLock();
await stream.writable.close();
} catch (_) {
// Best-effort close.
}
kx.free();
}
/**
* Receive loop: read datagrams, decrypt, FEC decode, play audio.
*
* Runs until the transport closes or disconnect() is called.
*/
async _recvLoop() {
if (this._recvLoopRunning) return;
this._recvLoopRunning = true;
try {
while (this._connected && this.datagramReader) {
const { value, done } = await this.datagramReader.read();
if (done) break;
this.stats.recv++;
// value is a Uint8Array datagram: header(12) + ciphertext
if (value.length <= MEDIA_HEADER_SIZE) continue; // too short
const headerAad = value.slice(0, MEDIA_HEADER_SIZE);
const ciphertext = value.slice(MEDIA_HEADER_SIZE);
// Decrypt
let fecPacket;
try {
fecPacket = this.cryptoSession.decrypt(headerAad, ciphertext);
this.stats.decrypted++;
} catch (e) {
// Decryption failure — corrupted or out-of-order packet.
// In a real implementation we'd handle sequence number gaps.
console.warn('[wzp-full] decrypt failed:', e);
continue;
}
// FEC decode: parse the FEC wire header and feed to decoder.
if (fecPacket.length < FEC_HEADER_SIZE) continue;
const blockId = fecPacket[0];
const symbolIdx = fecPacket[1];
const isRepair = fecPacket[2] !== 0;
const symbolData = fecPacket.slice(FEC_HEADER_SIZE);
const decoded = this.fecDecoder.add_symbol(blockId, symbolIdx, isRepair, symbolData);
if (decoded) {
this.stats.fecRecovered++;
// decoded is concatenated original PCM frames.
// Each frame is 1920 bytes (960 Int16 samples @ 48kHz mono).
const FRAME_BYTES = 1920;
for (let off = 0; off + FRAME_BYTES <= decoded.length; off += FRAME_BYTES) {
const pcmSlice = decoded.slice(off, off + FRAME_BYTES);
const pcm = new Int16Array(pcmSlice.buffer, pcmSlice.byteOffset, pcmSlice.byteLength / 2);
if (this.onAudio) {
this.onAudio(pcm);
}
}
}
}
} catch (e) {
if (this._connected) {
console.warn('[wzp-full] recv loop error:', e);
}
} finally {
this._recvLoopRunning = false;
}
}
/**
* Build a minimal 12-byte MediaHeader for use as AAD.
*
* Wire layout (from wzp-proto::packet::MediaHeader):
* Byte 0: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
* Byte 1: FecRatioLo(6)|unused(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)
* @returns {Uint8Array} 12-byte header
*/
_buildMediaHeader(seq) {
const buf = new Uint8Array(MEDIA_HEADER_SIZE);
// Byte 0: version=0, is_repair=0, codec=0 (Opus), quality_report=0, fec_ratio_hi=0
buf[0] = 0x00;
// Byte 1: fec_ratio_lo=0
buf[1] = 0x00;
// Bytes 2-3: sequence (BE u16)
buf[2] = (seq >> 8) & 0xFF;
buf[3] = seq & 0xFF;
// Bytes 4-7: timestamp (BE u32) — ms since session start
const ts = Date.now() - this._startTime;
buf[4] = (ts >> 24) & 0xFF;
buf[5] = (ts >> 16) & 0xFF;
buf[6] = (ts >> 8) & 0xFF;
buf[7] = ts & 0xFF;
// Bytes 8-11: FEC block/symbol/reserved/csrc — filled by FEC layer in production
return buf;
}
_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,
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._stopStatsTimer();
this.datagramWriter = null;
this.datagramReader = 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;
}
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
window.WZPFullClient = WZPFullClient;

View File

@@ -0,0 +1,345 @@
// WarzonePhone — Hybrid JS + WASM client (Variant 2).
// WebSocket transport, raw PCM, WASM FEC (RaptorQ) ready for WebTransport.
// Relies on wzp-core.js for UI and audio helpers.
//
// The WASM FEC module is loaded and exposed but not used on the wire yet,
// because WebSocket is TCP (no packet loss). FEC will activate when
// WebTransport (UDP) is added. A testFec() method demonstrates FEC
// encode -> simulate loss -> decode in the browser.
'use strict';
// WASM module path (served from /wasm/ by the wzp-web bridge).
const WZP_WASM_PATH = (window.__WZP_BASE_URL || '') + '/wasm/wzp_wasm.js';
class WZPHybridClient {
/**
* @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, fecRecovered}) 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, fecRecovered: 0 };
this._startTime = 0;
this._statsInterval = null;
this._connected = false;
// WASM FEC instances (loaded in connect()).
this._wasmModule = null;
this.fecEncoder = null;
this.fecDecoder = null;
this._fecReady = false;
}
/**
* Open WebSocket connection and load the WASM FEC module.
* @returns {Promise<void>} 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 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, fecRecovered: 0 };
this._startTime = Date.now();
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 = () => {
if (!this._connected) {
this._cleanup();
reject(new Error('WebSocket connection failed'));
} else {
this._status('Connection error');
}
};
});
// Wait for both WASM load and WS connect.
await Promise.all([wasmPromise, wsPromise]);
const fecStatus = this._fecReady ? 'FEC ready' : 'FEC unavailable';
this._status('Connected 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).
this.fecEncoder = null;
this.fecDecoder = null;
}
/**
* Send a PCM audio frame over the WebSocket.
* Currently sends raw PCM (same as pure client) since WebSocket is TCP.
* When WebTransport is added, this will FEC-encode before sending.
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
*/
async sendAudio(pcmBuffer) {
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
// Over WebSocket (TCP): send raw PCM, no FEC needed.
// Over WebTransport (UDP, future): would call this.fecEncoder.add_symbol()
// and send the resulting FEC-protected packets.
this.ws.send(pcmBuffer);
this.sequence++;
this.stats.sent++;
}
/**
* Test FEC encode -> simulate loss -> decode in the browser.
* Demonstrates that the WASM RaptorQ module works correctly.
*
* @param {Object} [opts]
* @param {number} [opts.blockSize=5] Source symbols per block
* @param {number} [opts.symbolSize=256] Padded symbol size
* @param {number} [opts.frameSize=100] Bytes per test frame
* @param {number} [opts.dropCount=2] Number of packets to drop
* @returns {Object} { success, sourcePackets, repairPackets, dropped, recovered, elapsed }
*/
testFec(opts) {
if (!this._fecReady) {
return { success: false, error: 'WASM FEC module not loaded' };
}
const blockSize = (opts && opts.blockSize) || 5;
const symbolSize = (opts && opts.symbolSize) || 256;
const frameSize = (opts && opts.frameSize) || 100;
const dropCount = (opts && opts.dropCount) || 2;
const HEADER_SIZE = 3; // block_id + symbol_idx + is_repair
const packetSize = HEADER_SIZE + 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.
const frames = [];
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;
}
frames.push(frame);
}
// Encode: feed frames to encoder; last one triggers block output.
let wireData = null;
for (const frame of frames) {
const result = encoder.add_symbol(frame);
if (result) {
wireData = result;
}
}
if (!wireData) {
// Flush if block didn't complete (shouldn't happen with exact blockSize).
wireData = encoder.flush();
}
// Parse wire packets.
const packets = [];
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 + HEADER_SIZE, offset + packetSize),
});
}
const sourcePackets = packets.filter(p => !p.isRepair).length;
const repairPackets = packets.filter(p => p.isRepair).length;
// Simulate packet loss: drop `dropCount` packets from the front (source symbols).
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;
}
}
const elapsed = performance.now() - t0;
// Verify decoded data matches original frames.
let success = false;
if (decoded) {
const expected = new Uint8Array(blockSize * frameSize);
let off = 0;
for (const frame of frames) {
expected.set(frame, off);
off += frame.length;
}
success = decoded.length === expected.length;
if (success) {
for (let i = 0; i < decoded.length; i++) {
if (decoded[i] !== expected[i]) {
success = false;
break;
}
}
}
}
// Free WASM objects.
encoder.free();
decoder.free();
return {
success,
sourcePackets,
repairPackets,
totalPackets: packets.length,
dropped: dropCount,
recovered: success,
decodedBytes: decoded ? decoded.length : 0,
expectedBytes: blockSize * frameSize,
elapsed: elapsed.toFixed(2) + 'ms',
};
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
async _loadWasm() {
try {
// Dynamic import of the wasm-pack generated JS glue.
this._wasmModule = await import(WZP_WASM_PATH);
// Initialize the WASM module (calls __wbg_init).
await this._wasmModule.default();
// Create FEC encoder/decoder instances.
// 5 symbols per block, 256-byte symbols — matches native wzp-fec defaults.
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
this._fecReady = true;
console.log('[wzp-hybrid] WASM FEC module loaded successfully');
} catch (e) {
console.warn('[wzp-hybrid] WASM FEC module failed to load:', e);
this._fecReady = false;
// Non-fatal: client still works without FEC (like pure variant).
}
}
_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;
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._fecReady,
});
}
}, 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.WZPHybridClient = WZPHybridClient;

View File

@@ -0,0 +1,168 @@
// 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;

View File

@@ -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 = (window.__WZP_BASE_URL || '') + '/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<void>} 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;

View File

@@ -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 = (window.__WZP_BASE_URL || '') + '/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<void>} 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;

View File

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

2
crates/wzp-web/static/wasm/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
package.json
*.d.ts

View File

@@ -0,0 +1,556 @@
/* @ts-self-types="./wzp_wasm.d.ts" */
/**
* Symmetric encryption session using ChaCha20-Poly1305.
*
* Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
* and key setup are identical so WASM and native peers interoperate.
*/
export class WzpCryptoSession {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
WzpCryptoSessionFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_wzpcryptosession_free(ptr, 0);
}
/**
* Decrypt a media payload with AAD.
*
* Returns plaintext on success, or throws on auth failure.
* @param {Uint8Array} header_aad
* @param {Uint8Array} ciphertext
* @returns {Uint8Array}
*/
decrypt(header_aad, ciphertext) {
const ptr0 = passArray8ToWasm0(header_aad, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passArray8ToWasm0(ciphertext, wasm.__wbindgen_malloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.wzpcryptosession_decrypt(this.__wbg_ptr, ptr0, len0, ptr1, len1);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v3;
}
/**
* Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
*
* Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
* @param {Uint8Array} header_aad
* @param {Uint8Array} plaintext
* @returns {Uint8Array}
*/
encrypt(header_aad, plaintext) {
const ptr0 = passArray8ToWasm0(header_aad, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passArray8ToWasm0(plaintext, wasm.__wbindgen_malloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.wzpcryptosession_encrypt(this.__wbg_ptr, ptr0, len0, ptr1, len1);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v3;
}
/**
* Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
* @param {Uint8Array} shared_secret
*/
constructor(shared_secret) {
const ptr0 = passArray8ToWasm0(shared_secret, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.wzpcryptosession_new(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
this.__wbg_ptr = ret[0] >>> 0;
WzpCryptoSessionFinalization.register(this, this.__wbg_ptr, this);
return this;
}
/**
* Current receive sequence number (for diagnostics / UI stats).
* @returns {number}
*/
recv_seq() {
const ret = wasm.wzpcryptosession_recv_seq(this.__wbg_ptr);
return ret >>> 0;
}
/**
* Current send sequence number (for diagnostics / UI stats).
* @returns {number}
*/
send_seq() {
const ret = wasm.wzpcryptosession_send_seq(this.__wbg_ptr);
return ret >>> 0;
}
}
if (Symbol.dispose) WzpCryptoSession.prototype[Symbol.dispose] = WzpCryptoSession.prototype.free;
export class WzpFecDecoder {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
WzpFecDecoderFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_wzpfecdecoder_free(ptr, 0);
}
/**
* Feed a received symbol.
*
* Returns the decoded block (concatenated original frames, unpadded) if
* enough symbols have been received to recover the block, or `undefined`.
* @param {number} block_id
* @param {number} symbol_idx
* @param {boolean} _is_repair
* @param {Uint8Array} data
* @returns {Uint8Array | undefined}
*/
add_symbol(block_id, symbol_idx, _is_repair, data) {
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.wzpfecdecoder_add_symbol(this.__wbg_ptr, block_id, symbol_idx, _is_repair, ptr0, len0);
let v2;
if (ret[0] !== 0) {
v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
}
return v2;
}
/**
* Create a new FEC decoder.
*
* * `block_size` — expected number of source symbols per block.
* * `symbol_size` — padded byte size of each symbol (must match encoder).
* @param {number} block_size
* @param {number} symbol_size
*/
constructor(block_size, symbol_size) {
const ret = wasm.wzpfecdecoder_new(block_size, symbol_size);
this.__wbg_ptr = ret >>> 0;
WzpFecDecoderFinalization.register(this, this.__wbg_ptr, this);
return this;
}
}
if (Symbol.dispose) WzpFecDecoder.prototype[Symbol.dispose] = WzpFecDecoder.prototype.free;
export class WzpFecEncoder {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
WzpFecEncoderFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_wzpfecencoder_free(ptr, 0);
}
/**
* Add a source symbol (audio frame).
*
* Returns encoded packets (all source + repair) when the block is complete,
* or `undefined` if the block is still accumulating.
*
* Each returned packet carries the 3-byte header:
* `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
* @param {Uint8Array} data
* @returns {Uint8Array | undefined}
*/
add_symbol(data) {
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.wzpfecencoder_add_symbol(this.__wbg_ptr, ptr0, len0);
let v2;
if (ret[0] !== 0) {
v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
}
return v2;
}
/**
* Force-flush the current (possibly partial) block.
*
* Returns all source + repair symbols with headers, or empty vec if no
* symbols have been accumulated.
* @returns {Uint8Array}
*/
flush() {
const ret = wasm.wzpfecencoder_flush(this.__wbg_ptr);
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v1;
}
/**
* Create a new FEC encoder.
*
* * `block_size` — number of source symbols (audio frames) per FEC block.
* * `symbol_size` — padded byte size of each symbol (default 256).
* @param {number} block_size
* @param {number} symbol_size
*/
constructor(block_size, symbol_size) {
const ret = wasm.wzpfecencoder_new(block_size, symbol_size);
this.__wbg_ptr = ret >>> 0;
WzpFecEncoderFinalization.register(this, this.__wbg_ptr, this);
return this;
}
}
if (Symbol.dispose) WzpFecEncoder.prototype[Symbol.dispose] = WzpFecEncoder.prototype.free;
/**
* X25519 key exchange: generate ephemeral keypair and derive shared secret.
*
* Usage from JS:
* ```js
* const kx = new WzpKeyExchange();
* const ourPub = kx.public_key(); // Uint8Array(32)
* // ... send ourPub to peer, receive peerPub ...
* const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
* const session = new WzpCryptoSession(secret);
* ```
*/
export class WzpKeyExchange {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
WzpKeyExchangeFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_wzpkeyexchange_free(ptr, 0);
}
/**
* Derive a 32-byte session key from the peer's public key.
*
* Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
* matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
* @param {Uint8Array} peer_public
* @returns {Uint8Array}
*/
derive_shared_secret(peer_public) {
const ptr0 = passArray8ToWasm0(peer_public, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.wzpkeyexchange_derive_shared_secret(this.__wbg_ptr, ptr0, len0);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v2;
}
/**
* Generate a new random X25519 keypair.
*/
constructor() {
const ret = wasm.wzpkeyexchange_new();
this.__wbg_ptr = ret >>> 0;
WzpKeyExchangeFinalization.register(this, this.__wbg_ptr, this);
return this;
}
/**
* Our public key (32 bytes).
* @returns {Uint8Array}
*/
public_key() {
const ret = wasm.wzpkeyexchange_public_key(this.__wbg_ptr);
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v1;
}
}
if (Symbol.dispose) WzpKeyExchange.prototype[Symbol.dispose] = WzpKeyExchange.prototype.free;
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg___wbindgen_is_function_3c846841762788c1: function(arg0) {
const ret = typeof(arg0) === 'function';
return ret;
},
__wbg___wbindgen_is_object_781bc9f159099513: function(arg0) {
const val = arg0;
const ret = typeof(val) === 'object' && val !== null;
return ret;
},
__wbg___wbindgen_is_string_7ef6b97b02428fae: function(arg0) {
const ret = typeof(arg0) === 'string';
return ret;
},
__wbg___wbindgen_is_undefined_52709e72fb9f179c: function(arg0) {
const ret = arg0 === undefined;
return ret;
},
__wbg___wbindgen_throw_6ddd609b62940d55: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
},
__wbg_call_2d781c1f4d5c0ef8: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.call(arg1, arg2);
return ret;
}, arguments); },
__wbg_crypto_38df2bab126b63dc: function(arg0) {
const ret = arg0.crypto;
return ret;
},
__wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) {
arg0.getRandomValues(arg1);
}, arguments); },
__wbg_length_ea16607d7b61445b: function(arg0) {
const ret = arg0.length;
return ret;
},
__wbg_msCrypto_bd5a034af96bcba6: function(arg0) {
const ret = arg0.msCrypto;
return ret;
},
__wbg_new_with_length_825018a1616e9e55: function(arg0) {
const ret = new Uint8Array(arg0 >>> 0);
return ret;
},
__wbg_node_84ea875411254db1: function(arg0) {
const ret = arg0.node;
return ret;
},
__wbg_process_44c7a14e11e9f69e: function(arg0) {
const ret = arg0.process;
return ret;
},
__wbg_prototypesetcall_d62e5099504357e6: function(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
},
__wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) {
arg0.randomFillSync(arg1);
}, arguments); },
__wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () {
const ret = module.require;
return ret;
}, arguments); },
__wbg_static_accessor_GLOBAL_8adb955bd33fac2f: function() {
const ret = typeof global === 'undefined' ? null : global;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_GLOBAL_THIS_ad356e0db91c7913: function() {
const ret = typeof globalThis === 'undefined' ? null : globalThis;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_SELF_f207c857566db248: function() {
const ret = typeof self === 'undefined' ? null : self;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_WINDOW_bb9f1ba69d61b386: function() {
const ret = typeof window === 'undefined' ? null : window;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_subarray_a068d24e39478a8a: function(arg0, arg1, arg2) {
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
return ret;
},
__wbg_versions_276b2795b1c6a219: function(arg0) {
const ret = arg0.versions;
return ret;
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
const ret = getArrayU8FromWasm0(arg0, arg1);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
},
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./wzp_wasm_bg.js": import0,
};
}
const WzpCryptoSessionFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_wzpcryptosession_free(ptr >>> 0, 1));
const WzpFecDecoderFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_wzpfecdecoder_free(ptr >>> 0, 1));
const WzpFecEncoderFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_wzpfecencoder_free(ptr >>> 0, 1));
const WzpKeyExchangeFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_wzpkeyexchange_free(ptr >>> 0, 1));
function addToExternrefTable0(obj) {
const idx = wasm.__externref_table_alloc();
wasm.__wbindgen_externrefs.set(idx, obj);
return idx;
}
function getArrayU8FromWasm0(ptr, len) {
ptr = ptr >>> 0;
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return decodeText(ptr, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function handleError(f, args) {
try {
return f.apply(this, args);
} catch (e) {
const idx = addToExternrefTable0(e);
wasm.__wbindgen_exn_store(idx);
}
}
function isLikeNone(x) {
return x === undefined || x === null;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasm;
function __wbg_finalize_init(instance, module) {
wasm = instance.exports;
wasmModule = module;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('wzp_wasm_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };

Binary file not shown.

473
docs/WEB_VARIANTS.md Normal file
View File

@@ -0,0 +1,473 @@
# WZP Web Client Variants
Three browser-based client implementations with different trade-offs between simplicity, features, and performance.
## Variant Comparison
```mermaid
graph LR
subgraph "Variant 1: Pure JS"
P_MIC[Mic] --> P_WRK[AudioWorklet<br/>48kHz PCM]
P_WRK --> P_WS[WebSocket<br/>TCP]
P_WS --> P_BRIDGE[wzp-web Bridge<br/>Opus + FEC + Crypto]
P_BRIDGE --> P_QUIC[QUIC Datagram<br/>to Relay]
end
style P_BRIDGE fill:#ff9f43
style P_WS fill:#74b9ff
```
```mermaid
graph LR
subgraph "Variant 2: Hybrid"
H_MIC[Mic] --> H_WRK[AudioWorklet<br/>48kHz PCM]
H_WRK --> H_FEC[WASM RaptorQ<br/>FEC Encode]
H_FEC --> H_WS[WebSocket<br/>TCP]
H_WS --> H_BRIDGE[wzp-web Bridge<br/>Opus + Crypto]
H_BRIDGE --> H_QUIC[QUIC Datagram<br/>to Relay]
end
style H_FEC fill:#a29bfe
style H_BRIDGE fill:#ff9f43
style H_WS fill:#74b9ff
```
```mermaid
graph LR
subgraph "Variant 3: Full WASM"
F_MIC[Mic] --> F_WRK[AudioWorklet<br/>48kHz PCM]
F_WRK --> F_FEC[WASM RaptorQ<br/>FEC Encode]
F_FEC --> F_ENC[WASM ChaCha20<br/>Encrypt]
F_ENC --> F_WT[WebTransport<br/>UDP Datagrams]
F_WT --> F_RELAY[Direct to Relay<br/>No Bridge]
end
style F_FEC fill:#a29bfe
style F_ENC fill:#ee5a24
style F_WT fill:#00b894
```
## Summary Table
| | Pure JS | Hybrid | Full WASM |
|--|---------|--------|-----------|
| **Bundle** | ~20KB JS | ~120KB (JS + 337KB WASM) | ~20KB JS + 337KB WASM |
| **Transport** | WebSocket (TCP) | WebSocket (TCP) | WebTransport (UDP) |
| **Encryption** | Bridge-side (ChaCha20 on QUIC) | Bridge-side | Browser-side ChaCha20-Poly1305 WASM |
| **FEC** | None | RaptorQ WASM (ready, not active over TCP) | RaptorQ WASM (active over UDP) |
| **Codec** | Bridge Opus (server-side) | Bridge Opus | Browser Opus (future) / Bridge Opus |
| **E2E Encrypted** | No (bridge sees plaintext PCM) | No (bridge sees plaintext PCM) | Yes (bridge eliminated) |
| **Latency** | ~50-80ms (TCP overhead) | ~50-80ms (TCP) | ~20-40ms (UDP datagrams) |
| **Loss Recovery** | TCP retransmit (adds latency) | TCP retransmit | RaptorQ FEC (no retransmit) |
| **Browser Support** | All browsers | All browsers | Chrome 97+, Edge 97+, Firefox 114+, Safari 17.4+ |
| **Relay Changes** | None | None | Needs HTTP/3 (h3-quinn) |
| **Status** | Ready | Ready (FEC testable in console) | Architecture complete, needs relay HTTP/3 |
## Variant 1: Pure JS
The lightest implementation. No WASM, no FEC, no browser-side encryption. The `wzp-web` Rust bridge handles everything on the server side.
### Architecture
```mermaid
sequenceDiagram
participant B as Browser
participant W as wzp-web Bridge
participant R as wzp-relay
B->>B: getUserMedia() mic access
B->>B: AudioWorklet captures 960 samples / 20ms
B->>W: WebSocket connect /ws/room-name
W->>R: QUIC connect (SNI = hashed room)
W->>R: Crypto handshake (X25519 + ChaCha20)
loop Every 20ms
B->>W: WS Binary: Int16[960] raw PCM
W->>W: Opus encode + FEC + Encrypt
W->>R: QUIC Datagram
end
loop Incoming
R->>W: QUIC Datagram
W->>W: Decrypt + FEC decode + Opus decode
W->>B: WS Binary: Int16[960] raw PCM
end
B->>B: AudioWorklet plays received PCM
```
### Data Flow
```
Browser (Pure JS)
├── Capture: getUserMedia → AudioWorklet (WZPCaptureProcessor)
│ └── 128-sample blocks accumulated → 960-sample frame
│ └── Float32 → Int16 conversion
│ └── postMessage(ArrayBuffer) to main thread
├── Send: onmessage → ws.send(pcmBuffer)
│ └── Binary WebSocket frame (1920 bytes = 960 × 2)
├── Receive: ws.onmessage → ArrayBuffer
│ └── Int16Array(960) → playback port
└── Playback: AudioWorklet (WZPPlaybackProcessor)
└── Ring buffer (max 120ms)
└── Int16 → Float32 → output blocks
```
### Files
- `js/wzp-pure.js``WZPPureClient` class (~100 lines)
- `js/wzp-core.js` — Shared UI + audio (used by all variants)
- `audio-processor.js` — AudioWorklet (unchanged)
### Limitations
- No packet loss recovery (TCP retransmit adds latency spikes)
- Bridge sees plaintext audio (not E2E encrypted)
- Full audio processing pipeline runs on server (Opus, FEC, crypto)
- Each browser connection = one QUIC session on the bridge
---
## Variant 2: Hybrid (JS + WASM FEC)
Adds RaptorQ forward error correction via a small WASM module. Same WebSocket transport as Pure — the FEC module is loaded and functional but doesn't add value over TCP (no packet loss). It's ready to activate when WebTransport replaces WebSocket.
### Architecture
```mermaid
sequenceDiagram
participant B as Browser
participant WASM as WASM Module
participant W as wzp-web Bridge
participant R as wzp-relay
B->>WASM: Load wzp_wasm.js (337KB)
WASM-->>B: WzpFecEncoder + WzpFecDecoder ready
B->>W: WebSocket connect /ws/room-name
W->>R: QUIC connect + handshake
loop Every 20ms
B->>B: AudioWorklet captures PCM
B->>WASM: fecEncoder.add_symbol(pcm_bytes)
WASM-->>B: FEC packets (source + repair) when block complete
B->>W: WS Binary: raw PCM (FEC not on wire over TCP)
end
Note over B,WASM: FEC encode/decode proven via testFec()
```
### WASM Module (wzp-wasm)
```mermaid
graph TD
subgraph "wzp-wasm (337KB)"
FE[WzpFecEncoder<br/>RaptorQ source block accumulator]
FD[WzpFecDecoder<br/>RaptorQ reconstruction]
KX[WzpKeyExchange<br/>X25519 ephemeral DH]
CS[WzpCryptoSession<br/>ChaCha20-Poly1305]
end
subgraph "Hybrid uses"
FE
FD
end
subgraph "Full uses"
FE
FD
KX
CS
end
style FE fill:#a29bfe
style FD fill:#a29bfe
style KX fill:#ee5a24
style CS fill:#ee5a24
```
### FEC Wire Format
```
Per symbol (encoded by WASM, 259 bytes):
┌──────────┬───────────┬──────────┬──────────────────┐
│ block_id │ symbol_idx│ is_repair│ symbol_data │
│ (1 byte) │ (1 byte) │ (1 byte) │ (256 bytes) │
└──────────┴───────────┴──────────┴──────────────────┘
Symbol data internals (256 bytes):
┌────────────┬──────────────────┬─────────┐
│ length │ audio frame data │ padding │
│ (2B LE) │ (variable) │ (zeros) │
└────────────┴──────────────────┴─────────┘
Block = 5 source symbols + ceil(5 × 0.5) = 3 repair symbols = 8 total
Any 5 of 8 received → full block recoverable (RaptorQ fountain code)
```
### Testing FEC in Browser Console
```javascript
// On any hybrid variant page, open console:
client.testFec({ lossRate: 0.3, blockSize: 5, symbolSize: 256 })
// Output: "FEC test passed — recovered from 30% loss"
client.testFec({ lossRate: 0.5 })
// Output: "FEC test passed — recovered from 50% loss"
```
### Files
- `js/wzp-hybrid.js``WZPHybridClient` class (~150 lines)
- `js/wzp-core.js` — Shared UI + audio
- `wasm/wzp_wasm.js` + `wasm/wzp_wasm_bg.wasm` — WASM module (337KB)
### Limitations
- FEC doesn't help over TCP WebSocket (no packet loss to recover from)
- Bridge still sees plaintext audio
- WebTransport activation is the unlock for FEC value
---
## Variant 3: Full WASM + WebTransport
The complete WZP client in the browser. No bridge server needed — the browser connects directly to the relay via WebTransport unreliable datagrams. All encryption and FEC happens in WASM.
### Architecture
```mermaid
sequenceDiagram
participant B as Browser
participant WASM as WASM Module
participant R as wzp-relay
B->>WASM: Load wzp_wasm.js
WASM-->>B: FEC + Crypto + KeyExchange ready
B->>R: WebTransport connect (HTTPS/HTTP3)
B->>R: Bidirectional stream open
Note over B,R: Key Exchange
B->>WASM: kx = new WzpKeyExchange()
B->>R: Stream: our X25519 public key (32 bytes)
R->>B: Stream: relay X25519 public key (32 bytes)
B->>WASM: secret = kx.derive_shared_secret(peer_pub)
B->>WASM: session = new WzpCryptoSession(secret)
Note over B,R: Media Flow (Unreliable Datagrams)
loop Every 20ms
B->>B: AudioWorklet captures PCM
B->>WASM: fecEncoder.add_symbol(pcm_bytes)
WASM-->>B: FEC symbols when block complete
B->>WASM: encrypted = session.encrypt(header, symbol)
B->>R: WebTransport datagram (encrypted)
end
loop Incoming
R->>B: WebTransport datagram (encrypted)
B->>WASM: plaintext = session.decrypt(header, ciphertext)
B->>WASM: frames = fecDecoder.add_symbol(...)
WASM-->>B: Decoded audio frames
B->>B: AudioWorklet plays PCM
end
```
### Encryption Flow
```mermaid
graph TD
subgraph "Key Exchange (once per session)"
KX_A[Browser: WzpKeyExchange.new<br/>Generate X25519 keypair] --> PUB_A[Send public key<br/>32 bytes over stream]
PUB_B[Receive relay public key<br/>32 bytes] --> DH[derive_shared_secret<br/>X25519 DH + HKDF-SHA256]
DH --> SESSION[WzpCryptoSession<br/>ChaCha20-Poly1305 256-bit key]
end
subgraph "Per-Packet Encryption"
HDR[Build MediaHeader<br/>12 bytes AAD] --> ENC[session.encrypt<br/>header=AAD plaintext=audio]
ENC --> NONCE[Nonce 12 bytes<br/>session_id 4 + seq 4 + dir 1 + pad 3]
ENC --> CT[Ciphertext + 16-byte Poly1305 tag]
CT --> DG[WebTransport datagram send]
end
style SESSION fill:#ee5a24
style NONCE fill:#fdcb6e
```
### Nonce Construction (matches native wzp-crypto)
```
Bytes 0-3: session_id (SHA-256(session_key)[:4])
Bytes 4-7: sequence_number (u32 BE, incrementing)
Byte 8: direction (0x00 = send, 0x01 = recv)
Bytes 9-11: 0x000000 (padding)
Total: 12 bytes — deterministic, never reused (seq increments)
```
### Send Pipeline Detail
```mermaid
graph TD
MIC[Mic PCM Int16 x 960] --> PAD[Pad to 256 bytes<br/>2-byte LE length + data + zeros]
PAD --> FEC[WzpFecEncoder.add_symbol<br/>Accumulate 5 frames per block]
FEC -->|Block complete| SYMBOLS[5 source + 3 repair symbols]
SYMBOLS --> HDR[Build 12-byte MediaHeader<br/>seq, timestamp, codec, fec_block, symbol_idx]
HDR --> ENCRYPT[WzpCryptoSession.encrypt<br/>AAD=header, payload=symbol]
ENCRYPT --> DG[WebTransport datagram<br/>header 12B + ciphertext + tag 16B]
style FEC fill:#a29bfe
style ENCRYPT fill:#ee5a24
style DG fill:#00b894
```
### Receive Pipeline Detail
```mermaid
graph TD
DG[WebTransport datagram] --> PARSE[Parse 12-byte MediaHeader]
PARSE --> DECRYPT[WzpCryptoSession.decrypt<br/>AAD=header, ciphertext=rest]
DECRYPT --> FEC_HDR[Parse 3-byte FEC header<br/>block_id + symbol_idx + is_repair]
FEC_HDR --> FEC_D[WzpFecDecoder.add_symbol]
FEC_D -->|Block decoded| FRAMES[Original audio frames]
FRAMES --> UNPAD[Strip 2-byte length prefix + padding]
UNPAD --> PLAY[AudioWorklet playback<br/>Int16 PCM x 960]
style DECRYPT fill:#ee5a24
style FEC_D fill:#a29bfe
style PLAY fill:#4a9eff
```
### Testing Crypto + FEC in Browser Console
```javascript
// On any full variant page, open console:
client.testCryptoFec()
// Tests: key exchange → encrypt → FEC encode → simulate 30% loss → FEC decode → decrypt
// Output: "Crypto+FEC test passed — key exchange, encrypt, FEC(30% loss), decrypt all OK"
```
### Files
- `js/wzp-full.js``WZPFullClient` class (~250 lines)
- `js/wzp-core.js` — Shared UI + audio
- `wasm/wzp_wasm.js` + `wasm/wzp_wasm_bg.wasm` — WASM module (337KB, shared with hybrid)
### Requirements (not yet met)
- Relay must support HTTP/3 WebTransport (h3-quinn integration)
- Real TLS certificate (WebTransport requires valid HTTPS)
- Browser with WebTransport support (Chrome 97+, Edge 97+, Firefox 114+, Safari 17.4+)
### Limitations
- No Opus encoding in browser yet (sends raw PCM, relay/peer decodes)
- Key exchange is simplified (no Ed25519 signature verification in WASM yet)
- No adaptive quality switching in browser (server-side only)
---
## Shared Infrastructure
### wzp-core.js
Common code used by all three variants:
```mermaid
graph TD
CORE[wzp-core.js] --> DETECT[detectVariant<br/>URL ?variant= param]
CORE --> ROOM[getRoom<br/>URL path / input field]
CORE --> AUDIO[startAudioContext<br/>48kHz AudioContext]
CORE --> CAP[connectCapture<br/>Mic to AudioWorklet]
CORE --> PLAY[connectPlayback<br/>AudioWorklet to speaker]
CORE --> UI[initUI<br/>Buttons, PTT, level meter]
CORE --> STATUS[updateStatus / updateStats<br/>DOM updates]
CAP --> WORKLET[AudioWorklet<br/>or ScriptProcessor fallback]
PLAY --> WORKLET
style CORE fill:#6c5ce7
style WORKLET fill:#00b894
```
### AudioWorklet Processors (audio-processor.js)
```
WZPCaptureProcessor:
AudioWorklet process() → 128 samples per call
Buffer internally until 960 samples (20ms frame)
Convert Float32 → Int16
postMessage(ArrayBuffer) to main thread
WZPPlaybackProcessor:
Receive Int16 PCM via port.onmessage
Convert Int16 → Float32
Write to ring buffer (max ~120ms / 6 frames)
process() reads from ring buffer → output
```
### index.html Boot Sequence
```mermaid
sequenceDiagram
participant PAGE as index.html
participant CORE as wzp-core.js
participant VAR as Variant JS
PAGE->>CORE: Load (static script tag)
CORE->>CORE: detectVariant() from URL
PAGE->>VAR: Dynamic script load (wzp-pure/hybrid/full.js)
VAR-->>PAGE: wzpBoot() called on load
PAGE->>CORE: initUI(callbacks)
Note over PAGE: User clicks Connect
PAGE->>CORE: startAudioContext()
PAGE->>VAR: new WZP*Client(options)
PAGE->>VAR: client.connect()
PAGE->>CORE: connectCapture(audioCtx, onFrame)
PAGE->>CORE: connectPlayback(audioCtx)
loop Audio flowing
CORE->>VAR: client.sendAudio(pcmBuffer)
VAR->>CORE: onAudio(Int16Array) callback
end
```
## Deployment
### Behind Caddy (recommended)
```
# Caddyfile
wzp.example.com {
reverse_proxy 127.0.0.1:8080
}
```
```bash
# Relay
./wzp-relay --listen 0.0.0.0:4433
# Web bridge (no --tls, Caddy handles SSL)
./wzp-web --port 8080 --relay 127.0.0.1:4433
```
### Direct TLS
```bash
./wzp-web --port 443 --relay 127.0.0.1:4433 --tls \
--cert /etc/letsencrypt/live/domain/fullchain.pem \
--key /etc/letsencrypt/live/domain/privkey.pem
```
### URL Patterns
```
https://domain/room-name → Pure (default)
https://domain/room-name?variant=pure → Pure JS
https://domain/room-name?variant=hybrid → Hybrid (JS + WASM FEC)
https://domain/room-name?variant=full → Full WASM (needs HTTP/3 relay)
```
## Future Work
1. **Relay HTTP/3 support** (h3-quinn) — unlocks Full variant for production
2. **Browser Opus encoding** — AudioEncoder API or Opus WASM, removes bridge dependency for Hybrid
3. **Ed25519 signatures in WASM** — full identity verification in Full variant
4. **Adaptive quality in browser** — monitor RTT/loss, switch profiles
5. **WebTransport fallback to WebSocket** — Full variant auto-degrades if WebTransport unavailable