add manual microphone gain and signal meter
This commit is contained in:
1 parent
ee81c519a1
commit
7b9ff4c098
5 files changed
+125
-15
No files matched your search
@@ -22,6 +22,8 @@ PORT=8081 npm start
|
||||
- Mute (M), deafen (D), per-peer volume, speaking indicators.
|
||||
- Voice capture uses mono 48 kHz Opus-oriented settings, a 64 kbps audio ceiling,
|
||||
and periodic packet-loss, jitter, and RTT quality diagnostics.
|
||||
- Audio input has manual gain control and a live signal/clipping meter in the
|
||||
settings dialog; microphone processing is split into `public/audio-manager.js`.
|
||||
|
||||
## ICE configuration (server side)
|
||||
|
||||
|
||||
+40
-15
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
export class AudioManager {
|
||||
constructor() {
|
||||
this.context = null;
|
||||
this.rawStream = null;
|
||||
this.outputStream = null;
|
||||
this.source = null;
|
||||
this.gainNode = null;
|
||||
this.analyser = null;
|
||||
this.meterData = new Float32Array(1024);
|
||||
this.gain = Number(localStorage.getItem('voicepoc.micGain') || 1);
|
||||
}
|
||||
|
||||
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.analyser = this.context.createAnalyser();
|
||||
this.analyser.fftSize = 1024;
|
||||
const destination = this.context.createMediaStreamDestination();
|
||||
this.source.connect(this.gainNode).connect(this.analyser);
|
||||
this.gainNode.connect(destination);
|
||||
this.outputStream = destination.stream;
|
||||
return this.outputStream;
|
||||
}
|
||||
|
||||
detachInput() {
|
||||
for (const node of [this.source, this.gainNode, this.analyser]) {
|
||||
try { node?.disconnect(); } catch {}
|
||||
}
|
||||
this.outputStream?.getTracks().forEach((track) => track.stop());
|
||||
this.rawStream?.getTracks().forEach((track) => track.stop());
|
||||
this.source = this.gainNode = this.analyser = null;
|
||||
this.outputStream = this.rawStream = null;
|
||||
}
|
||||
|
||||
setGain(value) {
|
||||
this.gain = Math.max(0, Math.min(2, Number(value) || 0));
|
||||
localStorage.setItem('voicepoc.micGain', String(this.gain));
|
||||
if (this.gainNode) this.gainNode.gain.setTargetAtTime(this.gain, this.context.currentTime, 0.015);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,17 @@
|
||||
<select id="audio-input" class="settings-select">
|
||||
<option value="">System default</option>
|
||||
</select>
|
||||
<label class="field-label" for="mic-gain">Microphone gain</label>
|
||||
<div class="gain-control">
|
||||
<input id="mic-gain" type="range" min="0" max="2" step="0.05" value="1" />
|
||||
<span id="mic-gain-value">100%</span>
|
||||
</div>
|
||||
<div class="signal-preview" aria-live="polite">
|
||||
<span>Signal</span>
|
||||
<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>
|
||||
</div>
|
||||
<p id="mic-clipping" class="hint hidden">Clipping detected — reduce microphone gain.</p>
|
||||
<p class="hint">Changes apply immediately when connected, or the next time you join.</p>
|
||||
<p class="hint">Overrides the server-provided ICE configuration (stored in <code>localStorage</code>, applied on next join).</p>
|
||||
<label class="toggle-row">
|
||||
|
||||
@@ -402,6 +402,10 @@ body.sidebar-collapsed #btn-sidebar-expand { display: grid; }
|
||||
border-radius: 6px; padding: 9px 10px; margin-bottom: 4px; font: inherit;
|
||||
}
|
||||
.settings-select:focus { outline: none; border-color: var(--brand); }
|
||||
.gain-control, .signal-preview { display: flex; align-items: center; gap: 10px; margin: 6px 0 10px; }
|
||||
.gain-control input { flex: 1; accent-color: var(--brand); }
|
||||
.gain-control span, .signal-preview > span:last-child { min-width: 48px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.signal-preview meter { flex: 1; height: 10px; }
|
||||
#ice-json {
|
||||
width: 100%; background: var(--bg-0); color: var(--text-0); border: 1px solid var(--bg-4);
|
||||
border-radius: 6px; padding: 10px; font: 12px/1.5 ui-monospace, "Cascadia Code", monospace;
|
||||
|
||||
Reference in new issue
Block a user