/**
* useSpeechSynthesis Hook
*
* Provides text-to-speech functionality using the Web Speech API.
* Allows playing assistant responses as audio.
*
* Features:
* - Play/pause/stop controls
* - Voice selection
* - Rate and pitch adjustment
* - Proper cleanup on unmount
*/
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
interface SpeechSynthesisOptions {
rate?: number; // 0.1 to 10, default 1
pitch?: number; // 0 to 2, default 1
volume?: number; // 0 to 1, default 1
voiceURI?: string; // Specific voice to use
lang?: string; // Language code, default 'en-CA'
}
interface UseSpeechSynthesisReturn {
// State
isSpeaking: boolean;
isPaused: boolean;
isSupported: boolean;
voices: SpeechSynthesisVoice[];
currentVoice: SpeechSynthesisVoice | null;
error: string | null;
// Actions
speak: (text: string, options?: SpeechSynthesisOptions) => void;
pause: () => void;
resume: () => void;
stop: () => void;
setVoice: (voiceURI: string) => void;
}
const DEFAULT_OPTIONS: SpeechSynthesisOptions = {
rate: 1,
pitch: 1,
volume: 1,
lang: 'en-CA',
};
export function useSpeechSynthesis(
defaultOptions: SpeechSynthesisOptions = {}
): UseSpeechSynthesisReturn {
const [isSpeaking, setIsSpeaking] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
const [currentVoice, setCurrentVoice] = useState<SpeechSynthesisVoice | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSupported, setIsSupported] = useState(false);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
const optionsRef = useRef({ ...DEFAULT_OPTIONS, ...defaultOptions });
// Check for browser support and load voices
useEffect(() => {
if (typeof window === 'undefined') return;
const synth = window.speechSynthesis;
if (!synth) {
setError('Speech synthesis not supported in this browser');
return;
}
setIsSupported(true);
// Load voices
const loadVoices = () => {
const availableVoices = synth.getVoices();
setVoices(availableVoices);
// Set default voice - prefer Canadian English, then any English
if (availableVoices.length > 0 && !currentVoice) {
const canadianVoice = availableVoices.find(
(v) => v.lang === 'en-CA' || v.lang.startsWith('en-CA')
);
const englishVoice = availableVoices.find((v) => v.lang.startsWith('en'));
const defaultVoice = canadianVoice || englishVoice || availableVoices[0];
setCurrentVoice(defaultVoice);
}
};
// Load voices initially and on change
loadVoices();
synth.addEventListener('voiceschanged', loadVoices);
return () => {
synth.removeEventListener('voiceschanged', loadVoices);
};
}, [currentVoice]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (typeof window !== 'undefined' && window.speechSynthesis) {
window.speechSynthesis.cancel();
}
};
}, []);
// Speak text
const speak = useCallback(
(text: string, options: SpeechSynthesisOptions = {}) => {
if (!isSupported) {
setError('Speech synthesis not supported');
return;
}
if (!text.trim()) {
return;
}
const synth = window.speechSynthesis;
// Cancel any ongoing speech
synth.cancel();
// Create new utterance
const utterance = new SpeechSynthesisUtterance(text);
utteranceRef.current = utterance;
// Apply options
const finalOptions = { ...optionsRef.current, ...options };
utterance.rate = finalOptions.rate ?? 1;
utterance.pitch = finalOptions.pitch ?? 1;
utterance.volume = finalOptions.volume ?? 1;
utterance.lang = finalOptions.lang ?? 'en-CA';
// Set voice
if (finalOptions.voiceURI) {
const voice = voices.find((v) => v.voiceURI === finalOptions.voiceURI);
if (voice) {
utterance.voice = voice;
}
} else if (currentVoice) {
utterance.voice = currentVoice;
}
// Event handlers
utterance.onstart = () => {
setIsSpeaking(true);
setIsPaused(false);
setError(null);
};
utterance.onend = () => {
setIsSpeaking(false);
setIsPaused(false);
};
utterance.onerror = (event) => {
console.error('Speech synthesis error:', event);
setError(`Speech error: ${event.error}`);
setIsSpeaking(false);
setIsPaused(false);
};
utterance.onpause = () => {
setIsPaused(true);
};
utterance.onresume = () => {
setIsPaused(false);
};
// Start speaking
synth.speak(utterance);
},
[isSupported, voices, currentVoice]
);
// Pause speaking
const pause = useCallback(() => {
if (!isSupported || !isSpeaking) return;
window.speechSynthesis.pause();
setIsPaused(true);
}, [isSupported, isSpeaking]);
// Resume speaking
const resume = useCallback(() => {
if (!isSupported || !isPaused) return;
window.speechSynthesis.resume();
setIsPaused(false);
}, [isSupported, isPaused]);
// Stop speaking
const stop = useCallback(() => {
if (!isSupported) return;
window.speechSynthesis.cancel();
setIsSpeaking(false);
setIsPaused(false);
}, [isSupported]);
// Set voice by URI
const setVoice = useCallback(
(voiceURI: string) => {
const voice = voices.find((v) => v.voiceURI === voiceURI);
if (voice) {
setCurrentVoice(voice);
}
},
[voices]
);
return {
isSpeaking,
isPaused,
isSupported,
voices,
currentVoice,
error,
speak,
pause,
resume,
stop,
setVoice,
};
}