Skip to content

fix(react): recreate Krisp processor per track#1321

Open
Topherhindman wants to merge 3 commits intomainfrom
fix/krisp-processor-stale-audio-context
Open

fix(react): recreate Krisp processor per track#1321
Topherhindman wants to merge 3 commits intomainfrom
fix/krisp-processor-stale-audio-context

Conversation

@Topherhindman
Copy link
Copy Markdown

@Topherhindman Topherhindman commented Apr 21, 2026

Summary

Noticed with our homepage agent in www that useKrispNoiseFilter currently throws InvalidAccessError: Failed to execute 'connect' on 'AudioNode': cannot connect to an AudioNode belonging to a different audio context. on the second session any time a consumer calls Room.disconnect() followed by a new session.start() without a page reload.

Root cause

The hook stored the KrispNoiseFilterProcessor in React.useState and only created it once (guarded by if (!krispProcessor)), reusing the same instance across mic republishes. A KrispNoiseFilterProcessor's internal audio graph is bound to the AudioContext it was init()-ed against, and Room.disconnect() closes that context. When the mic republishes on a fresh AudioContext, attaching the cached processor to the new track calls AudioNode.connect() on nodes from the old (closed) context — hence the error.

Duplicating the processor into React state while LocalAudioTrack already owns it was the underlying design mistake; the state copy had no mechanism to track when the real processor had been destroyed out from under it.

Fix

Three commits:

  1. fix(react): recreate Krisp processor per track — remove the duplicated state and treat LocalAudioTrack.getProcessor() as the source of truth. A single effect handles both attach and enable/disable sync:

    • If the current track already has the Krisp processor attached, just sync setEnabled(shouldEnable).
    • Otherwise, if the filter is wanted on, create a fresh KrispNoiseFilterProcessor bound to this track's AudioContext and attach it.

    Because nothing is cached across tracks, mic republishes can't produce a stale-processor-from-a-closed-context scenario.

  2. fix(react): detach Krisp processor on cancel — handle the cancel-mid-attach race where setProcessor resolves after the effect was cancelled (e.g. unmount during attach). The IIFE checks cancelled after each await and undoes the attach via track.stopProcessor() so the track is left as we found it.

  3. fix(react): guard processor cast and detach on cleanup — guard the returned processor value with a name === 'livekit-noise-filter' check before casting (prevents misrepresenting a non-Krisp TrackProcessor set by another module), and add a separate cleanup effect keyed on the track that detaches the processor on track change or unmount. Uses identity (track.getProcessor() === processor) so it only detaches the instance this hook owns. Closes the gap where a caller-owned trackRef outlives the hook's component or swaps to a different live track.

Test plan

  • Reproduced on www with the homepage agent: session.start()session.end()session.start() errors on second start.
  • With the patched build installed, three back-to-back start/end cycles in the same tab succeed without the error and with the filter active.
  • Toggle reuse path still works: setNoiseFilterEnabled flipping false → true does not destroy and recreate the processor.
  • pnpm --filter @livekit/components-react build / lint / test all pass (no new lint warnings).

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
components-js-storybook-5kld Ready Ready Preview, Comment Apr 29, 2026 4:24pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 21, 2026

🦋 Changeset detected

Latest commit: 34b330a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@livekit/components-react Patch
@agents-ui Patch
@livekit/component-example-next Patch
@livekit/components-js-docs Patch
@livekit/component-docs-storybook Patch
@livekit/components-docs-gen Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

size-limit report 📦

Path Size
LiveKitRoom only 6.01 KB (0%)
LiveKitRoom with VideoConference 32.24 KB (0%)
All exports 43.69 KB (0%)

The useKrispNoiseFilter hook stored the KrispNoiseFilterProcessor in
React state and only created it once, reusing the same instance across
sessions. A KrispNoiseFilterProcessor's internal audio graph is bound
to the AudioContext it was init'd against; after Room.disconnect()
closes that context and session.start() republishes the mic on a fresh
one, reattaching the old processor throws "InvalidAccessError: cannot
connect to an AudioNode belonging to a different audio context".

Drop the duplicated React state and treat LocalAudioTrack.getProcessor
as the source of truth. The effect now creates a fresh processor per
track — immune to track swaps because nothing is cached across them.
Copy link
Copy Markdown
Contributor

@1egoman 1egoman left a comment

Choose a reason for hiding this comment

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

This generally makes sense to me, but I would like @lukasIO to give the final approval given I am not that familiar with this code.

