File size: 5,055 Bytes
f56a29b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
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;
}
/**
* Shared hook for TTS preview playback (browser-native and API-based).
*
* - `previewing`: true while a preview is active (including audio playback)
* - `startPreview(opts)`: start a preview; rejects with non-abort errors
* - `stopPreview()`: cancel any active preview and reset state
*/
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);
/** Cancel in-flight work and release resources (no state update). */
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;
}
}, []);
/** Cancel any active preview and reset the previewing flag. */
const stopPreview = useCallback(() => {
cleanup();
setPreviewing(false);
}, [cleanup]);
// Cleanup on unmount (skip state update to avoid React warnings).
useEffect(() => cleanup, [cleanup]);
/**
* Start a TTS preview.
* Abort errors are swallowed; all other errors are re-thrown for the caller.
*/
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;
}
// API-based TTS
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');
}
// Decode base64 → Blob → Object URL
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 };
}
|