fix(stories): hard-cut the audio graph on stop so long imports actually halt

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.
This commit is contained in:
Jamie Pine
2026-04-25 04:27:15 -07:00
parent e7846eaf77
commit 9b3fa177e2
+20
View File
@@ -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,