diff --git a/public/app.js b/public/app.js index 703cdb9..3881396 100644 --- a/public/app.js +++ b/public/app.js @@ -1,6 +1,6 @@ import * as matrixcs from 'matrix-js-sdk'; import cytoscape from 'cytoscape'; -import { AudioManager } from './audio-manager.js'; +import { AudioManager, MAX_MIC_GAIN, MAX_MIC_GAIN_HIFI } from './audio-manager.js'; function captureRumIssue(message, severity = 'ERROR', extra = {}) { const rum = globalThis.OtelRUM?.last; @@ -33,6 +33,9 @@ window.addEventListener('unhandledrejection', (event) => { // --------------------------------------------------------------------------- const ROOM_UNITS = 100; // spatial plane is 0..100 x 0..100 const SPATIAL_SCALE = 0.12; // room units -> audio world units +// Per-user playback boost ceiling: 400% (+12 dB) over the spatial mix, matching +// the headroom the playback limiter in attachSpatial() is designed for. +const MAX_USER_VOLUME = 4; // Hi-Fi rooms: stereo Opus at 256 kbps with echo cancellation disabled — // for music/listening channels where fidelity beats echo-proofing. Marked // with a HI-FI badge in the channel list. @@ -43,6 +46,7 @@ const LS_NAME = 'voicepoc.nick'; const LS_ICE = 'voicepoc.iceOverride'; const LS_AUDIO_INPUT = 'voicepoc.audioInput'; const LS_SCREEN_HINT = 'voicepoc.screenHint'; +const LS_USER_VOLUME = 'voicepoc.userVolume'; const S = { ws: null, @@ -372,6 +376,10 @@ async function joinVoice(room) { // Hi-Fi profile must be resolved before capture: S.room still names the // previous channel here, so the target room is passed explicitly. const hifi = HIFI_ROOMS.has(room); + // Select the gain profile before capture: Hi-Fi rooms run raw (no AGC/echo + // cancellation) and get a higher ceiling, normal rooms keep their own value. + S.audioManager.setProfile(hifi); + updateMicGainUi(); // Mic permission & AudioContext: request immediately on the user-click gesture // so browsers don't block the microphone permission prompt or audio autoplay policy. @@ -445,6 +453,10 @@ 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; } + // Back to the normal-room gain profile. leaveVoice also runs while switching + // channels; joinVoice re-selects the target profile straight after, so the + // intermediate hop never touches the microphone. + S.audioManager.setProfile(false); S.audioManager.stop(); S.rawMicStream = null; S.micStream = null; @@ -681,7 +693,8 @@ function makePeerRecord(p) { state: p.state || { muted: false, deafened: false, sharing: false }, pc: null, polite: false, makingOffer: false, ignoreOffer: false, pendingCandidates: [], micStream: null, screenStreams: new Map(), - gain: null, panner: null, analyser: null, vol: 1, speaking: false, + gain: null, panner: null, analyser: null, limiter: null, + vol: savedUserVolume(p.name), speaking: false, resetAttempts: 0, lastIceNotice: null, icePath: null, @@ -691,6 +704,26 @@ function makePeerRecord(p) { }; } +// Per-user playback volume is remembered by nickname: peer ids are per-socket +// and change on every reconnect, so keying on the id would lose the boost on +// every rejoin/reload. +function savedUserVolumes() { + try { return JSON.parse(localStorage.getItem(LS_USER_VOLUME)) || {}; } catch { return {}; } +} + +function savedUserVolume(name) { + const n = Number(savedUserVolumes()[name]); + if (!Number.isFinite(n)) return 1; + return Math.max(0, Math.min(MAX_USER_VOLUME, n)); +} + +function rememberUserVolume(name, vol) { + const all = savedUserVolumes(); + if (Math.abs(vol - 1) < 0.001) delete all[name]; // 100% is the default — don't store noise + else all[name] = vol; + try { localStorage.setItem(LS_USER_VOLUME, JSON.stringify(all)); } catch {} +} + function createOrphanPeer(id) { const p = makePeerRecord({ id, name: 'peer-' + id.slice(0, 4), x: 50, y: 50 }); S.peers.set(id, p); @@ -974,15 +1007,32 @@ function attachSpatial(peer, stream) { gain.gain.value = S.deafened ? 0 : peer.vol; const analyser = ctx.createAnalyser(); analyser.fftSize = 256; - src.connect(panner).connect(gain).connect(analyser).connect(ctx.destination); - Object.assign(peer, { panner, gain, analyser }); + const limiter = makePlaybackLimiter(ctx); + // The analyser taps the signal before the per-user gain so the "speaking" + // highlight follows the peer's real level: at 400% it would otherwise be on + // permanently, and at 0% permanently dark. + src.connect(panner).connect(analyser).connect(gain).connect(limiter).connect(ctx.destination); + Object.assign(peer, { panner, gain, analyser, limiter }); updatePanner(peer); } +// Playback headroom: the destination hard-clips anything above 0 dBFS, so a +// 400% per-user boost needs a limiter to stay loud instead of crunching. +function makePlaybackLimiter(ctx) { + const limiter = ctx.createDynamicsCompressor(); + limiter.threshold.value = -1.5; + limiter.knee.value = 0; + limiter.ratio.value = 20; + limiter.attack.value = 0.002; + limiter.release.value = 0.15; + return limiter; +} + function detachSpatial(peer) { if (peer.gain) { try { peer.gain.disconnect(); } catch {} } + if (peer.limiter) { try { peer.limiter.disconnect(); } catch {} } if (peer.panner) { try { peer.panner.disconnect(); } catch {} } - peer.panner = peer.gain = peer.analyser = null; + peer.panner = peer.gain = peer.analyser = peer.limiter = null; } function updatePanner(peer) { @@ -1313,19 +1363,30 @@ function renderTiles() { volPop.className = 'volume-popup'; const volLabel = document.createElement('div'); volLabel.className = 'vol-title'; - volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`; + const applyVol = (next) => { + peer.vol = next; + volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`; + // setTargetAtTime ramps smoothly instead of zipper-clicking on drag + if (peer.gain && S.audioCtx && !S.deafened) peer.gain.gain.setTargetAtTime(peer.vol, S.audioCtx.currentTime, 0.02); + rememberUserVolume(peer.name, peer.vol); + }; + applyVol(peer.vol); const volInput = document.createElement('input'); volInput.type = 'range'; volInput.min = 0; - volInput.max = 2; + volInput.max = MAX_USER_VOLUME; volInput.step = 0.05; volInput.value = peer.vol; volInput.oninput = (e) => { e.stopPropagation(); - peer.vol = Number(volInput.value); - volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`; - // setTargetAtTime ramps smoothly instead of zipper-clicking on drag - if (peer.gain && S.audioCtx && !S.deafened) peer.gain.gain.setTargetAtTime(peer.vol, S.audioCtx.currentTime, 0.02); + applyVol(Number(volInput.value)); + }; + // Double-click snaps back to 100%: on a 400% range it is easy to lose + // the neutral point while dragging. + volInput.ondblclick = (e) => { + e.stopPropagation(); + volInput.value = String(1); + applyVol(1); }; volPop.append(volLabel, volInput); @@ -2362,10 +2423,22 @@ function updateMicGainUi() { const gain = S.audioManager.gain; const input = $('#mic-gain'); const value = $('#mic-gain-value'); + // The ceiling follows the active room profile (400% normal, 800% Hi-Fi). + if (input) input.max = String(S.audioManager.maxGain); if (input) input.value = gain; if (value) value.textContent = `${Math.round(gain * 100)}%`; + const hint = $('#mic-gain-hint'); + if (hint) { + hint.textContent = S.audioManager.hifi + ? `Hi-Fi room: raw capture, no AGC — up to ${Math.round(S.audioManager.maxGain * 100)}% here.` + : `This room allows up to ${Math.round(MAX_MIC_GAIN * 100)}%. Hi-Fi rooms (Code, Gaming) capture raw with no AGC and allow up to ${Math.round(MAX_MIC_GAIN_HIFI * 100)}%.`; + } } -$('#mic-gain').oninput = (ev) => { +const micGainInput = $('#mic-gain'); +// Ceiling comes from the AudioManager profile, so the slider can never offer a +// gain the engine would silently reject. +micGainInput.max = String(S.audioManager.maxGain); +micGainInput.oninput = (ev) => { S.audioManager.setGain(ev.target.value); updateMicGainUi(); }; diff --git a/public/audio-manager.js b/public/audio-manager.js index 9538067..5aacf03 100644 --- a/public/audio-manager.js +++ b/public/audio-manager.js @@ -1,3 +1,35 @@ +// Microphone gain ceilings. Normal rooms capture with echo cancellation enabled, +// which already lifts quiet microphones. Hi-Fi rooms capture raw (no AGC, no echo +// cancellation) for fidelity, so quiet mics arrive much lower and need more +// make-up gain before they reach the Opus encoder. +export const MAX_MIC_GAIN = 4; // 400% (+12 dB) — normal rooms +export const MAX_MIC_GAIN_HIFI = 8; // 800% (+18 dB) — Hi-Fi rooms, raw capture + +// Gain is stored per profile: dropping back from a Hi-Fi room to a normal one must +// not silently truncate a +18 dB setting the user dialled in. +const GAIN_KEY = { normal: 'voicepoc.micGain', hifi: 'voicepoc.micGainHifi' }; + +// Peaks above 0 dBFS are hard-clipped when the float graph is converted for the +// Opus encoder, which turns a big boost into crunch. The limiter only engages +// near full scale, so normal-level speech passes through untouched. +const LIMITER = { threshold: -1.5, knee: 0, ratio: 20, attack: 0.002, release: 0.15 }; + +function makeLimiter(context) { + const limiter = context.createDynamicsCompressor(); + limiter.threshold.value = LIMITER.threshold; + limiter.knee.value = LIMITER.knee; + limiter.ratio.value = LIMITER.ratio; + limiter.attack.value = LIMITER.attack; + limiter.release.value = LIMITER.release; + return limiter; +} + +function clampGain(value, max) { + const n = Number(value); + if (!Number.isFinite(n)) return 1; + return Math.max(0, Math.min(max, n)); +} + export class AudioManager { constructor() { this.context = null; @@ -5,10 +37,49 @@ export class AudioManager { this.outputStream = null; this.source = null; this.gainNode = null; + this.limiter = null; this.analyser = null; this.meterSink = null; this.meterData = new Float32Array(1024); - this.gain = Number(localStorage.getItem('voicepoc.micGain') || 1); + // The room is unknown until joinVoice() resolves it, so the stricter normal-room + // profile is the starting point; joinVoice() calls setProfile() before capture. + this.hifi = false; + this.maxGain = MAX_MIC_GAIN; + // Clamped on load too: an out-of-range localStorage value (older build, hand + // edit) must not bypass the ceiling of the profile it belongs to. + this.gain = this.readGain(); + } + + gainKey() { + return this.hifi ? GAIN_KEY.hifi : GAIN_KEY.normal; + } + + readGain() { + const stored = localStorage.getItem(this.gainKey()); + // No stored value yet: 100%. Users in Hi-Fi rooms raise it themselves — the + // ceiling change must not make anyone unexpectedly loud on first join. + return stored === null ? 1 : clampGain(stored, this.maxGain); + } + + storeGain() { + localStorage.setItem(this.gainKey(), String(this.gain)); + } + + applyGain() { + if (this.gainNode) this.gainNode.gain.setTargetAtTime(this.gain, this.context.currentTime, 0.015); + } + + // Switch between the normal and Hi-Fi gain profiles. Safe to call on every join: + // it is a no-op when the profile is unchanged. + setProfile(hifi) { + const next = Boolean(hifi); + if (next === this.hifi) return this.gain; + this.storeGain(); // keep what the profile we are leaving was set to + this.hifi = next; + this.maxGain = next ? MAX_MIC_GAIN_HIFI : MAX_MIC_GAIN; + this.gain = this.readGain(); + this.applyGain(); // live mic, if one is attached + return this.gain; } async ensureContext() { @@ -24,6 +95,7 @@ export class AudioManager { this.source = this.context.createMediaStreamSource(stream); this.gainNode = this.context.createGain(); this.gainNode.gain.value = this.gain; + this.limiter = makeLimiter(this.context); this.analyser = this.context.createAnalyser(); this.analyser.fftSize = 1024; this.meterSink = this.context.createGain(); @@ -32,25 +104,27 @@ export class AudioManager { 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); + // The analyser taps the signal post-gain but pre-limiter, so the clipping + // hint reports how hard the gain stage is pushed, not the limited peak. + this.gainNode.connect(this.limiter).connect(destination); this.outputStream = destination.stream; return this.outputStream; } detachInput() { - for (const node of [this.source, this.gainNode, this.analyser, this.meterSink]) { + for (const node of [this.source, this.gainNode, this.analyser, this.limiter, 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.source = this.gainNode = this.analyser = this.limiter = 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); + this.gain = clampGain(value, this.maxGain); + this.storeGain(); + this.applyGain(); } getLevel() { diff --git a/public/index.html b/public/index.html index fa0fdf3..f8f1139 100644 --- a/public/index.html +++ b/public/index.html @@ -142,7 +142,7 @@
- + 100%
@@ -150,7 +150,8 @@ -∞ dB
- + +

This room allows up to 400%. Hi-Fi rooms (Code, Gaming) capture raw with no AGC and allow up to 800%.

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

Screen sharing