|
|
|
|
| import { useState, useRef, useCallback, useEffect } from 'react'; |
| import { |
| ensureVoicesLoaded, |
| isBrowserTTSAbortError, |
| playBrowserTTSPreview, |
| } from '@/lib/audio/browser-tts-preview'; |
|
|
| export interface TTSPreviewOptions { |
| text: string; |
| providerId: string; |
| modelId?: string; |
| voice: string; |
| speed: number; |
| apiKey?: string; |
| baseUrl?: string; |
| providerOptions?: unknown; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function useTTSPreview() { |
| const [previewing, setPreviewing] = useState(false); |
| const cancelRef = useRef<(() => void) | null>(null); |
| const requestIdRef = useRef(0); |
| const audioRef = useRef<HTMLAudioElement | null>(null); |
| const audioUrlRef = useRef<string | null>(null); |
|
|
| |
| const cleanup = useCallback(() => { |
| requestIdRef.current += 1; |
| cancelRef.current?.(); |
| cancelRef.current = null; |
| if (audioRef.current) { |
| audioRef.current.pause(); |
| audioRef.current = null; |
| } |
| if (audioUrlRef.current) { |
| URL.revokeObjectURL(audioUrlRef.current); |
| audioUrlRef.current = null; |
| } |
| }, []); |
|
|
| |
| const stopPreview = useCallback(() => { |
| cleanup(); |
| setPreviewing(false); |
| }, [cleanup]); |
|
|
| |
| useEffect(() => cleanup, [cleanup]); |
|
|
| |
| |
| |
| |
| const startPreview = useCallback( |
| async (options: TTSPreviewOptions): Promise<void> => { |
| cleanup(); |
| const requestId = ++requestIdRef.current; |
| const isStale = () => requestIdRef.current !== requestId; |
|
|
| setPreviewing(true); |
| try { |
| if (options.providerId === 'browser-native-tts') { |
| if (typeof window === 'undefined' || !window.speechSynthesis) { |
| throw new Error('Browser does not support Speech Synthesis API'); |
| } |
| const voices = await ensureVoicesLoaded(); |
| if (isStale()) return; |
| if (voices.length === 0) { |
| throw new Error('No browser TTS voices available'); |
| } |
| const controller = playBrowserTTSPreview({ |
| text: options.text, |
| voice: options.voice, |
| rate: options.speed, |
| voices, |
| }); |
| cancelRef.current = controller.cancel; |
| await controller.promise; |
| if (!isStale()) { |
| cancelRef.current = null; |
| setPreviewing(false); |
| } |
| return; |
| } |
|
|
| |
| const body: Record<string, unknown> = { |
| text: options.text, |
| audioId: 'preview', |
| ttsProviderId: options.providerId, |
| ttsModelId: options.modelId, |
| ttsVoice: options.voice, |
| ttsSpeed: options.speed, |
| }; |
| if (options.apiKey?.trim()) body.ttsApiKey = options.apiKey; |
| if (options.baseUrl?.trim()) body.ttsBaseUrl = options.baseUrl; |
| if (options.providerOptions) body.ttsProviderOptions = options.providerOptions; |
|
|
| const res = await fetch('/api/generate/tts', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body), |
| }); |
| if (isStale()) return; |
|
|
| const data = await res.json().catch(() => ({ error: res.statusText })); |
| if (isStale()) return; |
|
|
| if (!res.ok || !data.base64) { |
| throw new Error(data.error || 'TTS preview failed'); |
| } |
|
|
| |
| const binaryStr = atob(data.base64); |
| const bytes = new Uint8Array(binaryStr.length); |
| for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i); |
| const blob = new Blob([bytes], { type: `audio/${data.format || 'mp3'}` }); |
|
|
| if (audioUrlRef.current) URL.revokeObjectURL(audioUrlRef.current); |
| const url = URL.createObjectURL(blob); |
| audioUrlRef.current = url; |
|
|
| const audio = new Audio(url); |
| audioRef.current = audio; |
| audio.onended = () => { |
| if (!isStale()) { |
| audioRef.current = null; |
| setPreviewing(false); |
| } |
| }; |
| audio.onerror = () => { |
| if (!isStale()) { |
| audioRef.current = null; |
| setPreviewing(false); |
| } |
| }; |
| await audio.play(); |
| } catch (error) { |
| if (!isStale()) { |
| cancelRef.current = null; |
| setPreviewing(false); |
| } |
| if (!isBrowserTTSAbortError(error)) { |
| throw error; |
| } |
| } |
| }, |
| [cleanup], |
| ); |
|
|
| return { previewing, startPreview, stopPreview }; |
| } |
|
|