/** * LocalOpenAITTSModule * Provides TTS via local or self-hosted OpenAI-compatible /audio/speech APIs. */ import { ApiTTSModuleBase } from './api-tts-module-base.js'; export class LocalOpenAITTSModule extends ApiTTSModuleBase { constructor() { super('local-openai-tts', 'Local OpenAI TTS'); this.voiceOptions = { voice: 'alloy', model: 'tts-1', speed: 1.0, response_format: 'mp3' }; this.voices = []; } getDefaultApiBaseUrl() { return 'http://localhost:8000/v1'; } async initialize() { try { this.reportProgress(10, 'Initializing Local OpenAI TTS'); const parentInit = await super.initialize(); if (!parentInit) { console.error('Local OpenAI TTS: Parent initialization failed'); return false; } const persistenceManager = this.getModule('persistence-manager'); if (!persistenceManager) { console.error('Local OpenAI TTS: Required dependency persistence-manager not found'); return false; } const preferredVoice = persistenceManager.getPreference('tts', `${this.id}_voice`, this.voiceOptions.voice); if (preferredVoice) { this.voiceOptions.voice = this.normalizeTextOption(preferredVoice, this.voiceOptions.voice); } const preferredModel = persistenceManager.getPreference('tts', `${this.id}_model`, this.voiceOptions.model); if (preferredModel) { this.voiceOptions.model = this.normalizeTextOption(preferredModel, this.voiceOptions.model); } const preferredFormat = persistenceManager.getPreference('tts', `${this.id}_format`, this.voiceOptions.response_format); if (preferredFormat) { this.voiceOptions.response_format = this.normalizeResponseFormat(preferredFormat); } const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed); if (typeof preferredSpeed === 'number') { this.voiceOptions.speed = this.normalizeAppSpeed(preferredSpeed); } this.isReady = Boolean(this.apiBaseUrl && this.voiceOptions.voice && this.voiceOptions.model); this.reportProgress(100, this.isReady ? 'Local OpenAI TTS initialized' : 'Local OpenAI TTS not configured'); return true; } catch (error) { console.error('Local OpenAI TTS: Initialization error:', error); this.isReady = false; return false; } } async loadVoices() { this.voices = []; return true; } selectVoiceForLocale() { return this.selectDefaultVoice(); } selectDefaultVoice() { this.voiceOptions.voice = this.normalizeTextOption(this.voiceOptions.voice, 'alloy'); return true; } getAvailableVoices() { return []; } async getVoices() { return []; } async generateSpeechAudio(text, options = {}) { if (!this.isReady || !this.apiBaseUrl) { return { success: false, reason: 'not_ready' }; } try { const processedText = this.preprocessText(text); if (!processedText) { return { success: false, reason: 'empty_text' }; } const payload = { model: this.normalizeTextOption(this.voiceOptions.model, 'tts-1'), input: processedText, voice: this.normalizeTextOption(this.voiceOptions.voice, 'alloy'), response_format: this.normalizeResponseFormat(this.voiceOptions.response_format), speed: this.getApiSpeed(this.voiceOptions.speed) }; const headers = { 'Content-Type': 'application/json' }; if (this.apiKey) { headers.Authorization = `Bearer ${this.apiKey}`; } const response = await fetch(`${this.apiBaseUrl.replace(/\/+$/, '')}/audio/speech`, { method: 'POST', headers, body: JSON.stringify(payload), signal: options.signal }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API error: ${response.status} ${response.statusText} - ${errorText}`); } const audioBlob = await response.blob(); const arrayBuffer = await audioBlob.arrayBuffer(); return { success: true, audioData: arrayBuffer }; } catch (error) { if (error?.name === 'AbortError') { console.error('Local OpenAI TTS: Speech request was aborted:', error); return { success: false, reason: 'aborted', error: error.message }; } console.error('Local OpenAI TTS: Error generating speech:', error); return { success: false, reason: 'api_error', error: error.message }; } } setVoiceOptions(options = {}) { const persistenceManager = this.getModule('persistence-manager'); if (typeof options.voice === 'string') { this.voiceOptions.voice = this.normalizeTextOption(options.voice, this.voiceOptions.voice); if (persistenceManager) { persistenceManager.updatePreference('tts', `${this.id}_voice`, this.voiceOptions.voice); } } if (typeof options.speed === 'number') { this.voiceOptions.speed = this.normalizeAppSpeed(options.speed); } if (typeof options.model === 'string') { this.voiceOptions.model = this.normalizeTextOption(options.model, this.voiceOptions.model); if (persistenceManager) { persistenceManager.updatePreference('tts', `${this.id}_model`, this.voiceOptions.model); } } if (typeof options.response_format === 'string') { this.voiceOptions.response_format = this.normalizeResponseFormat(options.response_format); if (persistenceManager) { persistenceManager.updatePreference('tts', `${this.id}_format`, this.voiceOptions.response_format); } } this.isReady = Boolean(this.apiBaseUrl && this.voiceOptions.voice && this.voiceOptions.model); this.notifyReadyState(); } handleApiKeyChanged(event) { if (!event?.detail || event.detail.provider !== this.id) return; const newKey = event.detail.key || ''; if (newKey && /^https?:\/\//i.test(newKey)) { console.error('Local OpenAI TTS: Received URL instead of API key, ignoring it'); return; } const oldKey = this.apiKey; this.apiKey = newKey; const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager && oldKey !== newKey) { persistenceManager.updatePreference('tts', `${this.id}_api_key`, newKey); } const wasReady = this.isReady; this.isReady = Boolean(this.apiBaseUrl && this.voiceOptions.voice && this.voiceOptions.model); if (wasReady !== this.isReady) { this.notifyReadyState(); } } handleApiUrlChanged(event) { if (!event?.detail || event.detail.provider !== this.id) return; const oldUrl = this.apiBaseUrl; const newUrl = String(event.detail.url || this.getDefaultApiBaseUrl()).trim().replace(/\/+$/, ''); this.apiBaseUrl = newUrl; const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager && oldUrl !== newUrl) { persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl); } const wasReady = this.isReady; this.isReady = Boolean(this.apiBaseUrl && this.voiceOptions.voice && this.voiceOptions.model); if (wasReady !== this.isReady || oldUrl !== newUrl) { this.notifyReadyState(); } } normalizeTextOption(value, fallback) { const text = String(value || '').trim(); return text || fallback; } normalizeResponseFormat(value) { const format = String(value || '').trim().toLowerCase(); const validFormats = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']; return validFormats.includes(format) ? format : 'mp3'; } getApiSpeed(speed) { const value = Number.isFinite(Number(speed)) ? Number(speed) : this.normalizeAppSpeed(speed); return Math.max(0.25, Math.min(4.0, value)); } normalizeAppSpeed(speed) { const value = Number.isFinite(Number(speed)) ? Number(speed) : 1.0; return Math.max(0.5, Math.min(2.0, value)); } } const localOpenAITTSModule = new LocalOpenAITTSModule(); export { localOpenAITTSModule }; if (window.moduleRegistry) { window.moduleRegistry.register(localOpenAITTSModule); } window.LocalOpenAITTSModule = localOpenAITTSModule;