From 3f97ec8619771ca472625f1cdf53c5efd7795605 Mon Sep 17 00:00:00 2001 From: fkwp Date: Wed, 16 Sep 2026 15:37:05 +0200 Subject: [PATCH] Show a live microphone level in the audio menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Draw the level beneath the microphone list as 24 segments, announced through role="meter", so it reads by count and not by hue alone. - Give denied permission and no input device their own message and next action, rather than a flat meter that reads as silence. - Read the selected device, and only while the menu is open, so nothing holds a second capture for the length of a call. - Follow a rise quickly and a fall slowly, in elapsed time rather than frames, so the meter does not chase the gaps between syllables. - Ignore a noise floor, below which a quiet room's hiss would light the first segments permanently. Smoothing changes how the meter moves, not where it settles, so it does not replace this. - Scroll only the device lists, and keep the meter pinned to the foot of the microphone section while its rows are in view. - Withhold the output select callback where the platform cannot route audio to a chosen device, which is what renders the speaker section disabled. - Track the in-flight device by kind as well as id: Chrome names both an input and an output "default", so the spinner lit the wrong row. - Disable every device while a selection is settling, so a second request cannot overtake the first. Two CSS choices are load-bearing and look arbitrary: the meter's wrapper is unpositioned, because a positioned one paints above the menu's outline and swallows the frame along that section, and the meter is held clear of that outline, because it is the only opaque thing in the menu. The meter opens its own short-lived capture rather than tapping the call's audio track. That lets it follow the picker instantly and avoids the pre-join track, which is frozen to the device selected when the screen mounted. The cost is a second capture while the menu is open. See the notes sidecar. Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC6, AC7, AC11, AC13, AC14, AC15, AC17, AC18, AC21, AC22 --- .../MediaMuteAndSwitchButton.module.css | 34 ++++++++++++ src/components/MediaMuteAndSwitchButton.tsx | 53 +++++++++++++------ .../MicrophoneLevelMeter.module.css | 10 +++- src/components/MicrophoneLevelMeter.tsx | 11 ++-- src/components/useMicrophoneLevel.ts | 12 ++++- src/state/MicrophoneLevel.test.ts | 43 ++++++++++++++- src/state/MicrophoneLevel.ts | 33 +++++++++++- 7 files changed, 171 insertions(+), 25 deletions(-) diff --git a/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css index e5bba2383..4c266f122 100644 --- a/src/components/MediaMuteAndSwitchButton.module.css +++ b/src/components/MediaMuteAndSwitchButton.module.css @@ -35,3 +35,37 @@ Please see LICENSE in the repository root for full details. transform: rotate(360deg); } } + +/* The menu is portalled outside the call root, so it cannot be sized against + the app's container. Radix measures the space it actually has and publishes + it here, which is neither a viewport unit nor a guessed pixel height. */ +.menu { + display: flex; + flex-direction: column; + max-block-size: var(--radix-dropdown-menu-content-available-height); +} + +/* Only the device lists scroll; the level meter stays put beneath them. */ +.deviceList { + overflow-y: auto; + min-block-size: 0; +} + +/* The meter belongs to the microphone section: it stays at the bottom of the + scrollport while that section is in view, and leaves with it when the list + is scrolled up to the speakers. The wrapper is deliberately unpositioned — + a positioned one paints above the menu's outline and swallows the frame + along this whole section. Sticky is resolved against the scroll container, + so it does not need one. */ + +.stickyMeter { + position: sticky; + inset-block-end: 0; + /* Opaque, so the list does not show through it as it scrolls past. The menu + draws its frame as an outline inset by one border width, and the device + rows are transparent at rest, so this is the only thing that can cover it: + hold it clear on the sides and the bottom. */ + background: var(--cpd-color-bg-canvas-default); + margin-inline: var(--cpd-border-width-1); + margin-block-end: var(--cpd-border-width-1); +} diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index bee654c02..a078a8428 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -272,6 +272,7 @@ export const MediaMuteAndSwitchButton: FC = ({ {/* The mute button lives inside */} {button} = ({ /> } > - {iconsAndLabels === "audio" && outputOptions && ( - <> - +
+ {iconsAndLabels === "audio" && outputOptions && ( + <> + + {deviceItems( + "output", + outputOptions, + selectedOutputOption, + onSelectOutput, + (n) => t("settings.devices.speaker_numbered", { n }), + )} + + + )} + + {/* The heading sits outside, so the meter can never ride up over it: + sticky only holds while this block is in view. */} +
{deviceItems( - "output", - outputOptions, - selectedOutputOption, - onSelectOutput, - (n) => t("settings.devices.speaker_numbered", { n }), + "input", + options, + selectedOption, + onSelect, + numberedLabel, )} - - - )} - - {deviceItems("input", options, selectedOption, onSelect, numberedLabel)} - {iconsAndLabels === "audio" && ( - - )} + {iconsAndLabels === "audio" && ( + <> + + {/* Closes the microphone section. The meter stays pinned until + this line reaches it, then leaves with the section. */} + + + )} +
+
{(toggles?.length ?? 0) > 0 &&
} {toggles?.map((toggle) => ( = ({ state }) => { +export const MicrophoneLevelMeter: FC = ({ state, className }) => { const { t } = useTranslation(); if (state.type !== "level") return ( -
- +
+ {state.type === "permission-denied" ? t("microphone_level.permission_denied") @@ -41,8 +42,8 @@ export const MicrophoneLevelMeter: FC = ({ state }) => { ); return ( -
- +
+
{ analyser.getByteTimeDomainData(samples); @@ -75,7 +78,14 @@ export function useMicrophoneLevel( const centred = (sample - 128) / 128; sum += centred * centred; } - const level = segmentsForVolume(Math.sqrt(sum / samples.length)); + const now = performance.now(); + displayed = smoothVolume( + displayed, + Math.sqrt(sum / samples.length), + now - previousFrame, + ); + previousFrame = now; + const level = segmentsForVolume(displayed); setState((current) => current.type === "level" && current.level === level ? current diff --git a/src/state/MicrophoneLevel.test.ts b/src/state/MicrophoneLevel.test.ts index 3dfc872fe..8ce3544b8 100644 --- a/src/state/MicrophoneLevel.test.ts +++ b/src/state/MicrophoneLevel.test.ts @@ -7,7 +7,13 @@ Please see LICENSE in the repository root for full details. import { describe, expect, test } from "vitest"; -import { METER_SEGMENTS, segmentsForVolume } from "./MicrophoneLevel"; +import { + ATTACK_MS, + METER_SEGMENTS, + RELEASE_MS, + segmentsForVolume, + smoothVolume, +} from "./MicrophoneLevel"; describe("segmentsForVolume", () => { test("shows nothing for silence", () => { @@ -47,3 +53,38 @@ describe("segmentsForVolume", () => { expect(segmentsForVolume(-1)).toBe(0); }); }); + +describe("smoothVolume", () => { + test("rises faster than it falls", () => { + const rise = smoothVolume(0, 1, 50); + const fall = 1 - smoothVolume(1, 0, 50); + + expect(rise).toBeGreaterThan(fall); + }); + + test("registers a syllable as it starts", () => { + // Most of the way there within one attack time constant, so speech does + // not lag the speaker. + expect(smoothVolume(0, 1, ATTACK_MS)).toBeGreaterThan(0.6); + }); + + test("rides over the gaps between words", () => { + // A pause of a few tens of milliseconds should not collapse the meter, or + // it flickers rather than reading as a level. + expect(smoothVolume(1, 0, 30)).toBeGreaterThan(0.7); + // A real silence still brings it down. + expect(smoothVolume(1, 0, RELEASE_MS * 3)).toBeLessThan(0.1); + }); + + test("behaves the same whatever the frame rate", () => { + const oneStep = smoothVolume(0, 1, 32); + let twoSteps = smoothVolume(0, 1, 16); + twoSteps = smoothVolume(twoSteps, 1, 16); + + expect(twoSteps).toBeCloseTo(oneStep, 5); + }); + + test("holds still when no time has passed", () => { + expect(smoothVolume(0.5, 1, 0)).toBe(0.5); + }); +}); diff --git a/src/state/MicrophoneLevel.ts b/src/state/MicrophoneLevel.ts index 94521c227..8a391cbb2 100644 --- a/src/state/MicrophoneLevel.ts +++ b/src/state/MicrophoneLevel.ts @@ -22,7 +22,7 @@ export type MicrophoneState = * Enough of them that they sit close together across the width of the menu: * the bars keep a fixed size, so too few leaves visible gaps between them. */ -export const METER_SEGMENTS = 32; +export const METER_SEGMENTS = 24; /** * Loudness below which the microphone is treated as picking up nothing. @@ -50,3 +50,34 @@ export function segmentsForVolume(volume: number): number { Math.ceil(Math.sqrt(aboveFloor) * METER_SEGMENTS), ); } + +/** + * How quickly the meter follows a rise in loudness, as a time constant in + * milliseconds. Short, so a syllable registers the moment it starts. + */ +export const ATTACK_MS = 50; + +/** + * How quickly the meter follows a fall. Longer than the attack: speech is full + * of gaps a few tens of milliseconds long, and a meter that tracked them + * exactly would flicker rather than read as a level. + */ +export const RELEASE_MS = 120; + +/** + * Moves a displayed level towards a new reading, fast upwards and slowly + * downwards. + * + * Framed in elapsed time rather than frames, so the meter behaves the same on a + * 60Hz and a 120Hz display, and does not jump when a frame is dropped. + */ +export function smoothVolume( + displayed: number, + reading: number, + elapsedMs: number, +): number { + if (elapsedMs <= 0) return displayed; + const timeConstant = reading > displayed ? ATTACK_MS : RELEASE_MS; + const towards = 1 - Math.exp(-elapsedMs / timeConstant); + return displayed + (reading - displayed) * towards; +}