feat: multi-party rooms (SFU) + push-to-talk radio mode
Room-based SFU relay: - Clients join named rooms (room name from QUIC SNI) - Each participant's packets forwarded to all others (no mixing) - Multiple rooms run concurrently on one relay - Web bridge passes room name from URL path to relay Push-to-talk (radio mode): - Toggle "Radio mode" checkbox after connecting - Hold PTT button or spacebar to transmit - Release to mute mic (receive-only) - Works on desktop (spacebar) and mobile (touch) URL routing: - /myroom → joins room "myroom" - Room name input field as fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ serde = { workspace = true }
|
|||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
|
quinn = { workspace = true }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-relay"
|
name = "wzp-relay"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
pub mod room;
|
||||||
pub mod session_mgr;
|
pub mod session_mgr;
|
||||||
|
|
||||||
pub use config::RelayConfig;
|
pub use config::RelayConfig;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
//! WarzonePhone relay daemon entry point.
|
//! WarzonePhone relay daemon entry point.
|
||||||
//!
|
//!
|
||||||
//! Accepts client QUIC connections and bridges pairs of clients together.
|
//! Supports two modes:
|
||||||
//! When a --remote relay is configured, forwards traffic to it instead.
|
//! - **Room mode** (default): clients join named rooms, packets forwarded to all others (SFU)
|
||||||
|
//! - **Forward mode** (--remote): all traffic forwarded to a remote relay
|
||||||
|
//!
|
||||||
|
//! Room names are passed via the QUIC SNI (server_name) field.
|
||||||
|
//! The web bridge connects with room name as SNI.
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
@@ -14,7 +18,7 @@ use tracing::{error, info};
|
|||||||
use wzp_proto::MediaTransport;
|
use wzp_proto::MediaTransport;
|
||||||
use wzp_relay::config::RelayConfig;
|
use wzp_relay::config::RelayConfig;
|
||||||
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
||||||
use wzp_relay::session_mgr::SessionManager;
|
use wzp_relay::room::{self, RoomManager};
|
||||||
|
|
||||||
fn parse_args() -> RelayConfig {
|
fn parse_args() -> RelayConfig {
|
||||||
let mut config = RelayConfig::default();
|
let mut config = RelayConfig::default();
|
||||||
@@ -39,7 +43,12 @@ fn parse_args() -> RelayConfig {
|
|||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Options:");
|
eprintln!("Options:");
|
||||||
eprintln!(" --listen <addr> Listen address (default: 0.0.0.0:4433)");
|
eprintln!(" --listen <addr> Listen address (default: 0.0.0.0:4433)");
|
||||||
eprintln!(" --remote <addr> Remote relay address for forwarding");
|
eprintln!(" --remote <addr> Remote relay for forwarding (disables room mode)");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Room mode (default):");
|
||||||
|
eprintln!(" Clients join rooms by name. Packets are forwarded to all");
|
||||||
|
eprintln!(" other participants in the same room (SFU model).");
|
||||||
|
eprintln!(" Room name comes from QUIC SNI or defaults to 'default'.");
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
@@ -57,78 +66,6 @@ struct RelayStats {
|
|||||||
downstream_packets: AtomicU64,
|
downstream_packets: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bridge two transports: A's packets go to B, B's go to A.
|
|
||||||
async fn run_bridge(
|
|
||||||
a: Arc<wzp_transport::QuinnTransport>,
|
|
||||||
b: Arc<wzp_transport::QuinnTransport>,
|
|
||||||
a_addr: SocketAddr,
|
|
||||||
b_addr: SocketAddr,
|
|
||||||
) {
|
|
||||||
info!(%a_addr, %b_addr, "bridging two clients");
|
|
||||||
|
|
||||||
let stats = Arc::new(RelayStats {
|
|
||||||
upstream_packets: AtomicU64::new(0),
|
|
||||||
downstream_packets: AtomicU64::new(0),
|
|
||||||
});
|
|
||||||
|
|
||||||
let stats_log = stats.clone();
|
|
||||||
let stats_handle = tokio::spawn(async move {
|
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
let ab = stats_log.upstream_packets.load(Ordering::Relaxed);
|
|
||||||
let ba = stats_log.downstream_packets.load(Ordering::Relaxed);
|
|
||||||
info!(a_to_b = ab, b_to_a = ba, "bridge stats");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let a1 = a.clone();
|
|
||||||
let b1 = b.clone();
|
|
||||||
let s1 = stats.clone();
|
|
||||||
let a_to_b = tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
match a1.recv_media().await {
|
|
||||||
Ok(Some(pkt)) => {
|
|
||||||
if let Err(e) = b1.send_media(&pkt).await {
|
|
||||||
error!("A→B send error: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
s1.upstream_packets.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
Ok(None) => { info!(%a_addr, "client A disconnected"); break; }
|
|
||||||
Err(e) => { error!(%a_addr, "A recv error: {e}"); break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let a2 = a.clone();
|
|
||||||
let b2 = b.clone();
|
|
||||||
let s2 = stats.clone();
|
|
||||||
let b_to_a = tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
match b2.recv_media().await {
|
|
||||||
Ok(Some(pkt)) => {
|
|
||||||
if let Err(e) = a2.send_media(&pkt).await {
|
|
||||||
error!("B→A send error: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
s2.downstream_packets.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
Ok(None) => { info!(%b_addr, "client B disconnected"); break; }
|
|
||||||
Err(e) => { error!(%b_addr, "B recv error: {e}"); break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
_ = a_to_b => {}
|
|
||||||
_ = b_to_a => {}
|
|
||||||
}
|
|
||||||
stats_handle.abort();
|
|
||||||
info!(%a_addr, %b_addr, "bridge ended");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run upstream forwarding: client → pipeline → remote.
|
|
||||||
async fn run_upstream(
|
async fn run_upstream(
|
||||||
client: Arc<wzp_transport::QuinnTransport>,
|
client: Arc<wzp_transport::QuinnTransport>,
|
||||||
remote: Arc<wzp_transport::QuinnTransport>,
|
remote: Arc<wzp_transport::QuinnTransport>,
|
||||||
@@ -159,7 +96,6 @@ async fn run_upstream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run downstream forwarding: remote → pipeline → client.
|
|
||||||
async fn run_downstream(
|
async fn run_downstream(
|
||||||
client: Arc<wzp_transport::QuinnTransport>,
|
client: Arc<wzp_transport::QuinnTransport>,
|
||||||
remote: Arc<wzp_transport::QuinnTransport>,
|
remote: Arc<wzp_transport::QuinnTransport>,
|
||||||
@@ -190,12 +126,6 @@ async fn run_downstream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Waiting client: address + transport.
|
|
||||||
struct WaitingClient {
|
|
||||||
addr: SocketAddr,
|
|
||||||
transport: Arc<wzp_transport::QuinnTransport>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let config = parse_args();
|
let config = parse_args();
|
||||||
@@ -205,29 +135,24 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.expect("failed to install rustls crypto provider");
|
.expect("failed to install rustls crypto provider");
|
||||||
|
|
||||||
info!(addr = %config.listen_addr, "WarzonePhone relay starting");
|
info!(addr = %config.listen_addr, "WarzonePhone relay starting");
|
||||||
if let Some(remote) = config.remote_relay {
|
|
||||||
info!(%remote, "forwarding mode → remote relay");
|
|
||||||
} else {
|
|
||||||
info!("bridge mode — pairs clients together (echo when alone)");
|
|
||||||
}
|
|
||||||
|
|
||||||
let (server_config, _cert) = wzp_transport::server_config();
|
let (server_config, _cert) = wzp_transport::server_config();
|
||||||
let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
|
let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
|
||||||
|
|
||||||
let _sessions = Arc::new(Mutex::new(SessionManager::new(config.max_sessions)));
|
// Forward mode
|
||||||
|
|
||||||
// Remote relay transport (forwarding mode only)
|
|
||||||
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> =
|
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> =
|
||||||
if let Some(remote_addr) = config.remote_relay {
|
if let Some(remote_addr) = config.remote_relay {
|
||||||
|
info!(%remote_addr, "forward mode → remote relay");
|
||||||
let client_cfg = wzp_transport::client_config();
|
let client_cfg = wzp_transport::client_config();
|
||||||
let conn = wzp_transport::connect(&endpoint, remote_addr, "localhost", client_cfg).await?;
|
let conn = wzp_transport::connect(&endpoint, remote_addr, "localhost", client_cfg).await?;
|
||||||
Some(Arc::new(wzp_transport::QuinnTransport::new(conn)))
|
Some(Arc::new(wzp_transport::QuinnTransport::new(conn)))
|
||||||
} else {
|
} else {
|
||||||
|
info!("room mode — clients join named rooms (SFU)");
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bridge mode: slot for waiting client
|
// Room manager (room mode only)
|
||||||
let waiting: Arc<Mutex<Option<WaitingClient>>> = Arc::new(Mutex::new(None));
|
let room_mgr = Arc::new(Mutex::new(RoomManager::new()));
|
||||||
|
|
||||||
info!("Listening for connections...");
|
info!("Listening for connections...");
|
||||||
|
|
||||||
@@ -238,15 +163,27 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let remote_transport = remote_transport.clone();
|
let remote_transport = remote_transport.clone();
|
||||||
let waiting = waiting.clone();
|
let room_mgr = room_mgr.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let addr = connection.remote_address();
|
let addr = connection.remote_address();
|
||||||
|
|
||||||
|
// Extract room name from QUIC handshake data (SNI).
|
||||||
|
// The web bridge connects with the room name as server_name.
|
||||||
|
let room_name = connection
|
||||||
|
.handshake_data()
|
||||||
|
.and_then(|hd| {
|
||||||
|
hd.downcast::<quinn::crypto::rustls::HandshakeData>().ok()
|
||||||
|
})
|
||||||
|
.and_then(|hd| hd.server_name.clone())
|
||||||
|
.unwrap_or_else(|| "default".to_string());
|
||||||
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||||
info!(%addr, "new client");
|
|
||||||
|
info!(%addr, room = %room_name, "new client");
|
||||||
|
|
||||||
if let Some(remote) = remote_transport {
|
if let Some(remote) = remote_transport {
|
||||||
// Forwarding mode
|
// Forward mode — same as before
|
||||||
let stats = Arc::new(RelayStats {
|
let stats = Arc::new(RelayStats {
|
||||||
upstream_packets: AtomicU64::new(0),
|
upstream_packets: AtomicU64::new(0),
|
||||||
downstream_packets: AtomicU64::new(0),
|
downstream_packets: AtomicU64::new(0),
|
||||||
@@ -273,71 +210,21 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tokio::select! { _ = up => {} _ = dn => {} }
|
tokio::select! { _ = up => {} _ = dn => {} }
|
||||||
stats_handle.abort();
|
stats_handle.abort();
|
||||||
transport.close().await.ok();
|
transport.close().await.ok();
|
||||||
info!(%addr, "forwarding session ended");
|
|
||||||
} else {
|
} else {
|
||||||
// Bridge mode — try to pair with a waiting client
|
// Room mode — join room and forward to all others
|
||||||
let peer = {
|
let participant_id = {
|
||||||
let mut slot = waiting.lock().await;
|
let mut mgr = room_mgr.lock().await;
|
||||||
slot.take()
|
mgr.join(&room_name, addr, transport.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(peer_client) = peer {
|
room::run_participant(
|
||||||
// Second client — bridge immediately
|
room_mgr.clone(),
|
||||||
run_bridge(peer_client.transport.clone(), transport.clone(), peer_client.addr, addr).await;
|
room_name,
|
||||||
peer_client.transport.close().await.ok();
|
participant_id,
|
||||||
transport.close().await.ok();
|
transport.clone(),
|
||||||
|
).await;
|
||||||
|
|
||||||
// After bridge ends, clean up so next pair can form
|
transport.close().await.ok();
|
||||||
info!("bridge complete, ready for next pair");
|
|
||||||
} else {
|
|
||||||
// First client — register and wait
|
|
||||||
{
|
|
||||||
let mut slot = waiting.lock().await;
|
|
||||||
*slot = Some(WaitingClient { addr, transport: transport.clone() });
|
|
||||||
}
|
|
||||||
info!(%addr, "waiting for peer (echo in meantime)");
|
|
||||||
|
|
||||||
// Echo loop — but check periodically if we've been claimed by a bridge
|
|
||||||
loop {
|
|
||||||
// Check if we've been taken from the waiting slot
|
|
||||||
// (meaning a second client connected and started the bridge)
|
|
||||||
{
|
|
||||||
let slot = waiting.lock().await;
|
|
||||||
if slot.is_none() {
|
|
||||||
// We were taken — a bridge is running with our transport.
|
|
||||||
// Just exit this task; the bridge task handles everything.
|
|
||||||
info!(%addr, "peer connected, exiting echo loop");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Echo with a short timeout so we can check the slot again
|
|
||||||
match tokio::time::timeout(
|
|
||||||
Duration::from_millis(100),
|
|
||||||
transport.recv_media()
|
|
||||||
).await {
|
|
||||||
Ok(Ok(Some(pkt))) => {
|
|
||||||
let _ = transport.send_media(&pkt).await;
|
|
||||||
}
|
|
||||||
Ok(Ok(None)) => {
|
|
||||||
info!(%addr, "disconnected while waiting");
|
|
||||||
// Clean up our slot
|
|
||||||
let mut slot = waiting.lock().await;
|
|
||||||
*slot = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
error!(%addr, "echo error: {e}");
|
|
||||||
let mut slot = waiting.lock().await;
|
|
||||||
*slot = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Timeout — loop back and check if we got paired
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
200
crates/wzp-relay/src/room.rs
Normal file
200
crates/wzp-relay/src/room.rs
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
//! Room management for multi-party calls.
|
||||||
|
//!
|
||||||
|
//! Each room holds N participants. When one participant sends a media packet,
|
||||||
|
//! the relay forwards it to all other participants in the room (SFU model).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use wzp_proto::MediaTransport;
|
||||||
|
|
||||||
|
/// Unique participant ID within a room.
|
||||||
|
pub type ParticipantId = u64;
|
||||||
|
|
||||||
|
static NEXT_PARTICIPANT_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
fn next_id() -> ParticipantId {
|
||||||
|
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A participant in a room.
|
||||||
|
struct Participant {
|
||||||
|
id: ParticipantId,
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A room holding multiple participants.
|
||||||
|
struct Room {
|
||||||
|
participants: Vec<Participant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Room {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
participants: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(&mut self, addr: std::net::SocketAddr, transport: Arc<wzp_transport::QuinnTransport>) -> ParticipantId {
|
||||||
|
let id = next_id();
|
||||||
|
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
|
||||||
|
self.participants.push(Participant { id, addr, transport });
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove(&mut self, id: ParticipantId) {
|
||||||
|
self.participants.retain(|p| p.id != id);
|
||||||
|
info!(room_size = self.participants.len(), participant = id, "left room");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn others(&self, exclude_id: ParticipantId) -> Vec<Arc<wzp_transport::QuinnTransport>> {
|
||||||
|
self.participants
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.id != exclude_id)
|
||||||
|
.map(|p| p.transport.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.participants.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.participants.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages all rooms on the relay.
|
||||||
|
pub struct RoomManager {
|
||||||
|
rooms: HashMap<String, Room>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoomManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
rooms: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join a room. Returns the participant ID.
|
||||||
|
pub fn join(
|
||||||
|
&mut self,
|
||||||
|
room_name: &str,
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
|
) -> ParticipantId {
|
||||||
|
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||||
|
room.add(addr, transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave a room. Removes the room if empty.
|
||||||
|
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) {
|
||||||
|
if let Some(room) = self.rooms.get_mut(room_name) {
|
||||||
|
room.remove(participant_id);
|
||||||
|
if room.is_empty() {
|
||||||
|
self.rooms.remove(room_name);
|
||||||
|
info!(room = room_name, "room closed (empty)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get transports for all OTHER participants in a room.
|
||||||
|
pub fn others(
|
||||||
|
&self,
|
||||||
|
room_name: &str,
|
||||||
|
participant_id: ParticipantId,
|
||||||
|
) -> Vec<Arc<wzp_transport::QuinnTransport>> {
|
||||||
|
self.rooms
|
||||||
|
.get(room_name)
|
||||||
|
.map(|r| r.others(participant_id))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get room size.
|
||||||
|
pub fn room_size(&self, room_name: &str) -> usize {
|
||||||
|
self.rooms.get(room_name).map(|r| r.len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all rooms with their sizes.
|
||||||
|
pub fn list(&self) -> Vec<(String, usize)> {
|
||||||
|
self.rooms.iter().map(|(k, v)| (k.clone(), v.len())).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the receive loop for one participant in a room.
|
||||||
|
/// Forwards all received packets to every other participant.
|
||||||
|
pub async fn run_participant(
|
||||||
|
room_mgr: Arc<Mutex<RoomManager>>,
|
||||||
|
room_name: String,
|
||||||
|
participant_id: ParticipantId,
|
||||||
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
|
) {
|
||||||
|
let addr = transport.connection().remote_address();
|
||||||
|
let mut packets_forwarded = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let pkt = match transport.recv_media().await {
|
||||||
|
Ok(Some(pkt)) => pkt,
|
||||||
|
Ok(None) => {
|
||||||
|
info!(%addr, participant = participant_id, "disconnected");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(%addr, participant = participant_id, "recv error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current list of other participants
|
||||||
|
let others = {
|
||||||
|
let mgr = room_mgr.lock().await;
|
||||||
|
mgr.others(&room_name, participant_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forward to all others
|
||||||
|
for other in &others {
|
||||||
|
// Best-effort: if one send fails, continue to others
|
||||||
|
if let Err(e) = other.send_media(&pkt).await {
|
||||||
|
// Don't log every failure — they'll be cleaned up when their recv loop breaks
|
||||||
|
let _ = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packets_forwarded += 1;
|
||||||
|
if packets_forwarded % 500 == 0 {
|
||||||
|
let room_size = {
|
||||||
|
let mgr = room_mgr.lock().await;
|
||||||
|
mgr.room_size(&room_name)
|
||||||
|
};
|
||||||
|
info!(
|
||||||
|
room = %room_name,
|
||||||
|
participant = participant_id,
|
||||||
|
forwarded = packets_forwarded,
|
||||||
|
room_size,
|
||||||
|
"participant stats"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
let mut mgr = room_mgr.lock().await;
|
||||||
|
mgr.leave(&room_name, participant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn room_join_leave() {
|
||||||
|
let mut mgr = RoomManager::new();
|
||||||
|
// Can't test with real transports, but test the room logic
|
||||||
|
assert_eq!(mgr.room_size("test"), 0);
|
||||||
|
assert!(mgr.list().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,8 +155,10 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
|
|||||||
Err(e) => { error!("create endpoint: {e}"); return; }
|
Err(e) => { error!("create endpoint: {e}"); return; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pass room name as QUIC SNI so the relay knows which room to join
|
||||||
|
let sni = if room.is_empty() { "default" } else { &room };
|
||||||
let connection =
|
let connection =
|
||||||
match wzp_transport::connect(&endpoint, relay_addr, "localhost", client_config).await {
|
match wzp_transport::connect(&endpoint, relay_addr, sni, client_config).await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => { error!("connect to relay: {e}"); return; }
|
Err(e) => { error!("connect to relay: {e}"); return; }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
.stats { margin-top: 0.5rem; font-size: 0.75rem; color: #555; font-family: monospace; }
|
.stats { margin-top: 0.5rem; font-size: 0.75rem; color: #555; font-family: monospace; }
|
||||||
.level { margin-top: 1rem; height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
|
.level { margin-top: 1rem; height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
|
||||||
.level-bar { height: 100%; background: #00d4ff; width: 0%; transition: width 50ms; }
|
.level-bar { height: 100%; background: #00d4ff; width: 0%; transition: width 50ms; }
|
||||||
|
.controls { margin-top: 1rem; display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
|
||||||
|
.controls label { font-size: 0.8rem; color: #888; cursor: pointer; display: flex; align-items: center; gap: 0.3rem; }
|
||||||
|
.controls input[type="checkbox"] { accent-color: #00d4ff; }
|
||||||
|
#pttBtn { display: none; background: #444; color: #e0e0e0; border: 2px solid #666; padding: 0.8rem 2rem; font-size: 1rem; border-radius: 12px; cursor: pointer; user-select: none; -webkit-user-select: none; touch-action: none; }
|
||||||
|
#pttBtn.transmitting { background: #ff4444; border-color: #ff6666; color: white; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -33,6 +38,10 @@
|
|||||||
<input type="text" id="room" placeholder="enter room name" value="">
|
<input type="text" id="room" placeholder="enter room name" value="">
|
||||||
</div>
|
</div>
|
||||||
<button id="callBtn" onclick="toggleCall()">Connect</button>
|
<button id="callBtn" onclick="toggleCall()">Connect</button>
|
||||||
|
<div class="controls" id="controls" style="display:none;">
|
||||||
|
<label><input type="checkbox" id="pttMode" onchange="togglePTT()"> Radio mode (push-to-talk)</label>
|
||||||
|
</div>
|
||||||
|
<button id="pttBtn">Hold to Talk</button>
|
||||||
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
||||||
<div class="status" id="status"></div>
|
<div class="status" id="status"></div>
|
||||||
<div class="stats" id="stats"></div>
|
<div class="stats" id="stats"></div>
|
||||||
@@ -48,6 +57,8 @@ let mediaStream = null;
|
|||||||
let captureNode = null;
|
let captureNode = null;
|
||||||
let playbackNode = null;
|
let playbackNode = null;
|
||||||
let active = false;
|
let active = false;
|
||||||
|
let transmitting = true; // in open-mic mode, always transmitting
|
||||||
|
let pttMode = false;
|
||||||
let framesSent = 0;
|
let framesSent = 0;
|
||||||
let framesRecv = 0;
|
let framesRecv = 0;
|
||||||
let startTime = 0;
|
let startTime = 0;
|
||||||
@@ -108,6 +119,7 @@ async function startCall() {
|
|||||||
framesSent = 0;
|
framesSent = 0;
|
||||||
framesRecv = 0;
|
framesRecv = 0;
|
||||||
startTime = Date.now();
|
startTime = Date.now();
|
||||||
|
showControls(true);
|
||||||
await startAudioCapture();
|
await startAudioCapture();
|
||||||
await startAudioPlayback();
|
await startAudioPlayback();
|
||||||
startStatsUpdate();
|
startStatsUpdate();
|
||||||
@@ -142,6 +154,7 @@ function stopCall() {
|
|||||||
btn.textContent = 'Connect';
|
btn.textContent = 'Connect';
|
||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
showControls(false);
|
||||||
cleanupAudio();
|
cleanupAudio();
|
||||||
if (ws) { ws.close(); ws = null; }
|
if (ws) { ws.close(); ws = null; }
|
||||||
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
|
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
|
||||||
@@ -163,8 +176,7 @@ async function startAudioCapture() {
|
|||||||
await audioCtx.audioWorklet.addModule('audio-processor.js');
|
await audioCtx.audioWorklet.addModule('audio-processor.js');
|
||||||
captureNode = new AudioWorkletNode(audioCtx, 'capture-processor');
|
captureNode = new AudioWorkletNode(audioCtx, 'capture-processor');
|
||||||
captureNode.port.onmessage = (e) => {
|
captureNode.port.onmessage = (e) => {
|
||||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN) return;
|
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
||||||
// e.data is an ArrayBuffer of Int16 PCM
|
|
||||||
ws.send(e.data);
|
ws.send(e.data);
|
||||||
framesSent++;
|
framesSent++;
|
||||||
|
|
||||||
@@ -182,7 +194,7 @@ async function startAudioCapture() {
|
|||||||
captureNode = audioCtx.createScriptProcessor(1024, 1, 1);
|
captureNode = audioCtx.createScriptProcessor(1024, 1, 1);
|
||||||
let acc = new Float32Array(0);
|
let acc = new Float32Array(0);
|
||||||
captureNode.onaudioprocess = (ev) => {
|
captureNode.onaudioprocess = (ev) => {
|
||||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN) return;
|
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
||||||
const input = ev.inputBuffer.getChannelData(0);
|
const input = ev.inputBuffer.getChannelData(0);
|
||||||
const n = new Float32Array(acc.length + input.length);
|
const n = new Float32Array(acc.length + input.length);
|
||||||
n.set(acc); n.set(input, acc.length); acc = n;
|
n.set(acc); n.set(input, acc.length); acc = n;
|
||||||
@@ -250,6 +262,55 @@ function startStatsUpdate() {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Push-to-talk ---
|
||||||
|
|
||||||
|
function togglePTT() {
|
||||||
|
pttMode = document.getElementById('pttMode').checked;
|
||||||
|
const btn = document.getElementById('pttBtn');
|
||||||
|
if (pttMode) {
|
||||||
|
transmitting = false;
|
||||||
|
btn.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
transmitting = true;
|
||||||
|
btn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTT button — hold to talk (mouse + touch)
|
||||||
|
document.getElementById('pttBtn').addEventListener('mousedown', () => { startTransmit(); });
|
||||||
|
document.getElementById('pttBtn').addEventListener('mouseup', () => { stopTransmit(); });
|
||||||
|
document.getElementById('pttBtn').addEventListener('mouseleave', () => { stopTransmit(); });
|
||||||
|
document.getElementById('pttBtn').addEventListener('touchstart', (e) => { e.preventDefault(); startTransmit(); });
|
||||||
|
document.getElementById('pttBtn').addEventListener('touchend', (e) => { e.preventDefault(); stopTransmit(); });
|
||||||
|
|
||||||
|
// Spacebar PTT
|
||||||
|
document.addEventListener('keydown', (e) => { if (pttMode && active && e.code === 'Space' && !e.repeat) { e.preventDefault(); startTransmit(); } });
|
||||||
|
document.addEventListener('keyup', (e) => { if (pttMode && active && e.code === 'Space') { e.preventDefault(); stopTransmit(); } });
|
||||||
|
|
||||||
|
function startTransmit() {
|
||||||
|
if (!pttMode || !active) return;
|
||||||
|
transmitting = true;
|
||||||
|
document.getElementById('pttBtn').classList.add('transmitting');
|
||||||
|
document.getElementById('pttBtn').textContent = 'Transmitting...';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTransmit() {
|
||||||
|
if (!pttMode) return;
|
||||||
|
transmitting = false;
|
||||||
|
document.getElementById('pttBtn').classList.remove('transmitting');
|
||||||
|
document.getElementById('pttBtn').textContent = 'Hold to Talk';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show controls when connected
|
||||||
|
function showControls(show) {
|
||||||
|
document.getElementById('controls').style.display = show ? 'flex' : 'none';
|
||||||
|
if (!show) {
|
||||||
|
document.getElementById('pttBtn').style.display = 'none';
|
||||||
|
pttMode = false;
|
||||||
|
transmitting = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set room from URL on load
|
// Set room from URL on load
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
const room = getRoom();
|
const room = getRoom();
|
||||||
|
|||||||
Reference in New Issue
Block a user