// 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; this.rawStream = null; this.outputStream = null; this.source = null; this.gainNode = null; this.limiter = null; this.analyser = null; this.meterSink = null; this.meterData = new Float32Array(1024); // 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() { 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.limiter = makeLimiter(this.context); 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); // 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.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.limiter = this.meterSink = null; this.outputStream = this.rawStream = null; } setGain(value) { this.gain = clampGain(value, this.maxGain); this.storeGain(); this.applyGain(); } 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(); } }