From 8dac0f73c036521dbea078e691479be4c7837a43 Mon Sep 17 00:00:00 2001 From: Isaac Sanchez-Hawkins <266845420+isanchez404@users.noreply.github.com> Date: Thu, 21 May 2026 08:51:49 -0400 Subject: [PATCH] fix(ui): revoke voice preview blob urls (#1342) * fix(ui): revoke voice preview blob urls * fix(ui): reset disposed preview audio --------- Co-authored-by: Isaac Sanchez --- .../sections/openchamber/VoiceSettings.tsx | 49 +++++------ .../openchamber/voicePreviewAudio.test.ts | 86 +++++++++++++++++++ .../sections/openchamber/voicePreviewAudio.ts | 13 +++ 3 files changed, 124 insertions(+), 24 deletions(-) create mode 100644 packages/ui/src/components/sections/openchamber/voicePreviewAudio.test.ts create mode 100644 packages/ui/src/components/sections/openchamber/voicePreviewAudio.ts diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx index 5c09ce29..2ddb50ee 100644 --- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx @@ -21,6 +21,7 @@ import { wasmSttService, WASM_MODELS } from '@/lib/voice/wasmSttService'; import type { WasmModelStatus } from '@/lib/voice/wasmSttService'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; +import { disposePreviewAudio } from './voicePreviewAudio'; const LANGUAGE_OPTIONS = [ { value: 'en-US', label: 'English' }, { value: 'es-ES', label: 'EspaƱol' }, @@ -313,14 +314,14 @@ export const VoiceSettings: React.FC = () => { const previewVoice = useCallback(async () => { if (previewAudio) { - previewAudio.pause(); - previewAudio.currentTime = 0; + disposePreviewAudio(previewAudio); setPreviewAudio(null); setIsPreviewPlaying(false); return; } setIsPreviewPlaying(true); + let audio: HTMLAudioElement | null = null; try { const response = await fetch('/api/tts/say/speak', { method: 'POST', @@ -336,16 +337,16 @@ export const VoiceSettings: React.FC = () => { const blob = await response.blob(); const url = URL.createObjectURL(blob); - const audio = new Audio(url); + audio = new Audio(url); audio.onended = () => { - URL.revokeObjectURL(url); + disposePreviewAudio(audio); setPreviewAudio(null); setIsPreviewPlaying(false); }; audio.onerror = () => { - URL.revokeObjectURL(url); + disposePreviewAudio(audio); setPreviewAudio(null); setIsPreviewPlaying(false); }; @@ -353,28 +354,28 @@ export const VoiceSettings: React.FC = () => { setPreviewAudio(audio); await audio.play(); } catch { + disposePreviewAudio(audio); + setPreviewAudio(null); setIsPreviewPlaying(false); } }, [sayVoice, speechRate, previewAudio, t]); useEffect(() => { return () => { - if (previewAudio) { - previewAudio.pause(); - } + disposePreviewAudio(previewAudio); }; }, [previewAudio]); const previewOpenAIVoice = useCallback(async () => { if (openaiPreviewAudio) { - openaiPreviewAudio.pause(); - openaiPreviewAudio.currentTime = 0; + disposePreviewAudio(openaiPreviewAudio); setOpenaiPreviewAudio(null); setIsOpenAIPreviewPlaying(false); return; } setIsOpenAIPreviewPlaying(true); + let audio: HTMLAudioElement | null = null; try { const response = await fetch('/api/tts/speak', { method: 'POST', @@ -394,16 +395,16 @@ export const VoiceSettings: React.FC = () => { const blob = await response.blob(); const url = URL.createObjectURL(blob); - const audio = new Audio(url); + audio = new Audio(url); audio.onended = () => { - URL.revokeObjectURL(url); + disposePreviewAudio(audio); setOpenaiPreviewAudio(null); setIsOpenAIPreviewPlaying(false); }; audio.onerror = () => { - URL.revokeObjectURL(url); + disposePreviewAudio(audio); setOpenaiPreviewAudio(null); setIsOpenAIPreviewPlaying(false); }; @@ -411,22 +412,21 @@ export const VoiceSettings: React.FC = () => { setOpenaiPreviewAudio(audio); await audio.play(); } catch { + disposePreviewAudio(audio); + setOpenaiPreviewAudio(null); setIsOpenAIPreviewPlaying(false); } }, [openaiVoice, speechRate, openaiPreviewAudio, openaiApiKey, t]); useEffect(() => { return () => { - if (openaiPreviewAudio) { - openaiPreviewAudio.pause(); - } + disposePreviewAudio(openaiPreviewAudio); }; }, [openaiPreviewAudio]); const previewCompatibleVoice = useCallback(async () => { if (compatiblePreviewAudio) { - compatiblePreviewAudio.pause(); - compatiblePreviewAudio.currentTime = 0; + disposePreviewAudio(compatiblePreviewAudio); setCompatiblePreviewAudio(null); setIsCompatiblePreviewPlaying(false); return; @@ -435,6 +435,7 @@ export const VoiceSettings: React.FC = () => { if (!openaiCompatibleUrl.trim()) return; setIsCompatiblePreviewPlaying(true); + let audio: HTMLAudioElement | null = null; try { const response = await fetch('/api/tts/speak', { method: 'POST', @@ -455,16 +456,16 @@ export const VoiceSettings: React.FC = () => { const blob = await response.blob(); const url = URL.createObjectURL(blob); - const audio = new Audio(url); + audio = new Audio(url); audio.onended = () => { - URL.revokeObjectURL(url); + disposePreviewAudio(audio); setCompatiblePreviewAudio(null); setIsCompatiblePreviewPlaying(false); }; audio.onerror = () => { - URL.revokeObjectURL(url); + disposePreviewAudio(audio); setCompatiblePreviewAudio(null); setIsCompatiblePreviewPlaying(false); }; @@ -472,15 +473,15 @@ export const VoiceSettings: React.FC = () => { setCompatiblePreviewAudio(audio); await audio.play(); } catch { + disposePreviewAudio(audio); + setCompatiblePreviewAudio(null); setIsCompatiblePreviewPlaying(false); } }, [openaiCompatibleUrl, openaiCompatibleVoice, openaiCompatibleTtsModel, speechRate, compatiblePreviewAudio, t]); useEffect(() => { return () => { - if (compatiblePreviewAudio) { - compatiblePreviewAudio.pause(); - } + disposePreviewAudio(compatiblePreviewAudio); }; }, [compatiblePreviewAudio]); diff --git a/packages/ui/src/components/sections/openchamber/voicePreviewAudio.test.ts b/packages/ui/src/components/sections/openchamber/voicePreviewAudio.test.ts new file mode 100644 index 00000000..4d3701f8 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/voicePreviewAudio.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'bun:test'; + +import { disposePreviewAudio } from './voicePreviewAudio'; + +type MockAudio = Pick & { + loadCalls: number; + paused: boolean; + removedAttributes: string[]; +}; + +const createMockAudio = (src: string): MockAudio => { + const audio: MockAudio = { + src, + loadCalls: 0, + paused: false, + removedAttributes: [], + load: () => { + audio.loadCalls += 1; + }, + pause: () => { + audio.paused = true; + }, + removeAttribute: (name: string) => { + audio.removedAttributes.push(name); + if (name === 'src') { + audio.src = ''; + } + }, + }; + + return audio; +}; + +const withRevokedUrls = (run: (revokedUrls: string[]) => void) => { + const originalRevokeObjectURL = URL.revokeObjectURL; + const revokedUrls: string[] = []; + URL.revokeObjectURL = ((url: string) => { + revokedUrls.push(url); + }) as typeof URL.revokeObjectURL; + + try { + run(revokedUrls); + } finally { + URL.revokeObjectURL = originalRevokeObjectURL; + } +}; + +describe('disposePreviewAudio', () => { + test('pauses audio, revokes blob URLs, and clears the source', () => { + withRevokedUrls((revokedUrls) => { + const audio = createMockAudio('blob:https://example.test/preview'); + + disposePreviewAudio(audio as unknown as HTMLAudioElement); + + expect(audio.paused).toBe(true); + expect(revokedUrls).toEqual(['blob:https://example.test/preview']); + expect(audio.removedAttributes).toEqual(['src']); + expect(audio.loadCalls).toBe(1); + expect(audio.src).toBe(''); + }); + }); + + test('does not revoke non-blob URLs', () => { + withRevokedUrls((revokedUrls) => { + const audio = createMockAudio('https://example.test/preview.mp3'); + + disposePreviewAudio(audio as unknown as HTMLAudioElement); + + expect(audio.paused).toBe(true); + expect(revokedUrls).toEqual([]); + expect(audio.removedAttributes).toEqual(['src']); + expect(audio.loadCalls).toBe(1); + }); + }); + + test('ignores missing audio', () => { + let threw = false; + try { + disposePreviewAudio(null); + } catch { + threw = true; + } + + expect(threw).toBe(false); + }); +}); diff --git a/packages/ui/src/components/sections/openchamber/voicePreviewAudio.ts b/packages/ui/src/components/sections/openchamber/voicePreviewAudio.ts new file mode 100644 index 00000000..bbfdf79f --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/voicePreviewAudio.ts @@ -0,0 +1,13 @@ +export const disposePreviewAudio = (audio: HTMLAudioElement | null | undefined) => { + if (!audio) return; + + audio.pause(); + + const { src } = audio; + if (src.startsWith('blob:')) { + URL.revokeObjectURL(src); + } + + audio.removeAttribute('src'); + audio.load(); +};