Skip to content

Add timeout and error handling for Web Audio API - #6

Open
ssevera1 wants to merge 2 commits into
mainfrom
improve/20260904-062117
Open

Add timeout and error handling for Web Audio API#6
ssevera1 wants to merge 2 commits into
mainfrom
improve/20260904-062117

Conversation

@ssevera1

@ssevera1 ssevera1 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

What

Add timeout protection and comprehensive error logging for AudioContext creation and microphone access with fallback diagnostics.

Why

Prevents indefinite hangs and improves debuggability by capturing context about failures including device state and browser capabilities.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added diagnostics on the error paths are useful, and the scope is tight (one file, no unrelated changes). But the "timeout protection" half of this PR does not work as described, and one of the timeouts introduces a resource leak.

1. The AudioContext timeout is a no-op — AudioEngine.ts:105-109 and AudioEngine.ts:158-162

audioContext = await this.withTimeout(
  Promise.resolve(new AudioContext({ sampleRate: 44100 })),
  AUDIO_CONTEXT_TIMEOUT,
  ...
);

new AudioContext(...) is a synchronous constructor. It has already fully run by the time Promise.resolve() is called, so the promise handed to withTimeout is always already-resolved and the 5s timer can never win the Promise.race. AUDIO_CONTEXT_TIMEOUT is effectively dead code, and the stated intent of "timeout protection ... for AudioContext creation" is not delivered.

If catching a synchronous constructor throw is the real goal, drop withTimeout here and keep just the try/catch plus diagnostics — that part works and is worth keeping. If the goal is guarding against a blocked/suspended context, the thing to time out is audioContext.resume(), which does return a promise.

2. The getUserMedia timeout leaks a live microphone stream — AudioEngine.ts:75-86

Promise.race does not cancel the loser. If the 10s timer fires first we reject and take the catch path, but the underlying getUserMedia promise is still pending. When the user eventually grants permission it resolves with a real MediaStream that nothing holds a reference to and nothing calls .stop() on. The mic stays open and the browser recording indicator stays lit until the tab is closed.

This is a regression: before this PR the stream was always assigned to this.stream, so stop() could clean it up. Please attach a cleanup handler to the original promise, roughly:

const micPromise = navigator.mediaDevices.getUserMedia({ ... });
micPromise.then((s) => { if (timedOut) s.getTracks().forEach((t) => t.stop()); }).catch(() => {});
stream = await this.withTimeout(micPromise, MICROPHONE_TIMEOUT, "getUserMedia");

Also worth reconsidering whether a 10s cap on getUserMedia is desirable at all. That promise is pending precisely while the user is reading the permission prompt, and 10s is a plausible amount of time for a person to take, so this will spuriously fail for anyone slow to decide.

3. timeoutHandle is likely a strict-mode type error — AudioEngine.ts:39, used at AudioEngine.ts:57

let timeoutHandle: NodeJS.Timeout;

Two problems. tsconfig.json sets "strict": true, and TypeScript definite-assignment analysis does not track assignments made inside a callback (the Promise executor), so clearTimeout(timeoutHandle) in the finally should raise TS2454 "Variable timeoutHandle is used before being assigned" under tsc -b. Please confirm against the CI build — I could not run the typecheck locally. Separately, NodeJS.Timeout is the wrong type for browser code: lib here is ["ES2020", "DOM", "DOM.Iterable"] and DOM setTimeout returns number, and @types/node is only present transitively, so the annotation resolves by accident. let timeoutHandle: ReturnType<typeof setTimeout> | undefined; fixes both.

Happy to re-review once these are addressed. Points 1 and 3 are small; point 2 is the one that affects users.

…op AudioContext timeout, fix strict-mode timer type
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

All three points in the review were verified against the code and fixed.

1. AudioContext timeout was a no-op — confirmed: new AudioContext(...) is a synchronous constructor, so wrapping Promise.resolve(new AudioContext(...)) in withTimeout raced against an already-settled promise. Removed the withTimeout wrapping in both _startCapture and _loadFile; kept the try/catch and diagnostics logging, since that part does catch a real (synchronous) constructor throw. Also removed the now-unused AUDIO_CONTEXT_TIMEOUT constant.

2. getUserMedia timeout leaked the mic stream — confirmed: Promise.race doesn't cancel the loser, so a late-arriving MediaStream after a timeout had no reference holding it and never got .stop()'d. Fixed by attaching a handler to the original getUserMedia promise that stops all tracks if the timeout already fired by the time it resolves.

