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 type { WasmModelStatus } from '@/lib/voice/wasmSttService';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { disposePreviewAudio } from './voicePreviewAudio';
|
||||||
const LANGUAGE_OPTIONS = [
|
const LANGUAGE_OPTIONS = [
|
||||||
{ value: 'en-US', label: 'English' },
|
{ value: 'en-US', label: 'English' },
|
||||||
{ value: 'es-ES', label: 'Español' },
|
{ value: 'es-ES', label: 'Español' },
|
||||||
@@ -313,14 +314,14 @@ export const VoiceSettings: React.FC = () => {
|
|||||||
|
|
||||||
const previewVoice = useCallback(async () => {
|
const previewVoice = useCallback(async () => {
|
||||||
if (previewAudio) {
|
if (previewAudio) {
|
||||||
previewAudio.pause();
|
disposePreviewAudio(previewAudio);
|
||||||
previewAudio.currentTime = 0;
|
|
||||||
setPreviewAudio(null);
|
setPreviewAudio(null);
|
||||||
setIsPreviewPlaying(false);
|
setIsPreviewPlaying(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPreviewPlaying(true);
|
setIsPreviewPlaying(true);
|
||||||
|
let audio: HTMLAudioElement | null = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/tts/say/speak', {
|
const response = await fetch('/api/tts/say/speak', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -336,16 +337,16 @@ export const VoiceSettings: React.FC = () => {
|
|||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const audio = new Audio(url);
|
audio = new Audio(url);
|
||||||
|
|
||||||
audio.onended = () => {
|
audio.onended = () => {
|
||||||
URL.revokeObjectURL(url);
|
disposePreviewAudio(audio);
|
||||||
setPreviewAudio(null);
|
setPreviewAudio(null);
|
||||||
setIsPreviewPlaying(false);
|
setIsPreviewPlaying(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
audio.onerror = () => {
|
audio.onerror = () => {
|
||||||
URL.revokeObjectURL(url);
|
disposePreviewAudio(audio);
|
||||||
setPreviewAudio(null);
|
setPreviewAudio(null);
|
||||||
setIsPreviewPlaying(false);
|
setIsPreviewPlaying(false);
|
||||||
};
|
};
|
||||||
@@ -353,28 +354,28 @@ export const VoiceSettings: React.FC = () => {
|
|||||||
setPreviewAudio(audio);
|
setPreviewAudio(audio);
|
||||||
await audio.play();
|
await audio.play();
|
||||||
} catch {
|
} catch {
|
||||||
|
disposePreviewAudio(audio);
|
||||||
|
setPreviewAudio(null);
|
||||||
setIsPreviewPlaying(false);
|
setIsPreviewPlaying(false);
|
||||||
}
|
}
|
||||||
}, [sayVoice, speechRate, previewAudio, t]);
|
}, [sayVoice, speechRate, previewAudio, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (previewAudio) {
|
disposePreviewAudio(previewAudio);
|
||||||
previewAudio.pause();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [previewAudio]);
|
}, [previewAudio]);
|
||||||
|
|
||||||
const previewOpenAIVoice = useCallback(async () => {
|
const previewOpenAIVoice = useCallback(async () => {
|
||||||
if (openaiPreviewAudio) {
|
if (openaiPreviewAudio) {
|
||||||
openaiPreviewAudio.pause();
|
disposePreviewAudio(openaiPreviewAudio);
|
||||||
openaiPreviewAudio.currentTime = 0;
|
|
||||||
setOpenaiPreviewAudio(null);
|
setOpenaiPreviewAudio(null);
|
||||||
setIsOpenAIPreviewPlaying(false);
|
setIsOpenAIPreviewPlaying(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsOpenAIPreviewPlaying(true);
|
setIsOpenAIPreviewPlaying(true);
|
||||||
|
let audio: HTMLAudioElement | null = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/tts/speak', {
|
const response = await fetch('/api/tts/speak', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -394,16 +395,16 @@ export const VoiceSettings: React.FC = () => {
|
|||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const audio = new Audio(url);
|
audio = new Audio(url);
|
||||||
|
|
||||||
audio.onended = () => {
|
audio.onended = () => {
|
||||||
URL.revokeObjectURL(url);
|
disposePreviewAudio(audio);
|
||||||
setOpenaiPreviewAudio(null);
|
setOpenaiPreviewAudio(null);
|
||||||
setIsOpenAIPreviewPlaying(false);
|
setIsOpenAIPreviewPlaying(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
audio.onerror = () => {
|
audio.onerror = () => {
|
||||||
URL.revokeObjectURL(url);
|
disposePreviewAudio(audio);
|
||||||
setOpenaiPreviewAudio(null);
|
setOpenaiPreviewAudio(null);
|
||||||
setIsOpenAIPreviewPlaying(false);
|
setIsOpenAIPreviewPlaying(false);
|
||||||
};
|
};
|
||||||
@@ -411,22 +412,21 @@ export const VoiceSettings: React.FC = () => {
|
|||||||
setOpenaiPreviewAudio(audio);
|
setOpenaiPreviewAudio(audio);
|
||||||
await audio.play();
|
await audio.play();
|
||||||
} catch {
|
} catch {
|
||||||
|
disposePreviewAudio(audio);
|
||||||
|
setOpenaiPreviewAudio(null);
|
||||||
setIsOpenAIPreviewPlaying(false);
|
setIsOpenAIPreviewPlaying(false);
|
||||||
}
|
}
|
||||||
}, [openaiVoice, speechRate, openaiPreviewAudio, openaiApiKey, t]);
|
}, [openaiVoice, speechRate, openaiPreviewAudio, openaiApiKey, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (openaiPreviewAudio) {
|
disposePreviewAudio(openaiPreviewAudio);
|
||||||
openaiPreviewAudio.pause();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [openaiPreviewAudio]);
|
}, [openaiPreviewAudio]);
|
||||||
|
|
||||||
const previewCompatibleVoice = useCallback(async () => {
|
const previewCompatibleVoice = useCallback(async () => {
|
||||||
if (compatiblePreviewAudio) {
|
if (compatiblePreviewAudio) {
|
||||||
compatiblePreviewAudio.pause();
|
disposePreviewAudio(compatiblePreviewAudio);
|
||||||
compatiblePreviewAudio.currentTime = 0;
|
|
||||||
setCompatiblePreviewAudio(null);
|
setCompatiblePreviewAudio(null);
|
||||||
setIsCompatiblePreviewPlaying(false);
|
setIsCompatiblePreviewPlaying(false);
|
||||||
return;
|
return;
|
||||||
@@ -435,6 +435,7 @@ export const VoiceSettings: React.FC = () => {
|
|||||||
if (!openaiCompatibleUrl.trim()) return;
|
if (!openaiCompatibleUrl.trim()) return;
|
||||||
|
|
||||||
setIsCompatiblePreviewPlaying(true);
|
setIsCompatiblePreviewPlaying(true);
|
||||||
|
let audio: HTMLAudioElement | null = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/tts/speak', {
|
const response = await fetch('/api/tts/speak', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -455,16 +456,16 @@ export const VoiceSettings: React.FC = () => {
|
|||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const audio = new Audio(url);
|
audio = new Audio(url);
|
||||||
|
|
||||||
audio.onended = () => {
|
audio.onended = () => {
|
||||||
URL.revokeObjectURL(url);
|
disposePreviewAudio(audio);
|
||||||
setCompatiblePreviewAudio(null);
|
setCompatiblePreviewAudio(null);
|
||||||
setIsCompatiblePreviewPlaying(false);
|
setIsCompatiblePreviewPlaying(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
audio.onerror = () => {
|
audio.onerror = () => {
|
||||||
URL.revokeObjectURL(url);
|
disposePreviewAudio(audio);
|
||||||
setCompatiblePreviewAudio(null);
|
setCompatiblePreviewAudio(null);
|
||||||
setIsCompatiblePreviewPlaying(false);
|
setIsCompatiblePreviewPlaying(false);
|
||||||
};
|
};
|
||||||
@@ -472,15 +473,15 @@ export const VoiceSettings: React.FC = () => {
|
|||||||
setCompatiblePreviewAudio(audio);
|
setCompatiblePreviewAudio(audio);
|
||||||
await audio.play();
|
await audio.play();
|
||||||
} catch {
|
} catch {
|
||||||
|
disposePreviewAudio(audio);
|
||||||
|
setCompatiblePreviewAudio(null);
|
||||||
setIsCompatiblePreviewPlaying(false);
|
setIsCompatiblePreviewPlaying(false);
|
||||||
}
|
}
|
||||||
}, [openaiCompatibleUrl, openaiCompatibleVoice, openaiCompatibleTtsModel, speechRate, compatiblePreviewAudio, t]);
|
}, [openaiCompatibleUrl, openaiCompatibleVoice, openaiCompatibleTtsModel, speechRate, compatiblePreviewAudio, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (compatiblePreviewAudio) {
|
disposePreviewAudio(compatiblePreviewAudio);
|
||||||
compatiblePreviewAudio.pause();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [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