Checkpoint current interactive fiction state

This commit is contained in:
2026-05-14 21:17:43 +02:00
parent c745efd1d2
commit 873049f7e6
183 changed files with 13755 additions and 1459 deletions
+189 -52
View File
@@ -23,6 +23,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
// State
this.currentAudio = null;
this.currentPlaybackFinish = null;
// Bind additional methods
this.bindMethods([
@@ -33,7 +34,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
'selectVoiceForLocale',
'selectDefaultVoice',
'generateSpeechAudio',
'preprocessText'
'preprocessText',
'getPlaybackVolume',
'applyCurrentVolume'
]);
}
@@ -77,6 +80,16 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
// Set up event listeners for API key and URL changes
document.addEventListener('tts:api:keyChanged', this.handleApiKeyChanged);
document.addEventListener('tts:api:urlChanged', this.handleApiUrlChanged);
this.addEventListener(document, 'preference-updated', (event) => {
const { category, key } = event.detail || {};
if (category !== 'audio') {
return;
}
if (['masterVolume', 'ttsVolume', 'master_volume', 'tts_volume'].includes(key)) {
this.applyCurrentVolume();
}
});
// Load voices
await this.loadVoices();
@@ -90,9 +103,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
this.isReady = !!this.apiKey;
if (!this.isReady) {
console.error(`${this.name}: Missing API key, initialization failed`);
this.reportProgress(100, `${this.name} initialization failed - missing API key`);
return false; // Properly report failure when API key is missing
console.info(`${this.name}: API key not configured; provider unavailable until configured`);
this.reportProgress(100, `${this.name} not configured`);
return true;
}
// Only mark as complete if we have an API key
@@ -190,49 +203,106 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
* Speak preloaded audio data
* @param {Object} preloadData - Preloaded audio data
* @param {Function} callback - Callback for when speech completes
* @returns {boolean} - Success status
* @returns {Promise<Object>} - Resolves when audio finishes playing
*/
speakPreloaded(preloadData, callback = null) {
async speakPreloaded(preloadData, callback = null) {
if (!preloadData || !preloadData.audioData) {
console.error(`${this.name}: Invalid preloaded data`);
if (callback) callback({ success: false, reason: 'invalid_data' });
return false;
const result = { success: false, reason: 'invalid_data' };
if (callback) callback(result);
return result;
}
// Create an audio element to play the audio
const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' });
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
// Set up state
this.isSpeaking = true;
this.currentAudio = audio;
// Set up event handlers
audio.onended = () => {
this.isSpeaking = false;
this.currentAudio = null;
URL.revokeObjectURL(audioUrl);
if (callback) callback({ success: true });
};
audio.onerror = (error) => {
console.error(`${this.name}: Audio playback error:`, error);
this.isSpeaking = false;
this.currentAudio = null;
URL.revokeObjectURL(audioUrl);
if (callback) callback({ success: false, reason: 'playback_error', error });
};
// Play the audio
audio.play().catch(error => {
console.error(`${this.name}: Failed to play audio:`, error);
if (callback) callback({ success: false, reason: 'playback_error', error });
return new Promise((resolve) => {
// Create an audio element to play the audio
const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' });
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
let settled = false;
audio.volume = this.getPlaybackVolume();
console.log(`${this.name}: Playback volume set to ${audio.volume.toFixed(2)}`);
// Set up state
this.isSpeaking = true;
this.currentAudio = audio;
const finish = (result) => {
if (settled) {
return;
}
settled = true;
this.isSpeaking = false;
if (this.currentAudio === audio) {
this.currentAudio = null;
}
if (this.currentPlaybackFinish === finish) {
this.currentPlaybackFinish = null;
}
URL.revokeObjectURL(audioUrl);
if (callback) callback(result);
resolve(result);
};
this.currentPlaybackFinish = finish;
// Set up event handlers
audio.onended = () => {
finish({ success: true });
};
audio.onerror = (error) => {
console.error(`${this.name}: Audio playback error:`, error);
finish({ success: false, reason: 'playback_error', error });
};
// Play the audio
audio.play().then(() => {
document.dispatchEvent(new CustomEvent('tts:audio-started', {
detail: { provider: this.id || this.name }
}));
}).catch(error => {
console.error(`${this.name}: Failed to play audio:`, error);
finish({ success: false, reason: 'playback_error', error });
});
});
return true;
}
/**
* Get the current effective TTS playback volume.
* @returns {number} Volume from 0 to 1.
*/
getPlaybackVolume() {
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager) {
return 1.0;
}
const masterVolume = persistenceManager.getPreference(
'audio',
'masterVolume',
persistenceManager.getPreference('audio', 'master_volume', 1.0)
);
const ttsVolume = persistenceManager.getPreference(
'audio',
'ttsVolume',
persistenceManager.getPreference('audio', 'tts_volume', 1.0)
);
return Math.max(0, Math.min(1, masterVolume * ttsVolume));
}
/**
* Apply updated volume settings to currently playing audio.
*/
applyCurrentVolume() {
if (!this.currentAudio) {
return;
}
this.currentAudio.volume = this.getPlaybackVolume();
console.log(`${this.name}: Updated current playback volume to ${this.currentAudio.volume.toFixed(2)}`);
}
/**
@@ -245,6 +315,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
// Stop current audio
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
if (this.currentPlaybackFinish) {
this.currentPlaybackFinish({ success: false, reason: 'stopped' });
}
// Clean up
this.isSpeaking = false;
@@ -258,6 +331,33 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
}
return true; // Already stopped
}
fadeOutCurrentAudio(duration = 1000) {
if (!this.currentAudio) {
return Promise.resolve(true);
}
const audio = this.currentAudio;
const startVolume = audio.volume;
const startedAt = performance.now();
return new Promise((resolve) => {
const tick = () => {
const progress = Math.min(1, (performance.now() - startedAt) / duration);
audio.volume = startVolume * (1 - progress);
if (progress >= 1) {
this.stop();
resolve(true);
return;
}
requestAnimationFrame(tick);
};
tick();
});
}
/**
* Speak text
@@ -290,6 +390,29 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
return true;
}
/**
* Calculate audio duration from audio buffer
* @param {ArrayBuffer} audioData - Audio data buffer
* @returns {Promise<number>} - Duration in milliseconds
*/
async calculateAudioDuration(audioData) {
try {
// Use Web Audio API to decode audio and get duration
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = await audioContext.decodeAudioData(audioData.slice(0));
const durationMs = audioBuffer.duration * 1000;
// Close the audio context to free resources
await audioContext.close();
console.log(`${this.name}: Calculated audio duration: ${durationMs.toFixed(0)}ms`);
return durationMs;
} catch (error) {
console.warn(`${this.name}: Failed to calculate audio duration:`, error);
return 0;
}
}
/**
* Preload speech for later playback
* @param {string} text - Text to preload
@@ -299,20 +422,26 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
if (!this.isReady) {
return { success: false, reason: 'not_ready' };
}
try {
// Generate speech
const result = await this.generateSpeechAudio(text);
if (!result.success) {
return { success: false, reason: 'generation_failed' };
}
// Calculate actual audio duration if not provided
let duration = result.duration || 0;
if (duration === 0 && result.audioData) {
duration = await this.calculateAudioDuration(result.audioData);
}
return {
success: true,
audioData: result.audioData,
text,
duration: result.duration || 0
duration: duration
};
} catch (error) {
return { success: false, reason: 'generation_error', error };
@@ -366,7 +495,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
// Save to preferences
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
if (persistenceManager && oldKey !== newKey) {
persistenceManager.updatePreference('tts', `${this.id}_api_key`, newKey);
}
@@ -384,12 +513,16 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
// If we have a key now (and didn't before), try initializing voices
if (this.isReady && !wasReady) {
// Reload voices with the new API key
this.loadVoices().then(() => {
this.loadVoices().then((voicesLoaded) => {
this.isReady = voicesLoaded !== false && !!this.apiKey;
// Then set up voice from preferences
this.setupVoiceFromPreferences().then(() => {
console.log(`${this.name}: Successfully initialized with new API key`);
console.log(`${this.name}: API key status: ${this.isReady ? 'ready' : 'not ready'}`);
// Notify the factory of our readiness change
ttsFactory.updateTTSAvailability();
document.dispatchEvent(new CustomEvent('tts:status:updated', {
detail: { provider: this.id, ready: this.isReady }
}));
});
});
} else {
@@ -415,7 +548,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
// Save to preferences
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
if (persistenceManager && oldUrl !== newUrl) {
persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl);
}
@@ -424,16 +557,20 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
console.log(`${this.name}: API URL changed, reinitializing`);
// Reload voices with the new API URL if we're ready
this.loadVoices().then(() => {
this.loadVoices().then((voicesLoaded) => {
this.isReady = voicesLoaded !== false && !!this.apiKey;
// Then set up voice from preferences
this.setupVoiceFromPreferences().then(() => {
console.log(`${this.name}: Successfully reinitialized with new API URL`);
console.log(`${this.name}: API URL status: ${this.isReady ? 'ready' : 'not ready'}`);
// Notify the TTS factory
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
ttsFactory.updateTTSAvailability();
}
document.dispatchEvent(new CustomEvent('tts:status:updated', {
detail: { provider: this.id, ready: this.isReady }
}));
});
});
}