fix even better audio
Build and Publish / Metadata (push) Successful in 1s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 27s

This commit is contained in:
developer committed 2026-09-23 14:49:00 +00:00
1 parent ce3d40aba8
commit 87f181504c
3 files changed
+169 -21

No files matched your search

+85 -12
View File
@@ -1,6 +1,6 @@
import * as matrixcs from 'matrix-js-sdk'; import * as matrixcs from 'matrix-js-sdk';
import cytoscape from 'cytoscape'; 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 = {}) { function captureRumIssue(message, severity = 'ERROR', extra = {}) {
const rum = globalThis.OtelRUM?.last; 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 ROOM_UNITS = 100; // spatial plane is 0..100 x 0..100
const SPATIAL_SCALE = 0.12; // room units -> audio world units 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 — // Hi-Fi rooms: stereo Opus at 256 kbps with echo cancellation disabled —
// for music/listening channels where fidelity beats echo-proofing. Marked // for music/listening channels where fidelity beats echo-proofing. Marked
// with a HI-FI badge in the channel list. // 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_ICE = 'voicepoc.iceOverride';
const LS_AUDIO_INPUT = 'voicepoc.audioInput'; const LS_AUDIO_INPUT = 'voicepoc.audioInput';
const LS_SCREEN_HINT = 'voicepoc.screenHint'; const LS_SCREEN_HINT = 'voicepoc.screenHint';
const LS_USER_VOLUME = 'voicepoc.userVolume';
const S = { const S = {
ws: null, ws: null,
@@ -372,6 +376,10 @@ async function joinVoice(room) {
// Hi-Fi profile must be resolved before capture: S.room still names the // Hi-Fi profile must be resolved before capture: S.room still names the
// previous channel here, so the target room is passed explicitly. // previous channel here, so the target room is passed explicitly.
const hifi = HIFI_ROOMS.has(room); 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 // Mic permission & AudioContext: request immediately on the user-click gesture
// so browsers don't block the microphone permission prompt or audio autoplay policy. // so browsers don't block the microphone permission prompt or audio autoplay policy.
@@ -445,6 +453,10 @@ function leaveVoice(reason) {
S.peers.clear(); S.peers.clear();
if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; } if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; }
if (S.ws) { try { S.ws.close(); } catch {} S.ws = 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.audioManager.stop();
S.rawMicStream = null; S.rawMicStream = null;
S.micStream = null; S.micStream = null;
@@ -681,7 +693,8 @@ function makePeerRecord(p) {
state: p.state || { muted: false, deafened: false, sharing: false }, state: p.state || { muted: false, deafened: false, sharing: false },
pc: null, polite: false, makingOffer: false, ignoreOffer: false, pc: null, polite: false, makingOffer: false, ignoreOffer: false,
pendingCandidates: [], micStream: null, screenStreams: new Map(), 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, resetAttempts: 0,
lastIceNotice: null, lastIceNotice: null,
icePath: 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) { function createOrphanPeer(id) {
const p = makePeerRecord({ id, name: 'peer-' + id.slice(0, 4), x: 50, y: 50 }); const p = makePeerRecord({ id, name: 'peer-' + id.slice(0, 4), x: 50, y: 50 });
S.peers.set(id, p); S.peers.set(id, p);
@@ -974,15 +1007,32 @@ function attachSpatial(peer, stream) {
gain.gain.value = S.deafened ? 0 : peer.vol; gain.gain.value = S.deafened ? 0 : peer.vol;
const analyser = ctx.createAnalyser(); const analyser = ctx.createAnalyser();
analyser.fftSize = 256; analyser.fftSize = 256;
src.connect(panner).connect(gain).connect(analyser).connect(ctx.destination); const limiter = makePlaybackLimiter(ctx);
Object.assign(peer, { panner, gain, analyser }); // 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); 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) { function detachSpatial(peer) {
if (peer.gain) { try { peer.gain.disconnect(); } catch {} } if (peer.gain) { try { peer.gain.disconnect(); } catch {} }
if (peer.limiter) { try { peer.limiter.disconnect(); } catch {} }
if (peer.panner) { try { peer.panner.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) { function updatePanner(peer) {
@@ -1313,19 +1363,30 @@ function renderTiles() {
volPop.className = 'volume-popup'; volPop.className = 'volume-popup';
const volLabel = document.createElement('div'); const volLabel = document.createElement('div');
volLabel.className = 'vol-title'; 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'); const volInput = document.createElement('input');
volInput.type = 'range'; volInput.type = 'range';
volInput.min = 0; volInput.min = 0;
volInput.max = 2; volInput.max = MAX_USER_VOLUME;
volInput.step = 0.05; volInput.step = 0.05;
volInput.value = peer.vol; volInput.value = peer.vol;
volInput.oninput = (e) => { volInput.oninput = (e) => {
e.stopPropagation(); e.stopPropagation();
peer.vol = Number(volInput.value); applyVol(Number(volInput.value));
volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`; };
// setTargetAtTime ramps smoothly instead of zipper-clicking on drag // Double-click snaps back to 100%: on a 400% range it is easy to lose
if (peer.gain && S.audioCtx && !S.deafened) peer.gain.gain.setTargetAtTime(peer.vol, S.audioCtx.currentTime, 0.02); // the neutral point while dragging.
volInput.ondblclick = (e) => {
e.stopPropagation();
volInput.value = String(1);
applyVol(1);
}; };
volPop.append(volLabel, volInput); volPop.append(volLabel, volInput);
@@ -2362,10 +2423,22 @@ function updateMicGainUi() {
const gain = S.audioManager.gain; const gain = S.audioManager.gain;
const input = $('#mic-gain'); const input = $('#mic-gain');
const value = $('#mic-gain-value'); 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 (input) input.value = gain;
if (value) value.textContent = `${Math.round(gain * 100)}%`; 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); S.audioManager.setGain(ev.target.value);
updateMicGainUi(); updateMicGainUi();
}; };
+81 -7
View File
@@ -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 { export class AudioManager {
constructor() { constructor() {
this.context = null; this.context = null;
@@ -5,10 +37,49 @@ export class AudioManager {
this.outputStream = null; this.outputStream = null;
this.source = null; this.source = null;
this.gainNode = null; this.gainNode = null;
this.limiter = null;
this.analyser = null; this.analyser = null;
this.meterSink = null; this.meterSink = null;
this.meterData = new Float32Array(1024); 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() { async ensureContext() {
@@ -24,6 +95,7 @@ export class AudioManager {
this.source = this.context.createMediaStreamSource(stream); this.source = this.context.createMediaStreamSource(stream);
this.gainNode = this.context.createGain(); this.gainNode = this.context.createGain();
this.gainNode.gain.value = this.gain; this.gainNode.gain.value = this.gain;
this.limiter = makeLimiter(this.context);
this.analyser = this.context.createAnalyser(); this.analyser = this.context.createAnalyser();
this.analyser.fftSize = 1024; this.analyser.fftSize = 1024;
this.meterSink = this.context.createGain(); this.meterSink = this.context.createGain();
@@ -32,25 +104,27 @@ export class AudioManager {
this.source.connect(this.gainNode).connect(this.analyser); this.source.connect(this.gainNode).connect(this.analyser);
// Keep the analyser alive without sending the microphone to the speakers. // Keep the analyser alive without sending the microphone to the speakers.
this.analyser.connect(this.meterSink).connect(this.context.destination); 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; this.outputStream = destination.stream;
return this.outputStream; return this.outputStream;
} }
detachInput() { 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 {} try { node?.disconnect(); } catch {}
} }
this.outputStream?.getTracks().forEach((track) => track.stop()); this.outputStream?.getTracks().forEach((track) => track.stop());
this.rawStream?.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; this.outputStream = this.rawStream = null;
} }
setGain(value) { setGain(value) {
this.gain = Math.max(0, Math.min(2, Number(value) || 0)); this.gain = clampGain(value, this.maxGain);
localStorage.setItem('voicepoc.micGain', String(this.gain)); this.storeGain();
if (this.gainNode) this.gainNode.gain.setTargetAtTime(this.gain, this.context.currentTime, 0.015); this.applyGain();
} }
getLevel() { getLevel() {
+3 -2
View File
@@ -142,7 +142,7 @@
</select> </select>
<label class="field-label" for="mic-gain">Microphone gain</label> <label class="field-label" for="mic-gain">Microphone gain</label>
<div class="gain-control"> <div class="gain-control">
<input id="mic-gain" type="range" min="0" max="2" step="0.05" value="1" /> <input id="mic-gain" type="range" min="0" max="4" step="0.05" value="1" />
<span id="mic-gain-value">100%</span> <span id="mic-gain-value">100%</span>
</div> </div>
<div class="signal-preview" aria-live="polite"> <div class="signal-preview" aria-live="polite">
@@ -150,7 +150,8 @@
<meter id="mic-level" min="0" max="1" low="0.35" high="0.8" optimum="0.55" value="0"></meter> <meter id="mic-level" min="0" max="1" low="0.35" high="0.8" optimum="0.55" value="0"></meter>
<span id="mic-level-value">-∞ dB</span> <span id="mic-level-value">-∞ dB</span>
</div> </div>
<p id="mic-clipping" class="hint hidden">Clipping detected — reduce microphone gain.</p> <p id="mic-clipping" class="hint hidden">Peaks are hitting the limiter — lower the gain if it sounds distorted.</p>
<p id="mic-gain-hint" class="hint">This room allows up to 400%. Hi-Fi rooms (Code, Gaming) capture raw with no AGC and allow up to 800%.</p>
<p class="hint">Changes apply immediately when connected, or the next time you join.</p> <p class="hint">Changes apply immediately when connected, or the next time you join.</p>
<h3>Screen sharing</h3> <h3>Screen sharing</h3>
<label class="field-label" for="screen-hint">Optimization</label> <label class="field-label" for="screen-hint">Optimization</label>