diff --git a/.changeset/krisp-processor-per-track.md b/.changeset/krisp-processor-per-track.md new file mode 100644 index 000000000..ca9f6414b --- /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`. 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 805cf2a0e..a6404acea 100644 --- a/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts +++ b/packages/react/src/hooks/cloud/krisp/useKrispNoiseFilter.ts @@ -44,25 +44,21 @@ 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 managedProcessorRef = React.useRef(undefined); + const managedProcessorTrackRef = React.useRef(undefined); + 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 +69,105 @@ 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); + 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; + } + + 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 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) { + if (track.getProcessor() === processor) { + await track.stopProcessor(); + } + return; + } + managedProcessorRef.current = processor; + managedProcessorTrackRef.current = track; + await processor.setEnabled(true); + if (cancelled) { + if (track.getProcessor() === processor) { + await track.stopProcessor(); + } + if (managedProcessorRef.current === processor) { + managedProcessorRef.current = undefined; + managedProcessorTrackRef.current = undefined; + } + 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]); + + 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); }); - } 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); - }); } - } - }, [shouldEnable, micPublication, krispProcessor]); + managedProcessorRef.current = undefined; + managedProcessorTrackRef.current = undefined; + }, + [micPublication?.track], + ); + + const trackProcessor = + micPublication?.track instanceof LocalAudioTrack + ? micPublication.track.getProcessor() + : undefined; + const processor = + trackProcessor?.name === 'livekit-noise-filter' + ? (trackProcessor as KrispNoiseFilterProcessor) + : undefined; return { setNoiseFilterEnabled, isNoiseFilterEnabled, isNoiseFilterPending, - processor: krispProcessor, + processor, }; }