Show a live microphone level in the audio menu

- 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
This commit is contained in:
fkwp
2026-09-16 15:37:05 +02:00
parent 9a68de193f
commit 3f97ec8619
7 changed files with 171 additions and 25 deletions
@@ -35,3 +35,37 @@ Please see LICENSE in the repository root for full details.
transform: rotate(360deg); 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);
}
+24 -3
View File
@@ -272,6 +272,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
{/* The mute button lives inside */} {/* The mute button lives inside */}
{button} {button}
<Menu <Menu
className={styles.menu}
title={title ?? defaultMenuTitle} title={title ?? defaultMenuTitle}
// Each section carries its own heading, so the menu's own title would // Each section carries its own heading, so the menu's own title would
// sit on top of the first one. Kept for the accessible name only. // sit on top of the first one. Kept for the accessible name only.
@@ -293,6 +294,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/> />
} }
> >
<div className={styles.deviceList}>
{iconsAndLabels === "audio" && outputOptions && ( {iconsAndLabels === "audio" && outputOptions && (
<> <>
<MenuTitle title={t("settings.devices.speaker")} /> <MenuTitle title={t("settings.devices.speaker")} />
@@ -307,10 +309,29 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
</> </>
)} )}
<MenuTitle title={optionsButtonLabel} /> <MenuTitle title={optionsButtonLabel} />
{deviceItems("input", options, selectedOption, onSelect, numberedLabel)} {/* The heading sits outside, so the meter can never ride up over it:
{iconsAndLabels === "audio" && ( sticky only holds while this block is in view. */}
<MicrophoneLevelMeter state={microphoneState} /> <div>
{deviceItems(
"input",
options,
selectedOption,
onSelect,
numberedLabel,
)} )}
{iconsAndLabels === "audio" && (
<>
<MicrophoneLevelMeter
state={microphoneState}
className={styles.stickyMeter}
/>
{/* Closes the microphone section. The meter stays pinned until
this line reaches it, then leaves with the section. */}
<Separator />
</>
)}
</div>
</div>
{(toggles?.length ?? 0) > 0 && <hr />} {(toggles?.length ?? 0) > 0 && <hr />}
{toggles?.map((toggle) => ( {toggles?.map((toggle) => (
<ToggleMenuItem <ToggleMenuItem
@@ -10,12 +10,20 @@ Please see LICENSE in the repository root for full details.
align-items: center; align-items: center;
gap: var(--cpd-space-3x); gap: var(--cpd-space-3x);
padding-block: var(--cpd-space-2x); padding-block: var(--cpd-space-2x);
padding-inline: var(--cpd-space-4x); /* Aligned with the device rows above: a menu item reserves a trailing column
for its chevron and gives its label an end margin, so the text stops well
short of the item's own padding. Without the same inset the bars run past
where the device names end. */
padding-inline-start: var(--cpd-space-4x);
padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x));
} }
.icon { .icon {
color: var(--cpd-color-icon-secondary); color: var(--cpd-color-icon-secondary);
flex-shrink: 0; flex-shrink: 0;
/* Same width as a device row's icon column, so the bars start where the
device names start. */
inline-size: 24px;
} }
.segments { .segments {
+6 -5
View File
@@ -16,6 +16,7 @@ import { METER_SEGMENTS, type MicrophoneState } from "../state/MicrophoneLevel";
interface Props { interface Props {
state: MicrophoneState; state: MicrophoneState;
className?: string;
} }
/** /**
@@ -25,13 +26,13 @@ interface Props {
* as whether the user is being heard: it keeps moving while muted, and the mute * as whether the user is being heard: it keeps moving while muted, and the mute
* control is what says nothing is transmitted. * control is what says nothing is transmitted.
*/ */
export const MicrophoneLevelMeter: FC<Props> = ({ state }) => { export const MicrophoneLevelMeter: FC<Props> = ({ state, className }) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (state.type !== "level") if (state.type !== "level")
return ( return (
<div className={styles.meter}> <div className={classNames(styles.meter, className)}>
<MicOnIcon width={20} height={20} className={styles.icon} aria-hidden /> <MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
<Text size="sm" className={styles.message}> <Text size="sm" className={styles.message}>
{state.type === "permission-denied" {state.type === "permission-denied"
? t("microphone_level.permission_denied") ? t("microphone_level.permission_denied")
@@ -41,8 +42,8 @@ export const MicrophoneLevelMeter: FC<Props> = ({ state }) => {
); );
return ( return (
<div className={styles.meter}> <div className={classNames(styles.meter, className)}>
<MicOnIcon width={20} height={20} className={styles.icon} aria-hidden /> <MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
<div <div
className={styles.segments} className={styles.segments}
role="meter" role="meter"
+11 -1
View File
@@ -11,6 +11,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { import {
type MicrophoneState, type MicrophoneState,
segmentsForVolume, segmentsForVolume,
smoothVolume,
} from "../state/MicrophoneLevel"; } from "../state/MicrophoneLevel";
/** /**
@@ -65,6 +66,8 @@ export function useMicrophoneLevel(
analyser.fftSize = 1024; analyser.fftSize = 1024;
context.createMediaStreamSource(stream).connect(analyser); context.createMediaStreamSource(stream).connect(analyser);
const samples = new Uint8Array(analyser.fftSize); const samples = new Uint8Array(analyser.fftSize);
let displayed = 0;
let previousFrame = performance.now();
const read = (): void => { const read = (): void => {
analyser.getByteTimeDomainData(samples); analyser.getByteTimeDomainData(samples);
@@ -75,7 +78,14 @@ export function useMicrophoneLevel(
const centred = (sample - 128) / 128; const centred = (sample - 128) / 128;
sum += centred * centred; 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) => setState((current) =>
current.type === "level" && current.level === level current.type === "level" && current.level === level
? current ? current
+42 -1
View File
@@ -7,7 +7,13 @@ Please see LICENSE in the repository root for full details.
import { describe, expect, test } from "vitest"; 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", () => { describe("segmentsForVolume", () => {
test("shows nothing for silence", () => { test("shows nothing for silence", () => {
@@ -47,3 +53,38 @@ describe("segmentsForVolume", () => {
expect(segmentsForVolume(-1)).toBe(0); 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);
});
});
+32 -1
View File
@@ -22,7 +22,7 @@ export type MicrophoneState =
* Enough of them that they sit close together across the width of the menu: * 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. * 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. * 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), 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;
}