diff --git a/README.md b/README.md index 2076e86..4d9c138 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ 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. +- 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`. +- Screen sharing uses the browser's default codec negotiation, with selectable + `detail` (text/UI) or `motion` (gameplay) content hints. ## ICE configuration (server side) @@ -28,6 +34,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,11 +45,17 @@ 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 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/k8s/deployment.yaml b/k8s/deployment.yaml index b57c4ae..a289a2e 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -36,6 +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" + # Prefer direct/STUN paths where they work, with public TURN fallback. + - name: ICE_TRANSPORT_POLICY + value: "all" - name: TURN_SECRET valueFrom: secretKeyRef: @@ -54,14 +57,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..c1105e5 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; @@ -36,6 +37,7 @@ const FALLBACK_ICE = { iceServers: [{ urls: ['stun:turn.k6n.net:3478', 'stun:stu const LS_NAME = 'voicepoc.nick'; const LS_ICE = 'voicepoc.iceOverride'; const LS_AUDIO_INPUT = 'voicepoc.audioInput'; +const LS_SCREEN_HINT = 'voicepoc.screenHint'; const S = { ws: null, @@ -47,6 +49,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, @@ -96,6 +100,113 @@ 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', + }; +} + +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; @@ -156,7 +267,14 @@ function effectiveIce() { } function microphoneConstraints(deviceId = localStorage.getItem(LS_AUDIO_INPUT)) { - const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; + const audio = { + echoCancellation: true, + noiseSuppression: false, + autoGainControl: false, + channelCount: 1, + sampleRate: 48000, + sampleSize: 16, + }; if (deviceId) audio.deviceId = { exact: deviceId }; return { audio }; } @@ -197,15 +315,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'); } @@ -221,8 +340,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 @@ -235,7 +354,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; @@ -289,7 +409,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')); @@ -448,6 +570,7 @@ function handleSignal(msg) { // deterministic initiator: lower id offers (no glare storm on join) ensurePC(S.peers.get(p.id), S.myId < p.id); } + S.me = chooseOpenParticipantPosition(); sendMyPos(true); renderAll(); setStatus(`voice connected — ${msg.peers.length + 1} in ${msg.room}`, 'online'); @@ -524,6 +647,10 @@ 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, + qualityTimer: null, + lastQualityNotice: 0, els: {}, }; } @@ -562,17 +689,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,16 +731,41 @@ 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), + }); + }); + 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) { @@ -633,6 +808,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; @@ -677,6 +853,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 {} @@ -701,9 +878,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); @@ -822,9 +1016,15 @@ async function startScreenShare() { for (const peer of S.peers.values()) addScreenToPeer(peer); const videoTrack = stream.getVideoTracks()[0]; if (videoTrack) { + const screenHint = localStorage.getItem(LS_SCREEN_HINT) || ''; + if ('contentHint' in videoTrack) videoTrack.contentHint = screenHint; 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'); @@ -873,6 +1073,21 @@ function stopScreenShare(stream, silent) { // --------------------------------------------------------------------------- // position sync // --------------------------------------------------------------------------- +function chooseOpenParticipantPosition() { + const occupied = [...S.peers.values()].map((peer) => ({ x: peer.x, y: peer.y })); + const candidates = []; + for (let row = 0; row < 5; row++) { + for (let col = 0; col < 7; col++) { + candidates.push({ x: 15 + col * 11.7, y: 15 + row * 17.5 }); + } + } + if (!occupied.length) return { x: 50, y: 50 }; + return candidates.sort((a, b) => { + const distance = (point) => Math.min(...occupied.map((other) => Math.hypot(point.x - other.x, point.y - other.y))); + return distance(b) - distance(a); + })[0]; +} + let lastPosSend = 0; function sendMyPos(force) { const now = performance.now(); @@ -896,16 +1111,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()) { @@ -1945,29 +2164,6 @@ setInterval(() => { } }, 5000); -// Spawn me slightly off-center so people don't land on top of each other. -// A single hashed angle on a *fixed* radius ring (the previous approach) -// puts anyone whose name hashes to a nearby angle almost exactly on top of -// each other too — on a small ring, "nearby angle" is only a few pixels of -// arc, and with just 360 angle buckets that's common (especially with the -// same recurring guest-xxxx test names). Hashing the radius independently -// from the angle means an angle collision no longer implies a full overlap. -// Both stay small enough that at refDistance=4/rolloffFactor=1.4 (see -// updatePanner) nobody starts out attenuated — this is purely about the -// dots being visually distinguishable, not audio range. -(() => { - const key = S.myId || S.name; - let hAngle = 0, hRadius = 0; - for (const c of key) { - hAngle = (hAngle * 33 + c.charCodeAt(0)) >>> 0; - hRadius = (hRadius * 17 + c.charCodeAt(0) + 7) >>> 0; - } - const a = (hAngle % 360) * (Math.PI / 180); - const r = 8 + (hRadius % 12); // 8..19 room units - S.me.x = 50 + r * Math.cos(a); - S.me.y = 50 + r * Math.sin(a); -})(); - window.addEventListener('keydown', (ev) => { if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA') return; if (ev.key === 'm') toggleMic(); @@ -1982,6 +2178,11 @@ navigator.mediaDevices?.addEventListener('devicechange', populateAudioInputs); // --------------------------------------------------------------------------- function openSettings() { populateAudioInputs(); + const gain = $('#mic-gain'); + if (gain) gain.value = S.audioManager.gain; + updateMicGainUi(); + const screenHint = $('#screen-hint'); + if (screenHint) screenHint.value = localStorage.getItem(LS_SCREEN_HINT) || ''; const override = localStorage.getItem(LS_ICE); $('#override-enabled').checked = !!override; const current = override ? JSON.parse(override) : effectiveIce(); @@ -1992,15 +2193,40 @@ 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(); +}; +$('#screen-hint').onchange = (ev) => { + localStorage.setItem(LS_SCREEN_HINT, ev.target.value); + toast('Screen-share hint saved — applies next time you share', 'ok'); +}; $('#audio-input').onchange = (ev) => switchAudioInput(ev.target.value); $('#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/audio-manager.js b/public/audio-manager.js new file mode 100644 index 0000000..9538067 --- /dev/null +++ b/public/audio-manager.js @@ -0,0 +1,73 @@ +export class AudioManager { + constructor() { + this.context = null; + this.rawStream = null; + this.outputStream = null; + this.source = null; + this.gainNode = null; + this.analyser = null; + this.meterSink = 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; + this.meterSink = this.context.createGain(); + this.meterSink.gain.value = 0; + const destination = this.context.createMediaStreamDestination(); + this.source.connect(this.gainNode).connect(this.analyser); + // Keep the analyser alive without sending the microphone to the speakers. + this.analyser.connect(this.meterSink).connect(this.context.destination); + this.gainNode.connect(destination); + this.outputStream = destination.stream; + return this.outputStream; + } + + detachInput() { + for (const node of [this.source, this.gainNode, this.analyser, this.meterSink]) { + try { node?.disconnect(); } catch {} + } + this.outputStream?.getTracks().forEach((track) => track.stop()); + this.rawStream?.getTracks().forEach((track) => track.stop()); + this.source = this.gainNode = this.analyser = this.meterSink = 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 823563a..eab06c3 100644 --- a/public/index.html +++ b/public/index.html @@ -126,6 +126,7 @@ + @@ -137,7 +138,26 @@ + +
Changes apply immediately when connected, or the next time you join.
+Details favors sharp text; motion favors smoother movement.
Overrides the server-provided ICE configuration (stored in localStorage, applied on next join).