/** * Voice synthesis manager for turn-by-turn navigation * Uses Web Speech API for voice instructions */ // ─── Types ──────────────────────────────────────────────────────────────────── export interface IVoiceConfig { enabled: boolean; language: string; rate: number; // 0.1 - 10, default 1 pitch: number; // 0 - 2, default 1 volume: number; // 0 - 1, default 1 voiceName?: string; // Specific voice to use (optional) } export interface IVoiceQueueItem { text: string; priority: 'normal' | 'urgent'; } // ─── VoiceSynthesisManager ──────────────────────────────────────────────────── /** * Manager for voice synthesis using Web Speech API * Provides queue-based speech with interrupt capability */ export class VoiceSynthesisManager { private config: IVoiceConfig = { enabled: true, language: 'en-US', rate: 1, pitch: 1, volume: 1, }; private queue: IVoiceQueueItem[] = []; private isSpeaking: boolean = false; private currentUtterance: SpeechSynthesisUtterance | null = null; private availableVoices: SpeechSynthesisVoice[] = []; private voicesLoaded: boolean = false; constructor(config?: Partial) { if (config) { this.configure(config); } this.initVoices(); } // ─── Configuration ────────────────────────────────────────────────────────── /** * Configure voice settings */ public configure(config: Partial): void { this.config = { ...this.config, ...config }; } /** * Get current configuration */ public getConfig(): IVoiceConfig { return { ...this.config }; } /** * Check if speech synthesis is supported */ public isSupported(): boolean { return typeof window !== 'undefined' && 'speechSynthesis' in window; } /** * Enable voice synthesis */ public enable(): void { this.config.enabled = true; } /** * Disable voice synthesis */ public disable(): void { this.config.enabled = false; this.stop(); } /** * Toggle voice synthesis */ public toggle(): void { if (this.config.enabled) { this.disable(); } else { this.enable(); } } /** * Check if voice is enabled */ public isEnabled(): boolean { return this.config.enabled; } // ─── Voice Selection ──────────────────────────────────────────────────────── /** * Initialize available voices */ private initVoices(): void { if (!this.isSupported()) return; // Voices may load asynchronously const loadVoices = () => { this.availableVoices = window.speechSynthesis.getVoices(); this.voicesLoaded = this.availableVoices.length > 0; }; loadVoices(); // Some browsers need to wait for voiceschanged event if (window.speechSynthesis.onvoiceschanged !== undefined) { window.speechSynthesis.onvoiceschanged = loadVoices; } } /** * Get available voices */ public getAvailableVoices(): SpeechSynthesisVoice[] { if (!this.isSupported()) return []; return window.speechSynthesis.getVoices(); } /** * Get voices for a specific language */ public getVoicesForLanguage(lang: string): SpeechSynthesisVoice[] { return this.getAvailableVoices().filter(voice => voice.lang.startsWith(lang) || voice.lang.startsWith(lang.split('-')[0]) ); } /** * Find the best voice for current language */ private findBestVoice(): SpeechSynthesisVoice | null { if (!this.voicesLoaded) { this.availableVoices = window.speechSynthesis.getVoices(); } // If a specific voice is requested, try to find it if (this.config.voiceName) { const requestedVoice = this.availableVoices.find( v => v.name === this.config.voiceName ); if (requestedVoice) return requestedVoice; } // Find a voice matching the language const langVoices = this.availableVoices.filter( v => v.lang.startsWith(this.config.language.split('-')[0]) ); // Prefer native/local voices, then default voices const nativeVoice = langVoices.find(v => v.localService); if (nativeVoice) return nativeVoice; const defaultVoice = langVoices.find(v => v.default); if (defaultVoice) return defaultVoice; // Return any matching voice return langVoices[0] || null; } // ─── Speech Methods ───────────────────────────────────────────────────────── /** * Speak text with normal priority (queued) */ public speak(text: string): void { if (!this.config.enabled || !this.isSupported()) return; this.queue.push({ text, priority: 'normal' }); this.processQueue(); } /** * Speak text with urgent priority (interrupts current speech) */ public speakUrgent(text: string): void { if (!this.config.enabled || !this.isSupported()) return; // Cancel current speech and clear queue this.stop(); // Add to front of queue this.queue.unshift({ text, priority: 'urgent' }); this.processQueue(); } /** * Process the speech queue */ private processQueue(): void { if (this.isSpeaking || this.queue.length === 0) return; if (!this.isSupported()) return; const item = this.queue.shift(); if (!item) return; this.isSpeaking = true; const utterance = new SpeechSynthesisUtterance(item.text); this.currentUtterance = utterance; // Apply configuration utterance.lang = this.config.language; utterance.rate = this.config.rate; utterance.pitch = this.config.pitch; utterance.volume = this.config.volume; // Set voice if available const voice = this.findBestVoice(); if (voice) { utterance.voice = voice; } // Event handlers utterance.onend = () => { this.isSpeaking = false; this.currentUtterance = null; this.processQueue(); }; utterance.onerror = (event) => { console.warn('[VoiceSynthesisManager] Speech error:', event.error); this.isSpeaking = false; this.currentUtterance = null; this.processQueue(); }; // Speak window.speechSynthesis.speak(utterance); } /** * Stop current speech and clear queue */ public stop(): void { if (!this.isSupported()) return; window.speechSynthesis.cancel(); this.queue = []; this.isSpeaking = false; this.currentUtterance = null; } /** * Pause current speech */ public pause(): void { if (!this.isSupported()) return; window.speechSynthesis.pause(); } /** * Resume paused speech */ public resume(): void { if (!this.isSupported()) return; window.speechSynthesis.resume(); } /** * Check if currently speaking */ public isSpeakingNow(): boolean { return this.isSpeaking; } /** * Get queue length */ public getQueueLength(): number { return this.queue.length; } // ─── Navigation-Specific Methods ──────────────────────────────────────────── /** * Speak approach maneuver instruction * "In [distance], [maneuver] onto [street]" */ public speakApproach(distance: string, maneuver: string, streetName?: string): void { let text = `In ${distance}, ${maneuver}`; if (streetName && streetName !== 'unnamed road') { text += ` onto ${streetName}`; } this.speak(text); } /** * Speak execute maneuver instruction (urgent - immediate action) * "[Maneuver] now" or just "[Maneuver]" */ public speakManeuver(maneuver: string, urgent: boolean = true): void { const text = urgent ? `${maneuver} now` : maneuver; if (urgent) { this.speakUrgent(text); } else { this.speak(text); } } /** * Speak arrival */ public speakArrival(): void { this.speakUrgent('You have arrived at your destination'); } /** * Speak off-route warning */ public speakOffRoute(): void { this.speakUrgent('You are off route. Recalculating.'); } /** * Speak route recalculated */ public speakRecalculated(): void { this.speak('Route recalculated'); } }