340 lines
8.7 KiB
TypeScript
340 lines
8.7 KiB
TypeScript
/**
|
|
* 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<IVoiceConfig>) {
|
|
if (config) {
|
|
this.configure(config);
|
|
}
|
|
this.initVoices();
|
|
}
|
|
|
|
// ─── Configuration ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Configure voice settings
|
|
*/
|
|
public configure(config: Partial<IVoiceConfig>): 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] on/onto [street]"
|
|
*/
|
|
public speakApproach(distance: string, maneuver: string, streetName?: string): void {
|
|
let text = `In ${distance}, ${maneuver}`;
|
|
if (streetName && streetName !== 'unnamed road') {
|
|
// Use "on" for continue, "onto" for turns/merges
|
|
const preposition = maneuver.startsWith('continue') ? 'on' : 'onto';
|
|
text += ` ${preposition} ${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');
|
|
}
|
|
}
|