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 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();
};