Files
ai.interactive.fiction/public/js/local-openai-tts-module.js

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;