Skip to content

Commit 51ce758

Browse files
authored
Report the real getUserMedia failure, not always 'access denied' (#156)
The recorder caught every getUserMedia rejection and showed 'Microphone access was denied', so a busy device (NotReadableError, e.g. another app or the companion holding the mic) or a missing one (NotFoundError) read as a permission problem the user could not fix. Classify by DOMException name and give an actionable message per case.
1 parent d748865 commit 51ce758

3 files changed

Lines changed: 51 additions & 2 deletions

File tree

scripts/verify-audio-capture.test.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const {
2929
audioStoragePath,
3030
formatRecordingDuration,
3131
uploadMeetingAudio,
32+
micErrorMessage,
3233
} = await import(pathToFileURL(bundled).href);
3334

3435
function read(rel) {
@@ -330,3 +331,28 @@ test("seed user has full access so gated capture is reachable in E2E", () => {
330331
const seed = read("supabase/seed.sql");
331332
assert.ok(seed.includes("has_full_access"), "seed must set has_full_access for the test user");
332333
});
334+
335+
test("micErrorMessage distinguishes denial from busy, missing, and unknown", () => {
336+
const denied = micErrorMessage(Object.assign(new Error("x"), { name: "NotAllowedError" }));
337+
assert.match(denied, /denied/i, "NotAllowedError must read as a permission denial");
338+
339+
const busy = micErrorMessage(Object.assign(new Error("x"), { name: "NotReadableError" }));
340+
assert.match(busy, /in use by another app/i, "NotReadableError must say the device is busy");
341+
assert.doesNotMatch(busy, /denied/i, "a busy device must not be reported as denied");
342+
343+
const missing = micErrorMessage(Object.assign(new Error("x"), { name: "NotFoundError" }));
344+
assert.match(missing, /no microphone/i, "NotFoundError must say no microphone was found");
345+
346+
const unknown = micErrorMessage(new Error("boom"));
347+
assert.match(unknown, /could not start the microphone/i, "unknown errors get a generic retry message");
348+
assert.doesNotMatch(unknown, /denied/i, "an unknown failure must not be reported as denied");
349+
});
350+
351+
test("recorder hook uses micErrorMessage, not a hardcoded denial string", () => {
352+
const hook = read("src/lib/hooks/use-meeting-recorder.ts");
353+
assert.ok(hook.includes("micErrorMessage(err)"), "hook must classify the getUserMedia error");
354+
assert.ok(
355+
!hook.includes('"Microphone access was denied."'),
356+
"hook must not hardcode the denial message for every failure"
357+
);
358+
});

src/lib/audio/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,28 @@ export function formatRecordingDuration(totalSeconds: number): string {
9494
: `${pad(minutes)}:${pad(seconds)}`;
9595
}
9696

97+
/**
98+
* Turn a getUserMedia failure into a specific, actionable message. The DOMException
99+
* `name` distinguishes a real permission denial from a device that is missing or held
100+
* by another app, so we never tell a user "access denied" when they already granted it.
101+
*/
102+
export function micErrorMessage(err: unknown): string {
103+
const name = err instanceof Error ? err.name : "";
104+
switch (name) {
105+
case "NotAllowedError":
106+
case "SecurityError":
107+
return "Microphone access was denied. Allow it for this site in your browser, then press Record again.";
108+
case "NotReadableError":
109+
case "AbortError":
110+
return "Your microphone is in use by another app. Close it (Zoom, the Minutia companion, another tab), then press Record again.";
111+
case "NotFoundError":
112+
case "OverconstrainedError":
113+
return "No microphone was found. Connect one, then press Record again.";
114+
default:
115+
return "Could not start the microphone. Check your browser's microphone settings and try again.";
116+
}
117+
}
118+
97119
export interface UploadMeetingAudioParams {
98120
meetingId: string;
99121
blob: Blob;

src/lib/hooks/use-meeting-recorder.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
audioContentType,
88
isRecordingSupported,
99
MEETING_AUDIO_BUCKET,
10+
micErrorMessage,
1011
pickAudioMimeType,
1112
} from "@/lib/audio";
1213
import {
@@ -134,8 +135,8 @@ export function useMeetingRecorder(meetingId: string): MeetingRecorder {
134135
let stream: MediaStream;
135136
try {
136137
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
137-
} catch {
138-
setError("Microphone access was denied.");
138+
} catch (err) {
139+
setError(micErrorMessage(err));
139140
setState("idle");
140141
return;
141142
}

0 commit comments

Comments
 (0)