/** * useSayTTS Hook * * React hook for macOS 'say' command text-to-speech playback. * Uses the native macOS speech synthesis via server API. * Uses Web Audio API for playback (better iOS Safari support). * * @example * ```typescript * const { speak, isPlaying, stop, isAvailable } = useSayTTS(); * * // Speak text * await speak('Hello, this is a test'); * * // Stop playback * stop(); * ``` */ import { useCallback, useEffect, useRef, useState } from 'react'; import { runtimeFetch } from '@/lib/runtime-fetch'; interface SayTTSStatusCache { available: boolean; voices: Array<{ name: string; locale: string }>; checkedAt: number; } interface UseSayTTSOptions { enabled?: boolean; } const SAY_TTS_STATUS_TTL_MS = 30000; let sayTTSStatusCache: SayTTSStatusCache | null = null; let sayTTSStatusRequest: Promise | null = null; async function getSayTTSStatus(): Promise { const now = Date.now(); if (sayTTSStatusCache && now - sayTTSStatusCache.checkedAt < SAY_TTS_STATUS_TTL_MS) { return sayTTSStatusCache; } if (sayTTSStatusRequest) { return sayTTSStatusRequest; } sayTTSStatusRequest = (async () => { try { const response = await runtimeFetch('/api/tts/say/status'); if (!response.ok) { const unavailableStatus: SayTTSStatusCache = { available: false, voices: [], checkedAt: Date.now(), }; sayTTSStatusCache = unavailableStatus; return unavailableStatus; } const data = await response.json(); const nextStatus: SayTTSStatusCache = { available: Boolean(data.available), voices: Array.isArray(data.voices) ? data.voices : [], checkedAt: Date.now(), }; sayTTSStatusCache = nextStatus; return nextStatus; } catch { const unavailableStatus: SayTTSStatusCache = { available: false, voices: [], checkedAt: Date.now(), }; sayTTSStatusCache = unavailableStatus; return unavailableStatus; } finally { sayTTSStatusRequest = null; } })(); return sayTTSStatusRequest; } export interface UseSayTTSReturn { /** Whether TTS is currently playing */ isPlaying: boolean; /** Whether the macOS say command is available */ isAvailable: boolean; /** Available voices */ voices: Array<{ name: string; locale: string }>; /** Current error if any */ error: string | null; /** Speak the given text */ speak: (text: string, options?: SpeakOptions) => Promise; /** Stop current playback */ stop: () => void; /** Check if service is available */ checkAvailability: () => Promise; /** Unlock audio for mobile Safari - call this on user gesture */ unlockAudio: () => Promise; } interface SpeakOptions { /** Voice to use (defaults to Samantha) */ voice?: string; /** Speech rate in words per minute (defaults to 200) */ rate?: number; /** Callback when playback starts */ onStart?: () => void; /** Callback when playback ends */ onEnd?: () => void; /** Callback on error */ onError?: (error: string) => void; } // Shared AudioContext for Web Audio API playback (better iOS support) let sharedAudioContext: AudioContext | null = null; function getAudioContext(): AudioContext { if (!sharedAudioContext) { sharedAudioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); } return sharedAudioContext; } export function useSayTTS(options: UseSayTTSOptions = {}): UseSayTTSReturn { const enabled = options.enabled ?? true; const [isPlaying, setIsPlaying] = useState(false); const [isAvailable, setIsAvailable] = useState(false); const [voices, setVoices] = useState>([]); const [error, setError] = useState(null); const audioSourceRef = useRef(null); const abortControllerRef = useRef(null); // Unlock audio for mobile Safari - must be called within user gesture const unlockAudio = useCallback(async (): Promise => { try { // Get or create AudioContext const ctx = getAudioContext(); // Resume if suspended (required for iOS Safari) if (ctx.state === 'suspended') { await ctx.resume(); console.log('[useSayTTS] AudioContext resumed'); } // Play a tiny silent buffer to fully unlock const buffer = ctx.createBuffer(1, 1, 22050); const source = ctx.createBufferSource(); source.buffer = buffer; source.connect(ctx.destination); source.start(0); console.log('[useSayTTS] Audio unlocked for mobile playback'); } catch (err) { console.error('[useSayTTS] Failed to unlock audio:', err); } }, []); // Check if macOS say is available const checkAvailability = useCallback(async (): Promise => { if (!enabled) { setIsAvailable(false); setVoices([]); return false; } try { const status = await getSayTTSStatus(); setIsAvailable(status.available); setVoices(status.voices); return status.available; } catch { setIsAvailable(false); setVoices([]); return false; } }, [enabled]); // Check availability on mount useEffect(() => { void checkAvailability(); }, [checkAvailability]); // Stop current playback const stop = useCallback(() => { if (audioSourceRef.current) { try { audioSourceRef.current.stop(); } catch { // Already stopped } audioSourceRef.current = null; } if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; } setIsPlaying(false); }, []); // Speak text using macOS say const speak = useCallback(async (text: string, options?: SpeakOptions): Promise => { // Stop any existing playback stop(); if (!text.trim()) { setError('No text to speak'); options?.onError?.('No text to speak'); return; } setError(null); try { // Create abort controller for this request abortControllerRef.current = new AbortController(); // Fetch audio from server const response = await runtimeFetch('/api/tts/say/speak', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ text: text.trim(), voice: options?.voice || 'Samantha', rate: options?.rate || 200, }), signal: abortControllerRef.current.signal, }); if (!response.ok) { const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); throw new Error(errorData.error || `HTTP ${response.status}`); } // Get audio data from response const audioBlob = await response.blob(); const arrayBuffer = await audioBlob.arrayBuffer(); console.log('[useSayTTS] Received audio:', audioBlob.size, 'bytes'); // Use Web Audio API for playback (same as useServerTTS) const ctx = getAudioContext(); // Resume context if suspended if (ctx.state === 'suspended') { await ctx.resume(); console.log('[useSayTTS] AudioContext resumed before playback'); } // Decode audio data console.log('[useSayTTS] Decoding audio data...'); const audioBuffer = await ctx.decodeAudioData(arrayBuffer); // Create source node const source = ctx.createBufferSource(); source.buffer = audioBuffer; source.connect(ctx.destination); audioSourceRef.current = source; // Set up event handlers source.onended = () => { console.log('[useSayTTS] Audio playback ended'); setIsPlaying(false); audioSourceRef.current = null; options?.onEnd?.(); }; // Start playback console.log('[useSayTTS] Starting audio playback via Web Audio API...'); setIsPlaying(true); options?.onStart?.(); source.start(0); } catch (err) { if ((err as Error).name === 'AbortError') { return; } const errorMsg = err instanceof Error ? err.message : 'Failed to speak'; console.error('[useSayTTS] Error:', errorMsg); setError(errorMsg); options?.onError?.(errorMsg); setIsPlaying(false); } }, [stop]); // Cleanup on unmount useEffect(() => { return () => { stop(); }; }, [stop]); return { isPlaying, isAvailable, voices, error, speak, stop, checkAvailability, unlockAudio, }; }