feat(dees-geo-map): Add voice-guided navigation with TTS, navigation guide controller and mock GPS simulator
This commit is contained in:
337
ts_web/elements/00group-map/dees-geo-map/geo-map.voice.ts
Normal file
337
ts_web/elements/00group-map/dees-geo-map/geo-map.voice.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 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] 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user