148 lines
5.4 KiB
JavaScript
148 lines
5.4 KiB
JavaScript
// 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();
|
|
}
|
|
}
|