fix even better audio
This commit is contained in:
1 parent
ce3d40aba8
commit
87f181504c
3 files changed
+169
-21
No files matched your search
+85
-12
@@ -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();
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user