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:
@@ -155,8 +155,10 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
|
||||
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 =
|
||||
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,
|
||||
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; }
|
||||
.level { margin-top: 1rem; height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -33,6 +38,10 @@
|
||||
<input type="text" id="room" placeholder="enter room name" value="">
|
||||
</div>
|
||||
<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="status" id="status"></div>
|
||||
<div class="stats" id="stats"></div>
|
||||
@@ -48,6 +57,8 @@ let mediaStream = null;
|
||||
let captureNode = null;
|
||||
let playbackNode = null;
|
||||
let active = false;
|
||||
let transmitting = true; // in open-mic mode, always transmitting
|
||||
let pttMode = false;
|
||||
let framesSent = 0;
|
||||
let framesRecv = 0;
|
||||
let startTime = 0;
|
||||
@@ -108,6 +119,7 @@ async function startCall() {
|
||||
framesSent = 0;
|
||||
framesRecv = 0;
|
||||
startTime = Date.now();
|
||||
showControls(true);
|
||||
await startAudioCapture();
|
||||
await startAudioPlayback();
|
||||
startStatsUpdate();
|
||||
@@ -142,6 +154,7 @@ function stopCall() {
|
||||
btn.textContent = 'Connect';
|
||||
btn.classList.remove('active');
|
||||
btn.disabled = false;
|
||||
showControls(false);
|
||||
cleanupAudio();
|
||||
if (ws) { ws.close(); ws = null; }
|
||||
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
|
||||
@@ -163,8 +176,7 @@ async function startAudioCapture() {
|
||||
await audioCtx.audioWorklet.addModule('audio-processor.js');
|
||||
captureNode = new AudioWorkletNode(audioCtx, 'capture-processor');
|
||||
captureNode.port.onmessage = (e) => {
|
||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
// e.data is an ArrayBuffer of Int16 PCM
|
||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
||||
ws.send(e.data);
|
||||
framesSent++;
|
||||
|
||||
@@ -182,7 +194,7 @@ async function startAudioCapture() {
|
||||
captureNode = audioCtx.createScriptProcessor(1024, 1, 1);
|
||||
let acc = new Float32Array(0);
|
||||
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 n = new Float32Array(acc.length + input.length);
|
||||
n.set(acc); n.set(input, acc.length); acc = n;
|
||||
@@ -250,6 +262,55 @@ function startStatsUpdate() {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// --- Push-to-talk ---
|
||||
|
||||
function togglePTT() {
|
||||
pttMode = document.getElementById('pttMode').checked;
|
||||
const btn = document.getElementById('pttBtn');
|
||||
if (pttMode) {
|
||||
transmitting = false;
|
||||
btn.style.display = 'block';
|
||||
} else {
|
||||
transmitting = true;
|
||||
btn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// PTT button — hold to talk (mouse + touch)
|
||||
document.getElementById('pttBtn').addEventListener('mousedown', () => { startTransmit(); });
|
||||
document.getElementById('pttBtn').addEventListener('mouseup', () => { stopTransmit(); });
|
||||
document.getElementById('pttBtn').addEventListener('mouseleave', () => { stopTransmit(); });
|
||||
document.getElementById('pttBtn').addEventListener('touchstart', (e) => { e.preventDefault(); startTransmit(); });
|
||||
document.getElementById('pttBtn').addEventListener('touchend', (e) => { e.preventDefault(); stopTransmit(); });
|
||||
|
||||
// Spacebar PTT
|
||||
document.addEventListener('keydown', (e) => { if (pttMode && active && e.code === 'Space' && !e.repeat) { e.preventDefault(); startTransmit(); } });
|
||||
document.addEventListener('keyup', (e) => { if (pttMode && active && e.code === 'Space') { e.preventDefault(); stopTransmit(); } });
|
||||
|
||||
function startTransmit() {
|
||||
if (!pttMode || !active) return;
|
||||
transmitting = true;
|
||||
document.getElementById('pttBtn').classList.add('transmitting');
|
||||
document.getElementById('pttBtn').textContent = 'Transmitting...';
|
||||
}
|
||||
|
||||
function stopTransmit() {
|
||||
if (!pttMode) return;
|
||||
transmitting = false;
|
||||
document.getElementById('pttBtn').classList.remove('transmitting');
|
||||
document.getElementById('pttBtn').textContent = 'Hold to Talk';
|
||||
}
|
||||
|
||||
// Show controls when connected
|
||||
function showControls(show) {
|
||||
document.getElementById('controls').style.display = show ? 'flex' : 'none';
|
||||
if (!show) {
|
||||
document.getElementById('pttBtn').style.display = 'none';
|
||||
pttMode = false;
|
||||
transmitting = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Set room from URL on load
|
||||
window.addEventListener('load', () => {
|
||||
const room = getRoom();
|
||||
|
||||
Reference in New Issue
Block a user