Say when there is no microphone rather than showing one at rest

With no microphone attached the menu drew a level indicator resting at
zero, announcing "No sound detected" — which is exactly what a working
but silent microphone draws. The two states people most need to tell
apart looked the same.

The level state now distinguishes a microphone that is absent from one
that is merely quiet, and the microphone group names the absence where
the indicator would be, as it already does for a denied permission. A
microphone that is merely slow to open still draws at rest, so the
indicator is on screen as soon as the menu is.

The product spec listed this as an edge case but no requirement carried
it, so no acceptance criterion covered it either. Added as AC28 with the
owner's authorisation, and the product spec's edge case reworded to say
what the menu does rather than that it shows nothing.

Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md — AC28.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-11 11:03:47 +02:00
co-authored by Claude Opus 5
parent eac200a727
commit 5728d42076
10 changed files with 100 additions and 9 deletions
+12
View File
@@ -150,6 +150,9 @@ Test names are the anchor; tests are created with exactly these names.
- AC27 [SC-003] — Every menu control is reachable and operable by keyboard alone in - AC27 [SC-003] — Every menu control is reachable and operable by keyboard alone in
Chromium and Firefox. Chromium and Firefox.
- check: `pnpm test:playwright --project=chromium --project=firefox -g "audio menu is keyboard operable in a real browser"` - check: `pnpm test:playwright --project=chromium --project=firefox -g "audio menu is keyboard operable in a real browser"`
- AC28 [review: #4254] — With no microphone present, the menu names the absence in place
of the level indicator, rather than an indicator at rest.
- check: `pnpm vitest run --project=unit -t "audio menu says when there is no microphone"`
## Rejected alternatives ## Rejected alternatives
@@ -298,6 +301,15 @@ is what holds it there.
container, which Compound does not expose today. Raised separately. The audio menu behaves container, which Compound does not expose today. Raised separately. The audio menu behaves
as specified standalone and as a widget, which is where its acceptance criteria are checked. as specified standalone and as a widget, which is where its acceptance criteria are checked.
### 2026-09-11 — review finding: a missing microphone read as a silent one (#4254)
- The product spec's edge case for no microphone mapped to no FR and so to no acceptance
criterion, and the menu drew a level indicator at rest, which is what a working but
silent microphone draws. The owner chose a hint in the indicator's place, as FR-018
requires for a denied permission, over omitting the microphone group entirely. The level
state now tells a microphone that is absent from one that is silent.
- Added as AC28 and the product spec's edge case reworded to match, both with the owner's
authorisation, since acceptance criteria and the product spec are human-owned.
## PRs ## PRs
- #4254 — draft, one commit per slice — AC1–AC27 (AC6, AC21, AC24 manual by the reviewer; - #4254 — draft, one commit per slice — AC1–AC27 (AC6, AC21, AC24 manual by the reviewer;
@@ -63,7 +63,7 @@ Join and leave chimes and reaction sounds are too loud during a call. The partic
- The menu is opened while muted: the operating system's "microphone in use" indicator turns on even though the participant is muted. No disclosure in the menu is required — that indicator is expected to behave the same whether the participant is muted or not, and mute state and microphone sampling are independent of one another. - The menu is opened while muted: the operating system's "microphone in use" indicator turns on even though the participant is muted. No disclosure in the menu is required — that indicator is expected to behave the same whether the participant is muted or not, and mute state and microphone sampling are independent of one another.
- The active microphone or speaker is unplugged while the menu is open — the menu must settle on a device that exists rather than showing a stale selection. - The active microphone or speaker is unplugged while the menu is open — the menu must settle on a device that exists rather than showing a stale selection.
- No microphone is present at all: the microphone section and the level indicator have nothing to show. - No microphone is present at all: the menu says so where the level indicator would be. An indicator at rest is what a working but silent microphone shows, so the two must not look alike.
- Microphone permission has not been granted yet — most likely when the menu is opened in the lobby before joining. - Microphone permission has not been granted yet — most likely when the menu is opened in the lobby before joining.
- Another application or browser tab holds the microphone exclusively, so the level indicator receives no signal even though the device is fine. Rare on current operating systems, which share the microphone between applications; where it does happen, the indicator shows a greyed-out state. - Another application or browser tab holds the microphone exclusively, so the level indicator receives no signal even though the device is fine. Rare on current operating systems, which share the microphone between applications; where it does happen, the indicator shows a greyed-out state.
- Sound-effects volume is set to zero: effects are silent but the rest of the call audio is unaffected. - Sound-effects volume is set to zero: effects are silent but the rest of the call audio is unaffected.
+1
View File
@@ -24,6 +24,7 @@
}, },
"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_absent": "No microphone found",
"mic_level_active": "Picking up sound", "mic_level_active": "Picking up sound",
"mic_level_label": "Microphone level", "mic_level_label": "Microphone level",
"mic_level_silent": "No sound detected", "mic_level_silent": "No sound detected",
@@ -55,6 +55,15 @@ export const Unavailable: Story = {
}, },
}; };
export const NoMicrophone: Story = {
args: { state: { type: "absent" } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByRole("meter")).toBe(null);
await expect(canvas.getByText(/No microphone found/)).toBeInTheDocument();
},
};
export const PermissionDenied: Story = { export const PermissionDenied: Story = {
args: { state: { type: "denied" } }, args: { state: { type: "denied" } },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
+10
View File
@@ -12,6 +12,16 @@ import userEvent from "@testing-library/user-event";
import { AudioLevelMeter } from "./AudioLevelMeter"; import { AudioLevelMeter } from "./AudioLevelMeter";
describe("AudioLevelMeter", () => { describe("AudioLevelMeter", () => {
test("a missing microphone is named rather than drawn as silence", () => {
render(<AudioLevelMeter state={{ type: "absent" }} />);
expect(screen.getByTestId("mic_absent")).toHaveTextContent(
/no microphone found/i,
);
// Bars resting at zero would read as a microphone that hears nothing,
// which is a different thing and the one people act on.
expect(screen.queryByRole("meter")).toBe(null);
});
test("a denied microphone renders a hint instead of the meter", () => { test("a denied microphone renders a hint instead of the meter", () => {
render(<AudioLevelMeter state={{ type: "denied" }} />); render(<AudioLevelMeter state={{ type: "denied" }} />);
expect(screen.getByTestId("mic_level_denied")).toHaveTextContent( expect(screen.getByTestId("mic_level_denied")).toHaveTextContent(
+10
View File
@@ -43,6 +43,16 @@ export const AudioLevelMeter: FC<AudioLevelMeterProps> = ({ state }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
// Bars resting at zero would read as a microphone that hears nothing, which
// is a different thing and the one people act on.
if (state.type === "absent")
return (
<div className={styles.message} data-testid="mic_absent">
<MicOffIcon width={24} height={24} aria-hidden />
<span>{t("audio_menu.mic_absent")}</span>
</div>
);
if (state.type === "denied") if (state.type === "denied")
return ( return (
<div className={styles.message} data-testid="mic_level_denied"> <div className={styles.message} data-testid="mic_level_denied">
@@ -369,6 +369,17 @@ describe("MediaMuteAndSwitchButton", () => {
}); });
describe("audio menu", () => { describe("audio menu", () => {
test("level indicator is on screen as soon as the menu opens", async () => {
// A microphone takes a moment to open. The meter waits at rest rather
// than appearing late and pushing the rest of the menu down.
getUserMedia.mockReturnValue(new Promise<MediaStream>(() => {}));
await openAudioMenu();
const meter = screen.getByRole("meter");
expect(meter).toHaveAttribute("aria-valuenow", "0");
expect(screen.queryByTestId("mic_absent")).toBe(null);
});
test("level indicator responds while muted", async () => { test("level indicator responds while muted", async () => {
await openAudioMenu({ enabled: false }); await openAudioMenu({ enabled: false });
@@ -484,6 +495,25 @@ describe("audio menu", () => {
expect(screen.getByRole("menu")).toBeInTheDocument(); expect(screen.getByRole("menu")).toBeInTheDocument();
}); });
test("audio menu says when there is no microphone", async () => {
await openAudioMenu({
micOptions: [],
audioControls: audioControls({ micDeviceId: undefined }),
});
// A meter resting at zero would read as a microphone that hears nothing,
// so the group names the absence instead.
expect(screen.getByTestId("mic_absent")).toHaveTextContent(
/no microphone found/i,
);
expect(screen.queryByRole("meter")).toBe(null);
// The rest of the menu is unaffected.
expect(
screen.getByRole("menuitemradio", { name: "Built-in Speakers" }),
).toBeInTheDocument();
expect(screen.getByTestId("sound_effect_volume")).toBeInTheDocument();
});
test("audio menu marks the active output", async () => { test("audio menu marks the active output", async () => {
await openAudioMenu(); await openAudioMenu();
@@ -710,6 +740,7 @@ describe("audio menu", () => {
enabled?: boolean; enabled?: boolean;
audioControls?: AudioControls; audioControls?: AudioControls;
onSelect?: (id: string) => void; onSelect?: (id: string) => void;
micOptions?: typeof micOptions;
} }
/** Renders the microphone button with the audio menu, closed. */ /** Renders the microphone button with the audio menu, closed. */
@@ -720,7 +751,7 @@ describe("audio menu", () => {
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={props.enabled ?? true} enabled={props.enabled ?? true}
onMuteClick={vi.fn()} onMuteClick={vi.fn()}
options={micOptions} options={props.micOptions ?? micOptions}
selectedOption="mic-1" selectedOption="mic-1"
onSelect={props.onSelect ?? vi.fn()} onSelect={props.onSelect ?? vi.fn()}
audioControls={props.audioControls ?? audioControls()} audioControls={props.audioControls ?? audioControls()}
+2 -2
View File
@@ -297,8 +297,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
screen for as long as any microphone is. */} screen for as long as any microphone is. */}
<div className={styles.stickyMeter}> <div className={styles.stickyMeter}>
{/* The capture is bound to the menu being open, so the {/* The capture is bound to the menu being open, so the
microphone is only ever held while the user is looking microphone is only ever held while the user is looking at
at the level. */} the level. */}
<MicrophoneLevel <MicrophoneLevel
deviceId={audioControls.micDeviceId} deviceId={audioControls.micDeviceId}
active={menuOpen} active={menuOpen}
@@ -227,6 +227,17 @@ describe("useMicrophoneLevel", () => {
expect(result.current).toEqual({ type: "unavailable" }); expect(result.current).toEqual({ type: "unavailable" });
}); });
test("a missing microphone is reported as absent, not as silence", () => {
const getUserMedia = vi.fn();
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
const { result } = renderHook(() =>
useMicrophoneLevel(undefined, true, STEPS),
);
expect(result.current).toEqual({ type: "absent" });
expect(getUserMedia).not.toHaveBeenCalled();
});
test("no capture is taken while metering is disabled", () => { test("no capture is taken while metering is disabled", () => {
const getUserMedia = vi.fn(); const getUserMedia = vi.fn();
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
+12 -5
View File
@@ -12,13 +12,16 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
* The state of the microphone level indicator. * The state of the microphone level indicator.
* *
* `inactive` is the resting state: nothing is being captured, because nothing * `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 * has asked for a level yet. `absent` means there is no microphone to capture
* the current signal level in the range 0..1. `denied` and `unavailable` are * from, which is not the same as one that hears nothing. `active` means we
* the two failure modes we distinguish, because they need different UI: the * hold a capture and `level` is the current signal level in the range 0..1.
* first is recoverable by the user, the second is not. * `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 = export type MicrophoneLevelState =
| { type: "inactive" } | { type: "inactive" }
| { type: "absent" }
| { type: "active"; level: number } | { type: "active"; level: number }
| { type: "denied" } | { type: "denied" }
| { type: "unavailable" }; | { type: "unavailable" };
@@ -57,10 +60,14 @@ export function useMicrophoneLevel(
}); });
useEffect(() => { useEffect(() => {
if (!enabled || deviceId === undefined) { if (!enabled) {
setState({ type: "inactive" }); setState({ type: "inactive" });
return; return;
} }
if (deviceId === undefined) {
setState({ type: "absent" });
return;
}
// Insecure contexts have no media devices at all; nothing can be metered. // Insecure contexts have no media devices at all; nothing can be metered.
if (!("mediaDevices" in navigator)) { if (!("mediaDevices" in navigator)) {
setState({ type: "unavailable" }); setState({ type: "unavailable" });