From 60a7d5035a5b07bb3ac85daa916f2d706308ff3c Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Tue, 21 Apr 2026 10:14:50 -0700 Subject: [PATCH 1/3] fix(react): recreate Krisp processor per track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/krisp-processor-per-track.md | 5 ++ .../hooks/cloud/krisp/useKrispNoiseFilter.ts | 89 ++++++++++++------- 2 files changed, 60 insertions(+), 34 deletions(-) create mode 100644 .changeset/krisp-processor-per-track.md diff --git a/.changeset/krisp-processor-per-track.md b/.changeset/krisp-processor-per-track.md new file mode 100644 index 000000000..56376b4ab --- /dev/null +++ b/.changeset/krisp-processor-per-track.md @@ -0,0 +1,5 @@ +--- +'@livekit/components-react': patch +--- + +Fix `useKrispNoiseFilter` throwing `InvalidAccessError` on the second session when `Room.disconnect()` + reconnect republishes the microphone. The hook now treats `LocalAudioTrack.getProcessor()` as the source of truth and creates a fresh `KrispNoiseFilterProcessor` per track, avoiding reuse of a processor whose internal audio graph is bound to a now-closed `AudioContext`. diff --git a/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts b/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts index 805cf2a0e..6cb2c2c9c 100644 --- a/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts +++ b/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts @@ -44,25 +44,18 @@ export function useKrispNoiseFilter(options: useKrispNoiseFilterOptions = {}) { const [isNoiseFilterPending, setIsNoiseFilterPending] = React.useState(false); const [isNoiseFilterEnabled, setIsNoiseFilterEnabled] = React.useState(false); let micPublication = useLocalParticipant().microphoneTrack; - const [krispProcessor, setKrispProcessor] = React.useState< - KrispNoiseFilterProcessor | undefined - >(); if (options.trackRef) { micPublication = options.trackRef.publication; } const setNoiseFilterEnabled = React.useCallback(async (enable: boolean) => { if (enable) { - const { KrispNoiseFilter, isKrispNoiseFilterSupported } = - await import('@livekit/krisp-noise-filter'); + const { isKrispNoiseFilterSupported } = await import('@livekit/krisp-noise-filter'); if (!isKrispNoiseFilterSupported()) { log.warn('LiveKit-Krisp noise filter is not supported in this browser'); return; } - if (!krispProcessor) { - setKrispProcessor(KrispNoiseFilter(options.filterOptions)); - } } setShouldEnable((prev) => { if (prev !== enable) { @@ -73,37 +66,65 @@ export function useKrispNoiseFilter(options: useKrispNoiseFilterOptions = {}) { }, []); React.useEffect(() => { - if (micPublication && micPublication.track instanceof LocalAudioTrack && krispProcessor) { - const currentProcessor = micPublication.track.getProcessor(); - if (currentProcessor && currentProcessor.name === 'livekit-noise-filter') { - setIsNoiseFilterPending(true); - (currentProcessor as KrispNoiseFilterProcessor).setEnabled(shouldEnable).finally(() => { - setIsNoiseFilterPending(false); - setIsNoiseFilterEnabled(shouldEnable); - }); - } else if (!currentProcessor && shouldEnable) { - setIsNoiseFilterPending(true); - micPublication?.track - ?.setProcessor(krispProcessor) - .then(() => krispProcessor.setEnabled(shouldEnable)) - .then(() => { - setIsNoiseFilterEnabled(true); - }) - .catch((e: any) => { - setIsNoiseFilterEnabled(false); - log.error('Krisp hook: error enabling filter', e); - }) - .finally(() => { - setIsNoiseFilterPending(false); - }); - } + const track = micPublication?.track; + if (!(track instanceof LocalAudioTrack)) return; + + const existing = track.getProcessor(); + // Processor already attached to this track — just sync enabled state. + if (existing?.name === 'livekit-noise-filter') { + setIsNoiseFilterPending(true); + (existing as KrispNoiseFilterProcessor).setEnabled(shouldEnable).finally(() => { + setIsNoiseFilterPending(false); + setIsNoiseFilterEnabled(shouldEnable); + }); + return; } - }, [shouldEnable, micPublication, krispProcessor]); + + if (!shouldEnable) return; + + // No processor on this track and the filter is wanted on — create a fresh + // processor bound to this track's AudioContext and attach. Each track gets + // its own processor: a KrispNoiseFilterProcessor's internal audio graph is + // permanently bound to the AudioContext it was init'd against, so it can't + // be reused across mic republishes (e.g. session.end() + session.start()). + let cancelled = false; + setIsNoiseFilterPending(true); + (async () => { + const { KrispNoiseFilter, isKrispNoiseFilterSupported } = + await import('@livekit/krisp-noise-filter'); + if (cancelled) return; + if (!isKrispNoiseFilterSupported()) { + setIsNoiseFilterPending(false); + return; + } + const processor = KrispNoiseFilter(options.filterOptions); + try { + await track.setProcessor(processor); + if (cancelled) return; + await processor.setEnabled(true); + if (cancelled) return; + setIsNoiseFilterEnabled(true); + } catch (e: any) { + setIsNoiseFilterEnabled(false); + log.error('Krisp hook: error enabling filter', e); + } finally { + setIsNoiseFilterPending(false); + } + })(); + return () => { + cancelled = true; + }; + }, [shouldEnable, micPublication?.track, options.filterOptions]); + + const processor = + micPublication?.track instanceof LocalAudioTrack + ? (micPublication.track.getProcessor() as KrispNoiseFilterProcessor | undefined) + : undefined; return { setNoiseFilterEnabled, isNoiseFilterEnabled, isNoiseFilterPending, - processor: krispProcessor, + processor, }; } From 63fa1611c01379a6586f0c3ac8c35e6dc586e76e Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Tue, 21 Apr 2026 11:44:06 -0700 Subject: [PATCH 2/3] fix(react): detach Krisp processor on cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/hooks/cloud/krisp/useKrispNoiseFilter.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts b/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts index 6cb2c2c9c..b20c46abc 100644 --- a/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts +++ b/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts @@ -100,9 +100,19 @@ export function useKrispNoiseFilter(options: useKrispNoiseFilterOptions = {}) { const processor = KrispNoiseFilter(options.filterOptions); try { await track.setProcessor(processor); - if (cancelled) return; + // If the effect was cancelled while setProcessor was in flight, the + // processor landed on a track the hook no longer manages (e.g. the + // component unmounted with the track still alive via a caller-owned + // trackRef). Undo the attach so the track is left as we found it. + if (cancelled) { + await track.stopProcessor(); + return; + } await processor.setEnabled(true); - if (cancelled) return; + if (cancelled) { + await track.stopProcessor(); + return; + } setIsNoiseFilterEnabled(true); } catch (e: any) { setIsNoiseFilterEnabled(false); From 34b330ac842eb1fdea90e1f6e2fea9589847c4e5 Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Wed, 29 Apr 2026 09:00:20 -0700 Subject: [PATCH 3/3] fix(react): guard processor cast and detach on cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/krisp-processor-per-track.md | 2 +- .../hooks/cloud/krisp/useKrispNoiseFilter.ts | 41 +++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/.changeset/krisp-processor-per-track.md b/.changeset/krisp-processor-per-track.md index 56376b4ab..ca9f6414b 100644 --- a/.changeset/krisp-processor-per-track.md +++ b/.changeset/krisp-processor-per-track.md @@ -2,4 +2,4 @@ '@livekit/components-react': patch --- -Fix `useKrispNoiseFilter` throwing `InvalidAccessError` on the second session when `Room.disconnect()` + reconnect republishes the microphone. The hook now treats `LocalAudioTrack.getProcessor()` as the source of truth and creates a fresh `KrispNoiseFilterProcessor` per track, avoiding reuse of a processor whose internal audio graph is bound to a now-closed `AudioContext`. +Fix `useKrispNoiseFilter` throwing `InvalidAccessError` on the second session when `Room.disconnect()` + reconnect republishes the microphone. The hook now treats `LocalAudioTrack.getProcessor()` as the source of truth and creates a fresh `KrispNoiseFilterProcessor` per track, avoiding reuse of a processor whose internal audio graph is bound to a now-closed `AudioContext`. Also tightens the cancellation race when `setProcessor` resolves after the effect was cancelled, guards the returned `processor` against non-Krisp `TrackProcessor`s set by other modules, and detaches the processor on track change / unmount so a caller-owned `trackRef` is left in the state it started in. diff --git a/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts b/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts index b20c46abc..a6404acea 100644 --- a/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts +++ b/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts @@ -48,6 +48,9 @@ export function useKrispNoiseFilter(options: useKrispNoiseFilterOptions = {}) { micPublication = options.trackRef.publication; } + const managedProcessorRef = React.useRef(undefined); + const managedProcessorTrackRef = React.useRef(undefined); + const setNoiseFilterEnabled = React.useCallback(async (enable: boolean) => { if (enable) { const { isKrispNoiseFilterSupported } = await import('@livekit/krisp-noise-filter'); @@ -105,12 +108,22 @@ export function useKrispNoiseFilter(options: useKrispNoiseFilterOptions = {}) { // component unmounted with the track still alive via a caller-owned // trackRef). Undo the attach so the track is left as we found it. if (cancelled) { - await track.stopProcessor(); + if (track.getProcessor() === processor) { + await track.stopProcessor(); + } return; } + managedProcessorRef.current = processor; + managedProcessorTrackRef.current = track; await processor.setEnabled(true); if (cancelled) { - await track.stopProcessor(); + if (track.getProcessor() === processor) { + await track.stopProcessor(); + } + if (managedProcessorRef.current === processor) { + managedProcessorRef.current = undefined; + managedProcessorTrackRef.current = undefined; + } return; } setIsNoiseFilterEnabled(true); @@ -126,9 +139,29 @@ export function useKrispNoiseFilter(options: useKrispNoiseFilterOptions = {}) { }; }, [shouldEnable, micPublication?.track, options.filterOptions]); - const processor = + React.useEffect( + () => () => { + const track = managedProcessorTrackRef.current; + const processor = managedProcessorRef.current; + if (!track || !processor) return; + if (track.getProcessor() === processor) { + track.stopProcessor().catch((e) => { + log.warn('Krisp hook: error detaching processor on cleanup', e); + }); + } + managedProcessorRef.current = undefined; + managedProcessorTrackRef.current = undefined; + }, + [micPublication?.track], + ); + + const trackProcessor = micPublication?.track instanceof LocalAudioTrack - ? (micPublication.track.getProcessor() as KrispNoiseFilterProcessor | undefined) + ? micPublication.track.getProcessor() + : undefined; + const processor = + trackProcessor?.name === 'livekit-noise-filter' + ? (trackProcessor as KrispNoiseFilterProcessor) : undefined; return {