From 365415740e2c31f2553267078a57797596e1d539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 22 Sep 2026 16:31:21 +0200 Subject: [PATCH 1/9] improve WebRTC NAT reliability diagnostics --- README.md | 5 +++ k8s/deployment.yaml | 12 +++---- public/app.js | 79 ++++++++++++++++++++++++++++++++++++++++++++- server.js | 9 ++++-- test.js | 10 ++++++ 5 files changed, 104 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2076e86..713ccb7 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ PORT=8081 npm start | `STUN_URLS` | comma-separated `stun:` urls (Google defaults) | | `TURN_URLS` | comma-separated `turn:`/`turns:` urls | | `TURN_USERNAME` / `TURN_CREDENTIAL` | static TURN creds | +| `ICE_TRANSPORT_POLICY` | WebRTC ICE policy: `all`, `relay`, or `none` | | `ICE_SERVERS_JSON` | full `{"iceServers":[...]}` override, wins over the above | | `PORT` / `HOST` | listen address (default 8081 / 0.0.0.0) | @@ -38,6 +39,10 @@ TURN_URLS='turn:turn.example.com:3478?transport=udp,turns:turn.example.com:5349' TURN_USERNAME=mats TURN_CREDENTIAL=secret npm start ``` +For deployments where clients are commonly behind separate NATs, set +`ICE_TRANSPORT_POLICY=relay` so media consistently uses the public TURN +service instead of depending on NAT hairpinning or direct candidate pairs. + ## POC shortcuts (productionize later) - No auth, no reconnection, rooms are in-memory, signaling is unencrypted ws diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index b57c4ae..bfd8686 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -36,6 +36,10 @@ spec: value: "stun:turn.k6n.net:3478,stun:stun.l.google.com:19302" - name: TURN_URLS value: "turn:turn.k6n.net:3478?transport=udp,turn:turn.k6n.net:3478?transport=tcp,turns:turn.k6n.net:5349?transport=tcp" + # Route media through the public coturn service. This avoids selecting + # direct/private candidates that fail across NATs or LAN hairpinning. + - name: ICE_TRANSPORT_POLICY + value: "relay" - name: TURN_SECRET valueFrom: secretKeyRef: @@ -54,14 +58,6 @@ spec: name: coturn-auth key: password optional: true - - name: TURN_LAN_HOST - value: "10.10.3.46" - - name: TURN_LAN_SECRET - valueFrom: - secretKeyRef: - name: coturn-lan-secret - key: static-auth-secret - optional: true - name: OTEL_INGEST_KEY valueFrom: secretKeyRef: diff --git a/public/app.js b/public/app.js index 6a452aa..e1a8efe 100644 --- a/public/app.js +++ b/public/app.js @@ -96,6 +96,41 @@ function toast(msg, kind = '') { setTimeout(() => t.remove(), 5000); } +const ICE_NOTICE_INTERVAL = 15000; +function notifyIceIssue(peer, message, extra = {}) { + const now = Date.now(); + const key = `${message}:${extra.url || ''}`; + if (!peer || peer.lastIceNotice?.key !== key || now - peer.lastIceNotice.at > ICE_NOTICE_INTERVAL) { + toast(`${peer?.name || 'Voice'}: ${message}`, 'warn'); + if (peer) peer.lastIceNotice = { key, at: now }; + } + captureRumIssue(message, 'WARNING', { + peerId: peer?.id, + peerName: peer?.name, + ...extra, + }); +} + +async function selectedIcePath(pc) { + if (!pc?.getStats) return null; + const stats = await pc.getStats(); + let pair; + stats.forEach((report) => { + if (report.type === 'candidate-pair' && (report.selected || report.nominated) && report.state === 'succeeded') { + pair = report; + } + }); + if (!pair) return null; + const local = stats.get(pair.localCandidateId); + const remote = stats.get(pair.remoteCandidateId); + return { + localType: local?.candidateType, + remoteType: remote?.candidateType, + protocol: pair.protocol, + path: local?.candidateType === 'relay' || remote?.candidateType === 'relay' ? 'TURN relay' : 'direct/STUN', + }; +} + function playRetroCue(name) { const ctx = S.audioCtx; if (!ctx) return; @@ -524,6 +559,8 @@ function makePeerRecord(p) { pendingCandidates: [], micStream: null, screenStreams: new Map(), gain: null, panner: null, analyser: null, vol: 1, speaking: false, resetAttempts: 0, + lastIceNotice: null, + icePath: null, els: {}, }; } @@ -562,17 +599,40 @@ function ensurePC(peer, initiator) { if (candidate) send({ type: 'candidate', to: peer.id, candidate }); }; pc.onicecandidateerror = (event) => { - captureRumIssue('WebRTC ICE candidate error', 'WARNING', { + notifyIceIssue(peer, `ICE server unavailable (${event.errorCode || 'unknown'})`, { peerId: peer.id, + iceConnectionState: pc.iceConnectionState, + iceGatheringState: pc.iceGatheringState, errorCode: event.errorCode, errorText: event.errorText, url: event.url, }); }; + pc.oniceconnectionstatechange = () => { + const state = pc.iceConnectionState; + if (state === 'disconnected') { + notifyIceIssue(peer, 'ICE disconnected; audio may stop', { + iceConnectionState: state, + iceGatheringState: pc.iceGatheringState, + }); + } else if (state === 'failed') { + notifyIceIssue(peer, 'ICE failed; trying TURN relay', { + iceConnectionState: state, + iceGatheringState: pc.iceGatheringState, + }); + } + }; pc.onconnectionstatechange = () => { renderTiles(); if (pc.connectionState === 'failed') { console.warn('peer connection failed, rebuilding it from scratch', peer.id); + notifyIceIssue(peer, 'connection failed; rebuilding voice path', { + connectionState: pc.connectionState, + iceConnectionState: pc.iceConnectionState, + iceGatheringState: pc.iceGatheringState, + signalingState: pc.signalingState, + resetAttempts: peer.resetAttempts, + }); captureRumIssue('WebRTC peer connection failed', 'ERROR', { peerId: peer.id, state: pc.connectionState, @@ -581,6 +641,23 @@ function ensurePC(peer, initiator) { recreatePeerConnection(peer, { announce: true }); } else if (pc.connectionState === 'connected') { peer.resetAttempts = 0; // a successful connection clears the failure count + selectedIcePath(pc).then((path) => { + if (!path) return; + peer.icePath = path; + toast(`${peer.name}: ${path.path} connection`, 'ok'); + captureRumIssue('WebRTC connection established', 'INFO', { + peerId: peer.id, + peerName: peer.name, + connectionState: pc.connectionState, + iceConnectionState: pc.iceConnectionState, + ...path, + }); + }).catch((err) => { + captureRumIssue('Could not inspect selected ICE path', 'WARNING', { + peerId: peer.id, + error: err?.message || String(err), + }); + }); } }; pc.ontrack = (ev) => onRemoteTrack(peer, ev); diff --git a/server.js b/server.js index 7c923f5..d61c6e6 100644 --- a/server.js +++ b/server.js @@ -24,6 +24,7 @@ const STATIC_DIR = fs.existsSync(path.join(__dirname, 'dist')) // STUN_URLS comma-separated stun: urls // TURN_URLS comma-separated turn:/turns: urls // TURN_USERNAME / TURN_CREDENTIAL static creds for all TURN urls +// ICE_TRANSPORT_POLICY optional WebRTC policy (all, relay, or none) // TURN_LAN_HOST / TURN_LAN_SECRET optional second, LAN-only TURN relay // (own coturn instance + secret, reachable only from // the local network via its own LoadBalancer IP with @@ -85,7 +86,12 @@ function iceConfig() { }); } } - return { iceServers }; + const config = { iceServers }; + const iceTransportPolicy = process.env.ICE_TRANSPORT_POLICY; + if (iceTransportPolicy && ['all', 'relay', 'none'].includes(iceTransportPolicy)) { + config.iceTransportPolicy = iceTransportPolicy; + } + return config; } // --------------------------------------------------------------------------- @@ -323,4 +329,3 @@ if (require.main === module) { module.exports = { server, wss, iceConfig, allRoomsSnapshot, interval, startServer }; - diff --git a/test.js b/test.js index ff05d8c..87dee0d 100644 --- a/test.js +++ b/test.js @@ -91,3 +91,13 @@ test('TURN credentials generation with TURN_SECRET', () => { delete process.env.TURN_SECRET; } }); + +test('ICE transport policy is passed through to browser configuration', () => { + const { iceConfig } = require('./server'); + process.env.ICE_TRANSPORT_POLICY = 'relay'; + try { + assert.strictEqual(iceConfig().iceTransportPolicy, 'relay'); + } finally { + delete process.env.ICE_TRANSPORT_POLICY; + } +}); From c9f9ff857e7180994bd75c91fdc850ac418f39e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 22 Sep 2026 16:39:20 +0200 Subject: [PATCH 2/9] add mobile ICE settings reset --- public/app.js | 14 ++++++++++++-- public/index.html | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/public/app.js b/public/app.js index e1a8efe..3150154 100644 --- a/public/app.js +++ b/public/app.js @@ -2074,10 +2074,20 @@ $('#ice-cancel').onclick = () => $('#modal-backdrop').classList.add('hidden'); $('#modal-backdrop').onclick = (ev) => { if (ev.target === $('#modal-backdrop')) $('#modal-backdrop').classList.add('hidden'); }; -$('#ice-reset').onclick = () => { +function resetIceSettings(reload = false) { localStorage.removeItem(LS_ICE); $('#override-enabled').checked = false; - fetchIce().then((server) => { $('#ice-json').value = JSON.stringify(server, null, 2); }); + fetchIce().then((server) => { + $('#ice-json').value = JSON.stringify(server, null, 2); + if (reload) location.reload(); + }).catch(() => { + if (reload) location.reload(); + }); +} +$('#ice-reset').onclick = () => resetIceSettings(); +$('#btn-reset-ice-mobile').onclick = () => { + toast('Resetting saved ICE settings…', 'ok'); + resetIceSettings(true); }; $('#ice-save').onclick = () => { const enabled = $('#override-enabled').checked; diff --git a/public/index.html b/public/index.html index 5782710..dd357f7 100644 --- a/public/index.html +++ b/public/index.html @@ -126,6 +126,7 @@ + From cb793c7ab6f72eb7706b56976c1cd5edaf937ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 22 Sep 2026 16:49:40 +0200 Subject: [PATCH 3/9] allow direct ICE with TURN fallback --- k8s/deployment.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index bfd8686..a289a2e 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -36,10 +36,9 @@ spec: value: "stun:turn.k6n.net:3478,stun:stun.l.google.com:19302" - name: TURN_URLS value: "turn:turn.k6n.net:3478?transport=udp,turn:turn.k6n.net:3478?transport=tcp,turns:turn.k6n.net:5349?transport=tcp" - # Route media through the public coturn service. This avoids selecting - # direct/private candidates that fail across NATs or LAN hairpinning. + # Prefer direct/STUN paths where they work, with public TURN fallback. - name: ICE_TRANSPORT_POLICY - value: "relay" + value: "all" - name: TURN_SECRET valueFrom: secretKeyRef: From f2d62ac142b11272f89ecc545d4b11416e9268ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 22 Sep 2026 17:03:01 +0200 Subject: [PATCH 4/9] tune voice quality and monitor media health --- README.md | 4 +++ public/app.js | 95 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 2 deletions(-) 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 {} From 1fc8f0d46989802ac1895b6585852572fc6097e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 22 Sep 2026 17:05:04 +0200 Subject: [PATCH 5/9] manage screen share preview lifecycle --- public/app.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/public/app.js b/public/app.js index 6d87f48..fd339cd 100644 --- a/public/app.js +++ b/public/app.js @@ -869,9 +869,26 @@ function onRemoteTrack(peer, ev) { track.addEventListener('ended', () => { if (peer.screenStreams.get(stream.id) === stream) { peer.screenStreams.delete(stream.id); + if (!peer.screenStreams.size) { + peer.state = { ...peer.state, sharing: false }; + captureRumIssue('Remote screen share ended', 'INFO', { + peerId: peer.id, + peerName: peer.name, + streamId: stream.id, + }); + } renderScreens(); + renderTiles(); } }); + stream.addEventListener('inactive', () => { + if (peer.screenStreams.get(stream.id) === stream) { + peer.screenStreams.delete(stream.id); + peer.state = { ...peer.state, sharing: false }; + renderScreens(); + renderTiles(); + } + }, { once: true }); renderScreens(); if (isNewScreenShare) playRetroCue('share'); console.log('remote screen share from', peer.name); @@ -993,6 +1010,10 @@ async function startScreenShare() { videoTrack.addEventListener('ended', () => stopScreenShare(stream)); } renderScreens(); + // Open a desktop preview automatically; mobile keeps the in-page preview. + if (window.matchMedia('(min-width: 700px)').matches) { + popOutShare({ key: `me:${stream.id}`, name: `${S.name} (you)`, stream, local: true }); + } renderTiles(); for (const btn of shareButtons) btn.classList.add('active'); toast('You are sharing your screen', 'ok'); From ee81c519a1c519e2e48133ccbb03f2658396f770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 22 Sep 2026 17:11:28 +0200 Subject: [PATCH 6/9] disable audio gain processing and prefer H264 --- public/app.js | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/public/app.js b/public/app.js index fd339cd..aad8628 100644 --- a/public/app.js +++ b/public/app.js @@ -156,6 +156,26 @@ function preferOpus(pc) { } } +function preferH264(pc) { + const capabilities = globalThis.RTCRtpReceiver?.getCapabilities?.('video'); + const codecs = capabilities?.codecs || []; + const rank = (codec) => { + const mime = codec.mimeType?.toLowerCase() || ''; + if (mime === 'video/h264') return 0; + if (mime === 'video/vp8') return 1; + if (mime === 'video/vp9') return 2; + return 3; + }; + const ordered = [...codecs].sort((a, b) => rank(a) - rank(b)); + for (const transceiver of pc.getTransceivers()) { + if (transceiver.receiver?.track?.kind === 'video' || transceiver.sender?.track?.kind === 'video') { + try { transceiver.setCodecPreferences(ordered); } catch (err) { + captureRumIssue('Could not prefer H.264 video codec', 'WARNING', { error: err?.message || String(err) }); + } + } + } +} + function monitorPeerQuality(peer) { if (peer.qualityTimer) clearInterval(peer.qualityTimer); peer.qualityTimer = setInterval(async () => { @@ -265,8 +285,8 @@ function effectiveIce() { function microphoneConstraints(deviceId = localStorage.getItem(LS_AUDIO_INPUT)) { const audio = { echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, + noiseSuppression: false, + autoGainControl: false, channelCount: 1, sampleRate: 48000, sampleSize: 16, @@ -1029,6 +1049,7 @@ function addScreenToPeer(peer) { } peerSenders.set(streamId, senders); } + preferH264(peer.pc); S.screenSenders.set(peer.id, peerSenders); // renegotiation fires automatically } From 7b9ff4c098cbc88e51202b9a671d23896b1ec000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 22 Sep 2026 17:21:12 +0200 Subject: [PATCH 7/9] add manual microphone gain and signal meter --- README.md | 2 ++ public/app.js | 55 ++++++++++++++++++++++++--------- public/audio-manager.js | 68 +++++++++++++++++++++++++++++++++++++++++ public/index.html | 11 +++++++ public/style.css | 4 +++ 5 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 public/audio-manager.js diff --git a/README.md b/README.md index edf0963..bca439e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ PORT=8081 npm start - 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. +- Audio input has manual gain control and a live signal/clipping meter in the + settings dialog; microphone processing is split into `public/audio-manager.js`. ## ICE configuration (server side) diff --git a/public/app.js b/public/app.js index aad8628..98626ad 100644 --- a/public/app.js +++ b/public/app.js @@ -1,5 +1,6 @@ import * as matrixcs from 'matrix-js-sdk'; import cytoscape from 'cytoscape'; +import { AudioManager } from './audio-manager.js'; function captureRumIssue(message, severity = 'ERROR', extra = {}) { const rum = globalThis.OtelRUM?.last; @@ -47,6 +48,8 @@ const S = { screenStreams: new Map(), // streamId -> local MediaStream screenSenders: new Map(), // peerId -> Map(streamId -> RTCRtpSender[]) audioCtx: null, + audioManager: new AudioManager(), + rawMicStream: null, me: { x: 50, y: 50 }, muted: false, deafened: false, @@ -331,15 +334,16 @@ async function switchAudioInput(deviceId) { return; } - const nextTrack = nextStream.getAudioTracks()[0]; + const nextOutput = await S.audioManager.attachInput(nextStream); + const nextTrack = nextOutput.getAudioTracks()[0]; nextTrack.enabled = !S.muted; await Promise.all([...S.peers.values()].map(async (peer) => { if (!peer.pc) return; const sender = peer.pc.getSenders().find((item) => item.track && item.track.kind === 'audio'); if (sender) await sender.replaceTrack(nextTrack); })); - S.micStream.getTracks().forEach((track) => track.stop()); - S.micStream = nextStream; + S.rawMicStream = nextStream; + S.micStream = nextOutput; updateMicButton(); toast('Microphone changed', 'ok'); } @@ -355,8 +359,8 @@ async function joinVoice(room) { // Mic permission & AudioContext: request immediately on the user-click gesture // so browsers don't block the microphone permission prompt or audio autoplay policy. - if (!S.audioCtx) S.audioCtx = new AudioContext(); - await S.audioCtx.resume().catch(() => {}); + await S.audioManager.ensureContext(); + S.audioCtx = S.audioManager.context; orientListener(); // Attach a one-time gesture listener in case browser autoplay policy keeps AudioContext suspended @@ -369,7 +373,8 @@ async function joinVoice(room) { window.addEventListener('keydown', resumeCtx, { passive: true }); try { - S.micStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints()); + S.rawMicStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints()); + S.micStream = await S.audioManager.attachInput(S.rawMicStream); populateAudioInputs(); } catch { S.micStream = null; @@ -423,7 +428,9 @@ function leaveVoice(reason) { S.peers.clear(); if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; } if (S.ws) { try { S.ws.close(); } catch {} S.ws = null; } - if (S.micStream) { S.micStream.getTracks().forEach((t) => t.stop()); S.micStream = null; } + S.audioManager.stop(); + S.rawMicStream = null; + S.micStream = null; // Keep share buttons enabled so clicking one prompts the user to join a channel updateMicButton(); document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active')); @@ -1106,16 +1113,20 @@ function rms(analyser) { return Math.sqrt(sum / meterBuf.length); } -let localAnalyser = null; +function updateMicSignal(level = S.audioManager.getLevel()) { + const meter = $('#mic-level'); + const value = $('#mic-level-value'); + const clipping = $('#mic-clipping'); + if (meter) meter.value = Math.min(1, level.rms * 3); + if (value) value.textContent = level.rms > 0 ? `${Math.round(20 * Math.log10(level.rms))} dB` : '-∞ dB'; + if (clipping) clipping.classList.toggle('hidden', !level.clipping); +} + setInterval(() => { if (!S.audioCtx) return; - if (S.micStream && !localAnalyser) { - const src = S.audioCtx.createMediaStreamSource(S.micStream); - localAnalyser = S.audioCtx.createAnalyser(); - localAnalyser.fftSize = 256; - src.connect(localAnalyser); // tap only — not connected to destination (no echo) - } - const meSpeaking = localAnalyser && !S.muted && rms(localAnalyser) > 0.04; + const localLevel = S.audioManager.getLevel(); + updateMicSignal(localLevel); + const meSpeaking = S.micStream && !S.muted && localLevel.rms > 0.04; els.meAvatar.classList.toggle('speaking', !!meSpeaking); let changed = false; for (const peer of S.peers.values()) { @@ -2192,6 +2203,9 @@ navigator.mediaDevices?.addEventListener('devicechange', populateAudioInputs); // --------------------------------------------------------------------------- function openSettings() { populateAudioInputs(); + const gain = $('#mic-gain'); + if (gain) gain.value = S.audioManager.gain; + updateMicGainUi(); const override = localStorage.getItem(LS_ICE); $('#override-enabled').checked = !!override; const current = override ? JSON.parse(override) : effectiveIce(); @@ -2202,6 +2216,17 @@ function openSettings() { ); $('#modal-backdrop').classList.remove('hidden'); } +function updateMicGainUi() { + const gain = S.audioManager.gain; + const input = $('#mic-gain'); + const value = $('#mic-gain-value'); + if (input) input.value = gain; + if (value) value.textContent = `${Math.round(gain * 100)}%`; +} +$('#mic-gain').oninput = (ev) => { + S.audioManager.setGain(ev.target.value); + updateMicGainUi(); +}; $('#audio-input').onchange = (ev) => switchAudioInput(ev.target.value); $('#ice-cancel').onclick = () => $('#modal-backdrop').classList.add('hidden'); $('#modal-backdrop').onclick = (ev) => { diff --git a/public/audio-manager.js b/public/audio-manager.js new file mode 100644 index 0000000..66c349b --- /dev/null +++ b/public/audio-manager.js @@ -0,0 +1,68 @@ +export class AudioManager { + constructor() { + this.context = null; + this.rawStream = null; + this.outputStream = null; + this.source = null; + this.gainNode = null; + this.analyser = null; + this.meterData = new Float32Array(1024); + this.gain = Number(localStorage.getItem('voicepoc.micGain') || 1); + } + + async ensureContext() { + if (!this.context) this.context = new AudioContext(); + await this.context.resume().catch(() => {}); + return this.context; + } + + async attachInput(stream) { + await this.ensureContext(); + this.detachInput(); + this.rawStream = stream; + this.source = this.context.createMediaStreamSource(stream); + this.gainNode = this.context.createGain(); + this.gainNode.gain.value = this.gain; + this.analyser = this.context.createAnalyser(); + this.analyser.fftSize = 1024; + const destination = this.context.createMediaStreamDestination(); + this.source.connect(this.gainNode).connect(this.analyser); + this.gainNode.connect(destination); + this.outputStream = destination.stream; + return this.outputStream; + } + + detachInput() { + for (const node of [this.source, this.gainNode, this.analyser]) { + try { node?.disconnect(); } catch {} + } + this.outputStream?.getTracks().forEach((track) => track.stop()); + this.rawStream?.getTracks().forEach((track) => track.stop()); + this.source = this.gainNode = this.analyser = null; + this.outputStream = this.rawStream = null; + } + + setGain(value) { + this.gain = Math.max(0, Math.min(2, Number(value) || 0)); + localStorage.setItem('voicepoc.micGain', String(this.gain)); + if (this.gainNode) this.gainNode.gain.setTargetAtTime(this.gain, this.context.currentTime, 0.015); + } + + getLevel() { + if (!this.analyser) return { rms: 0, peak: 0, clipping: false }; + this.analyser.getFloatTimeDomainData(this.meterData); + let sum = 0; + let peak = 0; + for (const sample of this.meterData) { + const magnitude = Math.abs(sample); + sum += sample * sample; + peak = Math.max(peak, magnitude); + } + const rms = Math.sqrt(sum / this.meterData.length); + return { rms, peak, clipping: peak >= 0.98 }; + } + + stop() { + this.detachInput(); + } +} diff --git a/public/index.html b/public/index.html index dd357f7..eb590ce 100644 --- a/public/index.html +++ b/public/index.html @@ -138,6 +138,17 @@ + +
+ + 100% +
+
+ Signal + + -∞ dB +
+

Changes apply immediately when connected, or the next time you join.

Overrides the server-provided ICE configuration (stored in localStorage, applied on next join).