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:
committed by
GitHub
co-authored by
Isaac Sanchez
parent
6fbdb22c7e
commit
8dac0f73c0
@@ -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();
|
||||
};
|
||||
Reference in New Issue
Block a user