Checkpoint current interactive fiction state
This commit is contained in:
@@ -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 }
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user