use-tts-preview.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. 'use client';
  2. import { useState, useRef, useCallback, useEffect } from 'react';
  3. import {
  4. ensureVoicesLoaded,
  5. isBrowserTTSAbortError,
  6. playBrowserTTSPreview,
  7. } from '@/lib/audio/browser-tts-preview';
  8. export interface TTSPreviewOptions {
  9. text: string;
  10. providerId: string;
  11. modelId?: string;
  12. voice: string;
  13. speed: number;
  14. apiKey?: string;
  15. baseUrl?: string;
  16. }
  17. /**
  18. * Shared hook for TTS preview playback (browser-native and API-based).
  19. *
  20. * - `previewing`: true while a preview is active (including audio playback)
  21. * - `startPreview(opts)`: start a preview; rejects with non-abort errors
  22. * - `stopPreview()`: cancel any active preview and reset state
  23. */
  24. export function useTTSPreview() {
  25. const [previewing, setPreviewing] = useState(false);
  26. const cancelRef = useRef<(() => void) | null>(null);
  27. const requestIdRef = useRef(0);
  28. const audioRef = useRef<HTMLAudioElement | null>(null);
  29. const audioUrlRef = useRef<string | null>(null);
  30. /** Cancel in-flight work and release resources (no state update). */
  31. const cleanup = useCallback(() => {
  32. requestIdRef.current += 1;
  33. cancelRef.current?.();
  34. cancelRef.current = null;
  35. if (audioRef.current) {
  36. audioRef.current.pause();
  37. audioRef.current = null;
  38. }
  39. if (audioUrlRef.current) {
  40. URL.revokeObjectURL(audioUrlRef.current);
  41. audioUrlRef.current = null;
  42. }
  43. }, []);
  44. /** Cancel any active preview and reset the previewing flag. */
  45. const stopPreview = useCallback(() => {
  46. cleanup();
  47. setPreviewing(false);
  48. }, [cleanup]);
  49. // Cleanup on unmount (skip state update to avoid React warnings).
  50. useEffect(() => cleanup, [cleanup]);
  51. /**
  52. * Start a TTS preview.
  53. * Abort errors are swallowed; all other errors are re-thrown for the caller.
  54. */
  55. const startPreview = useCallback(
  56. async (options: TTSPreviewOptions): Promise<void> => {
  57. cleanup();
  58. const requestId = ++requestIdRef.current;
  59. const isStale = () => requestIdRef.current !== requestId;
  60. setPreviewing(true);
  61. try {
  62. if (options.providerId === 'browser-native-tts') {
  63. if (typeof window === 'undefined' || !window.speechSynthesis) {
  64. throw new Error('Browser does not support Speech Synthesis API');
  65. }
  66. const voices = await ensureVoicesLoaded();
  67. if (isStale()) return;
  68. if (voices.length === 0) {
  69. throw new Error('No browser TTS voices available');
  70. }
  71. const controller = playBrowserTTSPreview({
  72. text: options.text,
  73. voice: options.voice,
  74. rate: options.speed,
  75. voices,
  76. });
  77. cancelRef.current = controller.cancel;
  78. await controller.promise;
  79. if (!isStale()) {
  80. cancelRef.current = null;
  81. setPreviewing(false);
  82. }
  83. return;
  84. }
  85. // API-based TTS
  86. const body: Record<string, unknown> = {
  87. text: options.text,
  88. audioId: 'preview',
  89. ttsProviderId: options.providerId,
  90. ttsModelId: options.modelId,
  91. ttsVoice: options.voice,
  92. ttsSpeed: options.speed,
  93. };
  94. if (options.apiKey?.trim()) body.ttsApiKey = options.apiKey;
  95. if (options.baseUrl?.trim()) body.ttsBaseUrl = options.baseUrl;
  96. const res = await fetch('/api/generate/tts', {
  97. method: 'POST',
  98. headers: { 'Content-Type': 'application/json' },
  99. body: JSON.stringify(body),
  100. });
  101. if (isStale()) return;
  102. const data = await res.json().catch(() => ({ error: res.statusText }));
  103. if (isStale()) return;
  104. if (!res.ok || !data.base64) {
  105. throw new Error(data.error || 'TTS preview failed');
  106. }
  107. // Decode base64 → Blob → Object URL
  108. const binaryStr = atob(data.base64);
  109. const bytes = new Uint8Array(binaryStr.length);
  110. for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
  111. const blob = new Blob([bytes], { type: `audio/${data.format || 'mp3'}` });
  112. if (audioUrlRef.current) URL.revokeObjectURL(audioUrlRef.current);
  113. const url = URL.createObjectURL(blob);
  114. audioUrlRef.current = url;
  115. const audio = new Audio(url);
  116. audioRef.current = audio;
  117. audio.onended = () => {
  118. if (!isStale()) {
  119. audioRef.current = null;
  120. setPreviewing(false);
  121. }
  122. };
  123. audio.onerror = () => {
  124. if (!isStale()) {
  125. audioRef.current = null;
  126. setPreviewing(false);
  127. }
  128. };
  129. await audio.play();
  130. } catch (error) {
  131. if (!isStale()) {
  132. cancelRef.current = null;
  133. setPreviewing(false);
  134. }
  135. if (!isBrowserTTSAbortError(error)) {
  136. throw error;
  137. }
  138. }
  139. },
  140. [cleanup],
  141. );
  142. return { previewing, startPreview, stopPreview };
  143. }