From 9b3fa177e22c06fe25e9ac62955fa925c91427b6 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 25 Apr 2026 04:27:15 -0700 Subject: [PATCH] fix(stories): hard-cut the audio graph on stop so long imports actually halt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit source.stop() was the only thing happening when a clip was halted, and on long imported buffers (multi-minute MP3s scheduled via source.start with a duration argument) it was silently failing to halt the buffer in some browsers — pause left the music playing and seek stacked another source on top of the original. The mute-the-WaveSurfer-element fix was a different bug along the same path; this is the one that actually addresses the duplicated audio. ActiveSource now carries the per-clip GainNode alongside the source, and stopSource detaches the onended handler before calling stop() (so the natural-end callback can't race with explicit teardown and re-delete a freshly rescheduled entry at the same id), then disconnects both nodes inside their own try/catch blocks. Even when stop() doesn't actually halt the buffer the graph is severed — no path from source to destination, no audio. --- app/src/lib/hooks/useStoryPlayback.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/src/lib/hooks/useStoryPlayback.ts b/app/src/lib/hooks/useStoryPlayback.ts index 3fd3d555..16a3d5ce 100644 --- a/app/src/lib/hooks/useStoryPlayback.ts +++ b/app/src/lib/hooks/useStoryPlayback.ts @@ -5,6 +5,7 @@ import { useStoryStore } from '@/stores/storyStore'; interface ActiveSource { source: AudioBufferSourceNode; + clipGain: GainNode; itemId: string; generationId: string; startTimeMs: number; @@ -61,11 +62,29 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) { const stopSource = useCallback((itemId: string) => { const activeSource = activeSourcesRef.current.get(itemId); if (activeSource) { + // Detach onended first so the natural-end handler doesn't race with + // the explicit teardown below and re-delete a fresh entry that has + // already been re-scheduled at this id. + activeSource.source.onended = null; try { activeSource.source.stop(); } catch { // Source may have already stopped } + // Hard-cut the audio graph regardless of whether stop() actually + // halted the buffer. Long imports were leaking audio when stop() + // was called on a source that was scheduled with a multi-minute + // duration; disconnecting from the destination guarantees silence. + try { + activeSource.source.disconnect(); + } catch { + // already disconnected + } + try { + activeSource.clipGain.disconnect(); + } catch { + // already disconnected + } activeSourcesRef.current.delete(itemId); } }, []); @@ -274,6 +293,7 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) { const activeSource: ActiveSource = { source, + clipGain, itemId: item.id, generationId: item.generation_id, startTimeMs: item.start_time_ms,