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 <isanchez-hawkins@arize.com>
This commit is contained in:
Isaac Sanchez-Hawkins
2026-05-21 15:51:49 +03:00
committed by GitHub
co-authored by Isaac Sanchez
parent 6fbdb22c7e
commit 8dac0f73c0
3 changed files with 124 additions and 24 deletions
@@ -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]);
@@ -0,0 +1,86 @@
import { describe, expect, test } from 'bun:test';
import { disposePreviewAudio } from './voicePreviewAudio';
type MockAudio = Pick<HTMLAudioElement, 'load' | 'pause' | 'removeAttribute' | 'src'> & {
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);
});
});
@@ -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();
};