Files
dees-catalog-geo/ts_web/elements/00group-map/dees-geo-map/geo-map.voice.ts

340 lines
8.7 KiB
TypeScript
Raw Normal View History

/**
* 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');
}
}