Add local microphone volume meter

This commit is contained in:
codex
2026-06-10 22:37:29 +02:00
parent 35a04db978
commit 89ad5d1dca
3 changed files with 137 additions and 2 deletions
+101 -2
View File
@@ -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);
+7
View File
@@ -111,6 +111,13 @@
<div class="control-actions" aria-label="Call controls">
<button id="share-button" type="button">Share Screen</button>
<button id="announce-button" type="button">Announce</button>
<div class="self-mic-control" aria-label="Local microphone volume">
<label>
<span>Mic</span>
<input id="self-mic-volume" type="range" min="0" max="200" step="1" />
</label>
<meter id="self-mic-meter" min="0" max="1" low="0.15" high="0.65" optimum="0.45"></meter>
</div>
</div>
</div>
<article class="self-preview">
+29
View File
@@ -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;