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 input level beneath the microphone list as 32 discrete segments, so the level reads by count and not by hue alone. - Expose it through role="meter" with aria-valuenow and aria-valuetext, so a screen reader announces it. - Give denied permission and no input device their own message and next action, rather than a flat meter that reads as silence. - Read the level only while the menu is open, so nothing holds a second capture of the microphone for the length of a call. - Read the device the picker has selected, so the meter re-points as soon as the selection changes. - Ignore anything below a noise floor: a quiet room is never digitally silent, and its hiss otherwise lights the first segments permanently, which reads as "it can hear me" when nobody is speaking. - Quantise loudness with a square root above that floor: amplitude puts ordinary speech near the bottom of the range, where a linear meter barely moves. - Withhold the output select callback where the platform cannot route audio to a chosen device, which is what renders the speaker section disabled. Safari and most Firefox builds land here. - Track the in-flight device by kind as well as id: Chrome names both an input and an output "default", so the spinner appeared on the wrong row. - Disable every device in the menu while a selection is settling, so a second request cannot overtake the first. The meter opens its own short-lived capture rather than tapping the call's audio track. That is what lets it follow the picker instantly, and it avoids the pre-join track, which is deliberately frozen to the device selected when the screen mounted. The cost is a second capture of the same device while the menu is open. See the spec's notes sidecar. Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC6, AC7, AC13, AC14, AC15, AC17, AC18, AC21, AC22
This commit is contained in:
@@ -163,6 +163,12 @@
|
||||
"login_auth_links_prompt": "Not registered yet?",
|
||||
"login_subheading": "To continue to Element",
|
||||
"login_title": "Login",
|
||||
"microphone_level": {
|
||||
"label": "Microphone level",
|
||||
"no_device": "No microphone found. Connect one, then try again.",
|
||||
"permission_denied": "Element Call cannot use your microphone. Allow access in your browser settings.",
|
||||
"value": "{{level}} of {{max}}"
|
||||
},
|
||||
"microphone_off": "Microphone off",
|
||||
"microphone_on": "Microphone on",
|
||||
"mute_microphone_button_label": "Mute microphone",
|
||||
|
||||
@@ -30,6 +30,11 @@ vi.mock("@livekit/track-processors", () => ({
|
||||
supportsBackgroundProcessors: (): boolean => false,
|
||||
}));
|
||||
|
||||
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("livekit-client", () => ({
|
||||
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Returns the minimum set of CallViewModel fields required by
|
||||
* createCallFooterViewModel, with all other properties stubbed to
|
||||
@@ -96,6 +101,31 @@ const twoMicsAndOneCamMediaDevices = mockMediaDevices({
|
||||
});
|
||||
|
||||
describe("createCallFooterViewModel", () => {
|
||||
describe("selectAudioOutputOption", () => {
|
||||
function buildFooterVm(): ReturnType<typeof createCallFooterViewModel> {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
return createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
}
|
||||
|
||||
it("is withheld where the platform cannot route audio to a chosen device", () => {
|
||||
outputSelectionMock.mockReturnValue(false);
|
||||
// Undefined is what renders the speaker section disabled.
|
||||
expect(buildFooterVm().selectAudioOutputOption$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is offered where the platform can route audio to a chosen device", () => {
|
||||
outputSelectionMock.mockReturnValue(true);
|
||||
expect(buildFooterVm().selectAudioOutputOption$.value).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("audioOptions and videoOptions", () => {
|
||||
function checkEmptyFor(platform: string, layout: Layout): void {
|
||||
platformMock.mockReturnValue(platform);
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { combineLatest, map, type Observable, switchMap } from "rxjs";
|
||||
import { supportsBackgroundProcessors } from "@livekit/track-processors";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
@@ -103,7 +104,13 @@ function buildDeviceBehaviors(
|
||||
selectedAudioOutput$: scope.behavior(
|
||||
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectAudioOutputOption$: constant(mediaDevices.audioOutput.select),
|
||||
// Safari and most Firefox builds cannot route audio to a chosen device at
|
||||
// all. Withholding the callback is what renders the section disabled.
|
||||
selectAudioOutputOption$: constant(
|
||||
supportsAudioOutputSelection()
|
||||
? mediaDevices.audioOutput.select
|
||||
: undefined,
|
||||
),
|
||||
videoOptions$: scope.behavior(options$(mediaDevices.videoInput.available$)),
|
||||
selectedVideo$: scope.behavior(
|
||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||
|
||||
@@ -348,6 +348,63 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: false });
|
||||
});
|
||||
|
||||
test("disables every device while a selection is settling", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
function Wrapper(): JSX.Element {
|
||||
const [selectedOption, setSelectedOption] = useState("mic1");
|
||||
return (
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption={selectedOption}
|
||||
onSelect={(id) => {
|
||||
void promise.then(() => setSelectedOption(id));
|
||||
}}
|
||||
outputOptions={[
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
]}
|
||||
selectedOutputOption="spk1"
|
||||
onSelectOutput={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByRole } = renderComponent(<Wrapper />);
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
||||
);
|
||||
|
||||
// In flight: nothing else can be picked, in either section, so a second
|
||||
// request cannot overtake the first.
|
||||
for (const name of ["Microphone 1", "Speakers", "Headset"]) {
|
||||
expect(screen.getByRole("menuitemradio", { name })).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
resolve();
|
||||
await promise;
|
||||
});
|
||||
|
||||
// Settled: choosable again.
|
||||
expect(
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 1" }),
|
||||
).not.toHaveAttribute("aria-disabled", "true");
|
||||
expect(
|
||||
screen.getByRole("menuitemradio", { name: "Headset" }),
|
||||
).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
test("lists speaker and microphone sections", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent(
|
||||
@@ -435,7 +492,7 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
expect(only).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
test("shows the speaker section disabled when no output can be chosen", async () => {
|
||||
test("shows the speaker section disabled when output selection is unsupported", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
type DeviceLabel,
|
||||
} from "../state/MediaDevices";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
||||
import { useMicrophoneLevel } from "./useMicrophoneLevel";
|
||||
|
||||
export interface MenuOptions {
|
||||
label: DeviceLabel | AudioOutputDeviceLabel;
|
||||
@@ -91,11 +93,23 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
videoBlurToggleClick,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
|
||||
// Which device we have asked for but not yet been given. Carries the kind as
|
||||
// well as the id, because an input and an output can share an id: "default"
|
||||
// names both on Chrome.
|
||||
const [plannedSelection, setPlannedSelection] = useState<{
|
||||
kind: "input" | "output";
|
||||
id: string;
|
||||
} | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const isBusy = busy ?? false;
|
||||
const { t } = useTranslation();
|
||||
const devices = useMediaDevices();
|
||||
// Only while the menu is open, so nothing holds a second capture of the
|
||||
// microphone for the length of a call.
|
||||
const microphoneState = useMicrophoneLevel(
|
||||
selectedOption,
|
||||
menuOpen && iconsAndLabels === "audio",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
|
||||
@@ -184,7 +198,17 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// A device we asked for that has not arrived yet. Until it does, nothing in
|
||||
// the menu can be picked, so a second request cannot overtake the first.
|
||||
const settling =
|
||||
plannedSelection !== null &&
|
||||
plannedSelection.id !==
|
||||
(plannedSelection.kind === "output"
|
||||
? selectedOutputOption
|
||||
: selectedOption);
|
||||
|
||||
const deviceItems = (
|
||||
kind: "input" | "output",
|
||||
items: MenuOptions[] | undefined,
|
||||
selected: string | undefined,
|
||||
select: ((id: string) => void) | undefined,
|
||||
@@ -194,7 +218,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
// Shown but not choosable when nothing can be picked here, or when there is
|
||||
// only one device. The entry stays visible so the menu keeps the same shape
|
||||
// on every platform.
|
||||
const disabled = select === undefined || list.length <= 1;
|
||||
const disabled = select === undefined || list.length <= 1 || settling;
|
||||
return list.map(({ label, id }) => (
|
||||
<MenuItem
|
||||
// A radio input inside a button is invalid, and the menu needs an
|
||||
@@ -217,21 +241,23 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
if (id === selected) return;
|
||||
setPlannedSelection(id);
|
||||
setPlannedSelection({ kind, id });
|
||||
select?.(id);
|
||||
}}
|
||||
key={id}
|
||||
role="menuitemradio"
|
||||
aria-checked={selected === id}
|
||||
>
|
||||
{selected !== id && plannedSelection === id && (
|
||||
<SpinnerIcon
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.rotate}
|
||||
aria-label={t("settings.devices.activating")}
|
||||
/>
|
||||
)}
|
||||
{selected !== id &&
|
||||
plannedSelection?.kind === kind &&
|
||||
plannedSelection.id === id && (
|
||||
<SpinnerIcon
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.rotate}
|
||||
aria-label={t("settings.devices.activating")}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
));
|
||||
};
|
||||
@@ -271,6 +297,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
<>
|
||||
<MenuTitle title={t("settings.devices.speaker")} />
|
||||
{deviceItems(
|
||||
"output",
|
||||
outputOptions,
|
||||
selectedOutputOption,
|
||||
onSelectOutput,
|
||||
@@ -280,7 +307,10 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
</>
|
||||
)}
|
||||
<MenuTitle title={optionsButtonLabel} />
|
||||
{deviceItems(options, selectedOption, onSelect, numberedLabel)}
|
||||
{deviceItems("input", options, selectedOption, onSelect, numberedLabel)}
|
||||
{iconsAndLabels === "audio" && (
|
||||
<MicrophoneLevelMeter state={microphoneState} />
|
||||
)}
|
||||
{(toggles?.length ?? 0) > 0 && <hr />}
|
||||
{toggles?.map((toggle) => (
|
||||
<ToggleMenuItem
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.meter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-3x);
|
||||
padding-block: var(--cpd-space-2x);
|
||||
padding-inline: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.segments {
|
||||
/* Spans the menu by spreading the gaps, so the bars keep their proportion
|
||||
instead of growing to fill the space. */
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.segment {
|
||||
flex: none;
|
||||
inline-size: var(--cpd-space-1x);
|
||||
block-size: var(--cpd-space-4x);
|
||||
border-radius: var(--cpd-space-1x);
|
||||
background: var(--cpd-color-bg-subtle-primary);
|
||||
}
|
||||
|
||||
/* Filled segments carry the level by count as well as by colour, so the meter
|
||||
stays readable without relying on hue. */
|
||||
.segmentLit {
|
||||
background: var(--cpd-color-bg-accent-rest);
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
||||
import { METER_SEGMENTS } from "../state/MicrophoneLevel";
|
||||
|
||||
describe("MicrophoneLevelMeter", () => {
|
||||
test("announces the level rather than relying on hue", () => {
|
||||
render(<MicrophoneLevelMeter state={{ type: "level", level: 6 }} />);
|
||||
|
||||
const meter = screen.getByRole("meter", { name: "Microphone level" });
|
||||
expect(meter).toHaveAttribute("aria-valuenow", "6");
|
||||
expect(meter).toHaveAttribute("aria-valuemax", String(METER_SEGMENTS));
|
||||
expect(meter).toHaveAttribute("aria-valuetext", `6 of ${METER_SEGMENTS}`);
|
||||
});
|
||||
|
||||
test("shows distinct messages for denied permission and no input device", () => {
|
||||
const denied = render(
|
||||
<MicrophoneLevelMeter state={{ type: "permission-denied" }} />,
|
||||
);
|
||||
// A next action, not a flat meter that reads as silence.
|
||||
expect(
|
||||
denied.getByText(/Allow access in your browser settings/),
|
||||
).toBeInTheDocument();
|
||||
expect(denied.queryByRole("meter")).toBeNull();
|
||||
|
||||
const missing = render(
|
||||
<MicrophoneLevelMeter state={{ type: "no-device" }} />,
|
||||
);
|
||||
expect(missing.getByText(/No microphone found/)).toBeInTheDocument();
|
||||
expect(missing.queryByRole("meter")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC } from "react";
|
||||
import { Text } from "@vector-im/compound-web";
|
||||
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./MicrophoneLevelMeter.module.css";
|
||||
import { METER_SEGMENTS, type MicrophoneState } from "../state/MicrophoneLevel";
|
||||
|
||||
interface Props {
|
||||
state: MicrophoneState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live input level of the selected microphone, shown beneath it.
|
||||
*
|
||||
* It says whether the microphone is picking anything up, which is not the same
|
||||
* 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 }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (state.type !== "level")
|
||||
return (
|
||||
<div className={styles.meter}>
|
||||
<MicOnIcon width={20} height={20} className={styles.icon} aria-hidden />
|
||||
<Text size="sm" className={styles.message}>
|
||||
{state.type === "permission-denied"
|
||||
? t("microphone_level.permission_denied")
|
||||
: t("microphone_level.no_device")}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.meter}>
|
||||
<MicOnIcon width={20} height={20} className={styles.icon} aria-hidden />
|
||||
<div
|
||||
className={styles.segments}
|
||||
role="meter"
|
||||
aria-label={t("microphone_level.label")}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={METER_SEGMENTS}
|
||||
aria-valuenow={state.level}
|
||||
aria-valuetext={t("microphone_level.value", {
|
||||
level: state.level,
|
||||
max: METER_SEGMENTS,
|
||||
})}
|
||||
>
|
||||
{Array.from({ length: METER_SEGMENTS }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden
|
||||
className={classNames(styles.segment, {
|
||||
[styles.segmentLit]: i < state.level,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
type MicrophoneState,
|
||||
segmentsForVolume,
|
||||
} from "../state/MicrophoneLevel";
|
||||
|
||||
/**
|
||||
* Reads the live input level of a microphone, while `active`.
|
||||
*
|
||||
* Capture is scoped to the caller being on screen: the meter only exists while
|
||||
* the menu that shows it is open, so nothing holds a second capture of the
|
||||
* device for the length of a call.
|
||||
*
|
||||
* The level says whether the microphone is picking anything up, which is not
|
||||
* the same as whether the user is being heard. It keeps moving while muted, and
|
||||
* the mute control is what says nothing is transmitted.
|
||||
*/
|
||||
export function useMicrophoneLevel(
|
||||
deviceId: string | undefined,
|
||||
active: boolean,
|
||||
): MicrophoneState {
|
||||
const [state, setState] = useState<MicrophoneState>({
|
||||
type: "level",
|
||||
level: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
let stopped = false;
|
||||
let stream: MediaStream | undefined;
|
||||
let context: AudioContext | undefined;
|
||||
let frame: number | undefined;
|
||||
|
||||
const stop = (): void => {
|
||||
stopped = true;
|
||||
if (frame !== undefined) cancelAnimationFrame(frame);
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
void context?.close();
|
||||
};
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio:
|
||||
deviceId === undefined ? true : { deviceId: { exact: deviceId } },
|
||||
});
|
||||
if (stopped) return;
|
||||
|
||||
context = new AudioContext();
|
||||
// Chrome starts the context suspended unless it was created during a
|
||||
// gesture; opening the menu is one, but resume explicitly so the meter
|
||||
// cannot silently sit at zero.
|
||||
if (context.state === "suspended") await context.resume();
|
||||
if (stopped) return;
|
||||
const analyser = context.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
context.createMediaStreamSource(stream).connect(analyser);
|
||||
const samples = new Uint8Array(analyser.fftSize);
|
||||
|
||||
const read = (): void => {
|
||||
analyser.getByteTimeDomainData(samples);
|
||||
// Root mean square of the waveform around its centre, which is the
|
||||
// loudness a listener perceives rather than the tallest spike.
|
||||
let sum = 0;
|
||||
for (const sample of samples) {
|
||||
const centred = (sample - 128) / 128;
|
||||
sum += centred * centred;
|
||||
}
|
||||
const level = segmentsForVolume(Math.sqrt(sum / samples.length));
|
||||
setState((current) =>
|
||||
current.type === "level" && current.level === level
|
||||
? current
|
||||
: { type: "level", level },
|
||||
);
|
||||
frame = requestAnimationFrame(read);
|
||||
};
|
||||
read();
|
||||
};
|
||||
|
||||
start().catch((e: unknown) => {
|
||||
const name = e instanceof Error ? e.name : "";
|
||||
if (name === "NotAllowedError" || name === "SecurityError") {
|
||||
setState({ type: "permission-denied" });
|
||||
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
||||
setState({ type: "no-device" });
|
||||
} else {
|
||||
logger.error("Could not read the microphone level", e);
|
||||
setState({ type: "no-device" });
|
||||
}
|
||||
});
|
||||
|
||||
return stop;
|
||||
}, [deviceId, active]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { METER_SEGMENTS, segmentsForVolume } from "./MicrophoneLevel";
|
||||
|
||||
describe("segmentsForVolume", () => {
|
||||
test("shows nothing for silence", () => {
|
||||
expect(segmentsForVolume(0)).toBe(0);
|
||||
});
|
||||
|
||||
test("shows nothing for the hiss of a quiet room", () => {
|
||||
// Without a noise floor these light the first segments permanently, which
|
||||
// reads as "it can hear me" when nobody is speaking.
|
||||
expect(segmentsForVolume(0.005)).toBe(0);
|
||||
expect(segmentsForVolume(0.015)).toBe(0);
|
||||
});
|
||||
|
||||
test("distinguishes quiet, normal and loud speech", () => {
|
||||
const quiet = segmentsForVolume(0.06);
|
||||
const normal = segmentsForVolume(0.2);
|
||||
const loud = segmentsForVolume(0.8);
|
||||
|
||||
expect(quiet).toBeGreaterThan(0);
|
||||
expect(normal).toBeGreaterThan(quiet);
|
||||
expect(loud).toBeGreaterThan(normal);
|
||||
});
|
||||
|
||||
test("moves the meter visibly for normal speech", () => {
|
||||
// Ordinary speech should reach the middle of the meter, not scrape along
|
||||
// the floor: a meter that barely moves reads as a broken microphone.
|
||||
expect(segmentsForVolume(0.2)).toBeGreaterThanOrEqual(METER_SEGMENTS / 4);
|
||||
});
|
||||
|
||||
test("never exceeds the meter", () => {
|
||||
expect(segmentsForVolume(1)).toBe(METER_SEGMENTS);
|
||||
expect(segmentsForVolume(4)).toBe(METER_SEGMENTS);
|
||||
});
|
||||
|
||||
test("treats a missing reading as silence", () => {
|
||||
expect(segmentsForVolume(NaN)).toBe(0);
|
||||
expect(segmentsForVolume(-1)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What the microphone selector can say about the input, beyond its level.
|
||||
*
|
||||
* Silence and a broken microphone look identical on a meter, so the states a
|
||||
* user has to act on are named rather than drawn as a flat bar.
|
||||
*/
|
||||
export type MicrophoneState =
|
||||
| { type: "level"; level: number }
|
||||
| { type: "permission-denied" }
|
||||
| { type: "no-device" };
|
||||
|
||||
/**
|
||||
* Steps the meter is drawn in. The level is quantised to these.
|
||||
*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Loudness below which the microphone is treated as picking up nothing.
|
||||
*
|
||||
* A quiet room is never digitally silent, and without a floor that hiss lights
|
||||
* the first segments permanently — which reads as "it can hear me" when nobody
|
||||
* is speaking.
|
||||
*/
|
||||
const NOISE_FLOOR = 0.02;
|
||||
|
||||
/**
|
||||
* Quantises a 0..1 volume to a whole number of meter segments.
|
||||
*
|
||||
* Exported for the tests: the mapping from loudness to segments is the part
|
||||
* that decides whether quiet, normal and loud speech look different.
|
||||
*/
|
||||
export function segmentsForVolume(volume: number): number {
|
||||
if (!Number.isFinite(volume) || volume <= NOISE_FLOOR) return 0;
|
||||
// Volume arrives as amplitude, where speech occupies a small part of the top
|
||||
// of the range. A square root spreads that out, so ordinary speech moves the
|
||||
// meter through its middle rather than barely leaving the floor.
|
||||
const aboveFloor = (Math.min(volume, 1) - NOISE_FLOOR) / (1 - NOISE_FLOOR);
|
||||
return Math.min(
|
||||
METER_SEGMENTS,
|
||||
Math.ceil(Math.sqrt(aboveFloor) * METER_SEGMENTS),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user