mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
{/* The mute button lives inside */}
|
||||
{button}
|
||||
<Menu
|
||||
className={styles.menu}
|
||||
title={title ?? defaultMenuTitle}
|
||||
// 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.
|
||||
@@ -293,24 +294,44 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
{iconsAndLabels === "audio" && outputOptions && (
|
||||
<>
|
||||
<MenuTitle title={t("settings.devices.speaker")} />
|
||||
<div className={styles.deviceList}>
|
||||
{iconsAndLabels === "audio" && outputOptions && (
|
||||
<>
|
||||
<MenuTitle title={t("settings.devices.speaker")} />
|
||||
{deviceItems(
|
||||
"output",
|
||||
outputOptions,
|
||||
selectedOutputOption,
|
||||
onSelectOutput,
|
||||
(n) => t("settings.devices.speaker_numbered", { n }),
|
||||
)}
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<MenuTitle title={optionsButtonLabel} />
|
||||
{/* The heading sits outside, so the meter can never ride up over it:
|
||||
sticky only holds while this block is in view. */}
|
||||
<div>
|
||||
{deviceItems(
|
||||
"output",
|
||||
outputOptions,
|
||||
selectedOutputOption,
|
||||
onSelectOutput,
|
||||
(n) => t("settings.devices.speaker_numbered", { n }),
|
||||
"input",
|
||||
options,
|
||||
selectedOption,
|
||||
onSelect,
|
||||
numberedLabel,
|
||||
)}
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<MenuTitle title={optionsButtonLabel} />
|
||||
{deviceItems("input", options, selectedOption, onSelect, numberedLabel)}
|
||||
{iconsAndLabels === "audio" && (
|
||||
<MicrophoneLevelMeter state={microphoneState} />
|
||||
)}
|
||||
{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?.map((toggle) => (
|
||||
<ToggleMenuItem
|
||||
|
||||
@@ -10,12 +10,20 @@ Please see LICENSE in the repository root for full details.
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-3x);
|
||||
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 {
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
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 {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { METER_SEGMENTS, type MicrophoneState } from "../state/MicrophoneLevel";
|
||||
|
||||
interface Props {
|
||||
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
|
||||
* control is what says nothing is transmitted.
|
||||
*/
|
||||
export const MicrophoneLevelMeter: FC<Props> = ({ state }) => {
|
||||
export const MicrophoneLevelMeter: FC<Props> = ({ state, className }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (state.type !== "level")
|
||||
return (
|
||||
<div className={styles.meter}>
|
||||
<MicOnIcon width={20} height={20} className={styles.icon} aria-hidden />
|
||||
<div className={classNames(styles.meter, className)}>
|
||||
<MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
|
||||
<Text size="sm" className={styles.message}>
|
||||
{state.type === "permission-denied"
|
||||
? t("microphone_level.permission_denied")
|
||||
@@ -41,8 +42,8 @@ export const MicrophoneLevelMeter: FC<Props> = ({ state }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.meter}>
|
||||
<MicOnIcon width={20} height={20} className={styles.icon} aria-hidden />
|
||||
<div className={classNames(styles.meter, className)}>
|
||||
<MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
|
||||
<div
|
||||
className={styles.segments}
|
||||
role="meter"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type MicrophoneState,
|
||||
segmentsForVolume,
|
||||
smoothVolume,
|
||||
} from "../state/MicrophoneLevel";
|
||||
|
||||
/**
|
||||
@@ -65,6 +66,8 @@ export function useMicrophoneLevel(
|
||||
analyser.fftSize = 1024;
|
||||
context.createMediaStreamSource(stream).connect(analyser);
|
||||
const samples = new Uint8Array(analyser.fftSize);
|
||||
let displayed = 0;
|
||||
let previousFrame = performance.now();
|
||||
|
||||
const read = (): void => {
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user