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] 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).