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
Chromium and Firefox.
- 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
@@ -298,6 +301,15 @@ is what holds it there.
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.
### 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
- #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 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.
- 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.
+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>.",
"audio_menu": {
"mic_absent": "No microphone found",
"mic_level_active": "Picking up sound",
"mic_level_label": "Microphone level",
"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 = {
args: { state: { type: "denied" } },
play: async ({ canvasElement }) => {
+10
View File
@@ -12,6 +12,16 @@ import userEvent from "@testing-library/user-event";
import { AudioLevelMeter } from "./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", () => {
render(<AudioLevelMeter state={{ type: "denied" }} />);
expect(screen.getByTestId("mic_level_denied")).toHaveTextContent(
+10
View File
@@ -43,6 +43,16 @@ export const AudioLevelMeter: FC<AudioLevelMeterProps> = ({ state }) => {
const { t } = useTranslation();
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")
return (
<div className={styles.message} data-testid="mic_level_denied">
@@ -369,6 +369,17 @@ describe("MediaMuteAndSwitchButton", () => {
});
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 () => {
await openAudioMenu({ enabled: false });
@@ -484,6 +495,25 @@ describe("audio menu", () => {
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 () => {
await openAudioMenu();
@@ -710,6 +740,7 @@ describe("audio menu", () => {
enabled?: boolean;
audioControls?: AudioControls;
onSelect?: (id: string) => void;
micOptions?: typeof micOptions;
}
/** Renders the microphone button with the audio menu, closed. */
@@ -720,7 +751,7 @@ describe("audio menu", () => {
iconsAndLabels="audio"
enabled={props.enabled ?? true}
onMuteClick={vi.fn()}
options={micOptions}
options={props.micOptions ?? micOptions}
selectedOption="mic-1"
onSelect={props.onSelect ?? vi.fn()}
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. */}
<div className={styles.stickyMeter}>
{/* The capture is bound to the menu being open, so the
microphone is only ever held while the user is looking
at the level. */}
microphone is only ever held while the user is looking at
the level. */}
<MicrophoneLevel
deviceId={audioControls.micDeviceId}
active={menuOpen}
@@ -227,6 +227,17 @@ describe("useMicrophoneLevel", () => {
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", () => {
const getUserMedia = vi.fn();
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.
*
* `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.
* has asked for a level yet. `absent` means there is no microphone to capture
* from, which is not the same as one that hears nothing. `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: "absent" }
| { type: "active"; level: number }
| { type: "denied" }
| { type: "unavailable" };
@@ -57,10 +60,14 @@ export function useMicrophoneLevel(
});
useEffect(() => {
if (!enabled || deviceId === undefined) {
if (!enabled) {
setState({ type: "inactive" });
return;
}
if (deviceId === undefined) {
setState({ type: "absent" });
return;
}
// Insecure contexts have no media devices at all; nothing can be metered.
if (!("mediaDevices" in navigator)) {
setState({ type: "unavailable" });