260 lines
9.1 KiB
JavaScript
260 lines
9.1 KiB
JavaScript
/**
|
|
* 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;
|