Add local microphone volume meter
This commit is contained in:
+101
-2
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user