3. timeoutHandle: NodeJS.Timeout was a latent strict-mode/type issue — confirmed both parts: @types/node is only present transitively (not a direct dependency), and building with the original type would fail TS2454 (verified by reproducing the pre-fix code and running npm run build, which fails without the change). Changed to ReturnType<typeof setTimeout> | undefined, which is correct for the DOM setTimeout used here and satisfies definite-assignment analysis.

Added a regression test (AudioEngine.test.ts) for point 2 — it fails against the pre-fix code (stopTrack called 0 times instead of 1) and passes after the fix.

Nothing was pushed back on — all three points held up under verification.

Checks:

  • npm run build (tsc -b && vite build): passes
  • npm test (vitest): 34/34 passing, including the new regression test

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three points from the previous round are genuinely fixed, and I verified each against the code: the dead withTimeout(Promise.resolve(new AudioContext(...))) wrapping is gone from both _startCapture and _loadFile (AudioEngine.ts:109-128, AudioEngine.ts:161-178) with the useful try/catch + diagnostics kept; the late-getUserMedia stream is now stopped (AudioEngine.ts:86-90) and has a regression test that exercises the exact ordering; and timeoutHandle is now ReturnType<typeof setTimeout> | undefined (AudioEngine.ts:38), which is both correct for DOM setTimeout and satisfies definite-assignment analysis. Scope is tight — one source file plus its test, no unrelated changes.

Blocking on the one thing that was raised last round and not addressed or replied to: the 10s timeout on getUserMedia itself.

AudioEngine.ts:5 / AudioEngine.ts:91 — a 10s cap on the permission prompt is a user-facing regression, and the round-2 fix makes it worse, not better

getUserMedia is pending precisely while the user is reading the browser permission prompt. That is not a hang — it is the normal first-run flow, and 10 seconds is a very plausible amount of time for a person to read a dialog, especially where the browser prompt is followed by an OS-level microphone prompt (macOS, Windows 11). So on first run this now fails for slow-to-decide users.

The leak fix compounds it: with AudioEngine.ts:88 in place, a user who clicks Allow at t=11s has their freshly granted stream immediately .stop()'d and still sees an error. Before this PR they got a working microphone. Stopping the stream is the right call given the timeout exists — the problem is the timeout.

The PR description says this "prevents indefinite hangs," but I could not identify an actual hang this guards against. If there is a concrete one (a specific browser/device where getUserMedia never settles after the user has already granted), please say which — that would change the analysis, and it would also argue for a narrower guard.

Options, roughly in order of preference:

  1. Drop the getUserMedia timeout and keep the diagnostics. The error logging at AudioEngine.ts:92-104 is the part of this PR with clear value and it stands on its own. withTimeout then has no call sites and can go too.
  2. Only time out when permission is already granted — gate on navigator.permissions.query({ name: 'microphone' }), so the timer never races a visible prompt. Handle the permissions API being unavailable (Firefox does not support the microphone descriptor) by skipping the timeout.
  3. If the timeout stays as-is, raise it substantially (60s+) and stop mislabeling it. AudioEngine.ts:104 wraps every failure as Microphone access denied: ..., so a timeout surfaces to the user as Microphone access denied: getUserMedia timed out after 10000ms — it propagates through useVoiceAnalysis.ts:54 to the UI. A timeout is not a denial and needs different copy and a retry affordance.

Two smaller notes, neither blocking on its own:

  • AudioEngine.ts:46audioContextState: this.audioContext?.state ?? 'not-initialized' is always 'not-initialized' at the only call site, because _startCapture does await this.stop() (which nulls audioContext) immediately before. It is a constant, not a diagnostic.
  • AudioEngine.ts:93timedOut = true is set in the catch for any getUserMedia failure, not just timeouts. Harmless today (a rejected micPromise hits the .catch(() => {}) at line 90, never the .then), but the name no longer matches the condition. settled or an explicit timeout check would read truer.

Happy to re-review promptly once the timeout question is resolved — including if the answer is "keep it, here's the hang it fixes."

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Auto-fix budget exhausted after 1 rounds — not attempting another pass.

Two bots disagreeing past this point usually means the proposal needs a human call, or is not worth the churn. Leaving the PR as-is; pr-janitor.yml will close it if it goes stale.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant