import { synthesizeSpeechWithOpenRouter } from './openRouterService'; import { synthesizeSpeechWithGemini } from './geminiVoiceService'; import { getGeminiApiKey, hasGeminiApiKey, hasOpenRouterApiKey } from './apiConfig'; import { VOICE_PROFILES, type VoiceProfile } from '../constants'; import type { UserSettings } from '../types'; export interface VoicePlaybackState { isPlaying: boolean; activeVoiceId: UserSettings['voiceName'] | null; mode: 'gemini' | 'openrouter' | 'webspeech' | 'idle'; error?: string; } export function getActiveVoiceEngine(): 'gemini' | 'openrouter' | 'webspeech' { if (hasGeminiApiKey()) return 'gemini'; if (hasOpenRouterApiKey()) return 'openrouter'; return 'webspeech'; } export class VoiceAuditionService { private currentAudio: HTMLAudioElement | null = null; private currentAudioUrl: string | null = null; private activeUtterance: SpeechSynthesisUtterance | null = null; /** * Returns the VoiceProfile configuration for a given voice name. */ getProfile(voiceId: UserSettings['voiceName']): VoiceProfile { return VOICE_PROFILES.find((p) => p.id === voiceId) || VOICE_PROFILES[0]; } /** * Auditions/plays a voice sample: * 1. Google Gemini native voice (gemini-3.8-flash-tts) if GEMINI_API_KEY is available. * 2. OpenRouter TTS if OPENROUTER_API_KEY is available. * 3. Browser Web Speech Synthesis fallback. */ async playPreview( voiceId: UserSettings['voiceName'], customText?: string, apiKey?: string, onEnded?: () => void, geminiApiKey?: string ): Promise { this.stop(); const profile = this.getProfile(voiceId); const textToSpeak = customText?.trim() || profile.sampleText; // 1. Prioritize Google Gemini Native Voice (gemini-3.8-flash-tts) const geminiKey = getGeminiApiKey(geminiApiKey); if (geminiKey) { try { const arrayBuffer = await synthesizeSpeechWithGemini( textToSpeak, profile.id, geminiKey ); const blob = new Blob([arrayBuffer], { type: 'audio/wav' }); const url = URL.createObjectURL(blob); this.currentAudioUrl = url; const audio = new Audio(url); this.currentAudio = audio; audio.onended = () => { this.cleanupAudio(); onEnded?.(); }; audio.onerror = () => { this.cleanupAudio(); this.playWebSpeech(textToSpeak, profile, onEnded); }; await audio.play(); return; } catch (err) { console.warn('Gemini 3.8 native voice synthesis preview failed, checking OpenRouter fallback:', err); } } // 2. OpenRouter TTS Fallback if (apiKey && apiKey.trim().length > 0) { try { const arrayBuffer = await synthesizeSpeechWithOpenRouter( textToSpeak, profile.openRouterVoice, apiKey ); const blob = new Blob([arrayBuffer], { type: 'audio/mpeg' }); const url = URL.createObjectURL(blob); this.currentAudioUrl = url; const audio = new Audio(url); this.currentAudio = audio; audio.onended = () => { this.cleanupAudio(); onEnded?.(); }; audio.onerror = () => { this.cleanupAudio(); this.playWebSpeech(textToSpeak, profile, onEnded); }; await audio.play(); return; } catch (err) { console.warn('OpenRouter TTS preview failed, falling back to Web Speech Synthesis:', err); } } // 3. Web Speech API fallback with calibrated pitch/rate tuning this.playWebSpeech(textToSpeak, profile, onEnded); } private playWebSpeech( text: string, profile: VoiceProfile, onEnded?: () => void ): void { const synth = typeof window !== 'undefined' && 'speechSynthesis' in window ? window.speechSynthesis : (typeof (globalThis as any).speechSynthesis !== 'undefined' ? (globalThis as any).speechSynthesis : null); const Utterance = typeof window !== 'undefined' && 'SpeechSynthesisUtterance' in window ? window.SpeechSynthesisUtterance : (typeof (globalThis as any).SpeechSynthesisUtterance !== 'undefined' ? (globalThis as any).SpeechSynthesisUtterance : null); if (synth && Utterance) { synth.cancel(); const utterance = new Utterance(text); utterance.pitch = profile.webSpeechTuning.pitch; utterance.rate = profile.webSpeechTuning.rate; utterance.onend = () => { this.activeUtterance = null; onEnded?.(); }; utterance.onerror = () => { this.activeUtterance = null; onEnded?.(); }; this.activeUtterance = utterance; synth.speak(utterance); } else { onEnded?.(); } } private cleanupAudio(): void { if (this.currentAudioUrl) { URL.revokeObjectURL(this.currentAudioUrl); this.currentAudioUrl = null; } this.currentAudio = null; } /** * Immediately stops any active audition preview audio. */ stop(): void { if (this.currentAudio) { try { this.currentAudio.pause(); this.currentAudio.currentTime = 0; } catch (e) {} this.cleanupAudio(); } const synth = typeof window !== 'undefined' && 'speechSynthesis' in window ? window.speechSynthesis : (typeof (globalThis as any).speechSynthesis !== 'undefined' ? (globalThis as any).speechSynthesis : null); if (synth) { try { synth.cancel(); } catch (e) {} this.activeUtterance = null; } } } export const defaultVoiceAudition = new VoiceAuditionService();