mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
Add the microphone level meter and its capture hook
useMicrophoneLevel opens a capture of the given microphone for as long as it is enabled, reports a smoothed 0..1 level per animation frame, and tears the capture down on disable, device change or unmount, including while the capture is still being acquired. A denied permission and a microphone that cannot be opened are reported as distinct states. AudioLevelMeter renders that state as a row of bars, focusable and announcing its state to a screen reader only while focused, with a hint in place of the meter when permission is denied and a greyed-out meter when the microphone is unavailable. Nothing renders it yet; the audio menu picks it up next. Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md, slice 2 — AC8, AC11, AC13, AC15. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,11 @@
|
|||||||
},
|
},
|
||||||
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <6>Cookie Policy</6>.",
|
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <6>Cookie Policy</6>.",
|
||||||
"audio_menu": {
|
"audio_menu": {
|
||||||
|
"mic_level_active": "Picking up sound",
|
||||||
|
"mic_level_label": "Microphone level",
|
||||||
|
"mic_level_silent": "No sound detected",
|
||||||
|
"mic_permission_denied": "Microphone access is blocked. Allow it in your browser settings.",
|
||||||
|
"mic_unavailable": "Microphone unavailable",
|
||||||
"title": "Audio controls"
|
"title": "Audio controls"
|
||||||
},
|
},
|
||||||
"call_ended_view": {
|
"call_ended_view": {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
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-start: var(--cpd-space-4x);
|
||||||
|
/* Stop the bars where the device labels stop rather than at the menu edge:
|
||||||
|
a menu item keeps a 4x margin after its label plus a 2x column reserved
|
||||||
|
for the chevron, on top of the menu's own 4x padding. */
|
||||||
|
padding-inline-end: calc(2 * var(--cpd-space-4x) + var(--cpd-space-2x));
|
||||||
|
border-radius: var(--cpd-radius-pill-effect);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter:focus-visible {
|
||||||
|
outline: 2px solid var(--cpd-color-border-focused);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--cpd-color-icon-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bars {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--cpd-space-2x);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
/* The bars share out whatever the gaps leave over, so the gap above and the
|
||||||
|
bar count together set how thick a bar is. Across the inset row that lands
|
||||||
|
near 6px per bar against the 8px gap, as in the design. */
|
||||||
|
flex: 1;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: var(--cpd-radius-pill-effect);
|
||||||
|
background-color: var(--cpd-color-bg-subtle-primary);
|
||||||
|
transition: background-color 60ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit {
|
||||||
|
background-color: var(--cpd-color-icon-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unavailable .icon,
|
||||||
|
.unavailable .bar {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--cpd-space-3x);
|
||||||
|
padding-block: var(--cpd-space-2x);
|
||||||
|
padding-inline-start: var(--cpd-space-4x);
|
||||||
|
padding-inline-end: calc(2 * var(--cpd-space-4x) + var(--cpd-space-2x));
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
font: var(--cpd-font-body-sm-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srOnly {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
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 { expect, within } from "storybook/test";
|
||||||
|
import { type JSX } from "react";
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { AudioLevelMeter } from "./AudioLevelMeter";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: AudioLevelMeter,
|
||||||
|
// The meter is only ever seen inside the audio menu, so give it a comparable
|
||||||
|
// width to judge the bar spacing against.
|
||||||
|
decorators: [
|
||||||
|
(Story): JSX.Element => (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
} satisfies Meta<typeof AudioLevelMeter>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Silent: Story = {
|
||||||
|
args: { state: { type: "active", level: 0 } },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const meter = within(canvasElement).getByRole("meter");
|
||||||
|
await expect(meter).toHaveAttribute("aria-valuetext", "No sound detected");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Speaking: Story = {
|
||||||
|
args: { state: { type: "active", level: 0.45 } },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const meter = within(canvasElement).getByRole("meter");
|
||||||
|
await expect(meter).toHaveAttribute("aria-valuetext", "Picking up sound");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Loud: Story = {
|
||||||
|
args: { state: { type: "active", level: 0.95 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Unavailable: Story = {
|
||||||
|
args: { state: { type: "unavailable" } },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const meter = within(canvasElement).getByRole("meter");
|
||||||
|
await expect(meter).toHaveAttribute("data-unavailable", "true");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PermissionDenied: Story = {
|
||||||
|
args: { state: { type: "denied" } },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.queryByRole("meter")).toBe(null);
|
||||||
|
await expect(
|
||||||
|
canvas.getByText(/Microphone access is blocked/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
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 userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
import { AudioLevelMeter } from "./AudioLevelMeter";
|
||||||
|
|
||||||
|
describe("AudioLevelMeter", () => {
|
||||||
|
test("a denied microphone renders a hint instead of the meter", () => {
|
||||||
|
render(<AudioLevelMeter state={{ type: "denied" }} />);
|
||||||
|
expect(screen.getByTestId("mic_level_denied")).toHaveTextContent(
|
||||||
|
/microphone access is blocked/i,
|
||||||
|
);
|
||||||
|
// A hint replaces the meter rather than sitting next to a dead one.
|
||||||
|
expect(screen.queryByRole("meter")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("level indicator greys out when the microphone is unavailable", () => {
|
||||||
|
render(<AudioLevelMeter state={{ type: "unavailable" }} />);
|
||||||
|
const meter = screen.getByRole("meter");
|
||||||
|
expect(meter).toHaveAttribute("data-unavailable", "true");
|
||||||
|
expect(meter).toHaveAttribute("aria-valuetext", "Microphone unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("level indicator reports its level to assistive technology", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<AudioLevelMeter state={{ type: "active", level: 0 }} />,
|
||||||
|
);
|
||||||
|
const meter = screen.getByRole("meter");
|
||||||
|
expect(meter).toHaveAttribute("aria-valuenow", "0");
|
||||||
|
expect(meter).toHaveAttribute("aria-valuetext", "No sound detected");
|
||||||
|
|
||||||
|
rerender(<AudioLevelMeter state={{ type: "active", level: 0.7 }} />);
|
||||||
|
expect(screen.getByRole("meter")).toHaveAttribute(
|
||||||
|
"aria-valuetext",
|
||||||
|
"Picking up sound",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("level indicator announces its state only while focused", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AudioLevelMeter state={{ type: "active", level: 0.7 }} />);
|
||||||
|
const meter = screen.getByRole("meter");
|
||||||
|
|
||||||
|
// Nothing is announced until the user puts the meter in focus, so the
|
||||||
|
// level does not talk over the rest of the menu.
|
||||||
|
expect(meter.textContent).not.toContain("Picking up sound");
|
||||||
|
await user.tab();
|
||||||
|
expect(meter).toHaveFocus();
|
||||||
|
expect(meter.textContent).toContain("Picking up sound");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
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, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
MicOnIcon,
|
||||||
|
MicOffIcon,
|
||||||
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import styles from "./AudioLevelMeter.module.css";
|
||||||
|
import { type MicrophoneLevelState } from "./useMicrophoneLevel";
|
||||||
|
|
||||||
|
// The bars share the row evenly, so the count is what sets their width: more
|
||||||
|
// bars means thinner ones, and a finer-grained reading of the level.
|
||||||
|
const BAR_COUNT = 18;
|
||||||
|
/**
|
||||||
|
* Level below which the meter reads as silent. Above the noise floor of a
|
||||||
|
* typical desk microphone, so an idle room does not announce itself as sound.
|
||||||
|
*/
|
||||||
|
const SPEECH_THRESHOLD = 0.06;
|
||||||
|
|
||||||
|
export interface AudioLevelMeterProps {
|
||||||
|
state: MicrophoneLevelState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live indicator of the signal level at the selected microphone.
|
||||||
|
*
|
||||||
|
* Focusable, so that a screen reader user can put it in focus and hear whether
|
||||||
|
* the microphone is picking anything up; the bars alone carry that information
|
||||||
|
* for everyone else.
|
||||||
|
*/
|
||||||
|
export const AudioLevelMeter: FC<AudioLevelMeterProps> = ({ state }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
|
||||||
|
if (state.type === "denied")
|
||||||
|
return (
|
||||||
|
<div className={styles.message} data-testid="mic_level_denied">
|
||||||
|
<MicOffIcon width={24} height={24} aria-hidden />
|
||||||
|
<span>{t("audio_menu.mic_permission_denied")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const unavailable = state.type === "unavailable";
|
||||||
|
const level = state.type === "active" ? state.level : 0;
|
||||||
|
const litBars = unavailable ? 0 : Math.round(level * BAR_COUNT);
|
||||||
|
const speaking = !unavailable && level >= SPEECH_THRESHOLD;
|
||||||
|
const stateText = unavailable
|
||||||
|
? t("audio_menu.mic_unavailable")
|
||||||
|
: speaking
|
||||||
|
? t("audio_menu.mic_level_active")
|
||||||
|
: t("audio_menu.mic_level_silent");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.meter, {
|
||||||
|
[styles.unavailable]: unavailable,
|
||||||
|
})}
|
||||||
|
data-testid="mic_level_meter"
|
||||||
|
data-unavailable={unavailable || undefined}
|
||||||
|
role="meter"
|
||||||
|
// The meter is the only place the "is my microphone working?" answer
|
||||||
|
// lives, so it has to be reachable without a pointer even though a
|
||||||
|
// meter is not an interactive control.
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||||
|
tabIndex={0}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
aria-label={t("audio_menu.mic_level_label")}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={1}
|
||||||
|
aria-valuenow={level}
|
||||||
|
aria-valuetext={stateText}
|
||||||
|
>
|
||||||
|
<MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
|
||||||
|
<div className={styles.bars}>
|
||||||
|
{Array.from({ length: BAR_COUNT }, (_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={classNames(styles.bar, { [styles.lit]: i < litBars })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Only announces while the meter holds focus, so the level does not
|
||||||
|
interrupt a screen reader reading the rest of the menu. */}
|
||||||
|
<span className={styles.srOnly} aria-live="polite">
|
||||||
|
{focused ? stateText : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
/*
|
||||||
|
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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { useMicrophoneLevel } from "./useMicrophoneLevel";
|
||||||
|
|
||||||
|
describe("useMicrophoneLevel", () => {
|
||||||
|
test("level indicator follows the microphone signal level", async () => {
|
||||||
|
const { stream } = fakeStream();
|
||||||
|
const getUserMedia = vi.fn().mockResolvedValue(stream);
|
||||||
|
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMicrophoneLevel("mic-1", true));
|
||||||
|
await waitFor(() => expect(result.current.type).toBe("active"));
|
||||||
|
expect(result.current).toEqual({ type: "active", level: 0 });
|
||||||
|
|
||||||
|
// Speech arrives.
|
||||||
|
amplitude = 0.2;
|
||||||
|
tick(4);
|
||||||
|
expect(result.current.type).toBe("active");
|
||||||
|
const loud = result.current as { type: "active"; level: number };
|
||||||
|
expect(loud.level).toBeGreaterThan(0.3);
|
||||||
|
|
||||||
|
// ...and stops. The level eases back down rather than snapping to zero.
|
||||||
|
amplitude = 0;
|
||||||
|
tick(30);
|
||||||
|
const quiet = result.current as { type: "active"; level: number };
|
||||||
|
expect(quiet.level).toBeLessThan(loud.level);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("metering resumes a capture the browser starts suspended", async () => {
|
||||||
|
const { stream } = fakeStream();
|
||||||
|
vi.stubGlobal("navigator", {
|
||||||
|
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMicrophoneLevel("mic-1", true));
|
||||||
|
await waitFor(() => expect(result.current.type).toBe("active"));
|
||||||
|
|
||||||
|
// A suspended context reports silence however loud the microphone is,
|
||||||
|
// which is what Firefox hands us when the page has no user activation.
|
||||||
|
expect(resumed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("level indicator stays idle for a silent microphone", async () => {
|
||||||
|
const { stream } = fakeStream();
|
||||||
|
vi.stubGlobal("navigator", {
|
||||||
|
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMicrophoneLevel("mic-1", true));
|
||||||
|
await waitFor(() => expect(result.current.type).toBe("active"));
|
||||||
|
tick(10);
|
||||||
|
expect(result.current).toEqual({ type: "active", level: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("capture is re-pointed when the microphone changes", async () => {
|
||||||
|
const first = fakeStream();
|
||||||
|
const second = fakeStream();
|
||||||
|
const getUserMedia = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(first.stream)
|
||||||
|
.mockResolvedValueOnce(second.stream);
|
||||||
|
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ id }: { id: string }) => useMicrophoneLevel(id, true),
|
||||||
|
{ initialProps: { id: "mic-1" } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.type).toBe("active"));
|
||||||
|
expect(getUserMedia).toHaveBeenLastCalledWith({
|
||||||
|
audio: { deviceId: { exact: "mic-1" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ id: "mic-2" });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getUserMedia).toHaveBeenLastCalledWith({
|
||||||
|
audio: { deviceId: { exact: "mic-2" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// The capture of the microphone we left must not survive the switch.
|
||||||
|
expect(first.stop).toHaveBeenCalled();
|
||||||
|
expect(second.stop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("menu capture is released when closed during a device switch", async () => {
|
||||||
|
const { stream, stop } = fakeStream();
|
||||||
|
let resolveCapture: (s: MediaStream) => void = () => {};
|
||||||
|
const capture = new Promise<MediaStream>((resolve) => {
|
||||||
|
resolveCapture = resolve;
|
||||||
|
});
|
||||||
|
const getUserMedia = vi.fn().mockReturnValue(capture);
|
||||||
|
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on),
|
||||||
|
{ initialProps: { on: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// The menu closes before the microphone finishes opening.
|
||||||
|
rerender({ on: false });
|
||||||
|
await act(async () => {
|
||||||
|
resolveCapture(stream);
|
||||||
|
await capture;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stop).toHaveBeenCalled();
|
||||||
|
expect(result.current).toEqual({ type: "inactive" });
|
||||||
|
expect(frames).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("microphone capture is closed when metering stops", async () => {
|
||||||
|
const { stream, stop } = fakeStream();
|
||||||
|
vi.stubGlobal("navigator", {
|
||||||
|
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on),
|
||||||
|
{ initialProps: { on: true } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.type).toBe("active"));
|
||||||
|
|
||||||
|
rerender({ on: false });
|
||||||
|
expect(stop).toHaveBeenCalled();
|
||||||
|
expect(closed).toBe(1);
|
||||||
|
expect(result.current).toEqual({ type: "inactive" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a denied microphone is distinguished from one that cannot be opened", async () => {
|
||||||
|
const denied = Object.assign(new Error("no"), { name: "NotAllowedError" });
|
||||||
|
const busy = Object.assign(new Error("busy"), { name: "NotReadableError" });
|
||||||
|
const getUserMedia = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(denied)
|
||||||
|
.mockRejectedValueOnce(busy);
|
||||||
|
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ id }: { id: string }) => useMicrophoneLevel(id, true),
|
||||||
|
{ initialProps: { id: "mic-1" } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current).toEqual({ type: "denied" }));
|
||||||
|
|
||||||
|
rerender({ id: "mic-2" });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current).toEqual({ type: "unavailable" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no capture is taken while metering is disabled", () => {
|
||||||
|
const getUserMedia = vi.fn();
|
||||||
|
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMicrophoneLevel("mic-1", false));
|
||||||
|
expect(getUserMedia).not.toHaveBeenCalled();
|
||||||
|
expect(result.current).toEqual({ type: "inactive" });
|
||||||
|
});
|
||||||
|
|
||||||
|
let amplitude = 0;
|
||||||
|
let frames: (() => void)[] = [];
|
||||||
|
let closed = 0;
|
||||||
|
let resumed = 0;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
amplitude = 0;
|
||||||
|
frames = [];
|
||||||
|
closed = 0;
|
||||||
|
resumed = 0;
|
||||||
|
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (cb: () => void) => {
|
||||||
|
frames.push(cb);
|
||||||
|
return frames.length;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("cancelAnimationFrame", () => {
|
||||||
|
frames = [];
|
||||||
|
});
|
||||||
|
vi.stubGlobal(
|
||||||
|
"AudioContext",
|
||||||
|
class {
|
||||||
|
public createAnalyser(): unknown {
|
||||||
|
return {
|
||||||
|
fftSize: 1024,
|
||||||
|
getFloatTimeDomainData: (out: Float32Array): void => {
|
||||||
|
out.fill(amplitude);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
public createMediaStreamSource(): { connect: () => void } {
|
||||||
|
return { connect: (): void => {} };
|
||||||
|
}
|
||||||
|
public async resume(): Promise<void> {
|
||||||
|
resumed++;
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
public async close(): Promise<void> {
|
||||||
|
closed++;
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
/** A microphone that reports a constant amplitude on every sample. */
|
||||||
|
function fakeStream(): {
|
||||||
|
stream: MediaStream;
|
||||||
|
stop: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
const stop = vi.fn();
|
||||||
|
const stream = {
|
||||||
|
getTracks: () => [{ stop }],
|
||||||
|
} as unknown as MediaStream;
|
||||||
|
return { stream, stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs one animation frame, if the hook has asked for one. */
|
||||||
|
function tick(times = 1): void {
|
||||||
|
for (let i = 0; i < times; i++) {
|
||||||
|
const pending = frames;
|
||||||
|
frames = [];
|
||||||
|
act(() => {
|
||||||
|
pending.forEach((f) => f());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
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 as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state of the microphone level indicator.
|
||||||
|
*
|
||||||
|
* `inactive` is the resting state: nothing is being captured, because nothing
|
||||||
|
* has asked for a level yet. `active` means we hold a capture and `level` is
|
||||||
|
* the current signal level in the range 0..1. `denied` and `unavailable` are
|
||||||
|
* the two failure modes we distinguish, because they need different UI: the
|
||||||
|
* first is recoverable by the user, the second is not.
|
||||||
|
*/
|
||||||
|
export type MicrophoneLevelState =
|
||||||
|
| { type: "inactive" }
|
||||||
|
| { type: "active"; level: number }
|
||||||
|
| { type: "denied" }
|
||||||
|
| { type: "unavailable" };
|
||||||
|
|
||||||
|
/** Signal at or below this many dBFS reads as silence. */
|
||||||
|
const FLOOR_DB = -60;
|
||||||
|
/**
|
||||||
|
* Smoothing applied to the displayed level. Rises are followed almost
|
||||||
|
* immediately so speech registers at once; falls are eased so the bars do not
|
||||||
|
* flicker between syllables.
|
||||||
|
*/
|
||||||
|
const ATTACK = 0.5;
|
||||||
|
const RELEASE = 0.12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes the signal level of a microphone, for as long as `enabled` holds.
|
||||||
|
*
|
||||||
|
* The capture is owned by this hook and is torn down whenever `enabled` goes
|
||||||
|
* false, the device changes, or the component unmounts, including when any of
|
||||||
|
* those happen while the capture is still being acquired. Nothing outlives the
|
||||||
|
* caller.
|
||||||
|
*
|
||||||
|
* @param deviceId - The microphone to observe, or undefined if none is selected.
|
||||||
|
* @param enabled - Whether to hold a capture at all.
|
||||||
|
*/
|
||||||
|
export function useMicrophoneLevel(
|
||||||
|
deviceId: string | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
): MicrophoneLevelState {
|
||||||
|
const [state, setState] = useState<MicrophoneLevelState>({
|
||||||
|
type: "inactive",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || deviceId === undefined) {
|
||||||
|
setState({ type: "inactive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guards every asynchronous continuation below: the effect can be cleaned
|
||||||
|
// up while getUserMedia is still in flight, and the stream it eventually
|
||||||
|
// resolves with must be stopped rather than left running.
|
||||||
|
let disposed = false;
|
||||||
|
let stream: MediaStream | undefined;
|
||||||
|
let context: AudioContext | undefined;
|
||||||
|
let frame: number | undefined;
|
||||||
|
|
||||||
|
const dispose = (): void => {
|
||||||
|
disposed = true;
|
||||||
|
if (frame !== undefined) cancelAnimationFrame(frame);
|
||||||
|
stream?.getTracks().forEach((t) => t.stop());
|
||||||
|
// close() rejects if the context is already closed, which is possible if
|
||||||
|
// the browser tore it down with the page.
|
||||||
|
context?.close().catch(() => {});
|
||||||
|
stream = undefined;
|
||||||
|
context = undefined;
|
||||||
|
frame = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getUserMedia({ audio: { deviceId: { exact: deviceId } } })
|
||||||
|
.then((acquired) => {
|
||||||
|
if (disposed) {
|
||||||
|
acquired.getTracks().forEach((t) => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stream = acquired;
|
||||||
|
context = new AudioContext();
|
||||||
|
// Firefox starts a context suspended unless the page has user
|
||||||
|
// activation, and a suspended context feeds the analyser silence
|
||||||
|
// rather than the microphone. Resuming one that already runs is a
|
||||||
|
// no-op.
|
||||||
|
void context.resume().catch(() => {});
|
||||||
|
const analyser = context.createAnalyser();
|
||||||
|
analyser.fftSize = 1024;
|
||||||
|
context.createMediaStreamSource(acquired).connect(analyser);
|
||||||
|
const samples = new Float32Array(analyser.fftSize);
|
||||||
|
|
||||||
|
let smoothed = 0;
|
||||||
|
const tick = (): void => {
|
||||||
|
if (disposed) return;
|
||||||
|
analyser.getFloatTimeDomainData(samples);
|
||||||
|
const level = amplitudeToLevel(rms(samples));
|
||||||
|
smoothed +=
|
||||||
|
(level - smoothed) * (level > smoothed ? ATTACK : RELEASE);
|
||||||
|
setState({ type: "active", level: smoothed });
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
setState({ type: "active", level: 0 });
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
if (disposed) return;
|
||||||
|
rootLogger
|
||||||
|
.getChild("[useMicrophoneLevel]")
|
||||||
|
.warn("Could not open microphone for level metering", e);
|
||||||
|
setState(classifyError(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
return dispose;
|
||||||
|
}, [deviceId, enabled]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Root mean square of a block of samples, as a linear amplitude. */
|
||||||
|
function rms(samples: Float32Array): number {
|
||||||
|
let sum = 0;
|
||||||
|
for (const s of samples) sum += s * s;
|
||||||
|
return Math.sqrt(sum / samples.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a linear amplitude onto 0..1 over a fixed dBFS window. */
|
||||||
|
function amplitudeToLevel(amplitude: number): number {
|
||||||
|
if (amplitude <= 0) return 0;
|
||||||
|
const db = 20 * Math.log10(amplitude);
|
||||||
|
if (db <= FLOOR_DB) return 0;
|
||||||
|
return Math.min(1, db / -FLOOR_DB + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyError(e: unknown): MicrophoneLevelState {
|
||||||
|
const name = e instanceof Error ? e.name : "";
|
||||||
|
// NotAllowedError is the modern name; SecurityError is what older WebKit
|
||||||
|
// raises for the same situation.
|
||||||
|
if (name === "NotAllowedError" || name === "SecurityError")
|
||||||
|
return { type: "denied" };
|
||||||
|
return { type: "unavailable" };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user