| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159 |
- 'use client';
- 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;
- }
- /**
- * 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;
- 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 };
- }
|