add manual microphone gain and signal meter
Build and Publish / Metadata (push) Successful in 1s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 24s

This commit is contained in:
mats committed 2026-09-22 17:21:12 +02:00
1 parent ee81c519a1
commit 7b9ff4c098
5 files changed
+125 -15

No files matched your search

+40 -15
View File
@@ -1,5 +1,6 @@
import * as matrixcs from 'matrix-js-sdk';
import cytoscape from 'cytoscape';
import { AudioManager } from './audio-manager.js';
function captureRumIssue(message, severity = 'ERROR', extra = {}) {
const rum = globalThis.OtelRUM?.last;
@@ -47,6 +48,8 @@ const S = {
screenStreams: new Map(), // streamId -> local MediaStream
screenSenders: new Map(), // peerId -> Map(streamId -> RTCRtpSender[])
audioCtx: null,
audioManager: new AudioManager(),
rawMicStream: null,
me: { x: 50, y: 50 },
muted: false,
deafened: false,
@@ -331,15 +334,16 @@ async function switchAudioInput(deviceId) {
return;
}
const nextTrack = nextStream.getAudioTracks()[0];
const nextOutput = await S.audioManager.attachInput(nextStream);
const nextTrack = nextOutput.getAudioTracks()[0];
nextTrack.enabled = !S.muted;
await Promise.all([...S.peers.values()].map(async (peer) => {
if (!peer.pc) return;
const sender = peer.pc.getSenders().find((item) => item.track && item.track.kind === 'audio');
if (sender) await sender.replaceTrack(nextTrack);
}));
S.micStream.getTracks().forEach((track) => track.stop());
S.micStream = nextStream;
S.rawMicStream = nextStream;
S.micStream = nextOutput;
updateMicButton();
toast('Microphone changed', 'ok');
}
@@ -355,8 +359,8 @@ async function joinVoice(room) {
// Mic permission & AudioContext: request immediately on the user-click gesture
// so browsers don't block the microphone permission prompt or audio autoplay policy.
if (!S.audioCtx) S.audioCtx = new AudioContext();
await S.audioCtx.resume().catch(() => {});
await S.audioManager.ensureContext();
S.audioCtx = S.audioManager.context;
orientListener();
// Attach a one-time gesture listener in case browser autoplay policy keeps AudioContext suspended
@@ -369,7 +373,8 @@ async function joinVoice(room) {
window.addEventListener('keydown', resumeCtx, { passive: true });
try {
S.micStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints());
S.rawMicStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints());
S.micStream = await S.audioManager.attachInput(S.rawMicStream);
populateAudioInputs();
} catch {
S.micStream = null;
@@ -423,7 +428,9 @@ 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; }
if (S.micStream) { S.micStream.getTracks().forEach((t) => t.stop()); S.micStream = null; }
S.audioManager.stop();
S.rawMicStream = null;
S.micStream = null;
// Keep share buttons enabled so clicking one prompts the user to join a channel
updateMicButton();
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
@@ -1106,16 +1113,20 @@ function rms(analyser) {
return Math.sqrt(sum / meterBuf.length);
}
let localAnalyser = null;
function updateMicSignal(level = S.audioManager.getLevel()) {
const meter = $('#mic-level');
const value = $('#mic-level-value');
const clipping = $('#mic-clipping');
if (meter) meter.value = Math.min(1, level.rms * 3);
if (value) value.textContent = level.rms > 0 ? `${Math.round(20 * Math.log10(level.rms))} dB` : '-∞ dB';
if (clipping) clipping.classList.toggle('hidden', !level.clipping);
}
setInterval(() => {
if (!S.audioCtx) return;
if (S.micStream && !localAnalyser) {
const src = S.audioCtx.createMediaStreamSource(S.micStream);
localAnalyser = S.audioCtx.createAnalyser();
localAnalyser.fftSize = 256;
src.connect(localAnalyser); // tap only — not connected to destination (no echo)
}
const meSpeaking = localAnalyser && !S.muted && rms(localAnalyser) > 0.04;
const localLevel = S.audioManager.getLevel();
updateMicSignal(localLevel);
const meSpeaking = S.micStream && !S.muted && localLevel.rms > 0.04;
els.meAvatar.classList.toggle('speaking', !!meSpeaking);
let changed = false;
for (const peer of S.peers.values()) {
@@ -2192,6 +2203,9 @@ navigator.mediaDevices?.addEventListener('devicechange', populateAudioInputs);
// ---------------------------------------------------------------------------
function openSettings() {
populateAudioInputs();
const gain = $('#mic-gain');
if (gain) gain.value = S.audioManager.gain;
updateMicGainUi();
const override = localStorage.getItem(LS_ICE);
$('#override-enabled').checked = !!override;
const current = override ? JSON.parse(override) : effectiveIce();
@@ -2202,6 +2216,17 @@ function openSettings() {
);
$('#modal-backdrop').classList.remove('hidden');
}
function updateMicGainUi() {
const gain = S.audioManager.gain;
const input = $('#mic-gain');
const value = $('#mic-gain-value');
if (input) input.value = gain;
if (value) value.textContent = `${Math.round(gain * 100)}%`;
}
$('#mic-gain').oninput = (ev) => {
S.audioManager.setGain(ev.target.value);
updateMicGainUi();
};
$('#audio-input').onchange = (ev) => switchAudioInput(ev.target.value);
$('#ice-cancel').onclick = () => $('#modal-backdrop').classList.add('hidden');
$('#modal-backdrop').onclick = (ev) => {