From 89ad5d1dcaaaef95841a87e815334b0a33d2305e Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 10 Jun 2026 22:37:29 +0200 Subject: [PATCH] Add local microphone volume meter --- public/app.js | 103 +++++++++++++++++++++++++++++++++++++++++++++- public/index.html | 7 ++++ public/styles.css | 29 +++++++++++++ 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/public/app.js b/public/app.js index f93a58b..083a01d 100644 --- a/public/app.js +++ b/public/app.js @@ -25,6 +25,8 @@ const cameraButton = document.querySelector("#camera-button"); const micButton = document.querySelector("#mic-button"); const shareButton = document.querySelector("#share-button"); const announceButton = document.querySelector("#announce-button"); +const selfMicVolumeInput = document.querySelector("#self-mic-volume"); +const selfMicMeter = document.querySelector("#self-mic-meter"); const announcementDialog = document.querySelector("#announcement-dialog"); const announcementForm = document.querySelector("#announcement-form"); const announcementInput = document.querySelector("#announcement-input"); @@ -35,6 +37,7 @@ const nameStorageKey = "kkkmeet.displayName"; const clientIdStorageKey = "kkkmeet.clientId"; const cameraStorageKey = "kkkmeet.cameraDeviceId"; const micStorageKey = "kkkmeet.micDeviceId"; +const micVolumeStorageKey = "kkkmeet.selfMicVolume"; const watchHeightStorageKey = "kkkmeet.watchPanelHeight"; const watchCollapsedStorageKey = "kkkmeet.watchPanelCollapsed"; const rtcConfig = { @@ -44,6 +47,12 @@ const rtcConfig = { let peerId = ""; let displayName = ""; let localStream; +let localProcessedAudioTrack; +let localMicSourceNode; +let localMicGainNode; +let localMicAnalyserNode; +let localMicDestination; +let micMeterAnimationFrame = 0; let screenStream; let localPresentationTile; let events; @@ -682,6 +691,7 @@ async function ensureLocalStream() { localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints()); } localVideo.srcObject = localStream; + rebuildLocalMicGraph(localStream.getAudioTracks()[0]); await refreshDeviceLists(); return localStream; } @@ -728,6 +738,81 @@ async function refreshDeviceLists() { ); } +function savedSelfMicVolume() { + const saved = Number(localStorage.getItem(micVolumeStorageKey)); + return Number.isFinite(saved) ? clamp(saved, 0, 200) : 100; +} + +function setSelfMicVolume(value) { + const normalizedVolume = clamp(Number(value) || 0, 0, 200); + selfMicVolumeInput.value = String(normalizedVolume); + localStorage.setItem(micVolumeStorageKey, String(normalizedVolume)); + if (localMicGainNode) { + localMicGainNode.gain.value = normalizedVolume / 100; + } +} + +function stopMicMeter() { + if (micMeterAnimationFrame) { + cancelAnimationFrame(micMeterAnimationFrame); + micMeterAnimationFrame = 0; + } + selfMicMeter.value = 0; +} + +function startMicMeter() { + stopMicMeter(); + if (!localMicAnalyserNode) return; + + const samples = new Uint8Array(localMicAnalyserNode.fftSize); + const tick = () => { + localMicAnalyserNode.getByteTimeDomainData(samples); + let sum = 0; + for (const sample of samples) { + const centered = (sample - 128) / 128; + sum += centered * centered; + } + selfMicMeter.value = Math.min(1, Math.sqrt(sum / samples.length) * 3); + micMeterAnimationFrame = requestAnimationFrame(tick); + }; + tick(); +} + +function rebuildLocalMicGraph(track) { + localMicSourceNode?.disconnect(); + localMicGainNode?.disconnect(); + localMicAnalyserNode?.disconnect(); + stopMicMeter(); + + localProcessedAudioTrack?.stop(); + localProcessedAudioTrack = undefined; + localMicSourceNode = undefined; + localMicGainNode = undefined; + localMicAnalyserNode = undefined; + localMicDestination = undefined; + + if (!track) return; + + const context = ensureAudioContext(); + const sourceStream = new MediaStream([track]); + localMicSourceNode = context.createMediaStreamSource(sourceStream); + localMicGainNode = context.createGain(); + localMicAnalyserNode = context.createAnalyser(); + localMicAnalyserNode.fftSize = 256; + localMicDestination = context.createMediaStreamDestination(); + localMicSourceNode.connect(localMicGainNode); + localMicGainNode.connect(localMicAnalyserNode); + localMicGainNode.connect(localMicDestination); + localProcessedAudioTrack = localMicDestination.stream.getAudioTracks()[0]; + localProcessedAudioTrack.enabled = track.enabled; + setSelfMicVolume(selfMicVolumeInput.value); + startMicMeter(); +} + +function outboundAudioTrack() { + return localProcessedAudioTrack || localStream?.getAudioTracks()[0]; +} + async function replaceLocalTrack(kind, deviceId) { if (!deviceId) return; @@ -755,10 +840,14 @@ async function replaceLocalTrack(kind, deviceId) { localStream.addTrack(newTrack); } + if (kind === "audio") { + rebuildLocalMicGraph(newTrack); + } + if (kind === "audio" || !screenStream) { for (const { connection } of peers.values()) { const sender = connection.getSenders().find((item) => item.track?.kind === kind); - await sender?.replaceTrack(newTrack); + await sender?.replaceTrack(kind === "audio" ? outboundAudioTrack() : newTrack); } } @@ -917,7 +1006,7 @@ async function sendRoomEvent(payload) { } function outboundTracks() { - const audioTracks = localStream?.getAudioTracks() || []; + const audioTracks = [outboundAudioTrack()].filter(Boolean); const videoTrack = screenStream?.getVideoTracks()[0] || localStream?.getVideoTracks()[0]; return [...audioTracks, videoTrack].filter(Boolean); } @@ -1119,6 +1208,9 @@ function setTrackEnabled(kind, enabled) { localStream?.getTracks().forEach((track) => { if (track.kind === kind) track.enabled = enabled; }); + if (kind === "audio" && localProcessedAudioTrack) { + localProcessedAudioTrack.enabled = enabled; + } } function updateToggleButton(button, enabled, enabledLabel, disabledLabel) { @@ -1313,6 +1405,10 @@ micButton.addEventListener("click", () => { setTrackEnabled("audio", enabled); }); +selfMicVolumeInput.addEventListener("input", () => { + setSelfMicVolume(selfMicVolumeInput.value); +}); + cameraSelect.addEventListener("change", () => { replaceLocalTrack("video", cameraSelect.value).catch((error) => { console.error(error); @@ -1371,6 +1467,9 @@ shareButton.addEventListener("click", async () => { } }); +selfMicVolumeInput.value = String(savedSelfMicVolume()); +selfMicMeter.value = 0; + const savedName = localStorage.getItem(nameStorageKey); if (savedName) { setDisplayName(savedName); diff --git a/public/index.html b/public/index.html index 195ab7c..8850c1d 100644 --- a/public/index.html +++ b/public/index.html @@ -111,6 +111,13 @@
+
+ + +
diff --git a/public/styles.css b/public/styles.css index df9f6bd..1b68a25 100644 --- a/public/styles.css +++ b/public/styles.css @@ -607,6 +607,35 @@ button[aria-pressed="false"] { font-size: 13px; } +.self-mic-control { + display: grid; + grid-template-columns: minmax(120px, 160px) 56px; + align-items: end; + gap: 8px; + min-width: 190px; +} + +.self-mic-control label { + display: grid; + grid-template-rows: 12px 18px; + gap: 5px; + color: #aeb7c3; + font-size: 11px; + font-weight: 650; +} + +.self-mic-control input { + width: 100%; + height: 18px; + padding: 0; +} + +.self-mic-control meter { + width: 56px; + height: 18px; + align-self: end; +} + .self-preview { position: relative; overflow: hidden;