Compare commits
6 Commits
09a18b086b
...
2de6e19956
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2de6e19956 | ||
|
|
ec437afbce | ||
|
|
137e7973c4 | ||
|
|
aa09275015 | ||
|
|
59bf3f6587 | ||
|
|
55d4004f86 |
45
Cargo.lock
generated
45
Cargo.lock
generated
@@ -169,6 +169,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core 0.4.5",
|
"axum-core 0.4.5",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -184,8 +185,10 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"serde",
|
"serde",
|
||||||
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.24.0",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -220,7 +223,7 @@ dependencies = [
|
|||||||
"sha1",
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite 0.28.0",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -380,6 +383,12 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -3140,6 +3149,18 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite 0.24.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
@@ -3149,7 +3170,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tungstenite",
|
"tungstenite 0.28.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3366,6 +3387,24 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tungstenite"
|
name = "tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
@@ -4228,6 +4267,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"axum 0.7.9",
|
"axum 0.7.9",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"quinn",
|
"quinn",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -4236,6 +4276,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"wzp-client",
|
"wzp-client",
|
||||||
|
|||||||
@@ -46,6 +46,23 @@ impl MediaHeader {
|
|||||||
/// Header size in bytes on the wire.
|
/// Header size in bytes on the wire.
|
||||||
pub const WIRE_SIZE: usize = 12;
|
pub const WIRE_SIZE: usize = 12;
|
||||||
|
|
||||||
|
/// Create a default header for raw PCM relay (used by WebSocket bridge).
|
||||||
|
pub fn default_pcm() -> Self {
|
||||||
|
Self {
|
||||||
|
version: 0,
|
||||||
|
is_repair: false,
|
||||||
|
codec_id: CodecId::Opus24k,
|
||||||
|
has_quality_report: false,
|
||||||
|
fec_ratio_encoded: 0,
|
||||||
|
seq: 0,
|
||||||
|
timestamp: 0,
|
||||||
|
fec_block: 0,
|
||||||
|
fec_symbol: 0,
|
||||||
|
reserved: 0,
|
||||||
|
csrc_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Encode the FEC ratio float (0.0-2.0+) to a 7-bit value (0-127).
|
/// Encode the FEC ratio float (0.0-2.0+) to a 7-bit value (0-127).
|
||||||
pub fn encode_fec_ratio(ratio: f32) -> u8 {
|
pub fn encode_fec_ratio(ratio: f32) -> u8 {
|
||||||
// Map 0.0-2.0 to 0-127, clamping at 127
|
// Map 0.0-2.0 to 0-127, clamping at 127
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ serde_json = "1"
|
|||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
quinn = { workspace = true }
|
quinn = { workspace = true }
|
||||||
prometheus = "0.13"
|
prometheus = "0.13"
|
||||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1"] }
|
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
||||||
|
tower-http = { version = "0.6", features = ["fs"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-relay"
|
name = "wzp-relay"
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ pub struct RelayConfig {
|
|||||||
/// reducing per-packet QUIC datagram overhead.
|
/// reducing per-packet QUIC datagram overhead.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trunking_enabled: bool,
|
pub trunking_enabled: bool,
|
||||||
|
/// Port for the WebSocket listener (browser clients connect here).
|
||||||
|
/// If None, WebSocket support is disabled.
|
||||||
|
pub ws_port: Option<u16>,
|
||||||
|
/// Directory to serve static files from (HTML/JS/WASM for web clients).
|
||||||
|
pub static_dir: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RelayConfig {
|
impl Default for RelayConfig {
|
||||||
@@ -55,6 +60,8 @@ impl Default for RelayConfig {
|
|||||||
probe_targets: Vec::new(),
|
probe_targets: Vec::new(),
|
||||||
probe_mesh: false,
|
probe_mesh: false,
|
||||||
trunking_enabled: false,
|
trunking_enabled: false,
|
||||||
|
ws_port: None,
|
||||||
|
static_dir: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub mod room;
|
|||||||
pub mod route;
|
pub mod route;
|
||||||
pub mod session_mgr;
|
pub mod session_mgr;
|
||||||
pub mod trunk;
|
pub mod trunk;
|
||||||
|
pub mod ws;
|
||||||
|
|
||||||
pub use config::RelayConfig;
|
pub use config::RelayConfig;
|
||||||
pub use handshake::accept_handshake;
|
pub use handshake::accept_handshake;
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ fn parse_args() -> RelayConfig {
|
|||||||
"--trunking" => {
|
"--trunking" => {
|
||||||
config.trunking_enabled = true;
|
config.trunking_enabled = true;
|
||||||
}
|
}
|
||||||
|
"--ws-port" => {
|
||||||
|
i += 1;
|
||||||
|
config.ws_port = Some(
|
||||||
|
args.get(i).expect("--ws-port requires a port number")
|
||||||
|
.parse().expect("invalid --ws-port number"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--static-dir" => {
|
||||||
|
i += 1;
|
||||||
|
config.static_dir = Some(
|
||||||
|
args.get(i).expect("--static-dir requires a directory path").to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
"--mesh-status" => {
|
"--mesh-status" => {
|
||||||
// Print mesh table from a fresh registry and exit.
|
// Print mesh table from a fresh registry and exit.
|
||||||
// In practice this is useful after the relay has been running;
|
// In practice this is useful after the relay has been running;
|
||||||
@@ -89,6 +102,8 @@ fn parse_args() -> RelayConfig {
|
|||||||
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
||||||
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
||||||
eprintln!(" --trunking Enable trunk batching for outgoing media in room mode.");
|
eprintln!(" --trunking Enable trunk batching for outgoing media in room mode.");
|
||||||
|
eprintln!(" --ws-port <port> WebSocket listener port for browser clients (e.g., 8080).");
|
||||||
|
eprintln!(" --static-dir <dir> Directory to serve static files from (HTML/JS/WASM).");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Room mode (default):");
|
eprintln!("Room mode (default):");
|
||||||
eprintln!(" Clients join rooms by name. Packets forwarded to all others (SFU).");
|
eprintln!(" Clients join rooms by name. Packets forwarded to all others (SFU).");
|
||||||
@@ -233,6 +248,20 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tokio::spawn(async move { mesh.run_all().await });
|
tokio::spawn(async move { mesh.run_all().await });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebSocket server for browser clients
|
||||||
|
if let Some(ws_port) = config.ws_port {
|
||||||
|
let ws_state = wzp_relay::ws::WsState {
|
||||||
|
room_mgr: room_mgr.clone(),
|
||||||
|
session_mgr: session_mgr.clone(),
|
||||||
|
auth_url: config.auth_url.clone(),
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
presence: presence.clone(),
|
||||||
|
};
|
||||||
|
let static_dir = config.static_dir.clone();
|
||||||
|
tokio::spawn(wzp_relay::ws::run_ws_server(ws_port, ws_state, static_dir));
|
||||||
|
info!(ws_port, "WebSocket listener enabled for browser clients");
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref url) = config.auth_url {
|
if let Some(ref url) = config.auth_url {
|
||||||
info!(url, "auth enabled — clients must present featherChat token");
|
info!(url, "auth enabled — clients must present featherChat token");
|
||||||
} else {
|
} else {
|
||||||
@@ -473,7 +502,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let participant_id = {
|
let participant_id = {
|
||||||
let mut mgr = room_mgr.lock().await;
|
let mut mgr = room_mgr.lock().await;
|
||||||
match mgr.join(&room_name, addr, transport.clone(), authenticated_fp.as_deref()) {
|
match mgr.join(&room_name, addr, room::ParticipantSender::Quic(transport.clone()), authenticated_fp.as_deref()) {
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
metrics.active_rooms.set(mgr.list().len() as i64);
|
metrics.active_rooms.set(mgr.list().len() as i64);
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -27,11 +27,51 @@ fn next_id() -> ParticipantId {
|
|||||||
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How to send data to a participant — either via QUIC transport or WebSocket channel.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum ParticipantSender {
|
||||||
|
Quic(Arc<wzp_transport::QuinnTransport>),
|
||||||
|
WebSocket(tokio::sync::mpsc::Sender<Bytes>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParticipantSender {
|
||||||
|
/// Send raw bytes to this participant.
|
||||||
|
pub async fn send_raw(&self, data: &[u8]) -> Result<(), String> {
|
||||||
|
match self {
|
||||||
|
ParticipantSender::WebSocket(tx) => {
|
||||||
|
tx.try_send(Bytes::copy_from_slice(data))
|
||||||
|
.map_err(|e| format!("ws send: {e}"))
|
||||||
|
}
|
||||||
|
ParticipantSender::Quic(transport) => {
|
||||||
|
let pkt = wzp_proto::MediaPacket {
|
||||||
|
header: wzp_proto::packet::MediaHeader::default_pcm(),
|
||||||
|
payload: Bytes::copy_from_slice(data),
|
||||||
|
quality_report: None,
|
||||||
|
};
|
||||||
|
transport.send_media(&pkt).await.map_err(|e| format!("quic send: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a QUIC participant.
|
||||||
|
pub fn is_quic(&self) -> bool {
|
||||||
|
matches!(self, ParticipantSender::Quic(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the QUIC transport if this is a QUIC participant.
|
||||||
|
pub fn as_quic(&self) -> Option<&Arc<wzp_transport::QuinnTransport>> {
|
||||||
|
match self {
|
||||||
|
ParticipantSender::Quic(t) => Some(t),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A participant in a room.
|
/// A participant in a room.
|
||||||
struct Participant {
|
struct Participant {
|
||||||
id: ParticipantId,
|
id: ParticipantId,
|
||||||
_addr: std::net::SocketAddr,
|
_addr: std::net::SocketAddr,
|
||||||
transport: Arc<wzp_transport::QuinnTransport>,
|
sender: ParticipantSender,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A room holding multiple participants.
|
/// A room holding multiple participants.
|
||||||
@@ -46,10 +86,10 @@ impl Room {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add(&mut self, addr: std::net::SocketAddr, transport: Arc<wzp_transport::QuinnTransport>) -> ParticipantId {
|
fn add(&mut self, addr: std::net::SocketAddr, sender: ParticipantSender) -> ParticipantId {
|
||||||
let id = next_id();
|
let id = next_id();
|
||||||
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
|
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
|
||||||
self.participants.push(Participant { id, _addr: addr, transport });
|
self.participants.push(Participant { id, _addr: addr, sender });
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,11 +98,11 @@ impl Room {
|
|||||||
info!(room_size = self.participants.len(), participant = id, "left room");
|
info!(room_size = self.participants.len(), participant = id, "left room");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn others(&self, exclude_id: ParticipantId) -> Vec<Arc<wzp_transport::QuinnTransport>> {
|
fn others(&self, exclude_id: ParticipantId) -> Vec<ParticipantSender> {
|
||||||
self.participants
|
self.participants
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| p.id != exclude_id)
|
.filter(|p| p.id != exclude_id)
|
||||||
.map(|p| p.transport.clone())
|
.map(|p| p.sender.clone())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +170,7 @@ impl RoomManager {
|
|||||||
&mut self,
|
&mut self,
|
||||||
room_name: &str,
|
room_name: &str,
|
||||||
addr: std::net::SocketAddr,
|
addr: std::net::SocketAddr,
|
||||||
transport: Arc<wzp_transport::QuinnTransport>,
|
sender: ParticipantSender,
|
||||||
fingerprint: Option<&str>,
|
fingerprint: Option<&str>,
|
||||||
) -> Result<ParticipantId, String> {
|
) -> Result<ParticipantId, String> {
|
||||||
if !self.is_authorized(room_name, fingerprint) {
|
if !self.is_authorized(room_name, fingerprint) {
|
||||||
@@ -138,7 +178,18 @@ impl RoomManager {
|
|||||||
return Err("not authorized for this room".to_string());
|
return Err("not authorized for this room".to_string());
|
||||||
}
|
}
|
||||||
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||||
Ok(room.add(addr, transport))
|
Ok(room.add(addr, sender))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join a room via WebSocket. Convenience wrapper around `join()`.
|
||||||
|
pub fn join_ws(
|
||||||
|
&mut self,
|
||||||
|
room_name: &str,
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
sender: tokio::sync::mpsc::Sender<Bytes>,
|
||||||
|
fingerprint: Option<&str>,
|
||||||
|
) -> Result<ParticipantId, String> {
|
||||||
|
self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Leave a room. Removes the room if empty.
|
/// Leave a room. Removes the room if empty.
|
||||||
@@ -152,12 +203,12 @@ impl RoomManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get transports for all OTHER participants in a room.
|
/// Get senders for all OTHER participants in a room.
|
||||||
pub fn others(
|
pub fn others(
|
||||||
&self,
|
&self,
|
||||||
room_name: &str,
|
room_name: &str,
|
||||||
participant_id: ParticipantId,
|
participant_id: ParticipantId,
|
||||||
) -> Vec<Arc<wzp_transport::QuinnTransport>> {
|
) -> Vec<ParticipantSender> {
|
||||||
self.rooms
|
self.rooms
|
||||||
.get(room_name)
|
.get(room_name)
|
||||||
.map(|r| r.others(participant_id))
|
.map(|r| r.others(participant_id))
|
||||||
@@ -305,10 +356,14 @@ async fn run_participant_plain(
|
|||||||
// Forward to all others
|
// Forward to all others
|
||||||
let pkt_bytes = pkt.payload.len() as u64;
|
let pkt_bytes = pkt.payload.len() as u64;
|
||||||
for other in &others {
|
for other in &others {
|
||||||
// Best-effort: if one send fails, continue to others
|
match other {
|
||||||
if let Err(e) = other.send_media(&pkt).await {
|
ParticipantSender::Quic(t) => {
|
||||||
// Don't log every failure — they'll be cleaned up when their recv loop breaks
|
let _ = t.send_media(&pkt).await;
|
||||||
let _ = e;
|
}
|
||||||
|
ParticipantSender::WebSocket(_) => {
|
||||||
|
// WS clients receive raw payload bytes
|
||||||
|
let _ = other.send_raw(&pkt.payload).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,12 +445,20 @@ async fn run_participant_trunked(
|
|||||||
|
|
||||||
let pkt_bytes = pkt.payload.len() as u64;
|
let pkt_bytes = pkt.payload.len() as u64;
|
||||||
for other in &others {
|
for other in &others {
|
||||||
let peer_addr = other.connection().remote_address();
|
match other {
|
||||||
let fwd = forwarders
|
ParticipantSender::Quic(t) => {
|
||||||
.entry(peer_addr)
|
let peer_addr = t.connection().remote_address();
|
||||||
.or_insert_with(|| TrunkedForwarder::new(other.clone(), sid_bytes));
|
let fwd = forwarders
|
||||||
if let Err(e) = fwd.send(&pkt).await {
|
.entry(peer_addr)
|
||||||
let _ = e;
|
.or_insert_with(|| TrunkedForwarder::new(t.clone(), sid_bytes));
|
||||||
|
if let Err(e) = fwd.send(&pkt).await {
|
||||||
|
let _ = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ParticipantSender::WebSocket(_) => {
|
||||||
|
// WS clients bypass trunking — send raw payload directly
|
||||||
|
let _ = other.send_raw(&pkt.payload).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
243
crates/wzp-relay/src/ws.rs
Normal file
243
crates/wzp-relay/src/ws.rs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
//! WebSocket transport for browser clients.
|
||||||
|
//!
|
||||||
|
//! Browsers connect via `GET /ws/{room}` → WebSocket upgrade.
|
||||||
|
//! First message must be auth JSON (if auth is enabled).
|
||||||
|
//! Subsequent messages are binary PCM frames forwarded to/from the room.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{
|
||||||
|
ws::{Message, WebSocket},
|
||||||
|
Path, State, WebSocketUpgrade,
|
||||||
|
},
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::auth;
|
||||||
|
use crate::metrics::RelayMetrics;
|
||||||
|
use crate::presence::PresenceRegistry;
|
||||||
|
use crate::room::RoomManager;
|
||||||
|
use crate::session_mgr::SessionManager;
|
||||||
|
|
||||||
|
/// Shared state for WebSocket handlers.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WsState {
|
||||||
|
pub room_mgr: Arc<Mutex<RoomManager>>,
|
||||||
|
pub session_mgr: Arc<Mutex<SessionManager>>,
|
||||||
|
pub auth_url: Option<String>,
|
||||||
|
pub metrics: Arc<RelayMetrics>,
|
||||||
|
pub presence: Arc<Mutex<PresenceRegistry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the WebSocket + static file server.
|
||||||
|
pub async fn run_ws_server(port: u16, state: WsState, static_dir: Option<String>) {
|
||||||
|
let mut app = Router::new()
|
||||||
|
.route("/ws/{room}", get(ws_upgrade_handler))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
if let Some(dir) = static_dir {
|
||||||
|
info!(dir = %dir, "serving static files");
|
||||||
|
app = app.fallback_service(ServeDir::new(dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
|
||||||
|
info!(%addr, "WebSocket server listening");
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.expect("failed to bind WS listener");
|
||||||
|
axum::serve(listener, app).await.expect("WS server failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ws_upgrade_handler(
|
||||||
|
Path(room): Path<String>,
|
||||||
|
State(state): State<WsState>,
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ws.on_upgrade(move |socket| handle_ws_connection(socket, room, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_ws_connection(socket: WebSocket, room: String, state: WsState) {
|
||||||
|
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||||
|
|
||||||
|
// 1. Auth: if auth_url is set, first message must be {"type":"auth","token":"..."}
|
||||||
|
let fingerprint: Option<String> = if let Some(ref auth_url) = state.auth_url {
|
||||||
|
match ws_rx.next().await {
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
match serde_json::from_str::<serde_json::Value>(&text) {
|
||||||
|
Ok(parsed) if parsed["type"] == "auth" => {
|
||||||
|
if let Some(token) = parsed["token"].as_str() {
|
||||||
|
match auth::validate_token(auth_url, token).await {
|
||||||
|
Ok(client) => {
|
||||||
|
state.metrics.auth_attempts.with_label_values(&["ok"]).inc();
|
||||||
|
info!(fingerprint = %client.fingerprint, "WS authenticated");
|
||||||
|
let _ = ws_tx
|
||||||
|
.send(Message::Text(r#"{"type":"auth_ok"}"#.into()))
|
||||||
|
.await;
|
||||||
|
Some(client.fingerprint)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state
|
||||||
|
.metrics
|
||||||
|
.auth_attempts
|
||||||
|
.with_label_values(&["fail"])
|
||||||
|
.inc();
|
||||||
|
let _ = ws_tx
|
||||||
|
.send(Message::Text(
|
||||||
|
format!(r#"{{"type":"auth_error","error":"{e}"}}"#)
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
warn!("WS auth failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("WS auth: missing token field");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!("WS: expected auth message as first frame");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!("WS: connection closed before auth");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = ws_tx
|
||||||
|
.send(Message::Text(r#"{"type":"auth_ok"}"#.into()))
|
||||||
|
.await;
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Create mpsc channel for outbound frames (room → browser)
|
||||||
|
let (tx, mut rx) = mpsc::channel::<Bytes>(64);
|
||||||
|
|
||||||
|
// 3. Create session
|
||||||
|
let session_id = {
|
||||||
|
let mut smgr = state.session_mgr.lock().await;
|
||||||
|
match smgr.create_session(&room, fingerprint.clone()) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(e) => {
|
||||||
|
error!(room = %room, "WS session rejected: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
state.metrics.active_sessions.inc();
|
||||||
|
|
||||||
|
// 4. Join room with WS sender
|
||||||
|
let addr: SocketAddr = ([0, 0, 0, 0], 0).into();
|
||||||
|
let participant_id = {
|
||||||
|
let mut mgr = state.room_mgr.lock().await;
|
||||||
|
match mgr.join_ws(&room, addr, tx, fingerprint.as_deref()) {
|
||||||
|
Ok(id) => {
|
||||||
|
state.metrics.active_rooms.set(mgr.list().len() as i64);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(room = %room, "WS room join denied: {e}");
|
||||||
|
state.metrics.active_sessions.dec();
|
||||||
|
let mut smgr = state.session_mgr.lock().await;
|
||||||
|
smgr.remove_session(session_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. Register presence
|
||||||
|
if let Some(ref fp) = fingerprint {
|
||||||
|
let mut reg = state.presence.lock().await;
|
||||||
|
reg.register_local(fp, None, Some(room.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(room = %room, participant = participant_id, "WS client joined");
|
||||||
|
|
||||||
|
// 6. Outbound task: mpsc rx → WS binary frames
|
||||||
|
let send_task = tokio::spawn(async move {
|
||||||
|
while let Some(data) = rx.recv().await {
|
||||||
|
if ws_tx
|
||||||
|
.send(Message::Binary(data.to_vec().into()))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Inbound: WS recv → fan-out to room
|
||||||
|
loop {
|
||||||
|
match ws_rx.next().await {
|
||||||
|
Some(Ok(Message::Binary(data))) => {
|
||||||
|
let others = {
|
||||||
|
let mgr = state.room_mgr.lock().await;
|
||||||
|
mgr.others(&room, participant_id)
|
||||||
|
};
|
||||||
|
for other in &others {
|
||||||
|
let _ = other.send_raw(&data).await;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.metrics
|
||||||
|
.packets_forwarded
|
||||||
|
.inc_by(others.len() as u64);
|
||||||
|
state
|
||||||
|
.metrics
|
||||||
|
.bytes_forwarded
|
||||||
|
.inc_by(data.len() as u64 * others.len() as u64);
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Close(_))) | None => break,
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Cleanup
|
||||||
|
send_task.abort();
|
||||||
|
info!(room = %room, participant = participant_id, "WS client disconnected");
|
||||||
|
|
||||||
|
if let Some(ref fp) = fingerprint {
|
||||||
|
let mut reg = state.presence.lock().await;
|
||||||
|
reg.unregister_local(fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut mgr = state.room_mgr.lock().await;
|
||||||
|
mgr.leave(&room, participant_id);
|
||||||
|
state.metrics.active_rooms.set(mgr.list().len() as i64);
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_id_str: String = session_id.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
state.metrics.remove_session_metrics(&session_id_str);
|
||||||
|
state.metrics.active_sessions.dec();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut smgr = state.session_mgr.lock().await;
|
||||||
|
smgr.remove_session(session_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ws_state_is_clone() {
|
||||||
|
// WsState must be Clone for axum's State extractor
|
||||||
|
fn assert_clone<T: Clone>() {}
|
||||||
|
assert_clone::<WsState>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,9 +66,12 @@
|
|||||||
(function() {
|
(function() {
|
||||||
var variant = WZPCore.detectVariant();
|
var variant = WZPCore.detectVariant();
|
||||||
var scriptMap = {
|
var scriptMap = {
|
||||||
pure: 'js/wzp-pure.js',
|
pure: 'js/wzp-pure.js',
|
||||||
hybrid: 'js/wzp-hybrid.js',
|
hybrid: 'js/wzp-hybrid.js',
|
||||||
full: 'js/wzp-full.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 src = scriptMap[variant] || scriptMap.pure;
|
||||||
var s = document.createElement('script');
|
var s = document.createElement('script');
|
||||||
@@ -117,8 +120,18 @@ function wzpBoot() {
|
|||||||
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
||||||
|
|
||||||
// Create client (currently always WZPPureClient; future: switch on variant)
|
// Create client based on detected variant
|
||||||
client = new WZPPureClient({
|
var variant = WZPCore.detectVariant();
|
||||||
|
var ClientClass = {
|
||||||
|
pure: window.WZPPureClient,
|
||||||
|
hybrid: window.WZPHybridClient,
|
||||||
|
full: window.WZPFullClient,
|
||||||
|
'ws': window.WZPWsClient,
|
||||||
|
'ws-fec': window.WZPWsFecClient,
|
||||||
|
'ws-full': window.WZPWsFullClient,
|
||||||
|
}[variant] || window.WZPPureClient;
|
||||||
|
|
||||||
|
var clientOpts = {
|
||||||
wsUrl: wsUrl,
|
wsUrl: wsUrl,
|
||||||
room: room,
|
room: room,
|
||||||
onAudio: function(pcm) {
|
onAudio: function(pcm) {
|
||||||
@@ -130,7 +143,26 @@ function wzpBoot() {
|
|||||||
onStats: function(stats) {
|
onStats: function(stats) {
|
||||||
WZPCore.updateStats(stats);
|
WZPCore.updateStats(stats);
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ const WZP_FRAME_SIZE = 960; // 20ms @ 48kHz
|
|||||||
function wzpDetectVariant() {
|
function wzpDetectVariant() {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const v = (params.get('variant') || 'pure').toLowerCase();
|
const v = (params.get('variant') || 'pure').toLowerCase();
|
||||||
if (v === 'hybrid' || v === 'full') return v;
|
const valid = ['pure', 'hybrid', 'full', 'ws', 'ws-fec', 'ws-full'];
|
||||||
|
if (valid.includes(v)) return v;
|
||||||
return 'pure';
|
return 'pure';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,14 @@ class WZPFullClient {
|
|||||||
*/
|
*/
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
this.url = options.url;
|
this.url = options.url;
|
||||||
|
this.wsUrl = options.wsUrl; // WS fallback URL
|
||||||
this.room = options.room;
|
this.room = options.room;
|
||||||
this.onAudio = options.onAudio || null;
|
this.onAudio = options.onAudio || null;
|
||||||
this.onStatus = options.onStatus || null;
|
this.onStatus = options.onStatus || null;
|
||||||
this.onStats = options.onStats || null;
|
this.onStats = options.onStats || null;
|
||||||
|
|
||||||
this.wt = null; // WebTransport instance
|
this.wt = null; // WebTransport instance
|
||||||
|
this.ws = null; // WebSocket fallback
|
||||||
this.datagramWriter = null; // WritableStreamDefaultWriter
|
this.datagramWriter = null; // WritableStreamDefaultWriter
|
||||||
this.datagramReader = null; // ReadableStreamDefaultReader
|
this.datagramReader = null; // ReadableStreamDefaultReader
|
||||||
this.cryptoSession = null; // WzpCryptoSession (WASM)
|
this.cryptoSession = null; // WzpCryptoSession (WASM)
|
||||||
@@ -48,6 +50,7 @@ class WZPFullClient {
|
|||||||
this.sequence = 0;
|
this.sequence = 0;
|
||||||
this._wasmModule = null;
|
this._wasmModule = null;
|
||||||
this._connected = false;
|
this._connected = false;
|
||||||
|
this._useWebTransport = false; // true if WT connected, false = WS fallback
|
||||||
this._startTime = 0;
|
this._startTime = 0;
|
||||||
this._statsInterval = null;
|
this._statsInterval = null;
|
||||||
this._recvLoopRunning = false;
|
this._recvLoopRunning = false;
|
||||||
@@ -61,49 +64,45 @@ class WZPFullClient {
|
|||||||
async connect() {
|
async connect() {
|
||||||
if (this._connected) return;
|
if (this._connected) return;
|
||||||
|
|
||||||
// --- Guard: WebTransport support ---
|
|
||||||
if (typeof WebTransport === 'undefined') {
|
|
||||||
throw new Error(
|
|
||||||
'WebTransport is not supported in this browser. ' +
|
|
||||||
'Use the hybrid (?variant=hybrid) or pure (?variant=pure) variant instead.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._status('Loading WASM module...');
|
this._status('Loading WASM module...');
|
||||||
|
|
||||||
// 1. Load WASM
|
// 1. Load WASM (FEC + crypto)
|
||||||
this._wasmModule = await import(WZP_WASM_PATH);
|
this._wasmModule = await import(WZP_WASM_PATH);
|
||||||
await this._wasmModule.default();
|
await this._wasmModule.default();
|
||||||
|
|
||||||
this._status('Connecting via WebTransport to ' + this.url + '...');
|
// 2. Try WebTransport first, fall back to WebSocket
|
||||||
|
let wtSuccess = false;
|
||||||
// 2. WebTransport connection
|
if (typeof WebTransport !== 'undefined' && this.url) {
|
||||||
// The URL should include the room, e.g. https://host:port/room
|
try {
|
||||||
const wtUrl = this.url + '/' + encodeURIComponent(this.room);
|
this._status('Trying WebTransport...');
|
||||||
this.wt = new WebTransport(wtUrl);
|
const wtUrl = this.url + '/' + encodeURIComponent(this.room);
|
||||||
|
this.wt = new WebTransport(wtUrl);
|
||||||
this.wt.closed.then(() => {
|
await Promise.race([
|
||||||
const wasConnected = this._connected;
|
this.wt.ready,
|
||||||
this._cleanup();
|
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
|
||||||
if (wasConnected) {
|
]);
|
||||||
this._status('WebTransport closed');
|
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;
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
}
|
||||||
this._cleanup();
|
|
||||||
this._status('WebTransport error: ' + err.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.wt.ready;
|
if (!wtSuccess) {
|
||||||
|
// WebSocket fallback (same as hybrid — WASM loaded but uses WS transport)
|
||||||
|
this._useWebTransport = false;
|
||||||
|
await this._connectWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Get datagram streams (unreliable, QUIC DATAGRAM frames)
|
// 3. Initialise FEC
|
||||||
this.datagramWriter = this.wt.datagrams.writable.getWriter();
|
|
||||||
this.datagramReader = this.wt.datagrams.readable.getReader();
|
|
||||||
|
|
||||||
// 4. Key exchange over a bidirectional stream
|
|
||||||
this._status('Performing key exchange...');
|
|
||||||
await this._performKeyExchange();
|
|
||||||
|
|
||||||
// 5. Initialise FEC (5 source symbols per block, 256-byte symbols)
|
|
||||||
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
|
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
|
||||||
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
|
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
|
||||||
|
|
||||||
@@ -113,10 +112,50 @@ class WZPFullClient {
|
|||||||
this._startTime = Date.now();
|
this._startTime = Date.now();
|
||||||
this._startStatsTimer();
|
this._startStatsTimer();
|
||||||
|
|
||||||
// 6. Start receive loop (runs until disconnect)
|
// 4. Start receive loop (WebTransport only — WS uses onmessage)
|
||||||
this._recvLoop();
|
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)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this._status('Connected to room: ' + this.room + ' (encrypted, FEC active)');
|
/**
|
||||||
|
* 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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,6 +167,10 @@ class WZPFullClient {
|
|||||||
try { this.wt.close(); } catch (_) { /* ignore */ }
|
try { this.wt.close(); } catch (_) { /* ignore */ }
|
||||||
this.wt = null;
|
this.wt = null;
|
||||||
}
|
}
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
this._cleanup();
|
this._cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +182,19 @@ class WZPFullClient {
|
|||||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||||
*/
|
*/
|
||||||
async sendAudio(pcmBuffer) {
|
async sendAudio(pcmBuffer) {
|
||||||
if (!this._connected || !this.datagramWriter || !this.cryptoSession) return;
|
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);
|
const pcmBytes = new Uint8Array(pcmBuffer);
|
||||||
|
|
||||||
|
|||||||
592
crates/wzp-web/static/js/wzp-ws-fec.js
Normal file
592
crates/wzp-web/static/js/wzp-ws-fec.js
Normal 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 = '/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;
|
||||||
749
crates/wzp-web/static/js/wzp-ws-full.js
Normal file
749
crates/wzp-web/static/js/wzp-ws-full.js
Normal 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 = '/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;
|
||||||
289
crates/wzp-web/static/js/wzp-ws.js
Normal file
289
crates/wzp-web/static/js/wzp-ws.js
Normal 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;
|
||||||
473
docs/WEB_VARIANTS.md
Normal file
473
docs/WEB_VARIANTS.md
Normal 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
|
||||||
257
docs/WS_RELAY_SPEC.md
Normal file
257
docs/WS_RELAY_SPEC.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# WS Support in wzp-relay — Implementation Spec
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add WebSocket listener to `wzp-relay` so browsers connect directly, eliminating `wzp-web` bridge.
|
||||||
|
|
||||||
|
```
|
||||||
|
Before: Browser → WS → wzp-web → QUIC → wzp-relay
|
||||||
|
After: Browser → WS → wzp-relay (handles both WS + QUIC)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
wzp-relay
|
||||||
|
├── QUIC listener (:4433) — native clients, inter-relay
|
||||||
|
├── WS listener (:8080) — browsers via Caddy
|
||||||
|
│ ├── GET /ws/{room} — WebSocket upgrade
|
||||||
|
│ └── Auth: first msg = {"type":"auth","token":"..."}
|
||||||
|
└── Shared RoomManager — both transports in same rooms
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Changes
|
||||||
|
|
||||||
|
### 1. Abstract `Participant` over transport type
|
||||||
|
|
||||||
|
**File: `room.rs`**
|
||||||
|
|
||||||
|
Currently:
|
||||||
|
```rust
|
||||||
|
struct Participant {
|
||||||
|
id: ParticipantId,
|
||||||
|
_addr: std::net::SocketAddr,
|
||||||
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change to:
|
||||||
|
```rust
|
||||||
|
struct Participant {
|
||||||
|
id: ParticipantId,
|
||||||
|
_addr: std::net::SocketAddr,
|
||||||
|
sender: ParticipantSender,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How to send a media packet to a participant.
|
||||||
|
enum ParticipantSender {
|
||||||
|
Quic(Arc<wzp_transport::QuinnTransport>),
|
||||||
|
WebSocket(tokio::sync::mpsc::Sender<bytes::Bytes>),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `others()` method returns `Vec<ParticipantSender>` instead of `Vec<Arc<QuinnTransport>>`.
|
||||||
|
|
||||||
|
`ParticipantSender` implements a `send_pcm(&self, data: &[u8])` method:
|
||||||
|
- **Quic**: wraps in `MediaPacket`, calls `transport.send_media()`
|
||||||
|
- **WebSocket**: sends raw binary frame via the mpsc channel
|
||||||
|
|
||||||
|
### 2. Add `join_ws()` to RoomManager
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn join_ws(
|
||||||
|
&mut self,
|
||||||
|
room_name: &str,
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
sender: tokio::sync::mpsc::Sender<bytes::Bytes>,
|
||||||
|
fingerprint: Option<&str>,
|
||||||
|
) -> Result<ParticipantId, String>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add WS listener in `main.rs`
|
||||||
|
|
||||||
|
New flag: `--ws-port 8080`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if let Some(ws_port) = config.ws_port {
|
||||||
|
let room_mgr = room_mgr.clone();
|
||||||
|
let auth_url = config.auth_url.clone();
|
||||||
|
let metrics = metrics.clone();
|
||||||
|
tokio::spawn(run_ws_server(ws_port, room_mgr, auth_url, metrics));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. WebSocket handler (`ws.rs` — new file)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use axum::{
|
||||||
|
extract::{ws::{Message, WebSocket}, Path, WebSocketUpgrade},
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn ws_handler(
|
||||||
|
Path(room): Path<String>,
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
/* state */
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ws.on_upgrade(move |socket| handle_ws(socket, room, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_ws(mut socket: WebSocket, room: String, state: WsState) {
|
||||||
|
let addr = /* peer addr */;
|
||||||
|
|
||||||
|
// 1. Auth: first message must be {"type":"auth","token":"..."}
|
||||||
|
let fingerprint = if let Some(ref auth_url) = state.auth_url {
|
||||||
|
match socket.recv().await {
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&text)?;
|
||||||
|
if parsed["type"] == "auth" {
|
||||||
|
let token = parsed["token"].as_str().unwrap();
|
||||||
|
let client = auth::validate_token(auth_url, token).await?;
|
||||||
|
Some(client.fingerprint)
|
||||||
|
} else { return; }
|
||||||
|
}
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
} else { None };
|
||||||
|
|
||||||
|
// 2. Create mpsc channel for outbound frames
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::channel::<bytes::Bytes>(64);
|
||||||
|
|
||||||
|
// 3. Join room
|
||||||
|
let participant_id = {
|
||||||
|
let mut mgr = state.room_mgr.lock().await;
|
||||||
|
mgr.join_ws(&room, addr, tx, fingerprint.as_deref())?
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Run send/recv loops
|
||||||
|
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||||
|
|
||||||
|
// Outbound: mpsc rx → WS send
|
||||||
|
let send_task = tokio::spawn(async move {
|
||||||
|
while let Some(data) = rx.recv().await {
|
||||||
|
if ws_tx.send(Message::Binary(data.to_vec())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inbound: WS recv → fan-out to room
|
||||||
|
loop {
|
||||||
|
match ws_rx.next().await {
|
||||||
|
Some(Ok(Message::Binary(data))) => {
|
||||||
|
// Raw PCM Int16 from browser — fan-out to all others
|
||||||
|
let others = {
|
||||||
|
let mgr = state.room_mgr.lock().await;
|
||||||
|
mgr.others(&room, participant_id)
|
||||||
|
};
|
||||||
|
for other in &others {
|
||||||
|
other.send_raw(&data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Close(_))) | None => break,
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Cleanup
|
||||||
|
send_task.abort();
|
||||||
|
let mut mgr = state.room_mgr.lock().await;
|
||||||
|
mgr.leave(&room, participant_id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Cross-transport fan-out
|
||||||
|
|
||||||
|
When a QUIC participant sends audio → WS participants receive raw PCM bytes.
|
||||||
|
When a WS participant sends audio → QUIC participants receive a `MediaPacket`.
|
||||||
|
|
||||||
|
The `ParticipantSender::send_raw()` method:
|
||||||
|
```rust
|
||||||
|
impl ParticipantSender {
|
||||||
|
async fn send_raw(&self, pcm_bytes: &[u8]) {
|
||||||
|
match self {
|
||||||
|
ParticipantSender::WebSocket(tx) => {
|
||||||
|
let _ = tx.try_send(bytes::Bytes::copy_from_slice(pcm_bytes));
|
||||||
|
}
|
||||||
|
ParticipantSender::Quic(transport) => {
|
||||||
|
// Wrap raw PCM in a MediaPacket
|
||||||
|
let pkt = MediaPacket {
|
||||||
|
header: MediaHeader::default_pcm(),
|
||||||
|
payload: bytes::Bytes::copy_from_slice(pcm_bytes),
|
||||||
|
quality_report: None,
|
||||||
|
};
|
||||||
|
let _ = transport.send_media(&pkt).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For QUIC→WS direction, `run_participant` extracts `pkt.payload` bytes and sends to WS channels.
|
||||||
|
|
||||||
|
### 6. Dependencies to add
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# wzp-relay/Cargo.toml
|
||||||
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
tokio = { version = "1", features = ["full"] } # already present
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Config change
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// config.rs
|
||||||
|
pub struct RelayConfig {
|
||||||
|
// ... existing fields ...
|
||||||
|
pub ws_port: Option<u16>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Docker compose change (featherChat side)
|
||||||
|
|
||||||
|
Remove `wzp-web` service entirely. Update Caddy to proxy `/audio/*` to relay's WS port:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Before:
|
||||||
|
wzp-web:
|
||||||
|
entrypoint: ["wzp-web"]
|
||||||
|
command: ["--port", "8080", "--relay", "172.28.0.10:4433"]
|
||||||
|
|
||||||
|
# After: REMOVED. Relay handles WS directly.
|
||||||
|
|
||||||
|
wzp-relay:
|
||||||
|
command:
|
||||||
|
- "--listen"
|
||||||
|
- "0.0.0.0:4433"
|
||||||
|
- "--ws-port"
|
||||||
|
- "8080"
|
||||||
|
- "--auth-url"
|
||||||
|
- "http://warzone-server:7700/v1/auth/validate"
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Stays the Same
|
||||||
|
|
||||||
|
- Browser's `startAudio()` — unchanged, still connects WS to `/audio/ws/ROOM`
|
||||||
|
- Caddy proxies `/audio/*` → relay:8080 (same path, different backend)
|
||||||
|
- Auth flow — same JSON token as first message
|
||||||
|
- PCM format — same Int16 binary frames
|
||||||
|
- QUIC clients — unchanged, still connect to :4433
|
||||||
|
- Room naming, ACL, session management — all unchanged
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
1. Start relay with `--ws-port 8080 --listen 0.0.0.0:4433`
|
||||||
|
2. Open browser, initiate call via featherChat
|
||||||
|
3. Verify audio flows (both directions)
|
||||||
|
4. Verify QUIC + WS clients can be in same room (mixed mode)
|
||||||
|
5. Verify auth works
|
||||||
|
6. Verify room cleanup on disconnect
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
1. Implement WS in relay
|
||||||
|
2. Test with featherChat (no featherChat changes needed)
|
||||||
|
3. Remove wzp-web from Docker stack
|
||||||
|
4. Later: add WebTransport alongside WS
|
||||||
Reference in New Issue
Block a user