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; +}