Skip to content

fix(scribe): bound microphone setup so a stalled resume cannot hang it - #964

Open
chinmayv095 wants to merge 2 commits into
elevenlabs:mainfrom
chinmayv095:fix/scribe-microphone-setup-timeout
Open

fix(scribe): bound microphone setup so a stalled resume cannot hang it#964
chinmayv095 wants to merge 2 commits into
elevenlabs:mainfrom
chinmayv095:fix/scribe-microphone-setup-timeout

Conversation

@chinmayv095

Copy link
Copy Markdown
Contributor

Fixes #887

What happens

webScribeMicrophoneSetup finishes with an unbounded await:

// packages/client/src/platform/web/scribeMicrophone.ts
if (audioContext.state === "suspended") {
  await audioContext.resume();
}

As @codewinkel reports, AudioContext.resume() in WebKit can fail to settle at all when it is called several awaits away from the originating user gesture. It neither resolves nor rejects. Nothing in the chain has a timeout, so setup hangs.

The socket stays open and can still receive session_started, so useScribe() reports connected and onSessionStarted fires while zero audio is ever captured.

The part that is worse than the issue says

The hang does not only make the session silently dead. It strands the microphone.

streamFromMicrophone only ever gets the cleanup handle from the resolved setup:

const result = await setup(...);
connection._audioCleanup = result.cleanup;

close() is written to cope with a setup that is still in flight, and its own comment says so:

// Flag first so a mic setup still resolving (see ScribeRealtime.
// streamFromMicrophone) can tell the connection is gone and clean up.
this._closed = true;
if (this._audioCleanup) { ... }

That handshake assumes the setup promise eventually settles. When it never does, _audioCleanup is never assigned and the if (connection._closed) result.cleanup() branch is never reached, so neither close() nor the server-initiated close path in connection.ts has anything to call. The cleanup closure that owns the MediaStream is reachable only from inside the hung setup. The tracks stay live and the browser's recording indicator stays on for the lifetime of the page, after the user has ended the session.

That is what makes this worth a timeout rather than documentation.

Why the timeout does not cover the whole setup

The issue asks to "wrap the microphone setup (or at minimum the audioContext.resume() call)". Wrapping the whole thing would include getUserMedia, and that is where the browser is showing the permission prompt and waiting for a human to click. That wait is unbounded by design, and a 10 second timeout across it would abort ordinary first-run sessions on the fairly common case of someone reading the dialog before clicking.

So the bound starts after getUserMedia resolves and covers the rest, which runs at machine speed with no user in the loop: worklet load, node construction, wiring, and the resume() that actually hangs. does not time out while the permission prompt is open pins this: a 60 second getUserMedia still succeeds under a 1000ms setup timeout.

The rest

setupTimeoutMs on ScribeMicrophoneConfig, defaulting to 10000. A value of 0, or any non-finite value, waits indefinitely and gives back exactly today's behaviour for anyone who wants it.

On timeout the rejection goes through the existing catch, which already calls cleanup(), and from there into the handler streamFromMicrophone already has: _emitError then connection.close(). So the failure surfaces as RealtimeEvents.ERROR followed by CLOSE, the same as a setup that rejects today. No new error path, and @codewinkel's application-level watchdog can come out.

One residual, stated plainly: the abandoned continuation cannot be cancelled, so it may still assign to the outer audioContext / source / scribeNode references after cleanup() has run. By then the tracks are stopped and the context is closed, so no hardware is held and no audio is delivered. Cancelling properly would mean threading an AbortSignal through the ScribeMicrophoneSetup contract, which is a bigger change to a platform-injectable interface and did not belong in a bug fix. Happy to do it separately if you want it.

Verification

Four tests added to the existing scribeMicrophone.test.ts, alongside the resume rejection test already there. Kept out of scribe/scribe.ts and its test file, which open PR #680 is editing.

Test Asserts
rejects and releases the microphone when resume never settles rejects, and track.stop / source.disconnect / scribeNode.disconnect / audioContext.close all ran
honors a custom setupTimeoutMs the configured value is the one used
does not time out while the permission prompt is open a 60s getUserMedia survives a 1000ms setup timeout
waits indefinitely when setupTimeoutMs is 0 opt-out restores the old behaviour

Fail-first, stashing only scribeMicrophone.ts: 2 failed, 7 passed. The two failures are the two timeout tests, and they fail by hitting vitest's own 5000ms limit rather than by assertion, which is the bug reproducing exactly: with no bound, the setup promise never settles. The other two are controls and pass both ways by design.

  • packages/client suite: 238 on main, 242 on the branch, delta exactly the four added, zero failures either side.
  • pnpm -w run check-types: 16 successful, 16 total.
  • prettier --check clean; the husky pre-commit turbo lint passed 29/29.
  • Changeset added, minor.

AudioContext.resume() can fail to settle at all in WebKit when called
several awaits away from the user gesture, and nothing in the microphone
setup chain had a timeout. The session then looked healthy while
capturing no audio.

It also stranded the microphone: streamFromMicrophone only assigns
_audioCleanup once setup resolves, so a setup that never settles leaves
close() with nothing to call and the tracks live for the lifetime of the
page.

Bound the work that follows getUserMedia, leaving the permission prompt
itself unbounded, and release the pipeline on timeout through the
existing catch. Configurable via setupTimeoutMs, default 10000, with 0
preserving the previous unbounded behaviour.

Fixes elevenlabs#887
@cursor

cursor Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

PR Summary

Medium Risk
Touches live microphone capture and session teardown on a failure path; behavior change is intentional but limited to web setup after getUserMedia, with an opt-out to preserve prior indefinite waits.

Overview
Fixes WebKit cases where AudioContext.resume() never settles after permission is granted, which left Scribe sessions “connected” with no audio and the mic indicator stuck on because cleanup never reached the caller.

webScribeMicrophoneSetup now wraps post-getUserMedia work (worklet load, pipeline wiring, resume()) in withSetupTimeout, default 10s. On timeout it rejects with a clear error and runs the existing catch cleanup(), so tracks and the context are released. getUserMedia stays unbounded so slow permission prompts are not cut off.

New setupTimeoutMs on ScribeMicrophoneConfig and MicrophoneOptions (default 10000; 0 or non-finite = wait indefinitely). Tests cover hung resume, custom timeout, permission-prompt scope, and opt-out; a type-level pin keeps the public API in sync with the config.

Reviewed by Cursor Bugbot for commit a9f594d. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit d54aeca. Configure here.

Comment thread packages/client/src/scribe/microphone.ts
MicrophoneOptions["microphone"] is a structural copy of
ScribeMicrophoneConfig rather than a reference to it, so adding the
option to the config alone left it unsettable through Scribe.connect and
useScribe even though the changeset advertised it.

Add the field to the public shape and pin the two together with a
type-level assertion, so a future drift fails the build.
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.

Scribe realtime: microphone setup can hang indefinitely when audioContext.resume() never settles (no timeout)

1 participant