Comment on lines +102 to +103
await track.setProcessor(processor);
if (cancelled) return;
Copy link
Copy Markdown
Contributor

@1egoman 1egoman Apr 21, 2026

Choose a reason for hiding this comment

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

question: Is it possible that track.setProcessor could run here, the effect could rerun (cancelled could be set to true), the bailout would occur, and if the effect didn't run again (maybe due to a higher level component unmount / etc), the track could be left with the processor set?

Is it worth doing something in the effect cleanup function to teardown the track processor and leave the track in the state that it started in?

Copy link
Copy Markdown
Author

@Topherhindman Topherhindman Apr 21, 2026

Choose a reason for hiding this comment

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

Ahh yeah! Good catch. This was a real race. I went back and forth on where to put the detach.

The constraint I ran into: at the moment the cleanup function fires, it can't reliably know whether track.setProcessor has actually landed, because the await may still be outstanding. The cleanup is what causes the bailout, it doesn't happen after it. Cleanup also can't await stopProcessor(), so the best it could do is fire and forget. And because cleanup runs on any dep change (including shouldEnable toggling), detaching unconditionally there would destroy the processor on every toggle and re-pay Krisp's wasm + model init on re-enable.

The spot that can do exactly what you described is the IIFE itself, after the await track.setProcessor(processor) resolves, since it knows the attach landed and can
await track.stopProcessor() cleanly. That's what the if (cancelled) { await track.stopProcessor(); return; } branches handle do. Same contract as "teardown on cancel, leave the track as you found it," just placed where the async state is legible.

The one case that isn't covered is "effect succeeded fully, then the component unmounts with the track still alive." For the default micPublication path i dont believe that can happen, as LocalAudioTrack.stop() destroys the processor during the room's own teardown, so the track can't outlive us. It could happen if a caller points trackRef at a track they own and keep alive past this hook's lifetime though. I looked at covering that with either a fire and forget detach in the cleanup function or a two effect split (attach lifecycle + enable sync), but both introduced worse problems. The cleanup function version broke toggle reuse by destroying the processor on every shouldEnable flip, and the two effect split regressed isNoiseFilterPending handling, made the returned processor a stale state value instead of the live track.getProcessor(), and created a processorRequested latch that reattached on future track swaps even while the filter was off.

Happy to revisit the caller owned trackRef case if you think it's common enough to justify the complexity. Maybe as a follow up? Otherwise I think I'd leave this PR scoped to the race you caught.

Copy link
Copy Markdown
Contributor

@1egoman 1egoman Apr 21, 2026

Choose a reason for hiding this comment

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

I think what you've done makes sense to me - I also get the sense this isn't common.

One other possible idea that came to mind just now - you could put the await stopProcessor() in an unrelated effect which would have the benefit of at least cleaning it up when the component the hook is in unmounts (or that secondary effect could be given a different set of dependencies, unlinked to the set passed into the effect that calls await startProcessor()).

If the effect was cancelled while `track.setProcessor(processor)` was
still in flight — e.g. the component unmounted with the track still
alive via a caller-owned `trackRef` — setProcessor could resolve
after the bailout, leaving the track with a Krisp processor attached
that the hook no longer manages. When the track outlives the hook
nothing was going to clean that up.

Tear down the attachment inside the IIFE at each post-setProcessor
cancellation check, so the track is left in the state the hook found
it in.

const processor =
micPublication?.track instanceof LocalAudioTrack
? (micPublication.track.getProcessor() as KrispNoiseFilterProcessor | undefined)
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.

issue: this cast assumes that no other processor has been set on the track by some other module.

Two correctness fixes from PR review:

- Guard the `KrispNoiseFilterProcessor` cast in the returned `processor`
  value with a `name === 'livekit-noise-filter'` check. The
  unconditional cast assumed nothing else could attach a
  `TrackProcessor` to the track; if anything did, the hook handed
  consumers a value typed as a Krisp processor that wasn't one.

- Add a separate cleanup effect that detaches the processor we own
  when the underlying track changes or the hook unmounts. Tracks the
  attached processor + its track via refs so cleanup can verify
  identity (`track.getProcessor() === processor`) before calling
  `stopProcessor()` — avoids accidentally detaching a Krisp processor
  that some other module owns. Refs are populated only after
  `setProcessor` lands, so a cancelled mid-attach leaves them clear.
  Closes the gap where the track outlives the hook (caller-owned
  `trackRef` unmount or in-place swap to a different live track),
  while leaving the existing toggle-reuse path on `shouldEnable`
  unchanged.
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.

4 participants