diff --git a/README.md b/README.md index 713ccb7..edf0963 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ PORT=8081 npm start - Custom STUN/TURN: gear icon → JSON override in `localStorage`, applied on next join. Server default comes from env (below). - Mute (M), deafen (D), per-peer volume, speaking indicators. +- Voice capture uses mono 48 kHz Opus-oriented settings, a 64 kbps audio ceiling, + and periodic packet-loss, jitter, and RTT quality diagnostics. ## ICE configuration (server side) @@ -49,5 +51,7 @@ service instead of depending on NAT hairpinning or direct candidate pairs. behind the preview ingress (use wss/TLS in front for real deploys). - No media server: N² mesh — fine for small rooms, needs SFU (mediasoup/livekit) beyond ~6–8 participants. +- For larger rooms or consistently lower bandwidth, replace the mesh with an SFU; + the current quality tuning improves the P2P path but does not change its N² cost. - Text channels are decorative. No persistence, no rate limiting. - Screen-share negotiation storms on join/leave are expected at small scale. diff --git a/public/app.js b/public/app.js index 3150154..6d87f48 100644 --- a/public/app.js +++ b/public/app.js @@ -131,6 +131,78 @@ async function selectedIcePath(pc) { }; } +async function tuneAudioSender(sender) { + if (!sender?.getParameters || !sender?.setParameters) return; + const params = sender.getParameters(); + if (!params.encodings?.length) params.encodings = [{}]; + params.encodings[0].maxBitrate = 64000; + params.encodings[0].dtx = false; + params.encodings[0].priority = 'high'; + await sender.setParameters(params); +} + +function preferOpus(pc) { + const capabilities = globalThis.RTCRtpReceiver?.getCapabilities?.('audio'); + const codecs = capabilities?.codecs || []; + const opus = codecs.filter((codec) => codec.mimeType?.toLowerCase() === 'audio/opus'); + if (!opus.length) return; + const ordered = [...opus, ...codecs.filter((codec) => !opus.includes(codec))]; + for (const transceiver of pc.getTransceivers()) { + if (transceiver.receiver?.track?.kind === 'audio' || transceiver.sender?.track?.kind === 'audio') { + try { transceiver.setCodecPreferences(ordered); } catch (err) { + captureRumIssue('Could not prefer Opus audio codec', 'WARNING', { error: err?.message || String(err) }); + } + } + } +} + +function monitorPeerQuality(peer) { + if (peer.qualityTimer) clearInterval(peer.qualityTimer); + peer.qualityTimer = setInterval(async () => { + const pc = peer.pc; + if (!pc || pc.connectionState !== 'connected' || !pc.getStats) return; + try { + const stats = await pc.getStats(); + let inbound; + let outbound; + let remoteInbound; + stats.forEach((report) => { + if (report.kind !== 'audio' && report.mediaType !== 'audio') return; + if (report.type === 'inbound-rtp') inbound = report; + if (report.type === 'outbound-rtp') outbound = report; + if (report.type === 'remote-inbound-rtp') remoteInbound = report; + }); + const packets = (inbound?.packetsReceived || 0) + (inbound?.packetsLost || 0); + const lossRatio = packets > 20 ? (inbound.packetsLost || 0) / packets : 0; + const jitterMs = (inbound?.jitter || 0) * 1000; + const rttMs = (remoteInbound?.roundTripTime || 0) * 1000; + const issue = lossRatio >= 0.05 || jitterMs >= 80 || rttMs >= 500; + if (issue && (!peer.lastQualityNotice || Date.now() - peer.lastQualityNotice > ICE_NOTICE_INTERVAL)) { + peer.lastQualityNotice = Date.now(); + const details = `quality degraded (loss ${Math.round(lossRatio * 100)}%, jitter ${Math.round(jitterMs)}ms${rttMs ? `, RTT ${Math.round(rttMs)}ms` : ''})`; + toast(`${peer.name}: ${details}`, 'warn'); + captureRumIssue('WebRTC audio quality degraded', 'WARNING', { + peerId: peer.id, + peerName: peer.name, + lossRatio, + jitterMs, + rttMs, + packetsReceived: inbound?.packetsReceived, + packetsLost: inbound?.packetsLost, + bytesReceived: inbound?.bytesReceived, + bytesSent: outbound?.bytesSent, + path: peer.icePath?.path, + }); + } + } catch (err) { + captureRumIssue('Could not inspect WebRTC audio quality', 'WARNING', { + peerId: peer.id, + error: err?.message || String(err), + }); + } + }, 5000); +} + function playRetroCue(name) { const ctx = S.audioCtx; if (!ctx) return; @@ -191,7 +263,14 @@ function effectiveIce() { } function microphoneConstraints(deviceId = localStorage.getItem(LS_AUDIO_INPUT)) { - const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; + const audio = { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + channelCount: 1, + sampleRate: 48000, + sampleSize: 16, + }; if (deviceId) audio.deviceId = { exact: deviceId }; return { audio }; } @@ -561,6 +640,8 @@ function makePeerRecord(p) { resetAttempts: 0, lastIceNotice: null, icePath: null, + qualityTimer: null, + lastQualityNotice: 0, els: {}, }; } @@ -658,16 +739,24 @@ function ensurePC(peer, initiator) { error: err?.message || String(err), }); }); + monitorPeerQuality(peer); } }; pc.ontrack = (ev) => onRemoteTrack(peer, ev); // send my mic; if I have none, ask to receive theirs if (S.micStream) { - for (const track of S.micStream.getAudioTracks()) pc.addTrack(track, S.micStream); + for (const track of S.micStream.getAudioTracks()) { + const sender = pc.addTrack(track, S.micStream); + tuneAudioSender(sender).catch((err) => captureRumIssue('Could not tune Opus audio sender', 'WARNING', { + peerId: peer.id, + error: err?.message || String(err), + })); + } } else { pc.addTransceiver('audio', { direction: 'recvonly' }); } + preferOpus(pc); // send my screen share if I am currently sharing when this peer connects if (S.sharing) { @@ -710,6 +799,7 @@ function recreatePeerConnection(peer, { announce }) { send({ type: 'reset', to: peer.id }); } if (peer.pc) { try { peer.pc.close(); } catch {} } + if (peer.qualityTimer) { clearInterval(peer.qualityTimer); peer.qualityTimer = null; } peer.pc = null; peer.makingOffer = false; peer.ignoreOffer = false; @@ -754,6 +844,7 @@ async function onCandidate(peer, candidate) { function destroyPeer(peer) { if (peer.pc) { try { peer.pc.close(); } catch {} peer.pc = null; } + if (peer.qualityTimer) { clearInterval(peer.qualityTimer); peer.qualityTimer = null; } detachSpatial(peer); if (peer.audioEl) { try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {}