Fix TTS module initialization and dependency issues. Update module IDs for consistency, improve circular dependency detection, and fix UI Controller event handling.

This commit is contained in:
2025-04-04 19:15:28 +00:00
parent 02c7b9ef28
commit 49a5af252c
33 changed files with 7227 additions and 4060 deletions
+66
View File
@@ -393,6 +393,10 @@ ol.choice {
#story {
overflow-x: visible;
text-align: justify;
text-justify: inter-word;
margin-bottom: 1.2em;
line-height: 1.5;
}
/* #story p span {
@@ -442,6 +446,12 @@ ol.choice {
-ms-animation: fadeIn ease 1s;
}
/* Style for individual words that need to fade in */
.word.fade-in {
animation-fill-mode: forwards;
opacity: 0; /* Start invisible */
}
@keyframes fadeIn {
0% {opacity:0;}
100% {opacity:1;}
@@ -655,3 +665,59 @@ ol.choice {
.fade-in-input {
animation: fadeInInput 0.5s ease forwards;
}
/* Text animation and typography styles */
/* Fade-in animation for text elements */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.fade-in {
opacity: 0;
animation: fadeIn 0.5s ease-in forwards;
}
/* Hyphenation settings */
.story-paragraph {
hyphens: auto;
-webkit-hyphens: auto;
-ms-hyphens: auto;
}
/* Justified text styles */
#story p {
text-align: justify;
text-justify: inter-word;
margin-bottom: 1.2em;
line-height: 1.5;
}
/* Typography for word elements in rendered paragraphs */
.word {
display: inline-block;
position: absolute;
}
/* Typography for hyphen at line breaks */
.hyphen-marker {
display: inline-block;
}
/* Highlight the latest paragraph being rendered */
.latest-paragraph {
color: #000;
}
/* Completed paragraphs style */
.completed-paragraph {
color: #222;
}
/* Animation speed controls */
#speed {
width: 80px;
vertical-align: middle;
margin: 0 5px;
}
+272 -91
View File
@@ -5,53 +5,54 @@
*/
import { BaseModule } from './base-module.js';
import { moduleRegistry } from './module-registry.js';
import { ModuleEvent } from './base-module.js'; // Add this import
class AnimationQueueModule extends BaseModule {
constructor() {
super('animation-queue', 'Animation Queue');
// Queue of scheduled animations/functions
this.queue = [];
// Animation timing properties
this.speed = 0.05; // Base animation speed (seconds per character)
this.delay = 0; // Current accumulated delay
// Module dependencies
this.dependencies = ['tts'];
this.tts = null; // TTS module reference
this.dependencies = [];
// Fast-forwarding state
this.isFastForwarding = false;
// Queue of scheduled animations/functions
this.timeoutQueue = [];
// Bind methods
this.schedule = this.schedule.bind(this);
this.fastForward = this.fastForward.bind(this);
this.clearAll = this.clearAll.bind(this);
this.setSpeed = this.setSpeed.bind(this);
}
async waitForDependencies() {
try {
// Wait for TTS module to be available
this.tts = moduleRegistry.getModule('tts');
if (!this.tts) {
console.warn("TTS module not ready, Animation Queue will have limited functionality");
return true; // Continue anyway
}
return true;
} catch (error) {
console.error("Error waiting for Animation Queue dependencies:", error);
return false;
}
// Animation timing properties - use parent's config system
this.updateConfig({
speed: 0.05, // Base animation speed (seconds per character)
fastForwardEnabled: false
});
this.delay = 0; // Current accumulated delay
this.tts = null; // TTS module reference
// Bind methods using parent's bindMethods utility
this.bindMethods([
'schedule',
'fastForward',
'clearAll',
'setSpeed',
'beginFastForward',
'endFastForward',
'emitAnimationComplete',
'cleanupStaleTasks',
'isAnyTtsSpeaking'
]);
}
async initialize() {
try {
// Nothing special to initialize here
this.reportProgress(20, "Initializing Animation Queue");
// We'll try to get the TTS module, but it's not a hard dependency
// We'll check for it again at runtime when needed
setTimeout(() => {
// Try to get TTS module after a delay to allow it to initialize
this.tts = this.getModule('tts-player');
if (!this.tts) {
console.log("Animation Queue: TTS Player module not found yet, will try again when needed");
}
}, 500);
this.reportProgress(100, "Animation Queue ready");
return true;
} catch (error) {
@@ -61,107 +62,273 @@ class AnimationQueueModule extends BaseModule {
}
/**
* Schedule a function to execute after a delay
* Schedule a function to execute after a delay, with optional TTS synchronization
* @param {Function} func - Function to execute
* @param {number} delay - Delay in milliseconds
* @param {...any} args - Arguments to pass to the function
* @returns {Object} - Timeout object that can be used to cancel
* @param {Object} options - Optional parameters including TTS text
* @returns {number} - Timeout ID for cancellation
*/
schedule(func, delay, ...args) {
schedule(func, delay, options = {}) {
if (typeof func !== 'function') {
console.error('Animation Queue: Not a function passed to schedule');
return null;
console.error('AnimationQueue: Invalid function provided to schedule');
return -1;
}
// Create timeout object with execute method
// Adjust delay based on fast-forward or speed settings
const actualDelay = this.config.fastForwardEnabled ? 0 : Math.max(0, delay * this.config.speed);
// Record the delay for tracking
this.delay = Math.max(this.delay, delay);
// If we don't have a reference to the TTS module yet, try to get it
if (!this.tts) {
this.tts = this.getModule('tts-player');
}
// Handle TTS if text is provided and TTS is available and enabled
let ttsSpeaking = false;
if (options.text && this.tts && typeof this.tts.isEnabled === 'function' && this.tts.isEnabled()) {
// If we're fast forwarding, don't speak
if (!this.config.fastForwardEnabled) {
ttsSpeaking = true;
// Request TTS to speak the text
this.tts.speak(options.text, (result) => {
ttsSpeaking = false;
// Check if this was keeping the queue busy
if (this.timeoutQueue.length === 0) {
this.emitAnimationComplete();
}
});
}
}
// Create a timeout object
const timeoutObject = {
execute: () => {
try {
func(...args);
} catch (error) {
console.error('Error executing scheduled function:', error);
}
},
func: func,
delay: actualDelay,
timeoutId: null,
createdAt: Date.now(),
delay: delay
executed: false,
startTime: Date.now(),
ttsSpeaking: ttsSpeaking,
// Add an execute method that marks the timeout as executed
execute: function() {
if (!this.executed) {
this.func();
this.executed = true;
}
}
};
// Apply speed factor to the delay
const adjustedDelay = delay * this.speed;
// Add to queue
this.timeoutQueue.push(timeoutObject);
// Schedule execution
// If we're fast forwarding, execute immediately
if (this.config.fastForwardEnabled) {
timeoutObject.execute();
return -1; // No timeout ID since it executed immediately
}
// Schedule the timeout
timeoutObject.timeoutId = setTimeout(() => {
// Execute the function
timeoutObject.execute();
// Remove from queue
const index = this.queue.indexOf(timeoutObject);
const index = this.timeoutQueue.indexOf(timeoutObject);
if (index !== -1) {
this.queue.splice(index, 1);
this.timeoutQueue.splice(index, 1);
}
}, adjustedDelay);
// If queue is empty and no TTS is speaking, emit animation complete
if (this.timeoutQueue.length === 0 && !this.isAnyTtsSpeaking()) {
this.emitAnimationComplete();
}
}, actualDelay);
// Add to queue
this.queue.push(timeoutObject);
// Update current total delay
this.delay = adjustedDelay + delay;
return timeoutObject;
return timeoutObject.timeoutId;
}
/**
* Fast-forward all pending animations
* Emit an animation complete event
*/
emitAnimationComplete() {
// Only emit if queue is empty and no TTS is speaking
if (this.timeoutQueue.length === 0 && !this.isAnyTtsSpeaking()) {
// Use parent's dispatchEvent method
this.dispatchEvent('ui:animation:complete', {
timestamp: Date.now()
});
}
}
/**
* Clean up any animation tasks that might have been missed
* (e.g. due to browser tab being inactive)
*/
cleanupStaleTasks() {
const now = Date.now();
const staleTasks = [];
// Find stale tasks
this.timeoutQueue.forEach(task => {
// If task has been running for more than 10 seconds, consider it stale
if (now - task.startTime > 10000 && !task.executed) {
staleTasks.push(task);
}
});
// Execute and remove stale tasks
staleTasks.forEach(task => {
console.log('AnimationQueue: Cleaning up stale task');
// Clear the timeout
if (task.timeoutId !== null) {
clearTimeout(task.timeoutId);
}
// Execute the task
task.execute();
// Remove from queue
const index = this.timeoutQueue.indexOf(task);
if (index !== -1) {
this.timeoutQueue.splice(index, 1);
}
});
}
/**
* Check if any TTS is currently speaking
* @returns {boolean} - True if TTS is speaking
*/
isAnyTtsSpeaking() {
// If we don't have a reference to the TTS module yet, try to get it
if (!this.tts) {
this.tts = this.getModule('tts-player');
}
// Check if TTS is speaking
if (this.tts && typeof this.tts.isSpeaking === 'function') {
return this.tts.isSpeaking();
}
// Default to false if TTS module is not available
return false;
}
/**
* Fast forward all pending animations and stop TTS
*/
fastForward() {
console.log(`Animation Queue: Fast-forwarding ${this.queue.length} pending items`);
if (this.timeoutQueue.length === 0) {
console.log('AnimationQueue: No animations to fast forward');
return;
}
// Stop TTS if playing
if (this.tts) {
console.log(`AnimationQueue: Fast forwarding ${this.timeoutQueue.length} pending items`);
// If we don't have a reference to the TTS module yet, try to get it
if (!this.tts) {
this.tts = this.getModule('tts-player');
}
// Stop any active TTS
if (this.tts && typeof this.tts.stop === 'function') {
this.tts.stop();
}
// Execute and clear all timeouts
const queueCopy = [...this.queue]; // Make a copy to avoid modification during iteration
queueCopy.forEach(timeoutObject => {
// Clear timeout
if (timeoutObject.timeoutId !== null) {
clearTimeout(timeoutObject.timeoutId);
// Execute all pending animations immediately
this.timeoutQueue.forEach(timeout => {
// Clear the timeout
if (timeout.timeoutId !== null) {
clearTimeout(timeout.timeoutId);
timeout.timeoutId = null;
}
// Execute immediately
timeoutObject.execute();
// Clear TTS flag
timeout.ttsSpeaking = false;
// Execute the function immediately
timeout.execute();
});
// Clear queue
this.queue = [];
// Clear the queue
this.timeoutQueue = [];
// Reset delay
this.delay = 0;
// Use direct DOM event dispatch instead of this.dispatchEvent
document.dispatchEvent(new CustomEvent('animations:fastForwarded', {
detail: { moduleId: this.id }
}));
// Update config using parent's updateConfig method
this.updateConfig({ fastForwardEnabled: false });
// Emit animation complete event
this.emitAnimationComplete();
// Log the fastforward completion
console.log('AnimationQueue: Fast forward complete');
// Use parent's dispatchEvent method
this.dispatchEvent('ui:animation:fastforward', { state: false });
}
/**
* Begin fast forwarding mode
*/
beginFastForward() {
if (this.config.fastForwardEnabled) return;
// Update config using parent's updateConfig method
this.updateConfig({ fastForwardEnabled: true });
// If we don't have a reference to the TTS module yet, try to get it
if (!this.tts) {
this.tts = this.getModule('tts-player');
}
// Stop any active TTS
if (this.tts && typeof this.tts.stop === 'function') {
this.tts.stop();
}
// Use parent's dispatchEvent method
this.dispatchEvent('ui:animation:fastforward', { state: true });
console.log('AnimationQueue: Fast forward mode activated');
}
/**
* End fast forwarding mode
*/
endFastForward() {
if (!this.config.fastForwardEnabled) return;
// Update config using parent's updateConfig method
this.updateConfig({ fastForwardEnabled: false });
// Use parent's dispatchEvent method
this.dispatchEvent('ui:animation:fastforward', { state: false });
console.log('AnimationQueue: Fast forward mode deactivated');
}
/**
* Clear all scheduled animations without executing them
*/
clearAll() {
console.log(`Animation Queue: Clearing ${this.queue.length} pending items`);
console.log(`Animation Queue: Clearing ${this.timeoutQueue.length} pending items`);
// Clear all timeouts
this.queue.forEach(timeoutObject => {
this.timeoutQueue.forEach(timeoutObject => {
if (timeoutObject.timeoutId !== null) {
clearTimeout(timeoutObject.timeoutId);
}
});
// Clear queue
this.queue = [];
this.timeoutQueue = [];
// Reset delay
this.delay = 0;
@@ -177,16 +344,33 @@ class AnimationQueueModule extends BaseModule {
return;
}
this.speed = speed;
// Update config using parent's updateConfig method
this.updateConfig({ speed });
console.log(`Animation Queue: Speed set to ${speed}`);
}
/**
* Get the current animation speed
* @returns {number} - Current animation speed factor
*/
getSpeed() {
return this.config.speed;
}
/**
* Check if fast forwarding is active
* @returns {boolean} - Whether fast forwarding is active
*/
isFastForwarding() {
return this.config.fastForwardEnabled;
}
/**
* Get current queue length
* @returns {number} - Number of items in the queue
*/
getQueueLength() {
return this.queue.length;
return this.timeoutQueue.length;
}
/**
@@ -206,6 +390,3 @@ moduleRegistry.register(AnimationQueue);
// Export the module
export { AnimationQueue };
// Keep a reference in window for loader system
window.AnimationQueue = AnimationQueue;
+689 -262
View File
@@ -1,280 +1,707 @@
/**
* ApiTTSHandler for AI Interactive Fiction
* Implementation using external TTS APIs like ElevenLabs
* API TTS Handler
* Provides TTS via external APIs (e.g., ElevenLabs)
*/
import { TTSHandler } from './tts-handler.js';
import { moduleRegistry } from './module-registry.js';
export class ApiTTSHandler extends TTSHandler {
constructor() {
super(); // Initialize the base TTSHandler
this.isReady = false;
this.enabled = false; // Disabled by default until options panel is implemented
this.audioElement = null;
// Set voice options through base class
this.voiceOptions = {
voice: '8JNqTOY3RaSYcHTVJZ0G', // Default ElevenLabs voice ID
model: 'eleven_multilingual_v1',
stability: 0,
similarityBoost: 0,
style: 0.5,
useSpeakerBoost: true
};
this.apiKey = 'd191e27c2e5b07573b39fe70f0783f48'; // From speech.js
this.apiUrl = 'https://api.elevenlabs.io/v1/text-to-speech';
this.voicesApiUrl = 'https://api.elevenlabs.io/v1/voices'; // Separate URL for voices endpoint
this.cache = new Map();
this.currentCallback = null;
}
/**
* Get the ID of this provider
* @returns {string} - Provider ID
*/
getId() {
return 'api';
}
/**
* Initialize the API TTS system
* @param {Function} progressCallback - Optional callback for progress updates
* @returns {Promise<boolean>} - Resolves to true if initialization was successful
*/
async initialize(progressCallback = null) {
try {
if (progressCallback) progressCallback(20, 'Setting up API TTS');
// Create audio element for playback
this.audioElement = new Audio();
// Set up audio event listeners
this.audioElement.onended = () => {
if (this.currentCallback) {
const callback = this.currentCallback;
this.currentCallback = null;
callback();
}
};
this.audioElement.onerror = (error) => {
console.error('Audio playback error:', error);
if (this.currentCallback) {
const callback = this.currentCallback;
this.currentCallback = null;
callback();
}
};
if (progressCallback) progressCallback(80, 'API TTS ready');
// Only check API if enabled
if (this.enabled) {
// Check if the API is reachable with a simple request
constructor() {
super();
this.id = 'api';
this.name = 'API TTS Handler';
// Voice options
this.voiceOptions = {
voice: 'pNInz6obpgDQGcFmaJgB', // Default German voice ID for ElevenLabs
model: 'eleven_multilingual_v2', // Use the multilingual model for better German
speed: 1.0
};
// State
this.available = false;
this.isReady = false;
this.currentAudio = null;
this.preloadCache = new Map();
// API endpoint
this.apiEndpoint = '/api/tts';
// Dependencies
this.dependencies = ['localization', 'persistence-manager'];
// Bind methods
this.bindMethods([
'initialize',
'speak',
'speakPreloaded',
'preloadSpeech',
'stop',
'isAvailable',
'getId',
'getVoices',
'setVoiceOptions',
'getModule',
'setupVoiceFromPreferences',
'selectVoiceForLocale',
'selectDefaultVoice'
]);
}
/**
* Get a module from the registry
* @param {string} moduleId - ID of the module to get
* @returns {Object|null} - The module or null if not found
*/
getModule(moduleId) {
return moduleRegistry.getModule(moduleId);
}
/**
* Initialize the API TTS handler
* @param {Function} progressCallback - Callback for progress updates
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize(progressCallback = null) {
try {
const testResponse = await fetch(this.voicesApiUrl, {
method: 'GET',
headers: {
'xi-api-key': this.apiKey
if (progressCallback) {
progressCallback(10, "Initializing API TTS Handler");
}
});
if (testResponse.ok) {
// Check for required dependencies
const localization = this.getModule('localization');
const persistenceManager = this.getModule('persistence-manager');
if (!localization) {
console.error("API TTS: Localization module not found, required dependency missing");
if (progressCallback) {
progressCallback(100, "API TTS initialization failed - missing localization");
}
return false;
}
if (!persistenceManager) {
console.error("API TTS: Persistence Manager module not found, required dependency missing");
if (progressCallback) {
progressCallback(100, "API TTS initialization failed - missing persistence manager");
}
return false;
}
// Create audio element
this.audioElement = new Audio();
if (progressCallback) {
progressCallback(30, "Loading voices");
}
// Load available voices
try {
await this.loadVoices();
} catch (error) {
console.warn("API TTS: Failed to load voices, continuing with initialization", error);
// Continue initialization even if voice loading fails
}
if (progressCallback) {
progressCallback(50, "Setting up voice preferences");
}
// Set up voice based on preferences and locale
try {
const voiceSetupSuccess = await this.setupVoiceFromPreferences();
if (!voiceSetupSuccess) {
console.warn("API TTS: Could not set up voice from preferences, using default");
}
} catch (error) {
console.warn("API TTS: Error setting up voice preferences", error);
// Continue initialization even if voice setup fails
}
// Check if API is available by making a test request
try {
if (progressCallback) {
progressCallback(70, "Checking API availability");
}
const response = await fetch(`${this.apiEndpoint}/voices`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
console.warn(`API TTS: API endpoint not available (${response.status} ${response.statusText}). Will use fallback.`);
this.available = false;
this.isReady = true; // Still mark as ready, just not available
if (progressCallback) {
progressCallback(100, "API TTS unavailable, using fallback");
}
// Return true to indicate the module initialized successfully
// even though the API is not available
return true;
}
const data = await response.json();
if (progressCallback) {
progressCallback(90, "API TTS available");
}
// Check for German voices and set default if available
if (data && data.voices && Array.isArray(data.voices)) {
const germanVoices = data.voices.filter(voice =>
voice.name.toLowerCase().includes('german') ||
voice.language === 'de' ||
voice.language === 'de-DE'
);
if (germanVoices.length > 0) {
// Use the first German voice as default
this.voiceOptions.voice = germanVoices[0].id;
console.log(`API TTS: Found German voice: ${germanVoices[0].name} (${germanVoices[0].id})`);
}
}
this.available = true;
this.isReady = true;
if (progressCallback) {
progressCallback(100, "API TTS Handler ready");
}
return true;
} catch (error) {
console.warn("API TTS: Error checking API availability:", error);
// Mark as ready but not available
this.available = false;
this.isReady = true;
if (progressCallback) {
progressCallback(100, "API TTS unavailable due to error");
}
// Return true to indicate the module initialized successfully
// even though the API is not available
return true;
}
} catch (error) {
console.error("Error initializing API TTS Handler:", error);
// Mark as ready but not available
this.available = false;
this.isReady = true;
console.log('API TTS initialized successfully');
} else {
console.warn('API TTS initialized but API may not be accessible');
}
} catch (apiError) {
console.warn('Could not verify API access, but continuing:', apiError);
// We'll still mark as ready and try when speak is called
this.isReady = true;
}
} else {
console.log('API TTS is disabled by default. Enable via options panel when implemented.');
}
if (progressCallback) progressCallback(100, 'API TTS initialization complete');
return this.isReady;
} catch (error) {
console.error('Error initializing API TTS:', error);
return false;
}
}
/**
* Check if API TTS is available
* @returns {boolean} - True if API TTS is ready to use
*/
isAvailable() {
return this.isReady && this.enabled;
}
/**
* Generate an MD5 hash for text caching
* @param {string} text - Text to hash
* @returns {string} - MD5 hash
*/
generateHash(text) {
// Simple hash function for client-side use
// For production, consider using a proper hashing library
let hash = 0;
if (text.length === 0) return hash.toString();
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16);
}
/**
* Convert text to speech via API and play it
* @param {string} text - Text to speak
* @param {Function} callback - Called when speech completes
*/
async speak(text, callback = null) {
if (!this.isAvailable() || !text) {
if (callback) callback();
return;
}
// Stop any current speech
this.stop();
// Set new callback
this.currentCallback = callback;
try {
// Check cache first
const cacheKey = this.generateHash(text + JSON.stringify(this.voiceOptions));
let audioUrl = this.cache.get(cacheKey);
if (!audioUrl) {
// Make API request to get audio
const response = await fetch(`${this.apiUrl}/${this.voiceOptions.voice}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'xi-api-key': this.apiKey
},
body: JSON.stringify({
text: text,
model_id: this.voiceOptions.model,
voice_settings: {
stability: this.voiceOptions.stability,
similarity_boost: this.voiceOptions.similarityBoost,
style: this.voiceOptions.style,
use_speaker_boost: this.voiceOptions.useSpeakerBoost
if (progressCallback) {
progressCallback(100, "API TTS initialization failed");
}
})
});
if (!response.ok) {
throw new Error(`API returned ${response.status}: ${response.statusText}`);
// Return true to indicate the module initialized successfully
// even though there was an error
return true;
}
// Get the audio data as blob
const audioBlob = await response.blob();
audioUrl = URL.createObjectURL(audioBlob);
// Store in cache
this.cache.set(cacheKey, audioUrl);
}
// Play the audio
this.audioElement.src = audioUrl;
await this.audioElement.play();
} catch (error) {
console.error('Error speaking with API TTS:', error);
if (this.currentCallback) {
const callback = this.currentCallback;
this.currentCallback = null;
callback();
}
}
}
/**
* Stop any ongoing speech
*/
stop() {
if (this.audioElement) {
this.audioElement.pause();
this.audioElement.currentTime = 0;
}
if (this.currentCallback) {
const callback = this.currentCallback;
this.currentCallback = null;
callback();
}
}
/**
* Set voice options
* @param {Object} options - Voice options
*/
setVoiceOptions(options = {}) {
if (options.voice !== undefined) this.voiceOptions.voice = options.voice;
if (options.model !== undefined) this.voiceOptions.model = options.model;
if (options.stability !== undefined) this.voiceOptions.stability = options.stability;
if (options.similarityBoost !== undefined) this.voiceOptions.similarityBoost = options.similarityBoost;
if (options.style !== undefined) this.voiceOptions.style = options.style;
if (options.useSpeakerBoost !== undefined) this.voiceOptions.useSpeakerBoost = options.useSpeakerBoost;
}
/**
* Get available voices from the API
* @returns {Promise<Array>} - Array of available voices
*/
async getVoices() {
if (!this.enabled) {
return [];
/**
* Set up voice based on preferences and locale
* @returns {Promise<boolean>} - Resolves with success status
*/
async setupVoiceFromPreferences() {
try {
// Get localization and persistence manager modules
const localization = this.getModule('localization');
const persistenceManager = this.getModule('persistence-manager');
// Both modules should be available as we checked in initialize
if (!localization || !persistenceManager) {
console.error("API TTS: Required modules not available for voice setup");
return this.selectDefaultVoice();
}
// Get current locale and preferred voice
const currentLocale = localization.getLocale();
const preferredVoice = persistenceManager.getPreference('tts', 'voice', '');
// If we have a preferred voice, use it
if (preferredVoice) {
this.voiceOptions.voice = preferredVoice;
console.log(`API TTS: Using preferred voice: ${preferredVoice}`);
return true;
}
// Otherwise select based on locale
console.log(`API TTS: No preferred voice, selecting for locale: ${currentLocale}`);
return this.selectVoiceForLocale(currentLocale);
} catch (error) {
console.error("API TTS: Error setting up voice from preferences:", error);
return this.selectDefaultVoice();
}
}
try {
const response = await fetch(this.voicesApiUrl, {
method: 'GET',
headers: {
'xi-api-key': this.apiKey
/**
* Load available voices from API
* @returns {Promise<boolean>} - Resolves with success status
*/
async loadVoices() {
try {
// Fetch available voices from API
const response = await fetch(`${this.apiEndpoint}/voices`);
if (!response.ok) {
console.warn(`API TTS: Failed to load voices - ${response.status} ${response.statusText}`);
return false;
}
const data = await response.json();
if (!data.voices || !Array.isArray(data.voices)) {
console.warn("API TTS: Invalid voice data received");
return false;
}
this.voices = data.voices;
console.log(`API TTS: Loaded ${this.voices.length} voices`);
return true;
} catch (error) {
console.error("Error loading API TTS voices:", error);
return false;
}
});
if (!response.ok) {
throw new Error(`API returned ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data.voices || [];
} catch (error) {
console.error('Error getting voices from API:', error);
return [];
}
}
/**
* Enable or disable the API TTS
* @param {boolean} enabled - Whether the API TTS should be enabled
*/
setEnabled(enabled) {
this.enabled = enabled;
if (enabled && !this.isReady) {
// Re-initialize if enabled
this.initialize();
/**
* Select a voice for the given locale
* @param {string} locale - Locale code
* @returns {boolean} - Success status
*/
selectVoiceForLocale(locale) {
if (!locale || this.voices.length === 0) {
return this.selectDefaultVoice();
}
// Normalize locale
const normalizedLocale = locale.toLowerCase();
// Try to find a voice for the exact locale
let matchingVoice = this.voices.find(voice =>
voice.lang && voice.lang.toLowerCase() === normalizedLocale
);
// If no exact match, try to find a voice for the language part
if (!matchingVoice) {
const langPart = normalizedLocale.split('-')[0];
matchingVoice = this.voices.find(voice =>
voice.lang && voice.lang.toLowerCase().startsWith(langPart)
);
}
// If still no match, use default
if (!matchingVoice) {
return this.selectDefaultVoice();
}
// Set the matching voice
this.voiceOptions.voice = matchingVoice.id;
console.log(`API TTS: Selected voice ${matchingVoice.name} for locale ${locale}`);
// Update preference
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'voice', matchingVoice.id || matchingVoice.name);
}
return true;
}
/**
* Select a default voice
* @returns {boolean} - Success status
*/
selectDefaultVoice() {
if (this.voices.length === 0) {
console.warn("API TTS: No voices available for default selection");
return false;
}
// Prefer English voices if available
const englishVoice = this.voices.find(voice =>
voice.lang && voice.lang.toLowerCase().startsWith('en')
);
if (englishVoice) {
this.voiceOptions.voice = englishVoice.id;
console.log(`API TTS: Selected default English voice ${englishVoice.name}`);
} else {
// Otherwise use the first available voice
this.voiceOptions.voice = this.voices[0].id;
console.log(`API TTS: Selected first available voice ${this.voices[0].name}`);
}
// Update preference
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'voice', this.voiceOptions.voice);
}
return true;
}
/**
* Preload speech for a text
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Preloaded audio data
*/
async preloadSpeech(text) {
if (!this.available || !text) {
return null;
}
try {
// Process text for TTS
const processedText = this.preprocessText(text);
console.log(`API TTS: Preloading speech for: "${processedText.substring(0, 50)}${processedText.length > 50 ? '...' : ''}"`);
// Make API request to generate speech
const response = await fetch(this.apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: processedText,
voice_id: this.voiceOptions.voice,
model_id: this.voiceOptions.model,
speed: this.voiceOptions.speed
})
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
// Get audio blob
const audioBlob = await response.blob();
// Create audio element but don't play it
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
// Store preloaded data
const preloadData = {
audio,
url: audioUrl,
text: processedText
};
this.preloadCache.set(text, preloadData);
return preloadData;
} catch (error) {
console.warn("API TTS: Error preloading speech:", error);
return null;
}
}
/**
* Speak text using preloaded audio
* @param {Object} preloadData - Preloaded audio data
* @param {Function} callback - Callback for when speech completes
* @returns {boolean} - Success status
*/
speakPreloaded(preloadData, callback = null) {
if (!this.available || !preloadData || !preloadData.audio) {
if (callback) {
setTimeout(() => callback({ success: false, reason: 'no_preloaded_data' }), 0);
}
return false;
}
try {
// Stop any current speech
this.stop();
const { audio, url, text } = preloadData;
// Dispatch start event
this.dispatchEvent('tts:speak:start', { text });
// Set up event listeners
audio.addEventListener('ended', () => {
this.currentAudio = null;
// Clean up URL object
URL.revokeObjectURL(url);
// Dispatch end event
this.dispatchEvent('tts:speak:end', { text });
if (callback) {
callback({ success: true });
}
}, { once: true });
audio.addEventListener('error', (error) => {
this.currentAudio = null;
// Clean up URL object
URL.revokeObjectURL(url);
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text,
error: error.message || 'Unknown error'
});
if (callback) {
callback({ success: false, reason: 'playback_error', error });
}
}, { once: true });
// Store reference to current audio
this.currentAudio = audio;
// Play the audio
audio.play();
return true;
} catch (error) {
console.error("API TTS: Error playing preloaded speech:", error);
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text: preloadData.text,
error: error.message || 'Unknown error'
});
if (callback) {
setTimeout(() => callback({ success: false, reason: 'playback_error', error }), 0);
}
return false;
}
}
/**
* Speak text
* @param {string} text - Text to speak
* @param {Function} callback - Callback for when speech completes
* @returns {boolean} - Success status
*/
async speak(text, callback = null) {
if (!this.available) {
if (callback) {
setTimeout(() => callback({ success: false, reason: 'not_available' }), 0);
}
return false;
}
try {
// Stop any current speech
this.stop();
// Check if we have this in the preload cache
if (this.preloadCache.has(text)) {
const preloadData = this.preloadCache.get(text);
this.preloadCache.delete(text); // Remove from cache
return this.speakPreloaded(preloadData, callback);
}
// Process text for TTS
const processedText = this.preprocessText(text);
// Dispatch start event
this.dispatchEvent('tts:speak:start', { text: processedText });
// Make API request to generate speech
const response = await fetch(this.apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: processedText,
voice_id: this.voiceOptions.voice,
model_id: this.voiceOptions.model,
speed: this.voiceOptions.speed
})
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
// Get audio blob
const audioBlob = await response.blob();
// Create audio element
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
// Set up event listeners
audio.addEventListener('ended', () => {
this.currentAudio = null;
// Clean up URL object
URL.revokeObjectURL(audioUrl);
// Dispatch end event
this.dispatchEvent('tts:speak:end', { text: processedText });
if (callback) {
callback({ success: true });
}
}, { once: true });
audio.addEventListener('error', (error) => {
this.currentAudio = null;
// Clean up URL object
URL.revokeObjectURL(audioUrl);
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text: processedText,
error: error.message || 'Unknown error'
});
if (callback) {
callback({ success: false, reason: 'playback_error', error });
}
}, { once: true });
// Store reference to current audio
this.currentAudio = audio;
// Play the audio
audio.play();
return true;
} catch (error) {
console.error("API TTS: Error generating speech:", error);
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text,
error: error.message || 'Unknown error'
});
if (callback) {
setTimeout(() => callback({ success: false, reason: 'generation_error', error }), 0);
}
return false;
}
}
/**
* Preprocess text for TTS
* @param {string} text - Text to preprocess
* @returns {string} - Processed text
*/
preprocessText(text) {
if (!text) return '';
// Trim whitespace
let processed = text.trim();
// Replace multiple spaces with a single space
processed = processed.replace(/\s+/g, ' ');
// Add a period at the end if there's no punctuation
if (!/[.!?]$/.test(processed)) {
processed += '.';
}
return processed;
}
/**
* Stop speaking
*/
stop() {
if (this.currentAudio) {
try {
this.currentAudio.pause();
this.currentAudio = null;
} catch (error) {
console.error("API TTS: Error stopping speech:", error);
}
}
}
/**
* Check if TTS is available
* @returns {boolean} - True if TTS is available
*/
isAvailable() {
return this.available;
}
/**
* Get handler ID
* @returns {string} - Handler ID
*/
getId() {
return this.id;
}
/**
* Get available voices
* @returns {Promise<Array>} - Resolves with array of voice objects
*/
async getVoices() {
if (!this.available) {
return [];
}
try {
const response = await fetch(`${this.apiEndpoint}/voices`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data && data.voices && Array.isArray(data.voices)) {
return data.voices.map(voice => ({
id: voice.id,
name: voice.name,
language: voice.language || 'unknown'
}));
}
return [];
} catch (error) {
console.error("API TTS: Error getting voices:", error);
return [];
}
}
/**
* Set voice options
* @param {Object} options - Voice options
*/
setVoiceOptions(options = {}) {
if (options.voice) {
this.voiceOptions.voice = options.voice;
}
if (options.model) {
this.voiceOptions.model = options.model;
}
if (typeof options.speed === 'number') {
// Clamp speed between 0.5 and 2.0
this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed));
}
}
}
/**
* Check if speech is currently playing
* @returns {boolean} - True if speaking
*/
isSpeaking() {
return this.audioElement !== null &&
!this.audioElement.paused &&
!this.audioElement.ended;
}
}
+456 -13
View File
@@ -9,6 +9,24 @@ export class BaseModule {
this.state = 'PENDING';
this.progress = 0;
this.progressCallback = null;
// Add standard event target for custom events
this.eventTarget = document.createElement('div');
// Add standard configuration object
this.config = {};
// Track event listeners for cleanup
this._eventListeners = [];
// Resource loading tracking
this._loadingResources = new Map();
this._totalResources = 0;
this._loadedResources = 0;
// Dependencies
this.dependencies = [];
this._loadedDependencies = new Map();
}
/**
@@ -23,15 +41,10 @@ export class BaseModule {
this.changeState('LOADING');
this.reportProgress(10, "Starting initialization");
// Load dependencies
const depsLoaded = await this.loadDependencies();
if (!depsLoaded) {
this.changeState('ERROR');
this.reportProgress(100, "Failed to load dependencies");
return false;
}
// Skip loadDependencies() call - now handled automatically
const depStatus = await this.waitForDependencies();
// Wait for dependencies
const depStatus = await this._waitForModuleDependencies();
if (!depStatus) {
// If dependencies aren't available, report waiting
this.changeState('WAITING');
@@ -59,24 +72,100 @@ export class BaseModule {
}
/**
* Load module dependencies - Override this in child classes
* @returns {Promise} - Resolves when dependencies are loaded
* Wait for module dependencies
* @returns {Promise<boolean>} - Resolves when dependencies are ready
*/
async _waitForModuleDependencies() {
if (!this.dependencies || this.dependencies.length === 0) {
return true;
}
try {
this.reportProgress(15, "Waiting for dependencies");
// Get moduleRegistry - first try import then fallback to window
const registry = window.moduleRegistry;
if (!registry) {
console.error(`${this.id}: Module registry not found, will retry`);
// Retry after a short delay to allow registry to be initialized
await new Promise(resolve => setTimeout(resolve, 100));
// Try again
const retryRegistry = window.moduleRegistry;
if (!retryRegistry) {
console.error(`${this.id}: Module registry still not found after retry`);
return false;
}
console.log(`${this.id}: Found module registry after retry`);
return this._continueWaitForDependencies(retryRegistry);
}
return this._continueWaitForDependencies(registry);
} catch (error) {
console.error(`${this.id}: Error waiting for dependencies:`, error);
return false;
}
}
/**
* Continue waiting for dependencies using the provided registry
* @param {ModuleRegistry} registry - The module registry
* @returns {Promise<boolean>} - Resolves when dependencies are ready
* @private
*/
async _continueWaitForDependencies(registry) {
try {
// Wait for all dependencies to be ready
const results = await registry.waitForModules(this.dependencies);
// Store references to dependencies
for (let i = 0; i < this.dependencies.length; i++) {
const depId = this.dependencies[i];
const depModule = registry.getModule(depId);
if (depModule) {
this._loadedDependencies.set(depId, depModule);
}
}
const allDepsReady = results.every(ready => ready === true);
if (allDepsReady) {
this.reportProgress(20, "Dependencies ready");
return true;
} else {
this.reportProgress(15, "Some dependencies not ready");
return false;
}
} catch (error) {
console.error(`${this.id}: Error in _continueWaitForDependencies:`, error);
return false;
}
}
/**
* Legacy method for backwards compatibility
* @deprecated Use dependencies array property instead
* @returns {Promise<boolean>} - Resolves when dependencies are loaded
*/
async loadDependencies() {
// This is now handled by _waitForModuleDependencies
return Promise.resolve(true);
}
/**
* Wait for dependencies to be ready - Override this in child classes
* @returns {Promise} - Resolves when dependencies are ready
* Legacy method for backwards compatibility
* @deprecated No longer needed as waitForDependencies is handled automatically
* @returns {Promise<boolean>} - Resolves when dependencies are ready
*/
async waitForDependencies() {
// This is now handled by _waitForModuleDependencies
return Promise.resolve(true);
}
/**
* Initialize the module - Override this in child classes
* @returns {Promise} - Resolves when initialization is complete
* @returns {Promise<boolean>} - Resolves when initialization is complete
*/
async initialize() {
return Promise.resolve(true);
@@ -116,6 +205,360 @@ export class BaseModule {
getState() {
return this.state;
}
/**
* Dispatch a module event
* @param {string} name - Event name
* @param {Object} detail - Event details
*/
dispatchEvent(name, detail = {}) {
const event = new CustomEvent(name, {
detail: { moduleId: this.id, ...detail },
bubbles: true
});
document.dispatchEvent(event);
return event;
}
/**
* Add an event listener with automatic tracking for cleanup
* @param {EventTarget} target - Event target (document, window, etc)
* @param {string} type - Event type
* @param {Function} listener - Event listener
* @param {Object} options - Event listener options
*/
addEventListener(target, type, listener, options = {}) {
target.addEventListener(type, listener, options);
this._eventListeners.push({ target, type, listener, options });
}
/**
* Remove a specific event listener
* @param {EventTarget} target - Event target
* @param {string} type - Event type
* @param {Function} listener - Event listener
* @param {Object} options - Event listener options
*/
removeEventListener(target, type, listener, options = {}) {
target.removeEventListener(type, listener, options);
this._eventListeners = this._eventListeners.filter(
item => !(item.target === target &&
item.type === type &&
item.listener === listener)
);
}
/**
* Remove all event listeners registered through addEventListener
*/
removeAllEventListeners() {
this._eventListeners.forEach(({ target, type, listener, options }) => {
target.removeEventListener(type, listener, options);
});
this._eventListeners = [];
}
/**
* Get a reference to another module
* @param {string} moduleId - ID of the module to get
* @returns {BaseModule|null} - The module or null if not found
*/
getModule(moduleId) {
// First check our dependency cache
if (this._loadedDependencies.has(moduleId)) {
return this._loadedDependencies.get(moduleId);
}
// Then check in the registry
return window.moduleRegistry ?
window.moduleRegistry.getModule(moduleId) : null;
}
/**
* Auto-bind methods to preserve 'this' context
* @param {Array<string>} methodNames - Array of method names to bind
*/
bindMethods(methodNames) {
methodNames.forEach(methodName => {
if (typeof this[methodName] === 'function') {
this[methodName] = this[methodName].bind(this);
} else {
console.warn(`Method ${methodName} not found on ${this.id} module`);
}
});
}
/**
* Update configuration
* @param {Object} newConfig - New configuration to merge
*/
updateConfig(newConfig = {}) {
this.config = { ...this.config, ...newConfig };
}
/**
* Get current configuration
* @returns {Object} - Current configuration
*/
getConfig() {
return { ...this.config };
}
/**
* Load a JavaScript file
* @param {string} url - URL of the script to load
* @param {boolean} [isModule=false] - Whether to load as a module
* @returns {Promise<HTMLScriptElement>} - Promise resolving to the loaded script element
*/
loadScript(url, isModule = false) {
return new Promise((resolve, reject) => {
// Track this resource
this._trackResource(url);
const script = document.createElement('script');
script.src = url;
if (isModule) {
script.type = 'module';
}
script.onload = () => {
this._resourceLoaded(url);
resolve(script);
};
script.onerror = (error) => {
this._resourceFailed(url, error);
reject(new Error(`Failed to load script: ${url}`));
};
document.head.appendChild(script);
});
}
/**
* Load a CSS stylesheet
* @param {string} url - URL of the stylesheet to load
* @returns {Promise<HTMLLinkElement>} - Promise resolving to the loaded link element
*/
loadCSS(url) {
return new Promise((resolve, reject) => {
// Track this resource
this._trackResource(url);
const link = document.createElement('link');
link.href = url;
link.rel = 'stylesheet';
link.onload = () => {
this._resourceLoaded(url);
resolve(link);
};
link.onerror = (error) => {
this._resourceFailed(url, error);
reject(new Error(`Failed to load stylesheet: ${url}`));
};
document.head.appendChild(link);
});
}
/**
* Preload an image
* @param {string} url - URL of the image to load
* @returns {Promise<HTMLImageElement>} - Promise resolving to the loaded image element
*/
loadImage(url) {
return new Promise((resolve, reject) => {
// Track this resource
this._trackResource(url);
const img = new Image();
img.onload = () => {
this._resourceLoaded(url);
resolve(img);
};
img.onerror = (error) => {
this._resourceFailed(url, error);
reject(new Error(`Failed to load image: ${url}`));
};
img.src = url;
});
}
/**
* Load JSON data
* @param {string} url - URL of the JSON file to load
* @returns {Promise<Object>} - Promise resolving to the parsed JSON data
*/
loadJSON(url) {
return new Promise((resolve, reject) => {
// Track this resource
this._trackResource(url);
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
return response.json();
})
.then(data => {
this._resourceLoaded(url);
resolve(data);
})
.catch(error => {
this._resourceFailed(url, error);
reject(new Error(`Failed to load JSON: ${url} - ${error.message}`));
});
});
}
/**
* Load a generic resource with fetch
* @param {string} url - URL of the resource to load
* @param {string} [responseType='text'] - Response type ('text', 'blob', 'arrayBuffer', etc.)
* @returns {Promise<any>} - Promise resolving to the loaded resource
*/
loadResource(url, responseType = 'text') {
return new Promise((resolve, reject) => {
// Track this resource
this._trackResource(url);
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
switch(responseType) {
case 'json': return response.json();
case 'blob': return response.blob();
case 'arrayBuffer': return response.arrayBuffer();
case 'formData': return response.formData();
case 'text':
default: return response.text();
}
})
.then(data => {
this._resourceLoaded(url);
resolve(data);
})
.catch(error => {
this._resourceFailed(url, error);
reject(new Error(`Failed to load resource: ${url} - ${error.message}`));
});
});
}
/**
* Load multiple resources at once
* @param {Array<Object>} resources - Array of resource descriptors
* @returns {Promise<Array>} - Promise resolving to an array of loaded resources
* @example
* loadResources([
* { type: 'script', url: '/js/lib.js' },
* { type: 'css', url: '/css/style.css' },
* { type: 'image', url: '/img/logo.png' },
* { type: 'json', url: '/data/config.json' }
* ])
*/
loadResources(resources) {
const promises = resources.map(resource => {
switch(resource.type) {
case 'script':
return this.loadScript(resource.url, resource.isModule);
case 'css':
return this.loadCSS(resource.url);
case 'image':
return this.loadImage(resource.url);
case 'json':
return this.loadJSON(resource.url);
default:
return this.loadResource(resource.url, resource.responseType);
}
});
return Promise.all(promises);
}
/**
* Track a resource being loaded
* @param {string} url - URL of the resource
* @private
*/
_trackResource(url) {
this._loadingResources.set(url, {
started: Date.now(),
completed: false,
failed: false
});
this._totalResources++;
// Report progress
this._updateResourceProgress();
}
/**
* Mark a resource as successfully loaded
* @param {string} url - URL of the resource
* @private
*/
_resourceLoaded(url) {
if (this._loadingResources.has(url)) {
const resource = this._loadingResources.get(url);
resource.completed = true;
resource.completedAt = Date.now();
this._loadedResources++;
// Report progress
this._updateResourceProgress();
}
}
/**
* Mark a resource as failed to load
* @param {string} url - URL of the resource
* @param {Error} error - Error that occurred
* @private
*/
_resourceFailed(url, error) {
if (this._loadingResources.has(url)) {
const resource = this._loadingResources.get(url);
resource.failed = true;
resource.error = error;
resource.completedAt = Date.now();
this._loadedResources++;
// Log the error
console.error(`${this.id}: Failed to load resource:`, url, error);
// Report progress
this._updateResourceProgress();
}
}
/**
* Update loading progress based on resources
* @private
*/
_updateResourceProgress() {
if (this._totalResources === 0) return;
const percent = Math.round((this._loadedResources / this._totalResources) * 100);
this.reportProgress(percent, `Loading resources: ${this._loadedResources}/${this._totalResources}`);
}
/**
* Dispose resources when module is destroyed
* Override in child classes to add custom cleanup
*/
dispose() {
this.removeAllEventListeners();
}
}
/**
+571 -180
View File
@@ -3,196 +3,587 @@
* Implementation using the browser's Web Speech API
*/
import { TTSHandler } from './tts-handler.js';
import { moduleRegistry } from './module-registry.js';
export class BrowserTTSHandler extends TTSHandler {
constructor() {
super(); // Initialize the base TTSHandler
this.synth = window.speechSynthesis;
this.utterance = null;
this.voices = [];
this.isReady = false;
// Initialize voice options through base class
this.voiceOptions = {
voice: '',
rate: 1.0,
pitch: 1.0,
volume: 1.0
};
}
/**
* Check if speech is currently playing
* @returns {boolean} - True if speaking
*/
isSpeaking() {
return this.synth && this.synth.speaking;
}
/**
* Get the ID of this provider
* @returns {string} - Provider ID
*/
getId() {
return 'browser';
}
/**
* Initialize the browser's speech synthesis
* @param {Function} progressCallback - Optional callback for progress updates
* @returns {Promise<boolean>} - Resolves to true if initialization was successful
*/
async initialize(progressCallback = null) {
if (!this.synth) {
console.warn('Web Speech API not supported in this browser');
return false;
}
try {
if (progressCallback) progressCallback(20, 'Loading speech synthesis');
// Get available voices
this.voices = await this.getVoices();
if (progressCallback) progressCallback(80, 'Speech synthesis loaded');
// If we have voices, we're ready
this.isReady = this.voices && this.voices.length > 0;
if (this.isReady) {
console.log('Browser TTS initialized with', this.voices.length, 'voices');
} else {
console.warn('Browser TTS initialized but no voices available');
}
if (progressCallback) progressCallback(100, 'Browser TTS ready');
return this.isReady;
} catch (error) {
console.error('Error initializing browser TTS:', error);
return false;
}
}
/**
* Get available voices
* @returns {Promise<Array>} - Array of available voices
*/
async getVoices() {
return new Promise((resolve) => {
// Some browsers get voices immediately, others need an event
const voices = this.synth.getVoices();
if (voices && voices.length > 0) {
resolve(voices);
} else {
// Wait for voiceschanged event
const voicesChangedHandler = () => {
this.synth.removeEventListener('voiceschanged', voicesChangedHandler);
resolve(this.synth.getVoices());
constructor() {
super();
this.id = 'browser';
this.name = 'Browser TTS Handler';
// Voice options
this.voiceOptions = {
voice: null, // Will be set during initialization
rate: 1.0,
pitch: 1.0,
volume: 1.0
};
this.synth.addEventListener('voiceschanged', voicesChangedHandler);
// State
this.available = false;
this.voices = [];
this.currentUtterance = null;
this.preloadCache = new Map();
// Safety mechanism: if after 3 seconds we still have no voices and no event,
// resolve with whatever we have (or empty array)
// This is not a setTimeout for synchronization, but a safety fallback
const safetyCheckVoices = () => {
const currentVoices = this.synth.getVoices() || [];
console.log(`Safety check: Found ${currentVoices.length} voices`);
resolve(currentVoices);
};
// Add dependencies
this.dependencies = ['localization', 'persistence-manager'];
// Use requestIdleCallback if available, otherwise requestAnimationFrame
if (window.requestIdleCallback) {
window.requestIdleCallback(safetyCheckVoices, { timeout: 3000 });
// Bind methods
this.bindMethods([
'initialize',
'speak',
'speakPreloaded',
'preloadSpeech',
'stop',
'isAvailable',
'getId',
'getVoices',
'setVoiceOptions',
'onVoicesChanged',
'getModule'
]);
}
/**
* Get a module from the registry
* @param {string} moduleId - ID of the module to get
* @returns {Object|null} - The module or null if not found
*/
getModule(moduleId) {
return moduleRegistry.getModule(moduleId);
}
/**
* Initialize the browser TTS handler
* @param {Function} progressCallback - Callback for progress updates
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize(progressCallback = null) {
try {
if (progressCallback) {
progressCallback(10, "Initializing Browser TTS Handler");
}
// Check if the browser supports speech synthesis
if (!window.speechSynthesis) {
console.error("Browser TTS: Speech synthesis not supported by browser");
if (progressCallback) {
progressCallback(100, "Browser TTS unavailable");
}
return false;
}
if (progressCallback) {
progressCallback(30, "Loading voices");
}
try {
// Load available voices
await this.loadVoices();
if (progressCallback) {
progressCallback(70, "Setting up voice");
}
// Get localization module
const localization = this.getModule('localization');
const persistenceManager = this.getModule('persistence-manager');
// Get current locale and preferred voice
let currentLocale = 'en-us';
let preferredVoice = '';
if (localization) {
currentLocale = localization.getLocale();
} else {
console.error("Browser TTS: Localization module not found");
}
if (persistenceManager) {
preferredVoice = persistenceManager.getPreference('tts', 'voice', '');
} else {
console.error("Browser TTS: Persistence Manager module not found");
}
// Set voice based on locale and preferences
await this.selectVoiceForLocale(currentLocale, preferredVoice);
// Check if we have a voice set
if (this.voiceOptions.voice) {
this.available = true;
this.isReady = true;
if (progressCallback) {
progressCallback(100, "Browser TTS Handler ready");
}
return true;
} else {
// Try one more time with a delay
console.log("Browser TTS: No voice set, trying again after delay");
if (progressCallback) {
progressCallback(80, "Retrying voice loading");
}
// Wait a bit and try again
return new Promise(resolve => {
setTimeout(async () => {
await this.loadVoices();
await this.selectVoiceForLocale(currentLocale, preferredVoice);
if (this.voiceOptions.voice) {
this.available = true;
this.isReady = true;
if (progressCallback) {
progressCallback(100, "Browser TTS Handler ready");
}
resolve(true);
} else {
console.error("Browser TTS: Failed to set voice after retry");
if (progressCallback) {
progressCallback(100, "Browser TTS initialization failed");
}
resolve(false);
}
}, 1000);
});
}
} catch (error) {
console.error("Browser TTS: Error loading voices:", error);
if (progressCallback) {
progressCallback(100, "Browser TTS initialization failed");
}
return false;
}
} catch (error) {
console.error("Browser TTS: Initialization error:", error);
if (progressCallback) {
progressCallback(100, "Browser TTS initialization failed");
}
return false;
}
}
/**
* Handle voices changed event
*/
async onVoicesChanged() {
await this.loadVoices();
const localization = this.getModule('localization');
const persistenceManager = this.getModule('persistence-manager');
let currentLocale = 'en-us';
let preferredVoice = '';
if (localization) {
currentLocale = localization.getLocale();
}
if (persistenceManager) {
preferredVoice = persistenceManager.getPreference('tts', 'voice', '');
}
await this.selectVoiceForLocale(currentLocale, preferredVoice);
}
/**
* Load available voices
* @returns {Promise<void>}
*/
async loadVoices() {
return new Promise(resolve => {
// Get available voices
const getVoices = () => {
this.voices = speechSynthesis.getVoices() || [];
console.log(`Browser TTS: Loaded ${this.voices.length} voices`);
resolve();
};
// Some browsers need a timeout to get voices
const timeoutId = setTimeout(() => {
if (this.voices.length === 0) {
this.voices = speechSynthesis.getVoices() || [];
console.log(`Browser TTS: Loaded ${this.voices.length} voices after timeout`);
resolve();
}
}, 1000);
// Try to get voices immediately
this.voices = speechSynthesis.getVoices() || [];
if (this.voices.length > 0) {
clearTimeout(timeoutId);
console.log(`Browser TTS: Loaded ${this.voices.length} voices immediately`);
resolve();
} else {
// If no voices are available yet, set up the onvoiceschanged event
speechSynthesis.onvoiceschanged = () => {
clearTimeout(timeoutId);
this.voices = speechSynthesis.getVoices() || [];
console.log(`Browser TTS: Loaded ${this.voices.length} voices from event`);
speechSynthesis.onvoiceschanged = null;
resolve();
};
}
});
}
/**
* Set voice based on locale
* @param {string} locale - Locale code (e.g., 'en-us', 'de', 'fr')
* @param {string} preferredVoice - Optional preferred voice name
* @returns {Promise<void>}
*/
async selectVoiceForLocale(locale = 'en-us', preferredVoice = '') {
// Normalize locale for comparison
const normalizedLocale = locale.toLowerCase().split('-')[0];
// If we have a preferred voice, try to use it first
if (preferredVoice) {
const matchingVoice = this.voices.find(voice =>
voice.name === preferredVoice ||
voice.voiceURI === preferredVoice
);
if (matchingVoice) {
this.voiceOptions.voice = matchingVoice;
console.log(`Browser TTS: Using preferred voice: ${matchingVoice.name}`);
return;
}
}
// Find voices matching the locale
const localeVoices = this.voices.filter(voice => {
const voiceLocale = voice.lang.toLowerCase();
return voiceLocale.startsWith(normalizedLocale) ||
voice.name.toLowerCase().includes(normalizedLocale);
});
if (localeVoices.length > 0) {
// Use the first matching voice
this.voiceOptions.voice = localeVoices[0];
console.log(`Browser TTS: Using ${normalizedLocale} voice: ${this.voiceOptions.voice.name}`);
return;
}
// If no matching voice found, try to find any voice
if (this.voices.length > 0) {
// Look for a preferred language voice (English)
const englishVoices = this.voices.filter(voice =>
voice.lang.toLowerCase().startsWith('en')
);
if (englishVoices.length > 0) {
this.voiceOptions.voice = englishVoices[0];
console.log(`Browser TTS: No ${normalizedLocale} voice found, using English voice: ${this.voiceOptions.voice.name}`);
} else {
// Use the first available voice
this.voiceOptions.voice = this.voices[0];
console.log(`Browser TTS: No ${normalizedLocale} or English voice found, using: ${this.voiceOptions.voice.name}`);
}
} else {
// Schedule for next frame, but with longer delay
setTimeout(safetyCheckVoices, 3000);
console.log("Browser TTS: No voices available");
}
}
});
}
/**
* Check if browser TTS is available
* @returns {boolean} - True if browser TTS is ready to use
*/
isAvailable() {
return this.isReady && this.synth;
}
/**
* Speak text using browser TTS
* @param {string} text - The text to speak
* @param {Function} callback - Called when speech completes
*/
speak(text, callback = null) {
if (!this.isAvailable() || !text) {
if (callback) callback();
return;
}
// Stop any current speech
this.stop();
try {
// Create a new utterance
this.utterance = new SpeechSynthesisUtterance(text);
// Apply voice options
if (this.voiceOptions.voice) {
// Find the voice by name or URI
const selectedVoice = this.voices.find(v =>
v.name === this.voiceOptions.voice ||
v.voiceURI === this.voiceOptions.voice
);
if (selectedVoice) {
this.utterance.voice = selectedVoice;
/**
* Preload speech for a text
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Preloaded speech data
*/
async preloadSpeech(text) {
if (!this.available || !text || !this.voiceOptions.voice) {
return null;
}
try {
// Process text for TTS
const processedText = this.preprocessText(text);
console.log(`Browser TTS: Preloading speech for: "${processedText.substring(0, 50)}${processedText.length > 50 ? '...' : ''}"`);
// Create utterance but don't speak it yet
const utterance = new SpeechSynthesisUtterance(processedText);
// Set voice and options
utterance.voice = this.voiceOptions.voice;
utterance.rate = this.voiceOptions.rate;
utterance.pitch = this.voiceOptions.pitch;
utterance.volume = this.voiceOptions.volume;
utterance.lang = this.voiceOptions.voice.lang;
// Store preloaded data
const preloadData = {
utterance,
text: processedText
};
this.preloadCache.set(text, preloadData);
return preloadData;
} catch (error) {
console.warn("Browser TTS: Error preloading speech:", error);
return null;
}
}
// Apply other options
this.utterance.rate = this.voiceOptions.rate;
this.utterance.pitch = this.voiceOptions.pitch;
this.utterance.volume = this.voiceOptions.volume;
// Handle end of speech
this.utterance.onend = () => {
if (callback) callback();
};
// Handle errors
this.utterance.onerror = (e) => {
console.error('Speech synthesis error:', e);
if (callback) callback();
};
// Start speaking
this.synth.speak(this.utterance);
} catch (error) {
console.error('Error speaking with browser TTS:', error);
if (callback) callback();
}
}
/**
* Stop any ongoing speech
*/
stop() {
if (this.synth) {
this.synth.cancel();
this.utterance = null;
/**
* Speak text using preloaded utterance
* @param {Object} preloadData - Preloaded speech data
* @param {Function} callback - Callback for when speech completes
* @returns {boolean} - Success status
*/
speakPreloaded(preloadData, callback = null) {
if (!this.available || !preloadData || !preloadData.utterance) {
if (callback) {
setTimeout(() => callback({ success: false, reason: 'no_preloaded_data' }), 0);
}
return false;
}
try {
// Stop any current speech
this.stop();
const { utterance, text } = preloadData;
// Dispatch start event
this.dispatchEvent('tts:speak:start', { text });
// Set up event listeners
utterance.onend = () => {
this.currentUtterance = null;
// Dispatch end event
this.dispatchEvent('tts:speak:end', { text });
if (callback) {
callback({ success: true });
}
};
utterance.onerror = (error) => {
this.currentUtterance = null;
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text,
error: error.error || 'Unknown error'
});
if (callback) {
callback({ success: false, reason: 'synthesis_error', error });
}
};
// Store reference to current utterance
this.currentUtterance = utterance;
// Speak the utterance
speechSynthesis.speak(utterance);
return true;
} catch (error) {
console.error("Browser TTS: Error playing preloaded speech:", error);
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text: preloadData.text,
error: error.message || 'Unknown error'
});
if (callback) {
setTimeout(() => callback({ success: false, reason: 'synthesis_error', error }), 0);
}
return false;
}
}
/**
* Speak text
* @param {string} text - Text to speak
* @param {Function} callback - Callback for when speech completes
* @returns {boolean} - Success status
*/
speak(text, callback = null) {
if (!this.available || !this.voiceOptions.voice) {
if (callback) {
setTimeout(() => callback({ success: false, reason: 'not_available' }), 0);
}
return false;
}
try {
// Stop any current speech
this.stop();
// Check if we have this in the preload cache
if (this.preloadCache.has(text)) {
const preloadData = this.preloadCache.get(text);
this.preloadCache.delete(text); // Remove from cache
return this.speakPreloaded(preloadData, callback);
}
// Process text for TTS
const processedText = this.preprocessText(text);
// Create utterance
const utterance = new SpeechSynthesisUtterance(processedText);
// Set voice and options
utterance.voice = this.voiceOptions.voice;
utterance.rate = this.voiceOptions.rate;
utterance.pitch = this.voiceOptions.pitch;
utterance.volume = this.voiceOptions.volume;
utterance.lang = this.voiceOptions.voice.lang;
// Dispatch start event
this.dispatchEvent('tts:speak:start', { text: processedText });
// Set up event listeners
utterance.onend = () => {
this.currentUtterance = null;
// Dispatch end event
this.dispatchEvent('tts:speak:end', { text: processedText });
if (callback) {
callback({ success: true });
}
};
utterance.onerror = (error) => {
this.currentUtterance = null;
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text: processedText,
error: error.error || 'Unknown error'
});
if (callback) {
callback({ success: false, reason: 'synthesis_error', error });
}
};
// Store reference to current utterance
this.currentUtterance = utterance;
// Speak the utterance
speechSynthesis.speak(utterance);
return true;
} catch (error) {
console.error("Browser TTS: Error generating speech:", error);
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text,
error: error.message || 'Unknown error'
});
if (callback) {
setTimeout(() => callback({ success: false, reason: 'synthesis_error', error }), 0);
}
return false;
}
}
/**
* Preprocess text for TTS
* @param {string} text - Text to preprocess
* @returns {string} - Processed text
*/
preprocessText(text) {
if (!text) return '';
// Trim whitespace
let processed = text.trim();
// Replace multiple spaces with a single space
processed = processed.replace(/\s+/g, ' ');
// Add a period at the end if there's no punctuation
if (!/[.!?]$/.test(processed)) {
processed += '.';
}
return processed;
}
/**
* Stop speaking
*/
stop() {
if (speechSynthesis) {
speechSynthesis.cancel();
this.currentUtterance = null;
}
}
/**
* Check if TTS is available
* @returns {boolean} - True if TTS is available
*/
isAvailable() {
return this.available && this.voiceOptions.voice !== null;
}
/**
* Get handler ID
* @returns {string} - Handler ID
*/
getId() {
return this.id;
}
/**
* Get available voices
* @returns {Array} - Array of voice objects
*/
getVoices() {
return this.voices.map(voice => ({
id: voice.voiceURI,
name: voice.name,
language: voice.lang
}));
}
/**
* Set voice options
* @param {Object} options - Voice options
*/
setVoiceOptions(options = {}) {
if (options.voice) {
// Find the voice by ID or name
const voice = this.voices.find(v =>
v.voiceURI === options.voice ||
v.name === options.voice
);
if (voice) {
this.voiceOptions.voice = voice;
}
}
if (typeof options.rate === 'number') {
// Clamp rate between 0.1 and 10
this.voiceOptions.rate = Math.max(0.1, Math.min(10, options.rate));
}
if (typeof options.pitch === 'number') {
// Clamp pitch between 0 and 2
this.voiceOptions.pitch = Math.max(0, Math.min(2, options.pitch));
}
if (typeof options.volume === 'number') {
// Clamp volume between 0 and 1
this.voiceOptions.volume = Math.max(0, Math.min(1, options.volume));
}
}
}
/**
* Set voice options
* @param {Object} options - Voice options
*/
setVoiceOptions(options = {}) {
if (options.voice !== undefined) this.voiceOptions.voice = options.voice;
if (options.rate !== undefined) this.voiceOptions.rate = options.rate;
if (options.pitch !== undefined) this.voiceOptions.pitch = options.pitch;
if (options.volume !== undefined) this.voiceOptions.volume = options.volume;
}
}
+39 -79
View File
@@ -8,11 +8,11 @@ import { moduleRegistry } from './module-registry.js';
class GameLoopModule extends BaseModule {
constructor() {
super('game-loop', 'Game Loop');
this.uiController = null;
this.socketClient = null;
this.ttsPlayer = null;
this.textBuffer = null;
this.isRunning = false;
// Dependencies
this.dependencies = ['ui-controller', 'socket-client', 'tts-player', 'text-buffer'];
// Game state
this.gameState = {
started: false,
canLoad: false,
@@ -20,78 +20,33 @@ class GameLoopModule extends BaseModule {
inventory: [],
commandHistory: []
};
this.isRunning = false;
// Bind methods using parent's bindMethods utility
this.bindMethods([
'start',
'setupSocketEventListeners',
'updateGameState',
'updateUIState',
'requestStartGame',
'requestSaveGame',
'requestLoadGame',
'addText'
]);
}
/**
* Load module dependencies
* @returns {Promise} - Resolves when dependencies are loaded
*/
async loadDependencies() {
// Basic dependency declaration - details handled in waitForDependencies
return true;
}
/**
* Wait for dependencies to be ready
*/
async waitForDependencies() {
try {
// Wait for TTS module with a timeout
const ttsReady = await moduleRegistry.waitForModule('tts', 15000);
if (ttsReady) {
this.ttsPlayer = moduleRegistry.getModule('tts');
this.reportProgress(30, "TTS module ready");
} else {
console.warn("TTS module not ready, game will have limited functionality");
}
// Wait for UI Controller with a timeout
const uiReady = await moduleRegistry.waitForModule('ui-controller', 15000);
if (uiReady) {
this.uiController = moduleRegistry.getModule('ui-controller');
this.reportProgress(50, "UI Controller ready");
} else {
console.warn("UI Controller not ready, game will have limited functionality");
}
// Get text buffer reference
this.textBuffer = moduleRegistry.getModule('text-buffer');
if (this.textBuffer) {
this.reportProgress(60, "Text buffer ready");
}
// Continue even with limited functionality
return true;
} catch (error) {
console.error("Error waiting for dependencies:", error);
return false;
}
}
/**
* Initialize the module
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize() {
this.reportProgress(100, "Game loop initialized");
return true;
}
/**
* Start the game loop
*/
start() {
console.log("GameLoop: Starting game sequence...");
try {
// Update UI with initial game state
if (this.uiController && this.ttsPlayer) {
this.updateUIState();
} else {
console.warn("GameLoop: UI Controller or TTS Player not ready for status update.");
}
// The dependencies are now automatically available via getModule
this.updateUIState();
console.log("GameLoop: Setting up socket listeners and connecting...");
@@ -105,12 +60,9 @@ class GameLoopModule extends BaseModule {
}
}
/**
* Set up socket event listeners and connect to server
*/
setupSocketEventListeners() {
// Get the socket client module
this.socketClient = moduleRegistry.getModule('socket-client');
// Get the socket client module using parent's getModule method
this.socketClient = this.getModule('socket-client');
if (!this.socketClient) {
console.error("Socket client module not found");
@@ -118,6 +70,8 @@ class GameLoopModule extends BaseModule {
}
// Connect UI controller to socket client for command handling
this.uiController = this.getModule('ui-controller');
if (this.uiController) {
this.uiController.socketClient = this.socketClient;
} else {
@@ -128,9 +82,14 @@ class GameLoopModule extends BaseModule {
this.socketClient.on('connect', () => {
console.log("GameLoop: Socket connected event received.");
// Request a new game start when we (re)connect
console.log("GameLoop: Requesting start game on (re)connect.");
this.requestStartGame();
// Request a new game start when we connect
// Only request start game if one isn't already in progress
if (!this.gameState.started) {
console.log("GameLoop: Requesting start game on connect.");
this.requestStartGame();
} else {
console.log("GameLoop: Game already started, skipping duplicate start request.");
}
});
// Listen for game state updates
@@ -157,8 +116,6 @@ class GameLoopModule extends BaseModule {
this.socketClient.connect().then(success => {
if (success) {
console.log("GameLoop: Socket connection established successfully.");
console.log("GameLoop: Requesting to start a new game");
this.requestStartGame();
} else {
console.error("GameLoop: Failed to connect to socket server");
}
@@ -229,12 +186,15 @@ class GameLoopModule extends BaseModule {
* @param {string} text - Text to add
*/
addText(text) {
if (!this.textBuffer) {
// Use parent's getModule method
const textBuffer = this.getModule('text-buffer');
if (!textBuffer) {
console.warn("Text buffer not available");
return;
}
this.textBuffer.addText(text);
textBuffer.addText(text);
}
}
File diff suppressed because it is too large Load Diff
+38 -35
View File
@@ -23,7 +23,7 @@ self.onmessage = function(e) {
break;
case 'generate':
if (!message.data || !message.data.text) {
if (!message.text) {
self.postMessage({
type: 'error',
error: 'No text provided for generation'
@@ -32,11 +32,17 @@ self.onmessage = function(e) {
}
// Store voice options
if (message.data.voice) voiceOptions.voice = message.data.voice;
if (message.data.speed) voiceOptions.speed = message.data.speed;
if (message.voice) voiceOptions.voice = message.voice;
if (message.speed) voiceOptions.speed = message.speed;
// Generate speech
generateSpeech(message.data.text)
generateSpeech(message.text)
.then(result => {
self.postMessage({
type: 'generated',
result: result
}, [result.audio.buffer]);
})
.catch(error => {
self.postMessage({
type: 'error',
@@ -73,46 +79,43 @@ async function generateSpeech(text) {
try {
// Load Kokoro if not already loaded
if (!kokoroLoaded) {
// Load the Kokoro script
self.importScripts('/js/kokoro-js.js');
if (!self.kokoro || !self.kokoro.KokoroTTS) {
throw new Error('Kokoro failed to load correctly');
try {
// Load the Kokoro script
self.importScripts('/js/kokoro.js');
if (!self.Kokoro) {
throw new Error('Kokoro failed to load correctly');
}
kokoroLoaded = true;
console.log('Kokoro loaded in worker');
} catch (loadError) {
console.error('Error loading Kokoro in worker:', loadError);
throw new Error(`Failed to load Kokoro: ${loadError.message}`);
}
kokoroLoaded = true;
}
// Create a new Kokoro instance for this generation
// We can't easily transfer the instance from the main thread, so we create it here
const kokoroTTS = self.kokoro.KokoroTTS;
// Create instance using from_pretrained
const tts = await kokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", {
dtype: "fp32",
device: "wasm",
cache: true // Use cache to speed up subsequent loads
});
// Generate speech
const result = await tts.generate(text, {
// Generate speech using Kokoro
const result = await self.Kokoro(text, {
voice: voiceOptions.voice,
speed: voiceOptions.speed
speed: voiceOptions.speed,
autoPlay: false
});
// Send the result back to the main thread
// We can't transfer the Float32Array directly, so let's transfer the buffer
const audioBuffer = result.audio.buffer;
// Extract audio data
const audioContext = new (self.AudioContext || self.webkitAudioContext)();
const audioBuffer = await audioContext.decodeAudioData(result.buffer);
self.postMessage({
type: 'generated',
result: {
audio: audioBuffer,
sampling_rate: result.sampling_rate
}
}, [audioBuffer]); // Transfer the buffer for better performance
// Get audio data as Float32Array
const audioData = audioBuffer.getChannelData(0);
// Return the result
return {
audio: audioData,
sampling_rate: audioBuffer.sampleRate
};
} catch (error) {
console.error('Error generating speech in worker:', error);
throw error;
} finally {
isProcessing = false;
+290 -230
View File
@@ -1,251 +1,311 @@
/**
* LayoutRenderer Module
* Translates the abstract layout data into concrete visual elements (DOM nodes).
* Layout Renderer Module
* Renders calculated paragraph layouts into the DOM with proper animations
*/
export class LayoutRenderer {
/**
* Create a new LayoutRenderer
* @param {Object} animationQueue - The AnimationQueue instance
*/
constructor(animationQueue) {
this.animationQueue = animationQueue;
this.fastForwardingAll = false;
}
import { BaseModule } from './base-module.js';
import { moduleRegistry } from './module-registry.js';
class LayoutRendererModule extends BaseModule {
constructor() {
super('layout-renderer', 'Layout Renderer');
// Module dependencies
this.dependencies = ['animation-queue'];
// Module references
this.animationQueue = null;
this.ttsPlayer = null;
// Configuration
this.updateConfig({
animation: {
defaultSpeed: 1.0,
wordAnimationClass: 'animate-word'
}
});
// Bind methods
this.bindMethods([
'renderParagraph',
'renderWord',
'scheduleWordAnimation'
]);
}
/**
* Render a paragraph based on layout data
* @param {Object} paragraphData - The layout data from ParagraphLayout
* @param {number} delay - Initial delay for animations
* @param {Array<number>} measure - Array of line width measurements
* @returns {Array} Array containing the paragraph element and the final delay
* Initialize the module
* @returns {Promise<boolean>} - Resolves with success status
*/
renderParagraph(paragraphData, delay = 0, measure = []) {
const stack = [];
let left = 0;
const p = document.createElement("p");
p.style.position = 'relative';
p.classList.add("latest-paragraph");
p.dataset.numberOfLines = paragraphData.breaks.length - 1;
const lineHeight = parseFloat(window.getComputedStyle(document.querySelector('#ruler')).lineHeight);
const lineWidth = parseFloat(window.getComputedStyle(document.getElementById('story')).width);
const pageHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height);
p.style.height = lineHeight * (paragraphData.breaks.length - 1) + 'px';
const paragraphHeight = parseFloat(p.style.height);
p.dataset.vpc = paragraphHeight * 100 / pageHeight;
p.style.marginBlockEnd = 0;
stack.push(p);
for (let i = 1; i < paragraphData.breaks.length; i++) {
left = measure[measure.length - 1] - measure[Math.min(i - 1, measure.length - 1)];
let lastChild = null;
let syllable = "";
async initialize() {
try {
this.reportProgress(10, "Initializing Layout Renderer");
for (let j = paragraphData.breaks[i-1].position; j <= paragraphData.breaks[i].position; j++) {
if (paragraphData.nodes[j].type === 'box' && paragraphData.nodes[j].value !== '' && j < paragraphData.breaks[i].position) {
if (j > paragraphData.breaks[i-1].position + 1 && paragraphData.nodes[j-1].type === 'penalty' && lastChild) {
syllable += '\u200c' + paragraphData.nodes[j].value;
lastChild.innerHTML = syllable;
left += paragraphData.nodes[j].width;
} else {
let word = document.createElement("span");
word.style.position = 'absolute';
word.classList.add("fade-in");
word.style.animationDuration = this.animationQueue.getSpeed() * 10 + 'ms';
word.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%';
word.style.left = left * 100 / lineWidth + '%';
syllable = paragraphData.nodes[j].value;
word.innerHTML = syllable;
lastChild = word;
// Get animation queue from module registry
this.animationQueue = this.getModule('animation-queue');
if (!this.animationQueue) {
console.warn("Layout Renderer: Animation Queue module not found in registry");
}
// We'll try to get the TTS module, but it's not a hard dependency
// We'll check for it again at runtime when needed
setTimeout(() => {
// Try to get TTS module after a delay to allow it to initialize
this.ttsPlayer = this.getModule('tts-player');
if (!this.ttsPlayer) {
console.log("Layout Renderer: TTS Player module not found yet, will try again when needed");
}
}, 500);
this.reportProgress(100, "Layout Renderer ready");
return true;
} catch (error) {
console.error("Error initializing Layout Renderer:", error);
return false;
}
}
/**
* Initialize default containers
*/
initializeContainers() {
// Check if story container exists
const storyContainer = document.getElementById('story');
if (!storyContainer) {
console.log('Story container not found, creating it');
const container = document.createElement('div');
container.id = 'story';
document.body.appendChild(container);
}
}
/**
* Render a paragraph from layout data
* @param {Object} layout - Layout data from paragraph-layout
* @param {Object} options - Rendering options
* @returns {HTMLElement} - The created paragraph element
*/
renderParagraph(layout, options = {}) {
const {
container = document.getElementById('paragraphs'),
id = `p-${Date.now()}`,
className = '',
style = {},
animateWords = true,
animationSpeed = this.config.animation.defaultSpeed,
tts = false,
onComplete = null
} = options;
if (!layout || !layout.breaks || !layout.nodes || !container) {
console.error('Invalid layout data or container');
return null;
}
// Create paragraph element
const paragraphElement = document.createElement('p');
paragraphElement.id = id;
paragraphElement.className = `paragraph ${className}`.trim();
paragraphElement.style.position = 'relative';
// Get line height and container width for positioning
const lineHeight = parseFloat(window.getComputedStyle(document.querySelector('#story')).lineHeight) || 1.5;
const containerWidth = parseFloat(window.getComputedStyle(container).width);
// Calculate paragraph height based on number of lines
const numLines = layout.breaks.length - 1;
paragraphElement.style.height = `${lineHeight * numLines}px`;
// Apply custom styles
Object.assign(paragraphElement.style, style);
// Create a fragment to build the paragraph
const fragment = document.createDocumentFragment();
// Track total delay for animations
let totalDelay = 0;
let wordElements = [];
// Process each line in the layout
for (let i = 1; i < layout.breaks.length; i++) {
// Track the current x position within the line
let xPosition = 0;
// Process nodes in this line
for (let j = layout.breaks[i-1].position; j < layout.breaks[i].position; j++) {
const node = layout.nodes[j];
// Handle different node types
switch (node.type) {
case 'box':
// This is a word
if (node.value && node.value.trim() !== '') {
const wordElement = this.renderWord(node.value, animateWords);
// Position the word within the line
wordElement.style.position = 'absolute';
wordElement.style.left = `${xPosition * 100 / containerWidth}%`;
wordElement.style.top = `${(i - 1) * lineHeight}px`;
// Update x position for next word
xPosition += node.width;
paragraphElement.appendChild(wordElement);
wordElements.push(wordElement);
}
break;
case 'glue':
// This is a space - calculate its width based on the ratio
const ratio = layout.breaks[i].ratio;
let spaceWidth = node.width;
if (!this.fastForwardingAll) {
this.insertAfter(delay, stack[stack.length-1], word);
if (ratio > 0) {
// Stretch space
spaceWidth += ratio * node.stretch;
} else if (ratio < 0) {
// Shrink space
spaceWidth += ratio * node.shrink;
}
delay += this.animationQueue.getSpeed();
left += paragraphData.nodes[j].width;
}
} else if (paragraphData.nodes[j].type === 'tag') {
if (paragraphData.nodes[j].value.substr(0, 2) == '</') {
stack.pop();
} else {
let tmp = document.createElement('div');
tmp.innerHTML = paragraphData.nodes[j].value;
const word = tmp.firstChild;
word.style.left = left * 100 / lineWidth + '%';
stack[stack.length-1].appendChild(word);
stack.push(word);
}
} else if (j > paragraphData.breaks[i-1].position && paragraphData.nodes[j].type === 'glue' && paragraphData.nodes[j].width !== 0 && j <= paragraphData.breaks[i].position) {
// Insert space character
if (paragraphData.breaks[i].ratio > 0) {
left += paragraphData.nodes[j].width + paragraphData.breaks[i].ratio * paragraphData.nodes[j].stretch;
} else {
left += paragraphData.nodes[j].width + paragraphData.breaks[i].ratio * paragraphData.nodes[j].shrink;
}
let word = document.createElement("span");
word.style.position = 'absolute';
word.classList.add("fade-in");
word.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%';
word.style.left = left * 100 / lineWidth + '%';
word.innerHTML = " ";
if (!this.fastForwardingAll) {
this.insertAfter(delay, stack[stack.length-1], word);
}
} else if (paragraphData.nodes[j].type === 'penalty' && paragraphData.nodes[j].penalty === 100 && j === paragraphData.breaks[i].position) {
// Create a hyphen at the end of the line if breaking at a hyphenation point
let hyphen = document.createElement("span");
hyphen.style.position = 'absolute';
hyphen.classList.add("fade-in");
hyphen.classList.add("hyphen-marker"); // Add a class for easier styling if needed
hyphen.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%';
hyphen.style.left = left * 100 / lineWidth + '%';
hyphen.innerHTML = "-";
// Ensure hyphen is visible with stronger styling
hyphen.style.fontWeight = "normal";
hyphen.style.opacity = "1";
if (!this.fastForwardingAll) {
this.insertAfter(delay, stack[stack.length-1], hyphen);
// Log for debugging
console.log("Inserted hyphen at line break:", i, "position:", left);
}
delay += this.animationQueue.getSpeed();
xPosition += spaceWidth;
break;
case 'penalty':
// This is a hyphen or line break opportunity
if (node.flagged && node.penalty < Infinity && j === layout.breaks[i].position) {
const hyphenElement = document.createElement('span');
hyphenElement.className = 'hyphen-marker';
hyphenElement.textContent = '-';
hyphenElement.style.position = 'absolute';
hyphenElement.style.left = `${xPosition * 100 / containerWidth}%`;
hyphenElement.style.top = `${(i - 1) * lineHeight}px`;
paragraphElement.appendChild(hyphenElement);
wordElements.push(hyphenElement);
}
break;
case 'tag':
// This is a preserved tag
if (typeof node.value === 'string') {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = node.value;
while (tempDiv.firstChild) {
const tagElement = tempDiv.firstChild;
tagElement.style.position = 'absolute';
tagElement.style.left = `${xPosition * 100 / containerWidth}%`;
tagElement.style.top = `${(i - 1) * lineHeight}px`;
paragraphElement.appendChild(tagElement);
// Estimate width for positioning next element
xPosition += 20; // Approximate width of tag
}
}
break;
}
}
}
return [p, delay];
}
/**
* Insert an element after a delay
* @param {number} delay - The delay in milliseconds
* @param {HTMLElement} target - The target element to append to
* @param {HTMLElement} el - The element to insert
* @param {boolean} fadeIn - Whether to fade in the element
*/
insertAfter(delay, target, el, fadeIn = true) {
if (fadeIn) {
el.classList.add("fade-in");
this.animationQueue.schedule(function() {
target.appendChild(el);
}, delay);
} else {
this.animationQueue.schedule(function() {
target.appendChild(el);
}, delay);
}
}
/**
* Show an element after a delay
* @param {number} delay - The delay in milliseconds
* @param {HTMLElement} el - The element to show
*/
showAfter(delay, el) {
el.classList.add("hide");
setTimeout(function() {
setTimeout(function() { el.classList.remove("hide") }, delay);
});
}
/**
* Render a visual tag
* @param {string} tagType - The type of tag (IMAGE, BACKGROUND, etc.)
* @param {string} tagValue - The value of the tag
* @param {HTMLElement} container - The container to append to
* @param {number} delay - The delay in milliseconds
* @returns {HTMLElement|null} The created element or null
*/
renderVisualTag(tagType, tagValue, container, delay = 0) {
switch (tagType) {
case "IMAGE":
const imageElement = document.createElement('img');
imageElement.src = tagValue;
container.appendChild(imageElement);
this.showAfter(delay, imageElement);
return imageElement;
case "BACKGROUND":
const outerScrollContainer = document.querySelector('#book');
outerScrollContainer.style.backgroundImage = 'url(' + tagValue + ')';
return null;
case "CHAPTER":
const h = document.createElement('H2');
h.appendChild(document.createTextNode(tagValue));
h.classList.add("chapter-heading");
h.classList.add("fade-in");
container.appendChild(h);
return h;
case "SEPARATOR":
const d = document.createElement('double');
d.appendChild(document.createTextNode('\u2766'));
d.classList.add("fade-in");
d.classList.add("separator");
container.appendChild(d);
return d;
default:
return null;
}
}
/**
* Set the fast forwarding state
* @param {boolean} state - The fast forwarding state
*/
setFastForwardingAll(state) {
this.fastForwardingAll = state;
}
/**
* Get the fast forwarding state
* @returns {boolean} The fast forwarding state
*/
getFastForwardingAll() {
return this.fastForwardingAll;
}
/**
* Smooth scroll to an element
* @param {HTMLElement} target - The target element to scroll to
* @param {number} duration - The duration of the scroll animation
*/
smoothScroll(target, duration) {
const display = document.getElementById('page_right');
const targetPosition = target.getBoundingClientRect().top;
const startPosition = display.scrollTop;
const distance = targetPosition;
let startTime = null;
// Add the paragraph to the container
container.appendChild(paragraphElement);
if (duration < 5) {
display.scrollTo(0, targetPosition);
return;
// Schedule animations for words if enabled
if (animateWords && this.animationQueue) {
// Schedule animations for each word with a faster timing
const baseDelay = 0; // Starting delay
const wordDelay = 20; // Delay between words in ms (reduced from 40)
wordElements.forEach((wordElement, index) => {
const delay = baseDelay + (index * wordDelay);
totalDelay = Math.max(totalDelay, delay);
this.scheduleWordAnimation(wordElement, delay, animationSpeed);
});
// Schedule TTS if enabled - start it earlier in the animation sequence
if (tts && this.ttsPlayer) {
// Get the full text for TTS
const fullText = layout.originalText || layout.processedText || paragraphElement.textContent;
// Schedule TTS with the animation queue - start after just a few words appear
this.animationQueue.schedule(() => {
this.ttsPlayer.speak(fullText, (result) => {
if (!result || !result.success) {
console.warn('TTS playback issue:', result ? result.reason : 'unknown');
}
});
}, Math.min(100, wordDelay * 3)); // Start TTS earlier
}
// Schedule completion callback
if (onComplete && typeof onComplete === 'function') {
const completionDelay = totalDelay + 200; // Reduced completion delay
this.animationQueue.schedule(onComplete, completionDelay);
}
} else if (onComplete && typeof onComplete === 'function') {
// If not animating, call onComplete immediately
setTimeout(onComplete, 0);
}
function animation(currentTime) {
if (startTime === null) startTime = currentTime;
const timeElapsed = currentTime - startTime;
const run = ease(timeElapsed, startPosition, distance, duration);
display.scrollTo(0, run);
if (timeElapsed < duration) requestAnimationFrame(animation);
return paragraphElement;
}
/**
* Render a single word
* @param {string} word - Word to render
* @param {boolean} animate - Whether to prepare for animation
* @returns {HTMLElement} - The created word element
*/
renderWord(word, animate = true) {
const wordElement = this.createWordElement(word);
// Apply initial styles for animation
if (animate) {
wordElement.style.opacity = '0';
wordElement.style.transform = 'translateY(5px)';
wordElement.style.display = 'inline-block';
}
function ease(t, b, c, d) {
t /= d / 2;
if (t < 1) return c / 2 * t * t + b;
t--;
return -c / 2 * (t * (t - 2) - 1) + b;
}
return wordElement;
}
/**
* Create a word element
* @param {string} word - Word to render
* @returns {HTMLElement} - The created word element
*/
createWordElement(word) {
const wordElement = document.createElement('span');
wordElement.className = 'word';
wordElement.textContent = word;
return wordElement;
}
/**
* Schedule a word animation with the animation queue
* @param {HTMLElement} wordElement - Word element to animate
* @param {number} delay - Delay before animation starts
* @param {number} speed - Animation speed factor
*/
scheduleWordAnimation(wordElement, delay, speed) {
if (!this.animationQueue) return;
requestAnimationFrame(animation);
const actualDelay = delay * speed;
this.animationQueue.schedule(() => {
wordElement.style.opacity = '1';
wordElement.style.transform = 'translateY(0)';
wordElement.style.transition = `opacity 0.2s ease-out, transform 0.3s ease-out`;
}, actualDelay);
}
}
// Create the singleton instance
const LayoutRenderer = new LayoutRendererModule();
// Register with the module registry
moduleRegistry.register(LayoutRenderer);
// Export the module
export { LayoutRenderer };
// Keep a reference in window for loader system
window.LayoutRenderer = LayoutRenderer;
+57 -2
View File
@@ -7,6 +7,10 @@
import { moduleRegistry } from './module-registry.js';
import { ModuleEvent } from './base-module.js';
// Ensure moduleRegistry is available globally before anything else runs
window.moduleRegistry = moduleRegistry;
console.log('Module registry initialized and assigned to window.moduleRegistry');
/**
* Module States
*/
@@ -45,6 +49,20 @@ const ModuleLoader = (function() {
}
console.log('Module Loader: Initialization started');
// Check for circular dependencies before proceeding
const circularDependencies = moduleRegistry.checkForCircularDependencies();
if (circularDependencies) {
const errorMsg = `Circular dependency detected: ${circularDependencies.join(' -> ')} -> ${circularDependencies[0]}`;
console.error(errorMsg);
document.body.innerHTML = `<div style="padding: 20px; color: white; background-color: #ff3333;">
<h2>Fatal Error: Circular Module Dependency</h2>
<p>${errorMsg}</p>
<p>Please check the browser console for more details.</p>
</div>`;
return;
}
// Create the loading overlay
createLoadingOverlay();
@@ -77,6 +95,7 @@ const ModuleLoader = (function() {
* @returns {Promise} - Resolves when all module scripts are loaded
*/
async function loadModuleScripts() {
// Define modules with their weights
const modulesToLoad = [
// Core functionality modules
@@ -84,10 +103,12 @@ const ModuleLoader = (function() {
{ id: 'localization', script: '/js/localization.js', weight: 40 },
{ id: 'text-processor', script: '/js/text-processor.js', weight: 40 },
{ id: 'paragraph-layout', script: '/js/paragraph-layout.js', weight: 40 },
{ id: 'layout-renderer', script: '/js/layout-renderer.js', weight: 45 }, // Add Layout Renderer module
{ id: 'animation-queue', script: '/js/animation-queue.js', weight: 50 },
// Audio and TTS modules
{ id: 'audio-manager', script: '/js/audio-manager.js', weight: 60 },
{ id: 'tts-factory', script: '/js/tts-factory.js', weight: 70 }, // TTSFactory must be loaded before TTSPlayer
{ id: 'tts', script: '/js/tts-player.js', weight: 75 },
// UI and interaction modules
@@ -143,6 +164,13 @@ const ModuleLoader = (function() {
// Find the game loop module instance
gameLoopModule = moduleRegistry.getModule('game-loop');
// Log dependency information for debugging
console.log('Module dependencies:');
Object.keys(modules).forEach(moduleId => {
const dependencies = moduleRegistry.getDependencies(moduleId);
console.log(`${moduleId} depends on: ${dependencies.length ? dependencies.join(', ') : 'none'}`);
});
// For each registered module, start initialization
Object.values(modules).forEach(async (module) => {
try {
@@ -165,9 +193,15 @@ const ModuleLoader = (function() {
}
};
// Log start of initialization
console.log(`Starting initialization of module: ${module.id}`);
// Initialize the module with progress callback
await module.initializeInterface(progressCallback);
// Log completion of initialization
console.log(`Completed initialization of module: ${module.id}`);
} catch (error) {
console.error(`Error initializing module ${module.id}:`, error);
}
@@ -307,7 +341,18 @@ const ModuleLoader = (function() {
});
if (allFinished && !isLoadingComplete) {
console.log('All modules finished loading. Proceeding to finalization...');
finalizeLoading();
} else if (!allFinished) {
// Log which modules are not finished yet
const pendingModules = Object.values(modules).filter(module => {
const state = module.getState();
return state !== ModuleState.FINISHED && state !== ModuleState.ERROR;
});
if (pendingModules.length > 0) {
console.log('Modules still pending:', pendingModules.map(m => `${m.id} (${m.getState()})`))
}
}
}
@@ -316,7 +361,13 @@ const ModuleLoader = (function() {
*/
function finalizeLoading() {
console.log('Loading completed. Finalizing...');
completeFinalization();
try {
completeFinalization();
} catch (error) {
console.error('Error during finalization:', error);
// Force hide the overlay even if there was an error
hideOverlay();
}
}
/**
@@ -331,7 +382,11 @@ const ModuleLoader = (function() {
// Hide the overlay first, then start the game loop
hideOverlay(() => {
console.log("Loader: Overlay hidden, starting Game Loop.");
gameLoopModule.start();
try {
gameLoopModule.start();
} catch (error) {
console.error("Error starting Game Loop:", error);
}
});
} else {
console.error("Loader: Game Loop module not found or start method missing.");
+188 -183
View File
@@ -1,214 +1,188 @@
/**
* Localization Module
* Manages translations and locale settings for the application
* Localization Module for AI Interactive Fiction
* Handles translations and locale settings
*/
import { BaseModule } from './base-module.js';
import { moduleRegistry } from './module-registry.js';
class LocalizationModule extends BaseModule {
/**
* Create a new localization module
*/
constructor() {
super('localization', 'Localization');
this.currentLocale = 'en-us'; // Default locale
// Current locale
this.currentLocale = 'en-us';
// Available translations
this.translations = {};
this.observers = new Set(); // Modules that need to be notified of locale changes
// Language names mapping
this.languageNames = {
'en-us': 'English (US)',
'en-gb': 'English (UK)',
'de': 'Deutsch',
'de-de': 'Deutsch (Deutschland)',
'fr': 'Français',
'fr-fr': 'Français (France)',
'es': 'Español',
'es-es': 'Español (España)',
'it': 'Italiano',
'ja': 'Japanese',
'ko': 'Korean',
'zh': 'Chinese (Simplified)',
'zh-tw': 'Chinese (Traditional)',
'ru': 'Russian',
'pt': 'Portuguese',
'pt-br': 'Portuguese (Brazil)'
};
// Bind methods
this.bindMethods([
'setLocale',
'getLocale',
'translate',
'getAvailableLocales',
'getLanguageName',
'getLanguage'
]);
}
/**
* Initialize the module
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize() {
try {
// Load translations
this.loadTranslations();
this.reportProgress(10, "Initializing localization");
// Set global locale for SmartyPants
window.locale = this.currentLocale;
// Load default translations
await this.loadTranslations('en-us');
this.reportProgress(100, "Localization module ready");
// Try to load browser locale if available
const browserLocale = navigator.language.toLowerCase();
if (browserLocale && browserLocale !== 'en-us') {
try {
this.reportProgress(50, `Loading browser locale: ${browserLocale}`);
await this.loadTranslations(browserLocale);
this.currentLocale = browserLocale;
} catch (localeError) {
console.warn(`Failed to load browser locale ${browserLocale}:`, localeError);
}
}
// We don't check for persistence manager here to avoid circular dependency
// The persistence manager will update our locale after it initializes if needed
this.reportProgress(100, "Localization ready");
return true;
} catch (error) {
console.error("Error initializing localization module:", error);
console.error("Error initializing localization:", error);
this.reportProgress(100, "Localization failed");
return false;
}
}
/**
* Load all translations
*/
loadTranslations() {
// Add English translations (default)
this.addTranslations('en-us', {
// UI elements
'by': 'powered by Generative AI',
'title': 'AI Interactive Fiction',
'subtitle': 'An open-world text adventure',
'speech': 'speech',
'speed': 'speed',
'restart': 'restart',
'save': 'save',
'load': 'load',
'prompt': 'What do you want to do next?',
'remark': '<i><sup>*</sup>click on page or press spacebar to fast forward text animation</i>',
// Tooltips
'title_speech': 'Toggle text to speech',
'title_speech_unavailable': 'Text-to-Speech not available',
'title_restart': 'Restart story from beginning',
'title_save': 'Save progress',
'title_load': 'Reload from save point',
// Confirm dialogs
'confirm_restart': 'Are you sure you want to restart the game? All progress will be lost.'
});
// Add German translations
this.addTranslations('de', {
'by': 'unterstützt durch KI',
'title': 'KI Interaktive Fiktion',
'subtitle': 'Ein Textabenteuer in offener Welt',
'speech': 'Sprache',
'speed': 'Tempo',
'restart': 'Neustart',
'save': 'Speichern',
'load': 'Laden',
'prompt': 'Was möchtest du als nächstes tun?',
'remark': '<i><sup>*</sup>Klicke auf die Seite oder drücke die Leertaste, um die Textanimation zu beschleunigen</i>',
'title_speech': 'Text-zu-Sprache umschalten',
'title_speech_unavailable': 'Text-zu-Sprache nicht verfügbar',
'title_restart': 'Geschichte von Anfang an neu starten',
'title_save': 'Fortschritt speichern',
'title_load': 'Von Speicherpunkt neu laden',
'confirm_restart': 'Bist du sicher, dass du das Spiel neu starten möchtest? Der gesamte Fortschritt geht verloren.'
});
// Add French translations
this.addTranslations('fr', {
'by': 'propulsé par l\'IA',
'title': 'Fiction Interactive IA',
'subtitle': 'Une aventure textuelle en monde ouvert',
'speech': 'parole',
'speed': 'vitesse',
'restart': 'recommencer',
'save': 'sauver',
'load': 'charger',
'prompt': 'Que voulez-vous faire ensuite?',
'remark': '<i><sup>*</sup>cliquez sur la page ou appuyez sur la barre d\'espace pour accélérer l\'animation du texte</i>',
'title_speech': 'Activer/désactiver la synthèse vocale',
'title_speech_unavailable': 'Synthèse vocale non disponible',
'title_restart': 'Redémarrer l\'histoire depuis le début',
'title_save': 'Sauvegarder la progression',
'title_load': 'Recharger depuis le point de sauvegarde',
'confirm_restart': 'Êtes-vous sûr de vouloir redémarrer le jeu? Tous les progrès seront perdus.'
});
}
/**
* Add translations for a specific locale
* @param {string} locale - Locale code
* @param {Object} translations - Translation key-value pairs
* Load translations for a locale
* @param {string} locale - Locale to load
* @returns {Promise<void>}
*/
addTranslations(locale, translations) {
if (!this.translations[locale]) {
this.translations[locale] = {};
async loadTranslations(locale) {
if (this.translations[locale]) {
return; // Already loaded
}
Object.assign(this.translations[locale], translations);
}
/**
* Get translation for a key in current locale
* @param {string} key - Translation key
* @param {string} [defaultValue] - Default value if translation not found
* @returns {string} - Translated text or default value
*/
translate(key, defaultValue = null) {
const localeTranslations = this.translations[this.currentLocale];
if (localeTranslations && localeTranslations[key] !== undefined) {
return localeTranslations[key];
try {
// Normalize locale
const normalizedLocale = locale.toLowerCase();
// Try to load the exact locale
const response = await fetch(`/locales/${normalizedLocale}.json`);
if (response.ok) {
const translations = await response.json();
this.translations[normalizedLocale] = translations;
} else {
// Try to load the language part only
const langPart = normalizedLocale.split('-')[0];
if (langPart !== normalizedLocale) {
const langResponse = await fetch(`/locales/${langPart}.json`);
if (langResponse.ok) {
const translations = await langResponse.json();
this.translations[normalizedLocale] = translations;
} else {
// Fallback to English
if (normalizedLocale !== 'en-us' && normalizedLocale !== 'en') {
await this.loadTranslations('en-us');
this.translations[normalizedLocale] = this.translations['en-us'];
} else {
// If English is not found, create an empty translation set
console.warn('English translations not found, using empty set');
this.translations[normalizedLocale] = {};
}
}
}
}
} catch (error) {
console.error(`Error loading translations for ${locale}:`, error);
throw error;
}
// Fall back to English if translation not found
if (this.currentLocale !== 'en-us' && this.translations['en-us'] && this.translations['en-us'][key]) {
return this.translations['en-us'][key];
}
// Return default value or key if no translation found
return defaultValue || key;
}
/**
* Set the current locale
* @param {string} locale - Locale code
* @param {string} locale - Locale to set
* @returns {Promise<boolean>} - Success status
*/
setLocale(locale) {
if (this.translations[locale]) {
this.currentLocale = locale;
// Update global locale for SmartyPants
window.locale = locale;
// Notify observers of locale change
this.notifyObservers();
console.log(`Localization: Locale set to ${locale}`);
return true;
}
async setLocale(locale) {
if (!locale) return false;
console.warn(`Localization: Locale ${locale} not available`);
return false;
try {
// Normalize locale
const normalizedLocale = locale.toLowerCase();
// Load translations if not already loaded
if (!this.translations[normalizedLocale]) {
await this.loadTranslations(normalizedLocale);
}
// Set current locale
this.currentLocale = normalizedLocale;
// Update persistence
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('app', 'locale', normalizedLocale);
}
// Dispatch locale change event
this.dispatchEvent('locale-changed', {
locale: normalizedLocale
});
return true;
} catch (error) {
console.error(`Error setting locale to ${locale}:`, error);
return false;
}
}
/**
* Get the current locale
* @returns {string} - Current locale code
* @returns {string} - Current locale
*/
getLocale() {
return this.currentLocale;
}
/**
* Register a module to be notified of locale changes
* @param {Object} module - Module to register
* @param {Function} updateMethod - Method to call on locale change
* Get the language part of the current locale (e.g., 'en' from 'en-us')
* @returns {string} - Language code
*/
registerObserver(module, updateMethod) {
if (typeof updateMethod !== 'function') {
console.error('Localization: Update method must be a function');
return;
}
this.observers.add({ module, updateMethod });
}
/**
* Unregister an observer module
* @param {Object} module - Module to unregister
*/
unregisterObserver(module) {
this.observers.forEach(observer => {
if (observer.module === module) {
this.observers.delete(observer);
}
});
}
/**
* Notify all observer modules of locale change
*/
notifyObservers() {
this.observers.forEach(observer => {
try {
observer.updateMethod(this.currentLocale);
} catch (error) {
console.error(`Error notifying observer for locale change:`, error);
}
});
getLanguage() {
return this.currentLocale.split('-')[0];
}
/**
@@ -216,34 +190,68 @@ class LocalizationModule extends BaseModule {
* @returns {Array<string>} - Array of locale codes
*/
getAvailableLocales() {
return Object.keys(this.translations);
// Return the keys of the language names object
// This is a simplification - in a real app, we would dynamically load available locales
return Object.keys(this.languageNames);
}
/**
* Get all translations for a specific locale
* Get the language name for a locale
* @param {string} locale - Locale code
* @returns {Object} - Translations for the locale
* @returns {string} - Language name
*/
getTranslationsForLocale(locale) {
return this.translations[locale] || {};
getLanguageName(locale) {
if (!locale) return '';
// Normalize locale
const normalizedLocale = locale.toLowerCase();
// Try exact match
if (this.languageNames[normalizedLocale]) {
return this.languageNames[normalizedLocale];
}
// Try language part only
const langPart = normalizedLocale.split('-')[0];
if (this.languageNames[langPart]) {
return this.languageNames[langPart];
}
// Fallback: return the locale code itself
return locale;
}
/**
* Get the current locale's direction (ltr or rtl)
* @returns {string} - Text direction ('ltr' or 'rtl')
* Translate a key
* @param {string} key - Translation key
* @param {Object} params - Parameters for interpolation
* @returns {string} - Translated text
*/
getTextDirection() {
// List of RTL languages
const rtlLocales = ['ar', 'he', 'fa', 'ur'];
translate(key, params = {}) {
if (!key) return '';
// Check if current locale starts with any RTL language code
for (const rtl of rtlLocales) {
if (this.currentLocale.startsWith(rtl)) {
return 'rtl';
// Get translations for current locale
const translations = this.translations[this.currentLocale] || {};
// Get translation or fallback to key
let translation = translations[key] || key;
// Interpolate parameters
if (params && Object.keys(params).length > 0) {
for (const [param, value] of Object.entries(params)) {
translation = translation.replace(new RegExp(`\{\{${param}\}\}`, 'g'), value);
}
}
return 'ltr';
return translation;
}
/**
* Clean up when module is disposed
*/
dispose() {
// Clear translations
this.translations = {};
}
}
@@ -255,6 +263,3 @@ moduleRegistry.register(Localization);
// Export the module
export { Localization };
// Keep a reference in window for loader system
window.Localization = Localization;
+133 -1
View File
@@ -6,20 +6,52 @@ export class ModuleRegistry {
constructor() {
this.modules = {};
this.readyPromises = {};
this.moduleDependencies = new Map(); // Track module dependencies
this.visitedModules = new Set(); // For circular dependency detection
this.recursionStack = new Set(); // For circular dependency detection
this.untrackedDependencies = new Map(); // Track unregistered dependencies
}
/**
* Register a module
* @param {BaseModule} module - Module to register
* @param {Array<string>} [dependencies] - Optional array of module dependencies
*/
register(module) {
register(module, dependencies = null) {
if (!module || !module.id) {
console.error('Invalid module - must have an id property');
return;
}
// Store the module
this.modules[module.id] = module;
// Store dependencies if provided, otherwise use module.dependencies
if (dependencies) {
this.moduleDependencies.set(module.id, dependencies);
// Also set them on the module itself for backwards compatibility
if (module.dependencies === undefined) {
module.dependencies = [...dependencies];
}
} else if (module.dependencies) {
// Use the module's own dependencies property
this.moduleDependencies.set(module.id, [...module.dependencies]);
} else {
// No dependencies
this.moduleDependencies.set(module.id, []);
}
// Check for circular dependencies
this.visitedModules.clear();
this.recursionStack.clear();
const circularDependency = this.detectCircularDependency(module.id);
if (circularDependency) {
const errorMsg = `Circular dependency detected: ${circularDependency.join(' -> ')} -> ${circularDependency[0]}`;
console.error(errorMsg);
throw new Error(errorMsg);
}
// Create a promise that will resolve when this module is ready
this.readyPromises[module.id] = new Promise((resolve) => {
// Set up a state change listener for this module
@@ -39,6 +71,76 @@ export class ModuleRegistry {
});
}
/**
* Detect circular dependencies using DFS algorithm
* @param {string} moduleId - Starting module ID
* @param {Array<string>} [path=[]] - Current dependency path
* @returns {Array<string>|null} - Array representing the circular dependency path, or null if none
*/
detectCircularDependency(moduleId, path = []) {
// If we've already checked this module completely, no need to check again
if (this.visitedModules.has(moduleId)) {
return null;
}
// If we're already visiting this module in the current path, we found a cycle
if (this.recursionStack.has(moduleId)) {
// Return the path that forms the cycle
const cycleStartIndex = path.indexOf(moduleId);
if (cycleStartIndex >= 0) {
return path.slice(cycleStartIndex);
}
return path;
}
// Add to recursion stack to mark as being visited
this.recursionStack.add(moduleId);
path.push(moduleId);
// Get dependencies for this module
const dependencies = this.getDependencies(moduleId);
// Check each dependency
for (const depId of dependencies) {
// Even if the dependency isn't registered yet, we need to track it
// for potential circular dependencies that will manifest later
// Create a temporary placeholder in the path for unregistered dependencies
const depPath = [...path];
if (!this.modules[depId]) {
// Log that we're tracking an unregistered dependency
console.log(`Module Registry: Tracking potential circular dependency with unregistered module: ${depId}`);
// Add to the dependency tracking for future checks
this.trackDependency(moduleId, depId);
continue;
}
const result = this.detectCircularDependency(depId, depPath);
if (result) {
return result;
}
}
// Remove from recursion stack as we're done with this module
this.recursionStack.delete(moduleId);
// Mark as fully visited
this.visitedModules.add(moduleId);
return null;
}
/**
* Track an unregistered dependency
* @param {string} moduleId - Module ID
* @param {string} depId - Unregistered dependency ID
*/
trackDependency(moduleId, depId) {
if (!this.untrackedDependencies.has(moduleId)) {
this.untrackedDependencies.set(moduleId, new Set());
}
this.untrackedDependencies.get(moduleId).add(depId);
}
/**
* Get a module by id
* @param {string} id - Module id
@@ -56,6 +158,33 @@ export class ModuleRegistry {
return this.modules;
}
/**
* Get dependencies for a module
* @param {string} id - Module id
* @returns {Array<string>} - Array of dependencies
*/
getDependencies(id) {
return this.moduleDependencies.get(id) || [];
}
/**
* Check if the dependency graph has any circular dependencies
* @returns {Array<string>|null} - Array representing the circular dependency path, or null if none
*/
checkForCircularDependencies() {
this.visitedModules.clear();
for (const moduleId in this.modules) {
this.recursionStack.clear();
const result = this.detectCircularDependency(moduleId);
if (result) {
return result;
}
}
return null;
}
/**
* Wait for a module to be ready (in FINISHED state)
* @param {string} id - Module id to wait for
@@ -92,3 +221,6 @@ export class ModuleRegistry {
// Create and export a singleton instance
export const moduleRegistry = new ModuleRegistry();
// Make registry accessible globally
window.moduleRegistry = moduleRegistry;
+331 -210
View File
@@ -11,10 +11,15 @@ class OptionsUIModule extends BaseModule {
*/
constructor() {
super('options-ui', 'Options UI');
// Dependencies
this.dependencies = ['persistence-manager', 'localization'];
this.persistenceManager = null;
this.ttsPlayer = null;
this.audioManager = null;
this.ttsFactory = null;
this.localization = null;
this.modal = null;
this.isOpen = false;
@@ -25,8 +30,19 @@ class OptionsUIModule extends BaseModule {
backdrop: true
};
// Elements reference
this.elements = null;
// Bound event handlers for proper this context
this.handleTtsSystemChanged = this.handleTtsSystemChanged.bind(this);
this.bindMethods([
'handleTtsSystemChanged',
'loadPreferences',
'populateTtsSystems',
'populateVoices',
'resetToDefaults',
'saveAndClose',
'applySettings'
]);
}
/**
@@ -47,31 +63,27 @@ class OptionsUIModule extends BaseModule {
}
}
/**
* Handle TTS system changes
* @param {CustomEvent} event - The event containing TTS system change details
*/
handleTtsSystemChanged(event) {
console.log("TTS system changed:", event.detail);
if (this.isOpen) {
// Refresh the voices list if the options UI is currently open
this.populateVoices();
}
}
/**
* Wait for dependencies to be ready
* @returns {Promise<boolean>} - Resolves when dependencies are ready
*/
async waitForDependencies() {
try {
// Wait for the persistence manager if available
this.persistenceManager = moduleRegistry.getModule('persistence-manager');
this.ttsPlayer = moduleRegistry.getModule('tts');
// Get required modules
this.persistenceManager = this.getModule('persistence-manager');
if (!this.persistenceManager) {
console.warn("Options UI: Persistence Manager not found");
}
this.localization = this.getModule('localization');
if (!this.localization) {
console.warn("Options UI: Localization module not found");
}
// These dependencies are optional - UI will adapt if not available
this.audioManager = moduleRegistry.getModule('audio-manager');
this.ttsFactory = this.getModule('tts-factory');
this.ttsPlayer = this.getModule('tts');
this.audioManager = this.getModule('audio-manager');
return true;
} catch (error) {
@@ -574,6 +586,273 @@ class OptionsUIModule extends BaseModule {
};
}
/**
* Load current preferences into UI
*/
loadPreferences() {
if (!this.persistenceManager || !this.elements) return;
// Wait for dependencies
this.waitForDependencies().then(() => {
// Get current preferences
const prefs = this.persistenceManager.getAllPreferences();
// Animation speed
if (this.elements.animationSpeed) {
this.elements.animationSpeed.value = prefs.animation.speed;
this.elements.animationSpeedValue.textContent = prefs.animation.speed;
}
// TTS enabled
if (this.elements.ttsEnabled) {
this.elements.ttsEnabled.checked = prefs.tts.enabled;
// Show/hide TTS options based on enabled state
const ttsOptionsContainer = document.querySelector('.tts-options-container');
if (ttsOptionsContainer) {
ttsOptionsContainer.style.display = prefs.tts.enabled ? 'block' : 'none';
}
}
// TTS system
this.populateTtsSystems();
// TTS volume
if (this.elements.ttsVolume) {
this.elements.ttsVolume.value = prefs.tts.volume * 100;
this.elements.ttsVolumeValue.textContent = Math.round(prefs.tts.volume * 100);
}
// TTS rate
if (this.elements.ttsRate) {
this.elements.ttsRate.value = prefs.tts.rate * 100;
this.elements.ttsRateValue.textContent = Math.round(prefs.tts.rate * 100);
}
// Language selection
if (this.elements.language && this.localization) {
const currentLocale = this.localization.getLocale();
const availableLocales = this.localization.getAvailableLocales();
// Clear existing options
this.elements.language.innerHTML = '';
// Add options for each available locale
availableLocales.forEach(locale => {
const option = document.createElement('option');
option.value = locale;
option.textContent = this.localization.getLanguageName(locale);
option.selected = locale === currentLocale;
this.elements.language.appendChild(option);
});
}
// Audio volumes
if (this.elements.masterVolume) {
this.elements.masterVolume.value = prefs.audio.masterVolume * 100;
this.elements.masterVolumeValue.textContent = Math.round(prefs.audio.masterVolume * 100);
}
if (this.elements.musicVolume) {
this.elements.musicVolume.value = prefs.audio.musicVolume * 100;
this.elements.musicVolumeValue.textContent = Math.round(prefs.audio.musicVolume * 100);
}
if (this.elements.sfxVolume) {
this.elements.sfxVolume.value = prefs.audio.sfxVolume * 100;
this.elements.sfxVolumeValue.textContent = Math.round(prefs.audio.sfxVolume * 100);
}
// Accessibility options
if (this.elements.highContrast) {
this.elements.highContrast.checked = prefs.accessibility.highContrast;
}
if (this.elements.largerText) {
this.elements.largerText.checked = prefs.accessibility.largerText;
}
});
}
/**
* Populate TTS systems dropdown
*/
populateTtsSystems() {
if (!this.elements || !this.elements.ttsSystem) return;
// Clear existing options
this.elements.ttsSystem.innerHTML = '';
// Get current TTS preferences
const currentProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser');
// Get available handlers from TTS factory
let availableHandlers = {};
if (this.ttsFactory) {
availableHandlers = this.ttsFactory.getAvailableHandlers();
} else {
// Fallback if TTS factory not available
availableHandlers = {
browser: true, // Assume browser TTS is available
api: false, // Assume API TTS is not available
kokoro: false // Assume Kokoro is not available
};
}
// Add option for each handler
const handlers = [
{ id: 'browser', name: 'Browser TTS', description: 'Uses your browser\'s built-in speech synthesis' },
{ id: 'api', name: 'API TTS', description: 'Uses a remote API for higher quality voices' },
{ id: 'kokoro', name: 'Kokoro TTS', description: 'Uses local AI-powered speech synthesis' }
];
handlers.forEach(handler => {
const option = document.createElement('option');
option.value = handler.id;
// Check if handler is available
const isAvailable = availableHandlers[handler.id] === true;
// Format option text
option.textContent = `${handler.name}${isAvailable ? '' : ' (unavailable)'}`;
option.title = handler.description;
// Disable option if handler is not available
option.disabled = !isAvailable;
// Select if this is the current provider
option.selected = handler.id === currentProvider;
this.elements.ttsSystem.appendChild(option);
});
// Populate voices for the selected system
this.populateVoices();
}
/**
* Populate voices dropdown for current TTS system
*/
populateVoices() {
if (!this.elements || !this.elements.ttsVoice) return;
// Clear existing options
this.elements.ttsVoice.innerHTML = '';
// Get current preferences
const currentVoice = this.persistenceManager.getPreference('tts', 'voice', '');
const currentProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser');
// Get current locale
const currentLocale = this.localization ? this.localization.getLocale() : 'en-us';
// Get voices from TTS factory
let voices = [];
if (this.ttsFactory) {
// Get active handler
const activeHandler = this.ttsFactory.getActiveHandler();
if (activeHandler) {
voices = activeHandler.getVoices();
}
}
// If no voices available, add a placeholder
if (voices.length === 0) {
const option = document.createElement('option');
option.value = '';
option.textContent = 'No voices available';
this.elements.ttsVoice.appendChild(option);
return;
}
// Sort voices by language and name
voices.sort((a, b) => {
// First sort by matching current locale
const aMatchesLocale = a.lang && a.lang.toLowerCase().startsWith(currentLocale.split('-')[0]);
const bMatchesLocale = b.lang && b.lang.toLowerCase().startsWith(currentLocale.split('-')[0]);
if (aMatchesLocale && !bMatchesLocale) return -1;
if (!aMatchesLocale && bMatchesLocale) return 1;
// Then sort by language name
const aLang = this.getLanguageNameFromCode(a.lang);
const bLang = this.getLanguageNameFromCode(b.lang);
if (aLang !== bLang) {
return aLang.localeCompare(bLang);
}
// Finally sort by voice name
return a.name.localeCompare(b.name);
});
// Group voices by language
const voicesByLang = {};
voices.forEach(voice => {
const langCode = voice.lang || 'unknown';
const langName = this.getLanguageNameFromCode(langCode);
if (!voicesByLang[langName]) {
voicesByLang[langName] = [];
}
voicesByLang[langName].push(voice);
});
// Add voices grouped by language
Object.keys(voicesByLang).sort().forEach(langName => {
// Create optgroup for language
const optgroup = document.createElement('optgroup');
optgroup.label = langName;
// Add voices for this language
voicesByLang[langName].forEach(voice => {
const option = document.createElement('option');
option.value = voice.name || voice.id;
option.textContent = voice.name;
option.selected = voice.name === currentVoice || voice.id === currentVoice;
optgroup.appendChild(option);
});
this.elements.ttsVoice.appendChild(optgroup);
});
}
/**
* Get language name from language code
* @param {string} code - Language code (e.g., 'en', 'de')
* @returns {string} - Language name
*/
getLanguageNameFromCode(code) {
// Use localization module if available
if (this.localization && typeof this.localization.getLanguageName === 'function') {
return this.localization.getLanguageName(code);
}
// Fallback language names
const languageNames = {
'en': 'English',
'de': 'German',
'fr': 'French',
'es': 'Spanish',
'it': 'Italian',
'ja': 'Japanese',
'ko': 'Korean',
'zh': 'Chinese',
'ru': 'Russian',
'ar': 'Arabic',
'hi': 'Hindi',
'pt': 'Portuguese',
'nl': 'Dutch',
'pl': 'Polish',
'sv': 'Swedish',
'tr': 'Turkish',
'uk': 'Ukrainian'
};
return languageNames[code] || code.toUpperCase();
}
/**
* Show the options UI
*/
@@ -616,190 +895,15 @@ class OptionsUIModule extends BaseModule {
}
/**
* Load current preferences into UI
* Handle TTS system changes
* @param {CustomEvent} event - The event containing TTS system change details
*/
loadPreferences() {
if (!this.persistenceManager || !this.elements) return;
handleTtsSystemChanged(event) {
console.log("TTS system changed:", event.detail);
const prefs = this.persistenceManager.getAllPreferences();
// Animation speed
const animSpeed = this.persistenceManager.getPreference('animation', 'speed', 50);
this.elements.animSpeed.value = animSpeed;
this.elements.animSpeedValue.textContent = `${animSpeed}%`;
// TTS settings
const ttsEnabled = this.persistenceManager.getPreference('tts', 'enabled', false);
const ttsProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser');
const ttsVoice = this.persistenceManager.getPreference('tts', 'voice', '');
const ttsVolume = this.persistenceManager.getPreference('tts', 'volume', 1.0);
const ttsRate = this.persistenceManager.getPreference('tts', 'rate', 1.0);
// TTS rate slider
this.elements.speechRate.value = Math.round(ttsRate * 100);
this.elements.speechRateValue.textContent = `${ttsRate.toFixed(1)}x`;
// TTS volume slider
this.elements.ttsVolume.value = Math.round(ttsVolume * 100);
this.elements.ttsVolumeValue.textContent = `${Math.round(ttsVolume * 100)}%`;
// Audio volumes
const masterVolume = this.persistenceManager.getPreference('audio', 'masterVolume', 1.0);
const musicVolume = this.persistenceManager.getPreference('audio', 'musicVolume', 0.7);
const sfxVolume = this.persistenceManager.getPreference('audio', 'sfxVolume', 1.0);
this.elements.masterVolume.value = Math.round(masterVolume * 100);
this.elements.masterVolumeValue.textContent = `${Math.round(masterVolume * 100)}%`;
this.elements.musicVolume.value = Math.round(musicVolume * 100);
this.elements.musicVolumeValue.textContent = `${Math.round(musicVolume * 100)}%`;
this.elements.sfxVolume.value = Math.round(sfxVolume * 100);
this.elements.sfxVolumeValue.textContent = `${Math.round(sfxVolume * 100)}%`;
// Accessibility settings
const highContrast = this.persistenceManager.getPreference('accessibility', 'highContrast', false);
const largerText = this.persistenceManager.getPreference('accessibility', 'largerText', false);
this.elements.highContrast.checked = highContrast;
this.elements.largerText.checked = largerText;
}
/**
* Populate TTS systems dropdown
*/
populateTtsSystems() {
if (!this.ttsPlayer || !this.elements) return;
const systems = this.ttsPlayer.getAvailableSystems();
const select = this.elements.ttsSystem;
// Clear existing options and listeners
select.innerHTML = '';
const newSelect = select.cloneNode(false);
select.parentNode.replaceChild(newSelect, select);
this.elements.ttsSystem = newSelect;
select = newSelect;
// Get current TTS info
const currentInfo = this.ttsPlayer.getTTSInfo();
const currentId = currentInfo.type || '';
// Create an option for each available system
systems.forEach(id => {
const option = document.createElement('option');
option.value = id;
switch (id) {
case 'browser':
option.textContent = 'Browser Built-in TTS';
break;
case 'kokoro':
option.textContent = 'Kokoro Neural TTS';
break;
case 'api':
option.textContent = 'API-based TTS';
break;
default:
option.textContent = id.charAt(0).toUpperCase() + id.slice(1);
}
if (id === currentId) {
option.selected = true;
}
select.appendChild(option);
});
// Add change listener
select.addEventListener('change', () => {
const selectedSystem = select.value;
if (this.ttsPlayer) {
this.ttsPlayer.switchTTS(selectedSystem);
// Update persistence
if (this.persistenceManager) {
this.persistenceManager.updatePreference('tts', 'provider', selectedSystem);
}
}
});
}
/**
* Populate voices dropdown for current TTS system
*/
async populateVoices() {
if (!this.ttsPlayer || !this.elements || !this.ttsPlayer.getVoices) return;
try {
const voices = await this.ttsPlayer.getVoices();
const select = this.elements.voiceSelect;
// Clear existing options and listeners
select.innerHTML = '';
const newSelect = select.cloneNode(false);
select.parentNode.replaceChild(newSelect, select);
this.elements.voiceSelect = newSelect;
select = newSelect;
if (!voices || voices.length === 0) {
const option = document.createElement('option');
option.value = '';
option.textContent = 'No voices available';
select.appendChild(option);
select.disabled = true;
return;
}
select.disabled = false;
// Get current preference
let currentVoice = '';
if (this.persistenceManager) {
currentVoice = this.persistenceManager.getPreference('tts', 'voice', '');
}
// Add voices to dropdown
voices.forEach(voice => {
const option = document.createElement('option');
option.value = voice.id || voice.name;
option.textContent = voice.name;
if (voice.id === currentVoice || voice.name === currentVoice) {
option.selected = true;
}
select.appendChild(option);
});
// Add change listener
select.addEventListener('change', () => {
const selectedVoice = select.value;
// Update TTS
if (this.ttsPlayer) {
this.ttsPlayer.setVoice(selectedVoice);
}
// Update persistence
if (this.persistenceManager) {
this.persistenceManager.updatePreference('tts', 'voice', selectedVoice);
}
});
console.log(`Voices populated for current TTS system. Selected: ${select.value}`);
} catch (error) {
console.error("Error populating voices:", error);
const select = this.elements.voiceSelect;
select.innerHTML = '';
const option = document.createElement('option');
option.value = '';
option.textContent = 'Error loading voices';
select.appendChild(option);
select.disabled = true;
if (this.isOpen) {
// Refresh the voices list if the options UI is currently open
this.populateVoices();
}
}
@@ -860,18 +964,35 @@ class OptionsUIModule extends BaseModule {
const ttsVolume = this.persistenceManager.getPreference('tts', 'volume', 1.0);
const ttsRate = this.persistenceManager.getPreference('tts', 'rate', 1.0);
if (this.ttsPlayer) {
// Set TTS system
if (ttsProvider) {
this.ttsPlayer.switchTTS(ttsProvider);
if (this.ttsFactory) {
// Set TTS provider if it's available
const availableHandlers = this.ttsFactory.getAvailableHandlers();
if (ttsProvider && availableHandlers[ttsProvider]) {
this.ttsFactory.setActiveHandler(ttsProvider);
}
// Apply voice options
this.ttsPlayer.setVoiceOptions({
voice: ttsVoice,
volume: ttsVolume,
rate: ttsRate
});
// Get the active handler
const activeHandler = this.ttsFactory.getActiveHandler();
if (activeHandler) {
// Set voice if specified
if (ttsVoice) {
activeHandler.setVoice(ttsVoice);
}
// Set options
activeHandler.setOptions({
volume: ttsVolume,
rate: ttsRate
});
}
}
// Apply language settings
if (this.localization && this.elements && this.elements.language) {
const selectedLocale = this.elements.language.value;
if (selectedLocale && selectedLocale !== this.localization.getLocale()) {
this.localization.setLocale(selectedLocale);
}
}
// Apply audio volume settings
+256 -64
View File
@@ -1,108 +1,300 @@
/**
* ParagraphLayout Module
* Interfaces with the Knuth-Plass line breaking algorithm to calculate optimal line breaks.
* Paragraph Layout Module
* Implements the Knuth and Plass line breaking algorithm for optimal typography
* and connects it to the text rendering pipeline.
*/
import { BaseModule } from './base-module.js';
import { moduleRegistry } from './module-registry.js';
class ParagraphLayoutModule extends BaseModule {
/**
* Create a new ParagraphLayout
*/
constructor() {
super('paragraph-layout', 'Paragraph Layout');
this.kapAlgorithm = null;
this.measureText = null;
// Module dependencies
this.dependencies = ['text-processor'];
// Caching canvas context for text measurements
this.textMeasureCtx = null;
// Configuration - use parent's config system
this.updateConfig({
maxLineWidth: 600,
hyphenationEnabled: true,
defaultFontSize: '1rem',
defaultFontFamily: "'EB Garamond', serif",
defaultLineHeight: 1.5,
debugMode: false
});
// Bind methods using parent's bindMethods utility
this.bindMethods([
'calculateLayout',
'measureText',
'setDebugMode',
'updateFont',
'processTextForLayout',
'initializeTextMeasurement',
'setupEventListeners',
'loadLayoutDependencies'
]);
}
/**
* Load module dependencies
* @returns {Promise} - Resolves when dependencies are loaded
*/
async loadDependencies() {
async initialize() {
try {
// First load linebreak.js if needed
if (!window.linebreak) {
await this.loadScript('/js/linebreak.js');
this.reportProgress(40, "Linebreak algorithm loaded");
this.reportProgress(20, "Initializing paragraph layout");
// Get text processor using parent's getModule method
this.textProcessor = this.getModule('text-processor');
if (!this.textProcessor) {
console.warn("Paragraph Layout: Text Processor not found, will use fallback processing");
}
// Then load knuth-and-plass.js if needed
if (!window.kap) {
await this.loadScript('/js/knuth-and-plass.js');
this.reportProgress(60, "KAP algorithm loaded");
}
// Load required dependencies
await this.loadLayoutDependencies();
this.kapAlgorithm = window.kap;
// Create off-screen canvas for text measurements
this.initializeTextMeasurement();
// Set up event listeners for config changes
this.setupEventListeners();
this.reportProgress(100, "Paragraph layout ready");
return true;
} catch (error) {
console.error("Error loading paragraph layout dependencies:", error);
console.error("Error initializing Paragraph Layout:", error);
return false;
}
}
/**
* Load a script dynamically
* @param {string} src - Script source URL
* @returns {Promise} - Resolves when script is loaded
* Load required dependencies for layout calculations
*/
loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
document.head.appendChild(script);
async loadLayoutDependencies() {
try {
this.reportProgress(30, "Loading layout dependencies");
// Load LinkedList.js first as it's required by linebreak.js
await this.loadScript('/js/linked-list.js');
// Load linebreak.js which is required by knuth-and-plass.js
await this.loadScript('/js/linebreak.js');
// Load knuth-and-plass.js which contains the kap function
await this.loadScript('/js/knuth-and-plass.js');
this.reportProgress(50, "Layout dependencies loaded");
return true;
} catch (error) {
console.error("Error loading layout dependencies:", error);
return false;
}
}
/**
* Initialize text measurement canvas
*/
initializeTextMeasurement() {
// Create off-screen canvas for text measurements
const canvas = document.createElement('canvas');
canvas.width = 2000;
canvas.height = 100;
this.textMeasureCtx = canvas.getContext('2d');
// Set default font
this.updateFont(this.config.defaultFontSize, this.config.defaultFontFamily);
}
setupEventListeners() {
// Use parent's addEventListener for automatic cleanup
this.addEventListener(document, 'ui:font:change', (event) => {
if (event.detail) {
const { fontSize, fontFamily } = event.detail;
if (fontSize || fontFamily) {
this.updateFont(
fontSize || this.config.defaultFontSize,
fontFamily || this.config.defaultFontFamily
);
}
}
});
// Use parent's addEventListener for automatic cleanup
this.addEventListener(document, 'ui:typography:hyphenation', (event) => {
if (event.detail && typeof event.detail.enabled === 'boolean') {
// Use parent's updateConfig method
this.updateConfig({ hyphenationEnabled: event.detail.enabled });
console.log(`Paragraph Layout: Hyphenation ${this.config.hyphenationEnabled ? 'enabled' : 'disabled'}`);
}
});
}
/**
* Initialize the module
* @returns {Promise<boolean>} - Resolves with success status
* Update the font for text measurements
* @param {string} fontSize - Font size (with units)
* @param {string} fontFamily - Font family
*/
async initialize() {
try {
// The measureText function will be provided by the game controller later
this.reportProgress(100, "Paragraph layout initialized");
return true;
} catch (error) {
console.error("Error initializing paragraph layout:", error);
return false;
updateFont(fontSize, fontFamily) {
if (!this.textMeasureCtx) return;
// Store the font settings
this.config.defaultFontSize = fontSize;
this.config.defaultFontFamily = fontFamily;
// Set the font on the canvas context
const fontString = `${fontSize} ${fontFamily}`;
this.textMeasureCtx.font = fontString;
if (this.config.debugMode) {
console.log(`Paragraph Layout: Font updated to ${fontString}`);
// Test measurement
const testText = "The quick brown fox jumps over the lazy dog";
const width = this.measureText(testText);
console.log(`Paragraph Layout: Test text width: ${width}px`);
}
}
/**
* Calculate layout for a paragraph
* @param {string} processedText - The pre-processed text (with SmartyPants and hyphenation)
* @param {Array<number>} measures - Array of line width measurements
* @param {boolean} hyphenate - Whether to enable hyphenation
* @param {Function} [measureFunc] - Optional specific measurement function for this call
* @returns {Object} Layout data with nodes and breaks
* Measure text width using canvas context
* @param {string} text - Text to measure
* @returns {number} - Text width in pixels
*/
calculateLayout(processedText, measures, hyphenate = true, measureFunc = null) {
const measure = measureFunc || this.measureText; // Use provided func or fallback to instance default
if (typeof measure !== 'function') {
throw new Error('No text measurement function available');
measureText(text) {
if (!this.textMeasureCtx) {
this.initializeTextMeasurement();
}
return this.kapAlgorithm(processedText, measure, measures, hyphenate);
if (!text) return 0;
return this.textMeasureCtx.measureText(text).width;
}
/**
* Set a new text measurement function
* @param {Function} measureFunc - The new measurement function
* Process text for layout (apply hyphenation and smartypants)
* @param {string} text - Text to process
* @returns {string} - Processed text
*/
setMeasureFunction(measureFunc) {
this.measureText = measureFunc;
processTextForLayout(text) {
if (!text) return '';
// Remove extra whitespace
text = text.trim().replace(/\s+/g, ' ');
try {
// Apply text processor transformations if available
if (this.textProcessor) {
// Apply smartypants for typography improvements
if (this.textProcessor.applySmartypants) {
text = this.textProcessor.applySmartypants(text);
}
// Apply hyphenation if enabled
if (this.config.hyphenationEnabled && this.textProcessor.hyphenateText) {
text = this.textProcessor.hyphenateText(text);
}
}
return text;
} catch (error) {
console.error("Error processing text for layout:", error);
return text;
}
}
/**
* Set a new Knuth and Plass algorithm implementation
* @param {Function} kapFunc - The new KAP algorithm function
* Calculate layout for a paragraph using Knuth and Plass algorithm
* @param {string} text - Text to layout
* @param {Object} options - Layout options
* @returns {Object} - Layout data with line breaks
*/
setKapAlgorithm(kapFunc) {
this.kapAlgorithm = kapFunc;
calculateLayout(text, options = {}) {
if (!text) return null;
try {
// Check if the kap function is available
if (typeof window.kap !== 'function') {
console.error("Paragraph Layout: kap function not available. Make sure knuth-and-plass.js is loaded.");
return null;
}
// Process text for layout (hyphenation, etc)
const processedText = this.processTextForLayout(text);
// Prepare options by merging with defaults
const layoutOptions = {
width: options.width || this.config.maxLineWidth,
fontSize: options.fontSize || this.config.defaultFontSize,
fontFamily: options.fontFamily || this.config.defaultFontFamily,
lineHeight: options.lineHeight || this.config.defaultLineHeight,
tolerance: options.tolerance || 3, // Tolerance for line breaking algorithm
demerits: options.demerits || {
line: 10, // Demerits for each line break
flagged: 100, // Demerits for flagged break points (like hyphens)
fitness: 3000 // Demerits for consecutive lines with very different looseness/tightness
}
};
// Update font for measurement
this.updateFont(layoutOptions.fontSize, layoutOptions.fontFamily);
// Create measure array - this is crucial for proper line breaking
// The first value is the full width, subsequent values can be for indented lines
const measure = [layoutOptions.width];
if (this.config.debugMode) {
console.log("Paragraph Layout: Calculating layout for text", {
text: processedText,
measure,
options: layoutOptions
});
}
// Use the global Knuth and Plass algorithm function with proper parameters
const layout = window.kap(
processedText,
this.measureText.bind(this),
measure,
this.config.hyphenationEnabled,
layoutOptions.tolerance,
layoutOptions.demerits
);
// If layout failed, return null
if (!layout || !layout.breaks || !layout.nodes) {
console.warn("Paragraph Layout: Failed to calculate layout for text");
return null;
}
if (this.config.debugMode) {
console.log("Paragraph Layout: Layout calculated", {
breaks: layout.breaks.length,
nodes: layout.nodes.length
});
}
// Return layout data with original text for reference
return {
...layout,
originalText: text,
processedText: processedText,
width: layoutOptions.width,
lineHeight: layoutOptions.lineHeight
};
} catch (error) {
console.error("Error calculating layout:", error);
return null;
}
}
/**
* Set debug mode
* @param {boolean} enabled - Whether debug mode should be enabled
*/
setDebugMode(enabled) {
// Use parent's updateConfig method
this.updateConfig({ debugMode: enabled });
console.log(`Paragraph Layout: Debug mode ${enabled ? 'enabled' : 'disabled'}`);
}
}
+408 -232
View File
@@ -11,35 +11,71 @@ class PersistenceManagerModule extends BaseModule {
*/
constructor() {
super('persistence-manager', 'Persistence Manager');
this.storage = window.localStorage;
this.stateKey = 'ai_fiction_state';
this.prefsKey = 'ai_fiction_prefs';
// Storage keys
this.keys = {
gameState: 'ai-interactive-fiction-state',
preferences: 'ai-interactive-fiction-preferences',
saveSlots: 'ai-interactive-fiction-saves'
};
// Current game state
this.gameState = null;
// User preferences
this.preferences = null;
// Save slots
this.saveSlots = {};
// Default preferences
this.defaultPreferences = {
animation: {
enabled: true,
speed: 50 // 0-100 scale, 50 is default
},
tts: {
enabled: false,
provider: 'browser', // 'browser', 'kokoro', 'elevenlabs'
provider: 'browser', // 'browser', 'api', 'kokoro'
voice: '',
volume: 1.0
volume: 1.0,
rate: 1.0,
language: 'en-us' // Default language, will be updated during initialization
},
audio: {
masterVolume: 1.0,
musicVolume: 0.7,
sfxVolume: 1.0
},
animation: {
speed: 50, // 0-100 scale
fastForwardKey: ' ' // Space key
sfxVolume: 1.0,
musicEnabled: true,
sfxEnabled: true
},
accessibility: {
highContrast: false,
largerText: false
},
app: {
locale: 'en-us',
theme: 'default'
}
};
// Current preferences (will be loaded from storage)
this.preferences = { ...this.defaultPreferences };
// Bind methods
this.bindMethods([
'saveGameState',
'loadGameState',
'savePreferences',
'loadPreferences',
'getPreference',
'updatePreference',
'resetPreferences',
'createSaveSlot',
'loadSaveSlot',
'deleteSaveSlot',
'getAllSaveSlots'
]);
// Add localization as a dependency
this.dependencies = ['localization'];
}
/**
@@ -48,224 +84,186 @@ class PersistenceManagerModule extends BaseModule {
*/
async initialize() {
try {
// Test storage availability
this.storage = this.getStorageObject();
this.reportProgress(10, "Initializing persistence manager");
// Load preferences automatically
// Load preferences first (with default language settings)
this.loadPreferences();
// Load save slots
this.loadSaveSlots();
// Get localization module
const localization = this.getModule('localization');
if (localization) {
// Update language preferences with current language
const language = localization.getLanguage();
// Update default preferences
this.defaultPreferences.tts.language = language;
this.defaultPreferences.app.locale = language;
// Update current preferences if they exist
if (this.preferences) {
// Only update if not already set by user
if (!this.preferences.tts.language || this.preferences.tts.language === 'en-us') {
this.preferences.tts.language = language;
}
if (!this.preferences.app.locale || this.preferences.app.locale === 'en-us') {
this.preferences.app.locale = language;
}
// Save updated preferences
this.savePreferences();
}
this.reportProgress(80, "Updated language preferences");
} else {
console.warn("Localization module not found or not ready, using default language settings");
// We'll continue without localization - it might initialize later
this.reportProgress(80, "Using default language settings");
}
this.reportProgress(100, "Persistence manager ready");
return true;
} catch (error) {
console.error("Error initializing persistence manager:", error);
// Continue without persistence rather than failing
return true;
this.reportProgress(100, "Persistence manager failed");
return false;
}
}
/**
* Get the appropriate storage object, testing availability
* @returns {Storage} - The storage object to use
*/
getStorageObject() {
try {
// Test if localStorage is available
if (window.localStorage) {
const testKey = '__storage_test__';
window.localStorage.setItem(testKey, testKey);
window.localStorage.removeItem(testKey);
return window.localStorage;
}
} catch (e) {
console.warn('localStorage not available, using memory storage');
// Create a memory-based storage fallback
return this.createMemoryStorage();
}
console.warn('localStorage not available, using memory storage');
return this.createMemoryStorage();
}
/**
* Create a memory-based storage fallback
* @returns {Object} - A storage-like object
*/
createMemoryStorage() {
const memoryStore = {};
return {
getItem: (key) => memoryStore[key] || null,
setItem: (key, value) => {
memoryStore[key] = String(value);
},
removeItem: (key) => {
delete memoryStore[key];
},
clear: () => {
Object.keys(memoryStore).forEach(key => {
delete memoryStore[key];
});
}
};
}
/**
* Save the current game state
* @param {Object} state - The game state to save
* @param {Object} state - Game state to save
* @returns {boolean} - Success status
*/
saveState(state) {
if (!this.storage) {
console.warn('No storage available, game state not saved.');
return false;
}
saveGameState(state) {
if (!state) return false;
try {
const stateString = JSON.stringify(state);
this.storage.setItem(this.stateKey, stateString);
console.log('Game state saved successfully.');
return true;
} catch (error) {
console.error('Error saving game state:', error);
return false;
}
}
/**
* Load the saved game state
* @returns {Object|null} The loaded state or null if no state exists
*/
loadState() {
if (!this.storage) {
console.warn('No storage available, cannot load game state.');
return null;
}
try {
const stateString = this.storage.getItem(this.stateKey);
if (!stateString) {
console.info('No saved game state found.');
return null;
}
this.gameState = state;
localStorage.setItem(this.keys.gameState, JSON.stringify(state));
const state = JSON.parse(stateString);
console.log('Game state loaded successfully.');
return state;
// Dispatch event
this.dispatchEvent('game-state-saved', {
timestamp: new Date().toISOString()
});
return true;
} catch (error) {
console.error('Error loading game state:', error);
return null;
console.error("Error saving game state:", error);
return false;
}
}
/**
* Check if a saved game state exists
* @returns {boolean} Whether a saved state exists
* Load the current game state
* @returns {Object|null} - Loaded game state or null if not found
*/
hasSavedState() {
if (!this.storage) return false;
return !!this.storage.getItem(this.stateKey);
}
/**
* Delete the saved game state
* @returns {boolean} Whether the state was successfully deleted
*/
clearState() {
if (!this.storage) return false;
loadGameState() {
try {
this.storage.removeItem(this.stateKey);
console.log('Game state cleared.');
return true;
const stateJson = localStorage.getItem(this.keys.gameState);
if (!stateJson) return null;
this.gameState = JSON.parse(stateJson);
return this.gameState;
} catch (error) {
console.error('Error clearing game state:', error);
return false;
console.error("Error loading game state:", error);
return null;
}
}
/**
* Save user preferences
* @param {Object} [preferences] - Preferences to save (defaults to current preferences)
* @returns {boolean} Whether preferences were successfully saved
* @returns {boolean} - Success status
*/
savePreferences(preferences = null) {
if (!this.storage) {
console.warn('No storage available, preferences not saved.');
return false;
}
// Use provided preferences or current preferences
const prefsToSave = preferences || this.preferences;
savePreferences() {
try {
const prefsString = JSON.stringify(prefsToSave);
this.storage.setItem(this.prefsKey, prefsString);
console.log('Preferences saved successfully.');
localStorage.setItem(this.keys.preferences, JSON.stringify(this.preferences));
// Update current preferences
if (preferences) {
this.preferences = { ...this.preferences, ...preferences };
}
// Dispatch event
this.dispatchEvent('preferences-saved', {
timestamp: new Date().toISOString()
});
return true;
} catch (error) {
console.error('Error saving preferences:', error);
console.error("Error saving preferences:", error);
return false;
}
}
/**
* Load user preferences
* @returns {Object} The loaded preferences or default preferences if none exist
* @returns {Object} - Loaded preferences or default preferences if not found
*/
loadPreferences() {
if (!this.storage) {
console.warn('No storage available, using default preferences.');
return { ...this.defaultPreferences };
}
try {
const prefsString = this.storage.getItem(this.prefsKey);
if (!prefsString) {
console.info('No saved preferences found, using defaults.');
this.preferences = { ...this.defaultPreferences };
return this.preferences;
const prefsJson = localStorage.getItem(this.keys.preferences);
if (prefsJson) {
// Parse stored preferences
const storedPrefs = JSON.parse(prefsJson);
// Merge with default preferences to ensure all keys exist
this.preferences = this.mergeWithDefaults(storedPrefs, this.defaultPreferences);
} else {
// Use default preferences if none found
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
// Try to set locale based on browser language
const browserLocale = navigator.language.toLowerCase();
if (browserLocale) {
this.preferences.app.locale = browserLocale;
}
}
const loadedPrefs = JSON.parse(prefsString);
// Merge with default preferences to ensure all fields exist
this.preferences = this.mergeWithDefaults(loadedPrefs, this.defaultPreferences);
console.log('Preferences loaded successfully.');
return this.preferences;
} catch (error) {
console.error('Error loading preferences:', error);
this.preferences = { ...this.defaultPreferences };
console.error("Error loading preferences:", error);
// Fall back to default preferences
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
return this.preferences;
}
}
/**
* Merge loaded preferences with default values to ensure all fields exist
* @param {Object} loaded - The loaded preferences
* @param {Object} defaults - The default preferences
* @returns {Object} Merged preferences
* @private
* Merge stored preferences with defaults to ensure all keys exist
* @param {Object} stored - Stored preferences
* @param {Object} defaults - Default preferences
* @returns {Object} - Merged preferences
*/
mergeWithDefaults(loaded, defaults) {
mergeWithDefaults(stored, defaults) {
const result = {};
// Start with defaults
for (const key in defaults) {
if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) {
// Recurse for nested objects
if (loaded && loaded[key]) {
result[key] = this.mergeWithDefaults(loaded[key], defaults[key]);
} else {
result[key] = { ...defaults[key] };
// For each category in defaults
for (const category in defaults) {
result[category] = {};
// Copy all settings from defaults for this category
for (const setting in defaults[category]) {
// Use stored value if it exists, otherwise use default
result[category][setting] = (stored[category] && stored[category][setting] !== undefined)
? stored[category][setting]
: defaults[category][setting];
}
// Copy any additional settings from stored that aren't in defaults
if (stored[category]) {
for (const setting in stored[category]) {
if (result[category][setting] === undefined) {
result[category][setting] = stored[category][setting];
}
}
} else {
// Use loaded value if available, otherwise default
result[key] = (loaded && loaded[key] !== undefined) ? loaded[key] : defaults[key];
}
}
// Copy any additional categories from stored that aren't in defaults
for (const category in stored) {
if (result[category] === undefined) {
result[category] = stored[category];
}
}
@@ -273,81 +271,262 @@ class PersistenceManagerModule extends BaseModule {
}
/**
* Update specific preferences
* @param {string} category - The preference category (e.g., 'tts', 'audio')
* @param {string} setting - The specific setting name
* @param {any} value - The new value
* @param {boolean} [saveImmediately=true] - Whether to save immediately
*/
updatePreference(category, setting, value, saveImmediately = true) {
// Ensure the category exists
if (!this.preferences[category]) {
console.warn(`Preference category '${category}' doesn't exist.`);
return false;
}
// Update the preference
this.preferences[category][setting] = value;
// Save if requested
if (saveImmediately) {
return this.savePreferences();
}
return true;
}
/**
* Get a specific preference value
* @param {string} category - The preference category
* @param {string} setting - The specific setting name
* @param {any} [defaultValue] - Default value if the preference doesn't exist
* @returns {any} The preference value
* Get a specific preference
* @param {string} category - Preference category
* @param {string} setting - Preference setting
* @param {*} defaultValue - Default value if preference not found
* @returns {*} - Preference value
*/
getPreference(category, setting, defaultValue = null) {
// Check if category exists
if (!this.preferences[category]) {
return defaultValue;
if (!this.preferences) {
this.loadPreferences();
}
// Check if setting exists in category
if (this.preferences[category].hasOwnProperty(setting)) {
if (this.preferences[category] && this.preferences[category][setting] !== undefined) {
return this.preferences[category][setting];
}
return defaultValue;
// If default value provided, use it
if (defaultValue !== null) {
return defaultValue;
}
// Otherwise check default preferences
if (this.defaultPreferences[category] && this.defaultPreferences[category][setting] !== undefined) {
return this.defaultPreferences[category][setting];
}
// If all else fails, return null
return null;
}
/**
* Update a specific preference
* @param {string} category - Preference category
* @param {string} setting - Preference setting
* @param {*} value - New value
* @returns {boolean} - Success status
*/
updatePreference(category, setting, value) {
if (!this.preferences) {
this.loadPreferences();
}
// Create category if it doesn't exist
if (!this.preferences[category]) {
this.preferences[category] = {};
}
// Update preference
const oldValue = this.preferences[category][setting];
this.preferences[category][setting] = value;
// Save preferences
this.savePreferences();
// Dispatch event if value changed
if (oldValue !== value) {
this.dispatchEvent('preference-changed', {
category,
setting,
value,
oldValue
});
}
return true;
}
/**
* Reset preferences to defaults
* @param {string} [category] - Optional category to reset (resets all if not specified)
* @param {boolean} [saveImmediately=true] - Whether to save immediately
* @returns {boolean} - Success status
*/
resetPreferences(category = null, saveImmediately = true) {
if (category) {
// Reset only specified category
if (this.defaultPreferences[category]) {
this.preferences[category] = { ...this.defaultPreferences[category] };
}
} else {
// Reset all preferences
this.preferences = { ...this.defaultPreferences };
resetPreferences() {
try {
// Clone default preferences
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
// Save preferences
this.savePreferences();
// Dispatch event
this.dispatchEvent('preferences-reset', {
timestamp: new Date().toISOString()
});
return true;
} catch (error) {
console.error("Error resetting preferences:", error);
return false;
}
// Save if requested
if (saveImmediately) {
return this.savePreferences();
}
return true;
}
/**
* Get all preferences
* @returns {Object} The current preferences
* @returns {Object} - All preferences
*/
getAllPreferences() {
return { ...this.preferences };
if (!this.preferences) {
this.loadPreferences();
}
return this.preferences;
}
/**
* Load save slots
* @returns {Object} - Save slots
*/
loadSaveSlots() {
try {
const slotsJson = localStorage.getItem(this.keys.saveSlots);
if (slotsJson) {
this.saveSlots = JSON.parse(slotsJson);
} else {
this.saveSlots = {};
}
return this.saveSlots;
} catch (error) {
console.error("Error loading save slots:", error);
this.saveSlots = {};
return this.saveSlots;
}
}
/**
* Save save slots
* @returns {boolean} - Success status
*/
saveSaveSlots() {
try {
localStorage.setItem(this.keys.saveSlots, JSON.stringify(this.saveSlots));
return true;
} catch (error) {
console.error("Error saving save slots:", error);
return false;
}
}
/**
* Create a new save slot
* @param {string} name - Save slot name
* @param {Object} state - Game state to save
* @returns {string|null} - Save slot ID or null if failed
*/
createSaveSlot(name, state) {
if (!name || !state) return null;
try {
// Generate unique ID
const id = `save_${Date.now()}`;
// Create save slot
this.saveSlots[id] = {
id,
name,
timestamp: new Date().toISOString(),
state
};
// Save save slots
this.saveSaveSlots();
// Dispatch event
this.dispatchEvent('save-slot-created', {
id,
name,
timestamp: new Date().toISOString()
});
return id;
} catch (error) {
console.error("Error creating save slot:", error);
return null;
}
}
/**
* Load a save slot
* @param {string} id - Save slot ID
* @returns {Object|null} - Game state or null if not found
*/
loadSaveSlot(id) {
if (!id || !this.saveSlots[id]) return null;
try {
const saveSlot = this.saveSlots[id];
// Set as current game state
this.gameState = saveSlot.state;
// Save current game state
this.saveGameState(this.gameState);
// Dispatch event
this.dispatchEvent('save-slot-loaded', {
id,
name: saveSlot.name,
timestamp: new Date().toISOString()
});
return this.gameState;
} catch (error) {
console.error("Error loading save slot:", error);
return null;
}
}
/**
* Delete a save slot
* @param {string} id - Save slot ID
* @returns {boolean} - Success status
*/
deleteSaveSlot(id) {
if (!id || !this.saveSlots[id]) return false;
try {
// Get save slot name before deleting
const name = this.saveSlots[id].name;
// Delete save slot
delete this.saveSlots[id];
// Save save slots
this.saveSaveSlots();
// Dispatch event
this.dispatchEvent('save-slot-deleted', {
id,
name,
timestamp: new Date().toISOString()
});
return true;
} catch (error) {
console.error("Error deleting save slot:", error);
return false;
}
}
/**
* Get all save slots
* @returns {Object} - All save slots
*/
getAllSaveSlots() {
if (!this.saveSlots) {
this.loadSaveSlots();
}
return this.saveSlots;
}
/**
* Clean up when module is disposed
*/
dispose() {
// Nothing to clean up
}
}
@@ -359,6 +538,3 @@ moduleRegistry.register(PersistenceManager);
// Export the module
export { PersistenceManager };
// Keep a reference in window for loader system
window.PersistenceManager = PersistenceManager;
+45 -74
View File
@@ -8,38 +8,38 @@ import { moduleRegistry } from './module-registry.js';
class SocketClientModule extends BaseModule {
constructor() {
super('socket-client', 'Socket Client');
// Dependencies
this.dependencies = ['text-buffer'];
this.socket = null;
this.textBuffer = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 2000; // 2 seconds
this.reconnectDelay = 2000;
this.url = null;
this.eventListeners = {};
this.defaultHost = 'localhost:3000'; // Default to localhost:3000 if not running in same origin
}
/**
* Load module dependencies
* @returns {Promise} - Resolves when dependencies are loaded
*/
async loadDependencies() {
try {
// We depend on the text-buffer module
this.reportProgress(30, "Waiting for text buffer");
// Dynamically load Socket.IO client if not already loaded
if (!window.io) {
this.reportProgress(40, "Loading Socket.IO client");
await this.loadSocketIO();
this.reportProgress(45, "Socket.IO client loaded");
}
return true;
} catch (error) {
console.error("Error loading Socket Client dependencies:", error);
return false;
}
this.defaultHost = 'localhost:3000';
// Bind methods using parent's bindMethods utility
this.bindMethods([
'connect',
'disconnect',
'send',
'sendCommand',
'requestStartGame',
'requestSaveGame',
'requestLoadGame',
'on',
'off',
'emitEvent',
'setupGameEventHandlers',
'processTextFragment',
'attemptReconnect',
'getConnectionStatus',
'loadSocketIO'
]);
}
/**
@@ -47,54 +47,8 @@ class SocketClientModule extends BaseModule {
* @returns {Promise<void>}
*/
loadSocketIO() {
return new Promise((resolve, reject) => {
// Check if Socket.IO is already loaded
if (typeof window.io !== 'undefined') {
resolve();
return;
}
// Load the Socket.IO client from the same server that served this page
const script = document.createElement('script');
script.src = '/socket.io/socket.io.js'; // Socket.IO automatically serves this
script.async = true;
script.onload = () => {
if (typeof window.io !== 'undefined') {
resolve();
} else {
reject(new Error('Failed to load Socket.IO client'));
}
};
script.onerror = () => {
reject(new Error('Failed to load Socket.IO client script'));
};
document.head.appendChild(script);
});
}
/**
* Wait for dependencies to be ready
*/
async waitForDependencies() {
try {
// Wait for the text buffer module to be available
const textBufferReady = await moduleRegistry.waitForModule('text-buffer', 10000);
if (textBufferReady) {
this.textBuffer = window.TextBuffer;
this.reportProgress(60, "Text buffer module ready");
return true;
} else {
console.warn("Text buffer module not ready, Socket Client will have limited functionality");
return true; // Continue anyway for graceful degradation
}
} catch (error) {
console.error("Error waiting for dependencies:", error);
return false;
}
// Use parent's loadScript method
return this.loadScript('/socket.io/socket.io.js');
}
/**
@@ -103,8 +57,25 @@ class SocketClientModule extends BaseModule {
*/
async initialize() {
try {
this.reportProgress(10, "Initializing Socket Client");
// Dynamically load Socket.IO client if not already loaded
if (!window.io) {
this.reportProgress(20, "Loading Socket.IO client");
await this.loadSocketIO();
this.reportProgress(30, "Socket.IO client loaded");
}
// Get text buffer using parent's getModule method
this.textBuffer = this.getModule('text-buffer');
if (!this.textBuffer) {
console.error("Socket Client: Failed to get text-buffer module");
return false;
}
this.reportProgress(50, "Setting up connection parameters");
// Use the current origin for the socket connection
// This automatically handles the Docker port mapping situation
const currentUrl = window.location.origin;
console.log(`Socket Client: Using origin for connection: ${currentUrl}`);
+125 -13
View File
@@ -9,9 +9,23 @@ class TextBufferModule extends BaseModule {
constructor() {
super('text-buffer', 'Text Buffer');
this.buffer = '';
this.sentenceEndRegex = /[.!?]\s+/g; // Detect sentence endings
this.onSentenceReadyCallback = null; // Callback for complete sentences
this.processingLock = false; // Lock to prevent concurrent processing
this.sentenceEndRegex = /[.!?]\s+/g;
this.onSentenceReadyCallback = null;
this.processingLock = false;
this.processingQueue = [];
this.isProcessingActive = false;
// Bind methods using parent's bindMethods utility
this.bindMethods([
'addText',
'processNextFromQueue',
'processSentences',
'processNextSentence',
'clear',
'getBuffer',
'getStatus',
'setOnSentenceReady'
]);
}
/**
@@ -20,6 +34,22 @@ class TextBufferModule extends BaseModule {
*/
async initialize() {
try {
// Use parent's addEventListener for automatic cleanup
this.addEventListener(document, 'ui:paragraph:complete', (event) => {
if (this.processingQueue.length > 0 && !this.isProcessingActive) {
console.log('TextBuffer: Previous paragraph complete, processing next from queue');
this.processNextFromQueue();
}
});
// Use parent's addEventListener for automatic cleanup
this.addEventListener(document, 'animation:complete', () => {
if (this.processingQueue.length > 0 && !this.isProcessingActive) {
console.log('TextBuffer: Animations complete, processing next from queue');
this.processNextFromQueue();
}
});
this.reportProgress(100, "Text buffer ready");
return true;
} catch (error) {
@@ -36,6 +66,11 @@ class TextBufferModule extends BaseModule {
if (typeof callback === 'function') {
this.onSentenceReadyCallback = callback;
console.log("Text Buffer: Sentence ready callback set");
// Process any queued text immediately
if (this.processingQueue.length > 0 && !this.isProcessingActive) {
this.processNextFromQueue();
}
} else {
console.warn("Text Buffer: Invalid sentence ready callback provided");
}
@@ -50,6 +85,30 @@ class TextBufferModule extends BaseModule {
console.log(`TextBuffer: Adding text: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
// Add to processing queue instead of directly to buffer
this.processingQueue.push(text);
// Process the queue if not already processing
if (!this.isProcessingActive && this.onSentenceReadyCallback) {
this.processNextFromQueue();
} else {
console.log('TextBuffer: Text queued for processing');
}
}
/**
* Process the next text fragment from the queue
*/
processNextFromQueue() {
if (this.processingQueue.length === 0 || this.isProcessingActive) {
return;
}
this.isProcessingActive = true;
const text = this.processingQueue.shift();
console.log(`TextBuffer: Processing next fragment from queue, remaining: ${this.processingQueue.length}`);
// Add text to buffer
this.buffer += text;
@@ -89,14 +148,22 @@ class TextBufferModule extends BaseModule {
if (!foundSentence) {
// No complete sentences yet
this.processingLock = false;
this.isProcessingActive = false;
// Use parent's dispatchEvent method
super.dispatchEvent('buffer:waiting', {
remainingText: this.buffer,
queueLength: this.processingQueue.length
});
return;
}
// Process each complete sentence
// Process the next complete sentence
this.processNextSentence();
} catch (error) {
console.error("Error processing sentences:", error);
this.processingLock = false;
this.isProcessingActive = false;
}
}
@@ -121,6 +188,7 @@ class TextBufferModule extends BaseModule {
if (endIndex === -1) {
// No complete sentence found
this.processingLock = false;
this.isProcessingActive = false;
return;
}
@@ -131,24 +199,52 @@ class TextBufferModule extends BaseModule {
console.log(`TextBuffer: Processing sentence: "${sentence.trim()}"`);
// Use parent's dispatchEvent method
super.dispatchEvent('buffer:sentence', {
sentence: sentence,
remaining: this.buffer.length
});
// Call the callback if set
if (this.onSentenceReadyCallback) {
this.onSentenceReadyCallback(sentence, () => {
// After processing is complete, check for more sentences
setTimeout(() => {
if (this.buffer.length > 0) {
this.processingLock = false; // Release lock immediately to allow processing of next sentence
// Check if there are more sentences to process
if (this.buffer.length > 0) {
// Use requestAnimationFrame to prevent stack overflow and ensure UI update between sentences
requestAnimationFrame(() => {
this.processSentences();
} else {
this.processingLock = false;
}
}, 0);
});
} else if (this.processingQueue.length > 0) {
// No more sentences in buffer but we have more text in the queue
requestAnimationFrame(() => {
this.isProcessingActive = false;
this.processNextFromQueue();
});
} else {
// All processed
this.isProcessingActive = false;
super.dispatchEvent('buffer:empty', {});
}
});
} else {
// No callback set, just process the next sentence
// No callback set, just release lock and continue processing
this.processingLock = false;
if (this.buffer.length > 0) {
this.processSentences();
// Use requestAnimationFrame instead of setTimeout for better performance
requestAnimationFrame(() => {
this.processSentences();
});
} else if (this.processingQueue.length > 0) {
requestAnimationFrame(() => {
this.isProcessingActive = false;
this.processNextFromQueue();
});
} else {
this.processingLock = false;
this.isProcessingActive = false;
}
}
}
@@ -158,6 +254,9 @@ class TextBufferModule extends BaseModule {
*/
clear() {
this.buffer = '';
this.processingQueue = [];
this.isProcessingActive = false;
this.processingLock = false;
}
/**
@@ -167,6 +266,19 @@ class TextBufferModule extends BaseModule {
getBuffer() {
return this.buffer;
}
/**
* Get the current processing status
* @returns {Object} - Processing status object
*/
getStatus() {
return {
bufferLength: this.buffer.length,
queueLength: this.processingQueue.length,
isProcessing: this.isProcessingActive,
isLocked: this.processingLock
};
}
}
// Create the singleton instance
+228 -220
View File
@@ -4,40 +4,118 @@
*/
import { BaseModule } from './base-module.js';
import { moduleRegistry } from './module-registry.js';
import Hyphenopoly from './hyphenopoly.module.js';
class TextProcessorModule extends BaseModule {
constructor() {
super('text-processor', 'Text Processor');
this.smartyPants = null; // Store the function reference here
this.smartypantsu = null; // Store the function reference here
this.hyphenator = null; // For hyphenation function
this.smartyPants = null;
this.smartypantsu = null;
this.hyphenator = null;
this.hyphenatorReady = false;
this.locale = 'en-us';
// Bind methods using parent's bindMethods utility
this.bindMethods([
'loadSmartyPantsScript',
'initializeHyphenation',
'process',
'isHyphenationAvailable',
'hyphenate',
'setLocale',
'handleLocaleChanged'
]);
// Add localization as a dependency
this.dependencies = ['localization'];
}
/**
* Load module dependencies
* Initialize the module
* @returns {Promise<boolean>} - Resolves with success status
*/
async loadDependencies() {
async initialize() {
try {
this.reportProgress(10, "Loading dependencies");
this.reportProgress(10, "Initializing text processor");
// Load SmartyPants script dynamically
await this.loadSmartyPantsScript();
this.reportProgress(50, "SmartyPants loaded");
// Get locale from Localization module if available
const localizationModule = this.getModule('localization');
if (!localizationModule) {
console.error("Localization module not found, required dependency missing");
this.reportProgress(100, "Text processor initialization failed - missing localization");
return false;
}
this.locale = localizationModule.getLocale();
// Register for locale changes using the proper event pattern
this.addEventListener(document, 'locale-changed', this.handleLocaleChanged);
this.reportProgress(30, `Locale set to ${this.locale}`);
// Initialize hyphenation in the background, but don't wait for it
this.initializeHyphenation();
this.reportProgress(90, "Dependencies loaded");
return true;
// Ensure global locale is set for SmartyPants
window.locale = this.locale;
// Load SmartyPants - critical dependency
this.reportProgress(40, "Loading SmartyPants");
try {
await this.loadSmartyPantsScript();
// Verify SmartyPants is properly loaded
if (!this.smartyPants || typeof this.smartyPants !== 'function') {
throw new Error('SmartyPants not properly loaded');
}
this.reportProgress(70, "SmartyPants loaded successfully");
} catch (error) {
console.error("Failed to load SmartyPants:", error);
this.reportProgress(100, "Text processor initialization failed - SmartyPants not available");
return false;
}
// Initialize hyphenation (non-critical)
this.reportProgress(80, "Initializing hyphenation");
try {
await this.initializeHyphenation();
this.reportProgress(100, "Text processor ready");
return true;
} catch (error) {
console.warn("Failed to initialize hyphenation:", error);
// Continue without hyphenation, still mark as successful
this.reportProgress(100, "Text processor ready (without hyphenation)");
return true;
}
} catch (error) {
console.error("Error loading Text Processor dependencies:", error);
console.error("Error initializing text processor:", error);
this.reportProgress(100, "Text processor initialization failed");
return false;
}
}
/**
* Handle locale changed event
* @param {CustomEvent} event - The locale-changed event
*/
handleLocaleChanged(event) {
if (event && event.detail && event.detail.locale) {
this.setLocale(event.detail.locale);
}
}
/**
* Set the locale for the text processor
* @param {string} locale - The locale to set
*/
setLocale(locale) {
this.locale = locale;
console.log(`Text processor locale set to ${locale}`);
// Reinitialize hyphenation with new locale if needed
if (this.hyphenatorReady) {
this.initializeHyphenation();
}
}
/**
* Load the SmartyPants script dynamically and wait for it to be ready
* @returns {Promise<void>}
@@ -52,233 +130,146 @@ class TextProcessorModule extends BaseModule {
resolve();
return;
}
// Load the script using a script tag
// Create script element
const script = document.createElement('script');
script.src = '/js/smartypants.js';
script.async = false; // Load synchronously relative to other scripts
script.type = 'text/javascript';
script.src = '/js/smartypants.js'; // Use relative URL
script.async = true;
// Set up load and error handlers
script.onload = () => {
// Use a microtask to ensure the script has executed
Promise.resolve().then(() => {
if (typeof window.SmartyPants === 'object' && typeof window.SmartyPants.smartypants === 'function') {
this.smartyPants = window.SmartyPants.smartypants;
this.smartypantsu = window.SmartyPants.smartypantsu;
console.log("SmartyPants loaded successfully via script tag");
resolve();
} else {
console.error("SmartyPants script loaded but functions not found on window.SmartyPants");
reject(new Error('SmartyPants functions not found after loading'));
}
});
if (typeof window.SmartyPants === 'object' && typeof window.SmartyPants.smartypants === 'function') {
this.smartyPants = window.SmartyPants.smartypants;
this.smartypantsu = window.SmartyPants.smartypantsu;
console.log("SmartyPants loaded successfully");
resolve();
} else {
const error = new Error('SmartyPants loaded but functions not found');
console.error(error);
reject(error);
}
};
script.onerror = () => {
console.error('Failed to load smartypants.js script');
reject(new Error('Failed to load smartypants.js script'));
script.onerror = (error) => {
console.error("Error loading SmartyPants script:", error);
reject(new Error('Failed to load SmartyPants script'));
};
// Add script to document
document.head.appendChild(script);
});
}
/**
* Initialize the module
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize() {
try {
this.reportProgress(70, "Initializing text processor");
// Get locale from Localization module if available
const localizationModule = moduleRegistry.getModule('localization');
if (localizationModule) {
this.locale = localizationModule.getLocale();
// Register as an observer for locale changes
localizationModule.registerObserver(this, (newLocale) => {
this.setLocale(newLocale);
});
}
// Ensure global locale is set for SmartyPants
window.locale = this.locale;
// Verify SmartyPants is available via the stored references
if (typeof this.smartyPants !== 'function') {
console.error("SmartyPants function not available for initialization");
return false;
}
// Final initialization steps
this.reportProgress(100, "Text processor ready");
return true;
} catch (error) {
console.error("Error initializing Text Processor:", error);
return false;
}
}
/**
* Initialize hyphenation using Hyphenopoly
* Initialize hyphenation using Hyphenopoly module
* @returns {Promise<boolean>} - Resolves when hyphenation is initialized
*/
initializeHyphenation() {
// Create custom events for hyphenation loading status
const hyphenationLoadedEvent = new CustomEvent('hyphenation-loaded');
// Add listener for hyphenation loaded event
document.addEventListener('hyphenation-loaded', () => {
console.log('Hyphenation module loaded');
this.hyphenatorReady = true;
}, { once: true });
// Check if Hyphenopoly is loaded
if (window.Hyphenopoly) {
this.setupHyphenopoly();
} else {
// Set up listener for when Hyphenopoly might be loaded later
window.addEventListener('hyphenopoly-loaded', () => {
this.setupHyphenopoly();
});
// Try loading Hyphenopoly if not already loading
if (!document.querySelector('script[src*="Hyphenopoly_Loader.js"]')) {
this.loadHyphenopolyScript();
}
}
}
/**
* Load the Hyphenopoly script
*/
loadHyphenopolyScript() {
// Create script element for loader
const script = document.createElement('script');
script.src = '/js/Hyphenopoly_Loader.js';
script.async = true;
script.onload = () => {
document.dispatchEvent(new CustomEvent('hyphenopoly-script-loaded'));
};
script.onerror = (error) => {
console.error('Failed to load Hyphenopoly:', error);
document.dispatchEvent(new CustomEvent('hyphenation-error', {
detail: { error: 'Failed to load Hyphenopoly script' }
}));
};
document.head.appendChild(script);
// Set up configuration for Hyphenopoly
window.Hyphenopoly = {
require: {
'en-us': 'FORCEHYPHENATION'
},
paths: {
maindir: '/js/',
patterndir: '/js/patterns/'
},
setup: {
selectors: {
'.hyphenate': {}
}
}
};
}
/**
* Set up Hyphenopoly when it's available
*/
setupHyphenopoly() {
// Wait for hyphenator to be available
if (window.Hyphenopoly && window.Hyphenopoly.hyphenators) {
// Get hyphenator for English
window.Hyphenopoly.hyphenators['en-us'].then((hyphenator) => {
console.log('Hyphenator ready');
this.hyphenator = hyphenator;
this.hyphenatorReady = true;
// Dispatch event that hyphenation is ready
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
}).catch(err => {
console.error('Error loading hyphenator:', err);
});
}
}
/**
* Set the hyphenator function
* @param {Function} hyphenatorFunc - The hyphenator function
*/
setHyphenator(hyphenatorFunc) {
if (typeof hyphenatorFunc === 'function') {
this.hyphenator = hyphenatorFunc;
this.hyphenatorReady = true;
console.log("Hyphenator function set explicitly");
} else {
console.warn("Invalid hyphenator provided");
}
}
/**
* Process text with SmartyPants and optional hyphenation
* @param {string} text - The text to process
* @param {boolean} useHyphenation - Whether to apply hyphenation
* @returns {string} - The processed text
*/
process(text, useHyphenation = false) {
if (!text) return '';
let processed = text;
// Apply SmartyPants for typographic punctuation using stored references
try {
if (typeof this.smartyPants === 'function') {
processed = this.smartyPants(processed);
} else {
console.warn("SmartyPants function not available for processing");
}
// Convert HTML entities to UTF-8 characters
if (typeof this.smartypantsu === 'function') {
processed = this.smartypantsu(processed);
} else {
console.warn("smartypantsu function not available for processing");
}
} catch (error) {
console.error("Error applying SmartyPants:", error);
}
// Apply hyphenation if enabled and available
if (useHyphenation && this.hyphenatorReady && this.hyphenator) {
return new Promise((resolve, reject) => {
try {
processed = this.hyphenator(processed);
console.log("Initializing hyphenation with Hyphenopoly module");
// Configure Hyphenopoly with our requirements
const hyphenatorPromise = Hyphenopoly.config({
require: [this.locale],
hyphen: '\u00AD', // Soft hyphen character
minWordLength: 5,
leftmin: 2,
rightmin: 2,
compound: "hyphen",
// Define a custom loader for the patterns
loader: (file) => {
return new Promise((resolve, reject) => {
const patternPath = `/js/patterns/${file}`;
console.log(`Loading hyphenation pattern: ${patternPath}`);
fetch(patternPath)
.then(response => {
if (!response.ok) {
throw new Error(`Failed to load ${file}: ${response.status} ${response.statusText}`);
}
return response.arrayBuffer();
})
.then(arrayBuffer => {
resolve(arrayBuffer);
})
.catch(error => {
console.error(`Error loading hyphenation pattern ${file}:`, error);
reject(error);
});
});
},
handleEvent: {
error: (e) => {
console.warn(`Hyphenopoly error: ${e.msg}`);
},
engineReady: (e) => {
console.log(`Hyphenopoly engine ready for ${e.msg}`);
}
}
});
// Get the hyphenator for our locale
hyphenatorPromise.get(this.locale)
.then(hyphenator => {
this.hyphenator = hyphenator;
this.hyphenatorReady = true;
console.log(`Hyphenator ready for ${this.locale}`);
// Dispatch event that hyphenation is ready
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
resolve(true); // Successfully initialized
})
.catch(error => {
console.error(`Failed to initialize hyphenator for ${this.locale}:`, error);
// Try to fall back to en-us if the current locale failed
if (this.locale !== 'en-us') {
console.log("Falling back to en-us hyphenation");
return hyphenatorPromise.get('en-us');
}
throw error;
})
.then(fallbackHyphenator => {
if (fallbackHyphenator) {
this.hyphenator = fallbackHyphenator;
this.hyphenatorReady = true;
console.log("Using fallback en-us hyphenator");
// Dispatch event that hyphenation is ready
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
resolve(true); // Successfully initialized with fallback
}
})
.catch(error => {
console.error("Failed to initialize hyphenation even with fallback:", error);
reject(error); // Failed to initialize
});
} catch (error) {
console.error("Error applying hyphenation:", error);
console.error("Error setting up hyphenation:", error);
reject(error); // Failed to initialize
}
}
return processed;
});
}
/**
* Check if hyphenation is available
* @returns {boolean} - Whether hyphenation is available
* @returns {boolean} - True if hyphenation is available
*/
isHyphenationAvailable() {
return this.hyphenatorReady && this.hyphenator !== null;
}
/**
* Apply only hyphenation to text
* Hyphenate a text using the Hyphenopoly module
* @param {string} text - The text to hyphenate
* @returns {string} - The hyphenated text
*/
hyphenate(text) {
if (!text || !this.hyphenatorReady || !this.hyphenator) {
if (!this.isHyphenationAvailable()) {
return text;
}
try {
return this.hyphenator(text);
} catch (error) {
@@ -288,16 +279,33 @@ class TextProcessorModule extends BaseModule {
}
/**
* Set the locale for text processing
* @param {string} locale - The locale code (e.g., 'en-us', 'de')
* Process text with typography enhancements
* @param {string} text - The text to process
* @param {Object} options - Processing options
* @param {boolean} [options.smartypants=true] - Whether to apply SmartyPants processing
* @param {boolean} [options.hyphenate=true] - Whether to apply hyphenation
* @returns {string} - The processed text
*/
setLocale(locale) {
if (locale && typeof locale === 'string') {
this.locale = locale.toLowerCase();
// Update global locale for SmartyPants
window.locale = this.locale;
console.log(`TextProcessor: Locale set to ${locale}`);
process(text, options = {}) {
const opts = {
smartypants: true,
hyphenate: true,
...options
};
let result = text;
// Apply SmartyPants if available and requested
if (opts.smartypants && this.smartyPants) {
result = this.smartyPants(result);
}
// Apply hyphenation if available and requested
if (opts.hyphenate && this.isHyphenationAvailable()) {
result = this.hyphenate(result);
}
return result;
}
}
+364 -518
View File
@@ -1,555 +1,401 @@
/**
* TTS Factory for AI Interactive Fiction
* Manages different TTS implementations with a common interface
* TTS Factory Module
* Creates and manages TTS handler instances
*/
class TTSFactory {
constructor() {
this.ttsHandler = null;
this.handlers = {};
this.initializationAttempted = false;
this.initializationPromise = null;
this.ttsEnabled = true;
this.progressCallback = null;
this.persistenceManager = null;
}
import { BaseModule } from './base-module.js';
import { moduleRegistry } from './module-registry.js';
import { BrowserTTSHandler } from './browser-tts-handler.js';
import { ApiTTSHandler } from './api-tts-handler.js';
import { KokoroHandler } from './kokoro-handler.js';
/**
* Initialize the TTS Factory - Static method for the module loader
* @param {Function} reportProgress - Function to report loading progress to the loader
* @returns {Promise} - Resolves when TTS is initialized
*/
static async initializeInterface(reportProgress = null) {
console.log('TTS Factory: Initializing interface');
// Create singleton instance if needed
if (!window.ttsFactory) {
window.ttsFactory = new TTSFactory();
}
// Initialize TTS with the progress callback
window.ttsFactory.progressCallback = reportProgress;
try {
// Start initialization process
await window.ttsFactory.initialize();
return true;
} catch (error) {
console.error('Error initializing TTS Factory:', error);
return false;
}
}
/**
* Initialize the TTS Factory
* This will load and initialize all available TTS handlers
* @returns {Promise} - Resolves when initialization is complete
*/
async initialize() {
if (this.initializationPromise) {
return this.initializationPromise;
}
this.initializationPromise = new Promise(async (resolve) => {
this.initializationAttempted = true;
const reportProgress = (percent, message) => {
console.log(`TTS progress: ${percent}% - ${message}`);
if (this.progressCallback && typeof this.progressCallback === 'function') {
this.progressCallback(percent, message);
}
};
try {
// Report starting initialization
reportProgress(10, 'Loading TTS modules');
class TTSFactoryModule extends BaseModule {
/**
* Create a new TTS factory
*/
constructor() {
super('tts-factory', 'TTS Factory');
// Get persistence manager if available
if (window.PersistenceManager) {
this.persistenceManager = window.PersistenceManager;
reportProgress(15, 'Persistence manager found, loading preferences');
// Load preferences to determine TTS enabled state and preferred provider
const prefs = this.persistenceManager.getAllPreferences();
if (prefs && prefs.tts) {
this.ttsEnabled = prefs.tts.enabled;
console.log(`TTS Factory: Setting initial TTS enabled state to ${this.ttsEnabled ? 'enabled' : 'disabled'} from preferences`);
}
}
// Available TTS handlers
this.handlers = {};
// Import needed modules dynamically
const [{ BrowserTTSHandler }, { KokoroHandler }, { ApiTTSHandler }] = await Promise.all([
import('./browser-tts-handler.js'),
import('./kokoro-handler.js'),
import('./api-tts-handler.js')
// Current active handler
this.activeHandler = null;
// Handler initialization status
this.initStatus = {
browser: false,
api: false,
kokoro: false
};
// TTS availability flag
this.ttsAvailable = false;
// Bind methods
this.bindMethods([
'registerHandler',
'initializeHandler',
'getHandler',
'setActiveHandler',
'getActiveHandler',
'getAvailableHandlers',
'speak',
'stop',
'pause',
'resume',
'getVoices',
'getPreference'
]);
reportProgress(20, 'TTS modules loaded');
// Create handlers
const browserHandler = new BrowserTTSHandler();
const kokoroHandler = new KokoroHandler();
const apiHandler = new ApiTTSHandler();
// Store handlers
this.handlers = {
browser: browserHandler,
kokoro: kokoroHandler,
api: apiHandler
};
// Get preferred TTS mode from options
const preferredTTSMode = this.getPreferredTTSMode();
// Initialize the preferred handler first
if (preferredTTSMode === 'browser') {
// User prefers browser TTS
await this.initializeBrowserTTS(browserHandler, reportProgress);
} else if (preferredTTSMode === 'api') {
// User prefers API TTS
await this.initializeApiTTS(apiHandler, reportProgress);
// Fallback to browser TTS if API fails
if (!apiHandler.isAvailable()) {
await this.initializeBrowserTTS(browserHandler, reportProgress);
}
} else {
// Default flow: prefer Kokoro, with browser as immediate fallback
// Initialize browser TTS immediately for a responsive experience
await this.initializeBrowserTTS(browserHandler, reportProgress);
// Then schedule Kokoro loading in the background
reportProgress(75, 'Scheduling Kokoro TTS initialization');
this.scheduleKokoroInitialization(kokoroHandler, reportProgress).then((kokoroAvailable) => {
if (kokoroAvailable) {
// Switch to Kokoro as it's the best option and set as preferred
this.ttsHandler = kokoroHandler;
this.setPreferredTTSMode('kokoro');
this.dispatchTTSReadyEvent(true, 'kokoro', kokoroHandler);
reportProgress(100, 'Kokoro TTS ready');
// Apply voice settings from preferences if available
this.applyVoiceSettingsFromPreferences();
} else if (!this.getPreferredTTSMode()) {
// If Kokoro failed and no preference was previously set,
// set browser as preferred mode
this.setPreferredTTSMode('browser');
// Add dependencies
this.dependencies = ['persistence-manager', 'localization'];
}
/**
* Initialize the module
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize() {
try {
this.reportProgress(10, "Initializing TTS factory");
// Get dependencies
const persistenceManager = this.getModule('persistence-manager');
const localization = this.getModule('localization');
if (!persistenceManager || !localization) {
console.error("TTS Factory: Required dependencies not found");
this.reportProgress(100, "TTS factory failed - missing dependencies");
return false;
}
});
}
// Apply voice settings from preferences for initial handler
this.applyVoiceSettingsFromPreferences();
// Resolve initialization even though Kokoro is still loading in background
reportProgress(80, 'TTS interface ready' +
(preferredTTSMode !== 'kokoro' ? '' : ' (Kokoro loading in background)'));
resolve(true);
} catch (error) {
console.error('Error initializing TTS Factory:', error);
// If we have any handler working, consider initialization successful
if (this.ttsHandler) {
reportProgress(100, `Using ${this.ttsHandler.getId()} TTS (fallback)`);
resolve(true);
} else {
this.dispatchTTSReadyEvent(false);
reportProgress(100, 'TTS initialization failed');
resolve(false);
}
}
});
return this.initializationPromise;
}
/**
* Apply stored voice settings from preferences
* @private
*/
applyVoiceSettingsFromPreferences() {
if (!this.ttsHandler || !this.persistenceManager) return;
const prefs = this.persistenceManager.getAllPreferences();
if (prefs && prefs.tts) {
if (prefs.tts.voice) {
console.log(`TTS Factory: Setting voice to ${prefs.tts.voice} from preferences`);
// Check if setVoice exists, otherwise try setting through voiceOptions
if (typeof this.ttsHandler.setVoice === 'function') {
this.ttsHandler.setVoice(prefs.tts.voice);
} else if (typeof this.ttsHandler.setVoiceOptions === 'function') {
this.ttsHandler.setVoiceOptions({ voice: prefs.tts.voice });
}
}
if (prefs.tts.rate !== undefined) {
console.log(`TTS Factory: Setting speech rate to ${prefs.tts.rate} from preferences`);
// Check if setSpeed exists, otherwise try setting through voiceOptions
if (typeof this.ttsHandler.setSpeed === 'function') {
this.ttsHandler.setSpeed(prefs.tts.rate);
} else if (typeof this.ttsHandler.setVoiceOptions === 'function') {
this.ttsHandler.setVoiceOptions({ rate: prefs.tts.rate });
}
}
if (prefs.tts.volume !== undefined && typeof this.ttsHandler.setVolume === 'function') {
console.log(`TTS Factory: Setting volume to ${prefs.tts.volume} from preferences`);
this.ttsHandler.setVolume(prefs.tts.volume);
}
}
}
/**
* Initialize browser TTS
* @param {BrowserTTSHandler} handler - The browser TTS handler
* @param {Function} reportProgress - Progress reporting function
* @returns {Promise<boolean>} - Resolves with availability status
*/
async initializeBrowserTTS(handler, reportProgress) {
reportProgress(30, 'Initializing browser TTS');
const browserAvailable = await handler.initialize();
if (browserAvailable) {
this.ttsHandler = handler;
this.dispatchTTSReadyEvent(true, 'browser', handler);
reportProgress(40, 'Browser TTS ready');
} else {
reportProgress(40, 'Browser TTS not available');
}
return browserAvailable;
}
/**
* Initialize API TTS
* @param {ApiTTSHandler} handler - The API TTS handler
* @param {Function} reportProgress - Progress reporting function
* @returns {Promise<boolean>} - Resolves with availability status
*/
async initializeApiTTS(handler, reportProgress) {
reportProgress(50, 'Initializing API TTS');
const apiAvailable = await handler.initialize();
if (apiAvailable) {
this.ttsHandler = handler;
this.dispatchTTSReadyEvent(true, 'api', handler);
reportProgress(70, 'API TTS ready');
}
return apiAvailable;
}
/**
* Get preferred TTS mode from storage
* @returns {string|null} - Preferred TTS mode or null if not set
*/
getPreferredTTSMode() {
// First check persistent settings if available
if (this.persistenceManager) {
const prefs = this.persistenceManager.getAllPreferences();
if (prefs && prefs.tts && prefs.tts.provider) {
console.log(`TTS Factory: Using preferred TTS mode '${prefs.tts.provider}' from persistence manager`);
return prefs.tts.provider;
}
}
// Fallback to localStorage if persistence manager is not available
try {
const savedMode = localStorage.getItem('preferred-tts-mode');
if (savedMode) {
console.log(`TTS Factory: Using preferred TTS mode '${savedMode}' from localStorage`);
return savedMode;
}
} catch (e) {
console.warn('Could not read TTS preference from localStorage');
}
// Default to Kokoro if no preference is found
return "kokoro";
}
/**
* Set preferred TTS mode in storage
* @param {string} mode - The TTS mode to save as preferred
*/
setPreferredTTSMode(mode) {
// Update in persistence manager if available
if (this.persistenceManager) {
this.persistenceManager.updatePreference('tts', 'provider', mode);
console.log(`TTS Factory: Saved preferred TTS mode '${mode}' to persistence manager`);
}
// Also save to localStorage as backup
try {
localStorage.setItem('preferred-tts-mode', mode);
} catch (e) {
console.warn('Could not save TTS preference to localStorage');
}
}
/**
* Schedule Kokoro initialization during idle time
* @param {Object} kokoroHandler - The Kokoro handler instance
* @param {Function} reportProgress - Progress reporting function
* @returns {Promise<boolean>} - Resolves with success status
*/
scheduleKokoroInitialization(kokoroHandler, reportProgress) {
// Immediately dispatch the loading started event so tts-player can catch it
window.dispatchEvent(new CustomEvent('kokoro-loading-started'));
return new Promise((resolve) => {
// Create the initialization function
const startKokoroInit = async () => {
try {
// Initialize Kokoro with progress callback
const kokoroAvailable = await kokoroHandler.initialize((percent, message) => {
// Scale progress to 80-95% range for the TTS module's overall progress
const scaledProgress = 80 + Math.floor(percent * 0.15);
reportProgress(scaledProgress, message || `Loading Kokoro TTS: ${percent}%`);
});
// Register available handlers
this.registerHandler('browser', new BrowserTTSHandler());
this.registerHandler('api', new ApiTTSHandler());
this.registerHandler('kokoro', new KokoroHandler());
this.reportProgress(30, "Registered TTS handlers");
// Get user preferences
const ttsEnabled = this.getPreference('tts', 'enabled', false);
const preferredProvider = this.getPreference('tts', 'provider', 'browser');
// Initialize handlers based on preferences
let initSuccess = false;
if (ttsEnabled) {
// Try to initialize preferred handler first
this.reportProgress(50, `Initializing preferred TTS handler: ${preferredProvider}`);
initSuccess = await this.initializeHandler(preferredProvider);
// Mark completion
if (kokoroAvailable) {
reportProgress(95, "Kokoro TTS initialized successfully");
if (initSuccess) {
this.setActiveHandler(preferredProvider);
} else {
reportProgress(95, "Kokoro TTS unavailable - using fallback");
// If preferred handler failed, try alternatives based on priority: Kokoro -> Browser -> None
console.warn(`Failed to initialize preferred TTS handler: ${preferredProvider}, trying alternatives`);
// Try Kokoro TTS as fallback if not already tried
if (preferredProvider !== 'kokoro') {
this.reportProgress(60, "Trying Kokoro TTS as fallback");
initSuccess = await this.initializeHandler('kokoro');
if (initSuccess) {
this.setActiveHandler('kokoro');
// Update preference to Kokoro since it worked
this.getModule('persistence-manager').updatePreference('tts', 'provider', 'kokoro');
}
}
// If Kokoro TTS failed, try Browser TTS
if (!initSuccess && preferredProvider !== 'browser') {
this.reportProgress(70, "Trying Browser TTS as fallback");
initSuccess = await this.initializeHandler('browser');
if (initSuccess) {
this.setActiveHandler('browser');
// Update preference to browser since it worked
this.getModule('persistence-manager').updatePreference('tts', 'provider', 'browser');
}
}
// Note: API TTS is not used as a fallback as it requires manual configuration
}
} else {
// Even if TTS is disabled, initialize handlers in the background
// so they're ready if the user enables TTS later
this.reportProgress(50, "TTS disabled, initializing handlers in background");
// Always dispatch event to indicate completion status
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
detail: { success: kokoroAvailable }
}));
// Initialize Kokoro and Browser handlers in parallel (not API as it requires configuration)
const initPromises = [
this.initializeHandler('kokoro'),
this.initializeHandler('browser')
];
resolve(kokoroAvailable);
} catch (error) {
console.error('Error initializing Kokoro:', error);
reportProgress(95, 'Kokoro TTS failed to initialize - using fallback');
// Wait for all handlers to initialize
await Promise.allSettled(initPromises);
// Dispatch completion event with error information
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
detail: { success: false, error: error.message }
}));
resolve(false);
// Check if any handler initialized successfully
initSuccess = this.initStatus.kokoro || this.initStatus.browser;
}
};
// Add timeout protection with a reasonable timeout (30 seconds for resource-intensive operations)
const timeoutId = setTimeout(() => {
reportProgress(95, 'Kokoro initialization timed out - using fallback');
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
detail: { success: false, error: "Timeout" }
// Set TTS availability flag and dispatch event
this.ttsAvailable = initSuccess;
// Dispatch event to notify UI about TTS availability
document.dispatchEvent(new CustomEvent('tts:availability', {
detail: { available: this.ttsAvailable }
}));
resolve(false);
}, 30000); // Increased timeout to 30 seconds since model loading is resource intensive
// Use requestIdleCallback to start initialization during idle time
if (window.requestIdleCallback) {
reportProgress(75, 'Scheduling Kokoro TTS for background loading');
window.requestIdleCallback(() => {
startKokoroInit().then(() => clearTimeout(timeoutId));
}, { timeout: 10000 });
} else {
reportProgress(75, 'Background loading not available, loading Kokoro normally');
this.reportProgress(100, initSuccess ? "TTS factory ready" : "TTS factory ready (no handlers available)");
// Use a microtask to avoid blocking the UI thread
Promise.resolve().then(() => startKokoroInit().then(() => clearTimeout(timeoutId)));
// Always return true since TTS is optional for the application
return true;
} catch (error) {
console.error("Error initializing TTS factory:", error);
this.reportProgress(100, "TTS factory failed");
// Set TTS availability to false and dispatch event
this.ttsAvailable = false;
document.dispatchEvent(new CustomEvent('tts:availability', {
detail: { available: false }
}));
// Still return true since TTS is optional
return true;
}
});
}
/**
* Dispatch a custom event when TTS is ready
* @param {boolean} available - Whether TTS is available
* @param {string} type - The type of TTS
* @param {Object} handler - The TTS handler object
*/
dispatchTTSReadyEvent(available, type = null, handler = null) {
const event = new CustomEvent('tts-ready', {
detail: {
available,
type,
handler,
enabled: this.ttsEnabled
}
});
window.dispatchEvent(event);
}
/**
* Get information about the active TTS system
* @returns {Object} - TTS system info
*/
getActiveTTSInfo() {
if (!this.ttsHandler) {
return { available: false, type: 'none', name: 'None' };
}
const id = this.ttsHandler.getId();
const name = {
'browser': 'Browser TTS',
'kokoro': 'Kokoro Neural TTS',
'api': 'ElevenLabs API TTS'
}[id] || 'Unknown TTS';
return {
available: true,
type: id,
name: name
};
}
/**
* Switch to a specific TTS handler
* @param {string} type - The handler ID to use
* @returns {boolean} - Success status
*/
switchTTS(type) {
if (!this.handlers[type] || !this.handlers[type].isAvailable()) {
return false;
}
this.ttsHandler = this.handlers[type];
this.dispatchTTSReadyEvent(true, type, this.ttsHandler);
// Update preferred TTS mode
this.setPreferredTTSMode(type);
return true;
}
/**
* Speak text using the active TTS handler
* @param {string} text - Text to speak
* @param {Function} callback - Called when speech completes
* @returns {boolean} - True if speech started successfully
*/
speak(text, callback = null) {
if (!this.ttsEnabled || !this.ttsHandler) {
console.warn("TTSFactory: No active TTS handler available or TTS disabled");
if (callback) callback("No TTS handler");
return false;
/**
* Register a TTS handler
* @param {string} id - Handler ID
* @param {Object} handler - TTS handler instance
*/
registerHandler(id, handler) {
if (!id || !handler) return;
this.handlers[id] = handler;
}
const handlerType = this.ttsHandler.getId();
console.log(`TTSFactory: Using ${handlerType} handler to speak "${text}"`);
try {
this.ttsHandler.speak(text, (result) => {
console.log(`TTSFactory: Speech completed using ${handlerType}`, result);
if (callback) callback(result);
});
return true;
} catch (error) {
console.error('Error speaking:', error);
if (callback) callback(error);
return false;
}
}
/**
* Stop any ongoing speech
*/
stop() {
if (this.ttsHandler) {
this.ttsHandler.stop();
}
}
/**
* Set voice options for the active handler
* @param {Object} options - Voice options
*/
setVoiceOptions(options = {}) {
if (this.ttsHandler && typeof this.ttsHandler.setVoiceOptions === 'function') {
this.ttsHandler.setVoiceOptions(options);
// Save settings to persistence manager if available
if (this.persistenceManager) {
if (options.voice !== undefined) {
this.persistenceManager.updatePreference('tts', 'voice', options.voice, false);
/**
* Initialize a specific TTS handler
* @param {string} id - Handler ID
* @returns {Promise<boolean>} - Success status
*/
async initializeHandler(id) {
if (!id || !this.handlers[id]) {
console.error(`TTS Factory: Handler '${id}' not found`);
return false;
}
if (options.rate !== undefined) {
this.persistenceManager.updatePreference('tts', 'rate', options.rate, false);
try {
this.reportProgress(0, `Initializing ${id} TTS handler`);
// Initialize the handler
const success = await this.handlers[id].initialize(
(progress, message) => {
this.reportProgress(progress, message);
}
);
// Update initialization status
this.initStatus[id] = success;
if (success) {
console.log(`TTS Factory: Successfully initialized ${id} TTS handler`);
} else {
console.error(`TTS Factory: Failed to initialize ${id} TTS handler`);
}
return success;
} catch (error) {
console.error(`TTS Factory: Error initializing ${id} TTS handler:`, error);
this.initStatus[id] = false;
return false;
}
if (options.volume !== undefined) {
this.persistenceManager.updatePreference('tts', 'volume', options.volume, false);
}
/**
* Get a TTS handler by ID
* @param {string} id - Handler ID
* @returns {Object|null} - TTS handler instance or null if not found
*/
getHandler(id) {
if (!id || !this.handlers[id]) return null;
return this.handlers[id];
}
/**
* Set the active TTS handler
* @param {string} id - Handler ID
* @returns {boolean} - Success status
*/
setActiveHandler(id) {
if (!id || !this.handlers[id] || !this.initStatus[id]) {
console.warn(`Cannot set active handler to ${id}: handler not found or not initialized`);
return false;
}
// Save all changes at once
this.persistenceManager.savePreferences();
}
}
}
/**
* Toggle TTS on/off
* @returns {boolean} - New TTS enabled state
*/
toggle() {
this.ttsEnabled = !this.ttsEnabled;
console.log(`TTS Factory: Toggling TTS to ${this.ttsEnabled ? 'enabled' : 'disabled'}`);
if (!this.ttsEnabled && this.ttsHandler) {
this.ttsHandler.stop();
// Stop current handler if active
if (this.activeHandler) {
this.handlers[this.activeHandler].stop();
}
// Set new active handler
this.activeHandler = id;
// Update preference
this.getModule('persistence-manager').updatePreference('tts', 'provider', id);
// Dispatch event
this.dispatchEvent('tts-handler-changed', {
handler: id
});
return true;
}
// Save the new state to preferences if persistence manager is available
if (this.persistenceManager) {
this.persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
console.log(`TTS Factory: Saved enabled state (${this.ttsEnabled}) to persistence manager`);
/**
* Get the active TTS handler
* @returns {Object|null} - Active TTS handler instance or null if none active
*/
getActiveHandler() {
if (!this.activeHandler) return null;
return this.handlers[this.activeHandler];
}
return this.ttsEnabled;
}
/**
* Check if TTS is enabled
* @returns {boolean} - Current TTS enabled state
*/
isEnabled() {
return this.ttsEnabled;
}
/**
* Get available handlers
* @returns {Object} - Map of available handlers
*/
getAvailableHandlers() {
const available = {};
Object.entries(this.handlers).forEach(([id, handler]) => {
if (handler.isAvailable()) {
available[id] = handler;
}
});
return available;
}
/**
* Get available voices from active handler
* @returns {Promise<Array>} - Array of available voices
*/
async getVoices() {
if (!this.ttsHandler || typeof this.ttsHandler.getVoices !== 'function') {
return [];
/**
* Get all available TTS handlers
* @returns {Object} - Map of handler IDs to initialization status
*/
getAvailableHandlers() {
const available = {};
for (const id in this.handlers) {
available[id] = this.initStatus[id];
}
return available;
}
try {
return await this.ttsHandler.getVoices();
} catch (error) {
console.error('Error getting voices:', error);
return [];
/**
* Speak text using the active TTS handler
* @param {string} text - Text to speak
* @param {Object} options - TTS options
* @returns {Promise<boolean>} - Success status
*/
async speak(text, options = {}) {
if (!this.activeHandler) {
console.warn("No active TTS handler");
return false;
}
try {
return await this.handlers[this.activeHandler].speak(text, options);
} catch (error) {
console.error("Error speaking text:", error);
return false;
}
}
/**
* Stop speaking
* @returns {boolean} - Success status
*/
stop() {
if (!this.activeHandler) return false;
try {
return this.handlers[this.activeHandler].stop();
} catch (error) {
console.error("Error stopping TTS:", error);
return false;
}
}
/**
* Pause speaking
* @returns {boolean} - Success status
*/
pause() {
if (!this.activeHandler) return false;
try {
return this.handlers[this.activeHandler].pause();
} catch (error) {
console.error("Error pausing TTS:", error);
return false;
}
}
/**
* Resume speaking
* @returns {boolean} - Success status
*/
resume() {
if (!this.activeHandler) return false;
try {
return this.handlers[this.activeHandler].resume();
} catch (error) {
console.error("Error resuming TTS:", error);
return false;
}
}
/**
* Get available voices for the active TTS handler
* @returns {Array} - Array of voice objects
*/
getVoices() {
if (!this.activeHandler) return [];
try {
return this.handlers[this.activeHandler].getVoices();
} catch (error) {
console.error("Error getting voices:", error);
return [];
}
}
/**
* Get a preference from the persistence manager
* @param {string} category - Preference category
* @param {string} key - Preference key
* @param {*} defaultValue - Default value if preference doesn't exist
* @returns {*} - Preference value
*/
getPreference(category, key, defaultValue) {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
return persistenceManager.getPreference(category, key, defaultValue);
}
return defaultValue;
}
/**
* Clean up when module is disposed
*/
dispose() {
// Stop any active TTS
if (this.activeHandler) {
this.handlers[this.activeHandler].stop();
}
// Dispose all handlers
for (const id in this.handlers) {
if (this.handlers[id].dispose) {
this.handlers[id].dispose();
}
}
// Clear handlers
this.handlers = {};
this.activeHandler = null;
}
}
}
// Create singleton instance
const ttsFactory = new TTSFactory();
// Create the singleton instance
const TTSFactory = new TTSFactoryModule();
// Export the factory
export { ttsFactory };
// Register with the module registry
moduleRegistry.register(TTSFactory);
// Keep global reference
window.ttsFactory = ttsFactory;
// Export the module
export { TTSFactory };
+14
View File
@@ -107,4 +107,18 @@ export class TTSHandler {
removeEventListener(eventName, callback) {
this.eventTarget.removeEventListener(eventName, callback);
}
/**
* Bind methods to this instance
* @param {Array<string>} methodNames - Array of method names to bind
*/
bindMethods(methodNames) {
if (!Array.isArray(methodNames)) return;
methodNames.forEach(methodName => {
if (typeof this[methodName] === 'function') {
this[methodName] = this[methodName].bind(this);
}
});
}
}
+271 -198
View File
@@ -1,59 +1,41 @@
/**
* TTS Player Module for AI Interactive Fiction
* Handles Text-to-Speech functionality with resource-aware loading and progress reporting
* TTS Player Module
* Manages TTS functionality and interacts with available TTS handlers
*/
import { BaseModule, ModuleEvent } from './base-module.js';
import { BaseModule } from './base-module.js';
import { moduleRegistry } from './module-registry.js';
class TTSPlayerModule extends BaseModule {
constructor() {
super('tts', 'Text-to-Speech');
this.ttsFactory = null;
this.isInitialized = false;
this.kokoroLoadingPromise = null;
this.kokoroLoadingStarted = false;
}
/**
* Load module dependencies
* @returns {Promise} - Resolves when dependencies are loaded
*/
async loadDependencies() {
try {
// Import the TTS Factory module
const { ttsFactory } = await import('./tts-factory.js');
this.ttsFactory = ttsFactory;
this.reportProgress(20, "TTS Factory loaded");
// Set up event listeners
window.addEventListener('tts-ready', this.handleTTSReadyEvent.bind(this));
// Create a Promise that resolves when Kokoro is loaded
this.kokoroLoadingPromise = new Promise(resolve => {
// Listen for when Kokoro starts loading
window.addEventListener('kokoro-loading-started', () => {
this.kokoroLoadingStarted = true;
this.reportProgress(50, "Loading Kokoro TTS");
});
// Listen for when Kokoro completes loading
window.addEventListener('kokoro-loading-complete', (event) => {
// Check if loading was successful from the event details
if (event.detail && event.detail.success === false) {
this.reportProgress(95, "Kokoro TTS failed to load - using fallback");
console.warn("Kokoro failed to load:", event.detail?.error || "unknown error");
} else {
this.reportProgress(95, "Kokoro TTS loaded");
}
resolve();
});
});
return true;
} catch (error) {
console.error("Error loading TTS dependencies:", error);
return false;
}
super('tts-player', 'TTS Player');
// Module dependencies
this.dependencies = ['tts-factory'];
// TTS state
this.enabled = true;
this.currentSpeech = null;
this.pendingCallback = null;
// Preloading mechanism
this.preloadQueue = [];
this.preloadedAudio = new Map(); // Cache for preloaded TTS
this.isPreloading = false;
// Bind methods using parent's bindMethods utility
this.bindMethods([
'speak',
'preloadSpeech',
'processPreloadQueue',
'stop',
'enable',
'isEnabled',
'isSpeaking',
'setVoice',
'setSpeed',
'getVoices',
'toggle'
]);
}
/**
@@ -62,197 +44,291 @@ class TTSPlayerModule extends BaseModule {
*/
async initialize() {
try {
// Initialize TTS Factory
await this.ttsFactory.constructor.initializeInterface((percent, message) => {
// Scale to 20-90% of our progress range
const scaledPercent = 20 + (percent * 0.7);
this.reportProgress(scaledPercent, message);
this.reportProgress(20, "Initializing TTS Player");
// Get TTS Factory dependency
const ttsFactory = this.getModule('tts-factory');
if (!ttsFactory) {
console.error("TTS Player: TTS Factory dependency not found");
this.reportProgress(100, "TTS Player failed - missing dependencies");
return false;
}
// Check TTS availability from TTS Factory
this.enabled = ttsFactory.ttsAvailable && ttsFactory.getPreference('tts', 'enabled', false);
// Set up event listeners
this.addEventListener(document, 'tts:enabled', (event) => {
if (event.detail) {
this.enabled = event.detail.enabled;
console.log(`TTS Player: TTS ${this.enabled ? 'enabled' : 'disabled'}`);
}
});
// IMPORTANT: Always wait for Kokoro's loading promise to resolve
this.reportProgress(90, "Waiting for Kokoro TTS to complete loading");
// Listen for TTS availability changes
this.addEventListener(document, 'tts:availability', (event) => {
if (event.detail) {
const available = event.detail.available;
console.log(`TTS Player: TTS availability changed to ${available ? 'available' : 'unavailable'}`);
// If TTS becomes unavailable, disable it
if (!available) {
this.enabled = false;
// Notify UI that TTS is disabled
document.dispatchEvent(new CustomEvent('tts:stateChange', {
detail: { enabled: false, available: false }
}));
}
}
});
// Wait for the Kokoro loading promise to complete with a timeout
try {
// Add a timeout to prevent waiting forever
const timeoutPromise = new Promise(resolve => setTimeout(() => {
console.log("TTS Player: Kokoro loading timed out, continuing without Kokoro");
resolve(false);
}, 10000)); // 10 second timeout
// Race between normal completion and timeout
await Promise.race([this.kokoroLoadingPromise, timeoutPromise]);
this.reportProgress(95, "Kokoro TTS loading completed or timed out");
} catch (err) {
console.warn("TTS Player: Error waiting for Kokoro:", err);
this.reportProgress(95, "Error waiting for Kokoro, continuing anyway");
}
// Listen for TTS toggle events from UI
this.addEventListener(document, 'tts:toggle', () => {
this.toggle();
// Dispatch state change event for UI to update
document.dispatchEvent(new CustomEvent('tts:stateChange', {
detail: { enabled: this.enabled, available: ttsFactory.ttsAvailable }
}));
});
this.isInitialized = true;
// Listen for sentence ready events to preload TTS
this.addEventListener(document, 'buffer:sentence', (event) => {
if (event.detail && event.detail.sentence && this.enabled) {
// Add to preload queue
this.preloadSpeech(event.detail.sentence);
}
});
// Final status check
const ttsInfo = this.ttsFactory.getActiveTTSInfo();
if (ttsInfo.available) {
this.reportProgress(100, `TTS Player initialized using ${ttsInfo.name}`);
return true;
} else {
this.reportProgress(100, "TTS initialization complete but no voices available");
return true; // Still consider this a success, just with no voices
}
// Dispatch initial state to UI
document.dispatchEvent(new CustomEvent('tts:stateChange', {
detail: { enabled: this.enabled, available: ttsFactory.ttsAvailable }
}));
this.reportProgress(100, "TTS Player ready");
return true;
} catch (error) {
console.error("Error initializing TTS Player:", error);
this.reportProgress(100, "TTS initialization failed, continuing without TTS");
this.isInitialized = true; // Mark as initialized anyway to not block other modules
return true; // Return true to not block the application
return false;
}
}
/**
* Handle TTS ready event from the factory
* @param {CustomEvent} event - The TTS ready event
* Preload speech for a sentence
* @param {string} text - Text to preload
*/
handleTTSReadyEvent(event) {
const { available, type } = event.detail;
preloadSpeech(text) {
if (!text || !this.enabled) return;
if (available && type) {
this.reportProgress(95, `TTS system ready: ${type}`);
} else {
this.reportProgress(95, "No TTS system available");
// Don't preload if already in cache
if (this.preloadedAudio.has(text)) return;
// Add to preload queue
this.preloadQueue.push(text);
console.log(`TTS Player: Added to preload queue: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
// Start processing the queue if not already processing
if (!this.isPreloading) {
this.processPreloadQueue();
}
}
// Public API methods
/**
* Get information about the active TTS system
* @returns {Object} - TTS system info
* Process the preload queue
*/
getTTSInfo() {
if (!this.ttsFactory) return { available: false, type: 'none', name: 'None' };
return this.ttsFactory.getActiveTTSInfo();
async processPreloadQueue() {
if (this.preloadQueue.length === 0 || this.isPreloading) return;
this.isPreloading = true;
const text = this.preloadQueue.shift();
try {
// Get TTSFactory from module registry
const ttsFactory = this.getModule('tts-factory');
if (!ttsFactory) {
console.error("TTS Player: TTSFactory module not found in registry");
this.isPreloading = false;
return;
}
// Only preload if we're not currently speaking or the text is different from current speech
if (!this.isSpeaking() || (this.currentSpeech && this.currentSpeech !== text)) {
console.log(`TTS Player: Preloading speech for: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
// Use the preload method of the TTS factory if available
if (typeof ttsFactory.preloadSpeech === 'function') {
await ttsFactory.preloadSpeech(text);
this.preloadedAudio.set(text, true);
} else {
// Fallback: use normal speak method with a dummy callback
ttsFactory.speak(text, () => {
ttsFactory.stop(); // Stop immediately after generation
this.preloadedAudio.set(text, true);
});
}
}
} catch (error) {
console.warn("TTS Player: Error preloading speech:", error);
} finally {
this.isPreloading = false;
// Process next in queue if available
if (this.preloadQueue.length > 0) {
// Use requestAnimationFrame to prevent blocking
requestAnimationFrame(() => this.processPreloadQueue());
}
}
}
/**
* Toggle TTS functionality on/off
* @returns {boolean} - New TTS enabled state
*/
toggle() {
if (!this.ttsFactory) return false;
return this.ttsFactory.toggle();
}
/**
* Speak text using the active TTS system
* Speak text
* @param {string} text - Text to speak
* @param {Function} callback - Called when speech completes
* @param {Function} callback - Optional callback for when speech completes
* @returns {boolean} - True if speech started successfully
*/
speak(text, callback) {
if (!this.ttsFactory) {
console.warn("TTS Factory not available for speak");
if (callback) callback("TTS not available");
return;
speak(text, callback = null) {
if (!text) return false;
console.log(`TTS Player: Speaking "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`, this.enabled ? "(TTS enabled)" : "(TTS disabled)");
// Store the current speech text
this.currentSpeech = text;
if (!this.enabled) {
console.log("TTS Player: TTS is disabled, not speaking");
if (callback) {
setTimeout(() => callback({ success: false, reason: 'tts_disabled' }), 0);
}
return false;
}
console.log(`TTS Player speaking: "${text}"`);
this.ttsFactory.speak(text, (result) => {
console.log("TTS Player speak complete", result);
if (callback) callback(result);
});
// Get TTSFactory from module registry
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
this.pendingCallback = callback;
// Check if this text was preloaded
const wasPreloaded = this.preloadedAudio.has(text);
if (wasPreloaded) {
console.log("TTS Player: Using preloaded speech");
this.preloadedAudio.delete(text); // Remove from cache after use
}
// Start TTS with minimal delay to synchronize with text rendering
ttsFactory.speak(text, (result) => {
// Store the completed result
this.currentSpeech = null;
// Call the callback if provided
if (this.pendingCallback) {
this.pendingCallback(result);
this.pendingCallback = null;
}
// Process next in preload queue if any
if (this.preloadQueue.length > 0 && !this.isPreloading) {
this.processPreloadQueue();
}
});
return true;
} else {
console.error("TTS Player: TTSFactory module not found in registry");
if (callback) {
setTimeout(() => callback({ success: false, reason: 'no_tts_factory' }), 0);
}
return false;
}
}
/**
* Stop any ongoing speech
* Stop speaking
*/
stop() {
if (this.ttsFactory) {
this.ttsFactory.stop();
}
}
/**
* Set voice options for the active TTS system
* @param {Object} options - Voice options
*/
setVoiceOptions(options) {
if (this.ttsFactory) {
this.ttsFactory.setVoiceOptions(options);
}
}
/**
* Set speech rate/speed
* @param {number} speed - Speech rate (0.5-2.0)
*/
setSpeed(speed) {
this.setVoiceOptions({ rate: speed });
}
/**
* Set the volume for speech
* @param {number} volume - Volume level (0.0-1.0)
*/
setVolume(volume) {
this.setVoiceOptions({ volume: volume });
}
/**
* Set the voice for speech
* @param {string} voice - Voice identifier
*/
setVoice(voice) {
this.setVoiceOptions({ voice: voice });
}
/**
* Switch to a specific TTS system
* @param {string} type - The TTS system to use ('kokoro', 'browser', or 'api')
* @returns {boolean} - Success status
*/
switchTTS(type) {
if (!this.ttsFactory) return false;
const result = this.ttsFactory.switchTTS(type);
// If the switch was successful, refresh the voice list
if (result) {
// Notify listeners that the TTS system changed
window.dispatchEvent(new CustomEvent('tts-system-changed', {
detail: {
type,
info: this.getTTSInfo()
}
}));
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
ttsFactory.stop();
}
return result;
this.currentSpeech = null;
this.pendingCallback = null;
}
/**
* Get available TTS systems
* @returns {Array<string>} - Array of available TTS system IDs
* Toggle TTS enabled state
*/
getAvailableSystems() {
if (!this.ttsFactory) return [];
const handlers = this.ttsFactory.getAvailableHandlers();
return Object.keys(handlers);
toggle() {
this.enabled = !this.enabled;
this.enable(this.enabled);
return this.enabled;
}
/**
* Get available voices for the active TTS system
* @returns {Promise<Array>} - Array of voice objects
* Enable or disable TTS
* @param {boolean} enabled - Whether TTS should be enabled
*/
async getVoices() {
if (!this.ttsFactory) return [];
return this.ttsFactory.getVoices();
enable(enabled) {
this.enabled = enabled;
console.log(`TTS Player: ${this.enabled ? 'Enabled' : 'Disabled'}`);
// Save preference if persistence manager is available
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'enabled', this.enabled);
}
}
/**
* Is TTS enabled currently
* Check if TTS is enabled
* @returns {boolean} - Whether TTS is enabled
*/
isEnabled() {
if (!this.ttsFactory) return false;
return this.ttsFactory.isEnabled();
return this.enabled;
}
/**
* Check if TTS is currently speaking
* @returns {boolean} - Whether TTS is speaking
*/
isSpeaking() {
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
return ttsFactory.isSpeaking();
}
return false;
}
/**
* Set the voice to use
* @param {string} voice - Voice identifier
*/
setVoice(voice) {
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
ttsFactory.configure({ voice });
}
}
/**
* Set the speech rate/speed
* @param {number} speed - Speech rate (0.5-2.0)
*/
setSpeed(speed) {
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
ttsFactory.configure({ speed });
}
}
/**
* Get available voices
* @returns {Promise<Array>} - Resolves with array of voice objects
*/
async getVoices() {
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
return ttsFactory.getVoices();
}
return [];
}
}
@@ -264,6 +340,3 @@ moduleRegistry.register(TTSPlayer);
// Export the module
export { TTSPlayer };
// Keep a reference in window for loader system
window.TTSPlayer = TTSPlayer;
+165 -162
View File
@@ -4,10 +4,11 @@ import { ModuleEvent } from './base-module.js';
class UIController extends BaseModule {
constructor() {
super('ui-controller');
super('ui-controller', 'UI Controller');
// Declare dependencies on TTS, animation-queue, and our new UI modules
this.dependencies = ['tts', 'animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects'];
// Remove 'tts' from direct dependencies to break circular dependency
// UI Controller will access TTS through the Game Loop instead
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client'];
// References to sub-modules
this.displayHandler = null;
@@ -32,55 +33,74 @@ class UIController extends BaseModule {
// Add TTS toggle state
this.ttsEnabled = false;
this.ttsAvailable = true; // Add TTS availability state
// Bind methods that use 'this' internally or are used as callbacks/event handlers
this.initialize = this.initialize.bind(this); // Bind initialize as it calls dispatchEvent
this.handleCommand = this.handleCommand.bind(this); // Bind event handler
this.displayText = this.displayText.bind(this); // Bind if passed as callback
this.setupBookInterface = this.setupBookInterface.bind(this);
this.applyBookSizing = this.applyBookSizing.bind(this);
this.setupEventListeners = this.setupEventListeners.bind(this);
this.setupMainUI = this.setupMainUI.bind(this);
this.initializeTextBuffer = this.initializeTextBuffer.bind(this);
this.showUI = this.showUI.bind(this);
this.hideUI = this.hideUI.bind(this);
this.clearDisplay = this.clearDisplay.bind(this);
this.sendCommand = this.sendCommand.bind(this);
this.updateButtonStates = this.updateButtonStates.bind(this);
// Store a bound version of dispatchEvent for use in methods
this._dispatchModuleEvent = (name, detail) => {
document.dispatchEvent(new CustomEvent(name, {
detail: { moduleId: this.id, ...detail },
bubbles: true
}));
};
// Bind methods using the parent class bindMethods utility
this.bindMethods([
'initialize',
'handleCommand',
'displayText',
'setupBookInterface',
'applyBookSizing',
'setupEventListeners',
'setupMainUI',
'initializeTextBuffer',
'showUI',
'hideUI',
'clearDisplay',
'sendCommand',
'updateButtonStates'
]);
}
async initialize() {
this.reportProgress(0, 'Initializing UI Controller');
try {
this.reportProgress(0, 'Initializing UI Controller');
this.reportProgress(20, 'Setting up book interface');
// Set up book interface
this.setupBookInterface();
this.reportProgress(30, 'Setting up UI components');
this.reportProgress(30, 'Getting module dependencies');
// Get module references
this.displayHandler = moduleRegistry.getModule('ui-display-handler');
this.inputHandler = moduleRegistry.getModule('ui-input-handler');
this.effects = moduleRegistry.getModule('ui-effects');
// Get module references using parent's getModule method
this.displayHandler = this.getModule('ui-display-handler');
this.inputHandler = this.getModule('ui-input-handler');
this.effects = this.getModule('ui-effects');
this.textBuffer = this.getModule('text-buffer');
this.socketClient = this.getModule('socket-client');
this.animationQueue = this.getModule('animation-queue');
// Get additional dependencies
this.textBuffer = moduleRegistry.getModule('text-buffer');
this.ttsHandler = moduleRegistry.getModule('tts');
this.socketClient = moduleRegistry.getModule('socket-client');
this.animationQueue = moduleRegistry.getModule('animation-queue');
// Check for required UI modules
if (!this.displayHandler) {
console.error('UI Controller: Display handler module not found');
return false;
}
if (!this.displayHandler || !this.inputHandler || !this.effects) {
console.error('UI Controller: Required UI modules not found');
if (!this.inputHandler) {
console.error('UI Controller: Input handler module not found');
return false;
}
if (!this.effects) {
console.error('UI Controller: UI effects module not found');
return false;
}
// Check for other required modules
if (!this.textBuffer) {
console.error('UI Controller: Text buffer module not found');
return false;
}
if (!this.socketClient) {
console.error('UI Controller: Socket client module not found');
return false;
}
if (!this.animationQueue) {
console.error('UI Controller: Animation queue module not found');
return false;
}
@@ -89,24 +109,25 @@ class UIController extends BaseModule {
// Set up event listeners between components
this.setupEventListeners();
this.reportProgress(80, 'Finalizing UI initialization');
this.reportProgress(70, 'Setting up main UI');
// Initialize main UI container
await this.setupMainUI();
this.reportProgress(80, 'Initializing text buffer');
// Initialize text buffer handler
this.initializeTextBuffer();
this.reportProgress(100, 'UI Controller ready');
this.isReady = true;
this.isVisible = true;
this.reportProgress(100, 'UI Controller ready');
this.dispatchEvent(new ModuleEvent('ui:ready', { controller: this }));
// Start ambient effects
this.effects.startAmbientEffects();
// Use the DOM event API directly instead of this.dispatchEvent
this._dispatchModuleEvent('ui:ready', { controller: this });
return true;
} catch (error) {
console.error('Error initializing UI Controller:', error);
@@ -151,95 +172,41 @@ class UIController extends BaseModule {
});
// Listen for text display events - use arrow function to preserve context
document.addEventListener('ui:text:complete', () => {
// Use the DOM event API directly
this._dispatchModuleEvent('ui:ready:for:next', {});
document.addEventListener('ui:text:complete', (event) => {
console.log('UIController: Text complete event received, ready for next text');
});
// Listen for socket connection events
document.addEventListener('socket:connected', () => {
console.log('UI Controller: Socket connected');
console.log('UIController: Socket connected');
this.updateButtonStates();
});
document.addEventListener('socket:disconnected', () => {
console.log('UI Controller: Socket disconnected');
console.log('UIController: Socket disconnected');
this.updateButtonStates();
});
// Handle speed reset
const speedReset = document.getElementById('speed_reset');
if (speedReset) {
speedReset.addEventListener('click', (e) => {
e.preventDefault();
const speedSlider = document.getElementById('speed');
if (speedSlider) {
speedSlider.value = 50;
if (this.animationQueue) {
this.animationQueue.setSpeed(1.0);
}
// Listen for TTS state change events
document.addEventListener('tts:stateChange', (event) => {
if (event.detail) {
if (typeof event.detail.enabled === 'boolean') {
this.ttsEnabled = event.detail.enabled;
}
});
}
// Handle speed slider change for animation speed
const speedSlider = document.getElementById('speed');
if (speedSlider) {
speedSlider.addEventListener('input', (e) => {
if (this.animationQueue) {
// Convert slider value (0-100) to animation speed
// Using formula from Documentation.md: lower values = slower speed
const value = parseInt(e.target.value);
const speed = Math.pow(100.0 - value, 3) / 10000 * 10 + 0.01;
this.animationQueue.setSpeed(speed);
console.log(`UI Controller: Animation speed set to ${speed.toFixed(3)}`);
// Save to persistence manager if available
if (window.PersistenceManager) {
window.PersistenceManager.updatePreference('animation', 'speed', value);
}
}
});
// Set initial speed from persistence manager if available
if (window.PersistenceManager) {
const savedSpeed = window.PersistenceManager.getPreference('animation', 'speed', 50);
speedSlider.value = savedSpeed;
// Apply initial speed
if (this.animationQueue) {
const speed = Math.pow(100.0 - savedSpeed, 3) / 10000 * 10 + 0.01;
this.animationQueue.setSpeed(speed);
if (typeof event.detail.available === 'boolean') {
this.ttsAvailable = event.detail.available;
}
this.updateButtonStates();
}
}
});
// Handle speech toggle with proper state management
const speechToggle = document.getElementById('speech');
if (speechToggle && this.ttsHandler) {
// Remove disabled attribute to make it clickable
speechToggle.removeAttribute('disabled');
speechToggle.addEventListener('click', (e) => {
e.preventDefault();
console.log('Speech toggle clicked');
// Toggle TTS state
if (this.ttsHandler && typeof this.ttsHandler.toggle === 'function') {
this.ttsEnabled = this.ttsHandler.toggle();
// Update button text
speechToggle.textContent = this.ttsEnabled ? 'mute' : 'speech';
// Save preference if persistence manager is available
const persistenceManager = moduleRegistry.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
}
console.log(`UI Controller: TTS ${this.ttsEnabled ? 'enabled' : 'disabled'}`);
} else {
console.warn('TTS Handler does not have toggle method');
}
});
}
// Listen for TTS availability events
document.addEventListener('tts:availability', (event) => {
if (event.detail && typeof event.detail.available === 'boolean') {
this.ttsAvailable = event.detail.available;
this.updateButtonStates();
}
});
// Add options button to controls section
const controlsSection = document.getElementById('controls');
@@ -251,53 +218,35 @@ class UIController extends BaseModule {
optionsButton.href = '#';
optionsButton.textContent = 'options';
optionsButton.title = 'Show game options';
// Add event listener
optionsButton.className = 'control-button';
optionsButton.addEventListener('click', (e) => {
e.preventDefault();
const optionsUI = moduleRegistry.getModule('options-ui');
if (optionsUI && optionsUI.toggle) {
optionsUI.toggle();
}
document.dispatchEvent(new CustomEvent('ui:showOptions'));
});
// Add to controls
controlsSection.appendChild(document.createTextNode(' | '));
controlsSection.appendChild(optionsButton);
}
// Add speech toggle button
const speechToggle = document.getElementById('speech-toggle');
if (speechToggle) {
speechToggle.addEventListener('click', (e) => {
e.preventDefault();
// Dispatch an event for the TTS module to handle instead of calling directly
document.dispatchEvent(new CustomEvent('tts:toggle'));
});
}
}
// Enable all controls buttons
const controlButtons = document.querySelectorAll('#controls a');
controlButtons.forEach(button => {
button.removeAttribute('disabled');
// Listen for window resize events
window.addEventListener('resize', () => {
this.applyBookSizing();
});
// Book click for fast-forwarding - make sure it triggers the animation queue
if (this.bookElement) {
this.bookElement.addEventListener('click', (event) => {
// Only if not clicking on a link or control
if (event.target.tagName !== 'A' &&
!event.target.closest('#controls') &&
!event.target.closest('#command_input')) {
if (this.animationQueue) {
console.log('UI Controller: Fast-forwarding animations');
this.animationQueue.fastForward();
}
}
});
}
// Space key for fast-forwarding
document.addEventListener('keydown', (e) => {
if (e.key === ' ' &&
document.activeElement.tagName !== 'TEXTAREA' &&
document.activeElement.tagName !== 'INPUT') {
if (this.animationQueue) {
console.log('UI Controller: Fast-forwarding animations (space key)');
this.animationQueue.fastForward();
e.preventDefault(); // Prevent page scrolling
}
// Listen for key events
document.addEventListener('keydown', (event) => {
// Pass to input handler
if (this.inputHandler) {
this.inputHandler.handleKeyboardInput(event);
}
});
}
@@ -319,10 +268,30 @@ class UIController extends BaseModule {
initializeTextBuffer() {
// Initialize text buffer handling
if (this.textBuffer) {
console.log('UIController: Setting up text buffer callback');
this.textBuffer.setOnSentenceReady((text, callback) => {
console.log('UI Controller: Displaying sentence');
this.displayText(text).then(callback);
console.log('UIController: Received sentence from text buffer, displaying');
// Use the display handler to show text with proper formatting and TTS
this.displayText(text)
.then(() => {
console.log('UIController: Display of sentence completed, continuing...');
// Signal that we're ready to process the next sentence
if (typeof callback === 'function') {
// Use a small timeout to prevent potential stack overflow with many sentences
setTimeout(() => callback(), 10);
}
})
.catch(error => {
console.error('UIController: Error displaying text:', error);
// Continue anyway to prevent blocking
if (typeof callback === 'function') callback();
});
});
console.log('UIController: Text buffer callback set up');
} else {
console.warn('UIController: Text buffer module not found');
}
}
@@ -342,7 +311,20 @@ class UIController extends BaseModule {
break;
case 'input':
if (this.socketClient) {
this.socketClient.sendCommand(command.text);
console.log(`UI Controller: Sending command to socket: "${command.text}"`);
const success = this.socketClient.sendCommand(command.text);
if (success) {
console.log('UI Controller: Command sent successfully');
} else {
console.error('UI Controller: Failed to send command to socket');
// Display an error message to the user
this.displayHandler.displayText('⚠️ Unable to send command. Server connection might be lost.', {
style: { color: '#990000' }
});
}
} else {
console.error('UI Controller: Socket client not available for sending commands');
}
break;
case 'menu':
@@ -354,7 +336,7 @@ class UIController extends BaseModule {
break;
default:
// Handle general UI commands or pass to game logic
this._dispatchModuleEvent('ui:command', command);
this.dispatchEvent(new ModuleEvent('ui:command', command));
}
}
@@ -369,6 +351,7 @@ class UIController extends BaseModule {
const saveButton = document.getElementById('save');
const loadButton = document.getElementById('reload');
const restartButton = document.getElementById('rewind');
const speechToggle = document.getElementById('speech-toggle');
// Update save button state
if (saveButton) {
@@ -396,6 +379,26 @@ class UIController extends BaseModule {
restartButton.setAttribute('disabled', 'disabled');
}
}
// Update speech toggle button state
if (speechToggle) {
// Update the button appearance based on TTS state
if (this.ttsEnabled) {
speechToggle.classList.add('active');
speechToggle.title = 'Disable speech';
} else {
speechToggle.classList.remove('active');
speechToggle.title = 'Enable speech';
}
// Disable the button completely if TTS is not available
if (this.ttsAvailable === false) {
speechToggle.setAttribute('disabled', 'disabled');
speechToggle.title = 'Speech not available';
} else {
speechToggle.removeAttribute('disabled');
}
}
}
// Public API methods
File diff suppressed because it is too large Load Diff
+25 -33
View File
@@ -1,10 +1,9 @@
import { BaseModule } from './base-module.js';
import { moduleRegistry } from './module-registry.js';
import { ModuleEvent } from './base-module.js';
class UIEffects extends BaseModule {
constructor() {
super('ui-effects');
super('ui-effects', 'UI Effects');
// No external dependencies
this.dependencies = [];
@@ -13,8 +12,8 @@ class UIEffects extends BaseModule {
this.activeEffects = new Map();
this.ambientEffectsActive = false;
// Effects configuration
this.effectsConfig = {
// Effects configuration - use the config object from BaseModule
this.updateConfig({
candleFlicker: {
intensity: 0.5,
speed: 0.8
@@ -26,32 +25,25 @@ class UIEffects extends BaseModule {
backgroundEffects: {
enabled: true
}
};
});
// Bind methods that use 'this' internally or are used as callbacks/event handlers
this.initialize = this.initialize.bind(this); // Bind initialize as it calls dispatchEvent
this.updateCandleEffect = this.updateCandleEffect.bind(this); // Used with requestAnimationFrame
this.setupEffectElements = this.setupEffectElements.bind(this);
this.createEffectsOverlay = this.createEffectsOverlay.bind(this);
this.createCandleEffect = this.createCandleEffect.bind(this);
this.createLightingElement = this.createLightingElement.bind(this);
this.setupAmbientEffects = this.setupAmbientEffects.bind(this);
this.setupCandleFlickerEffect = this.setupCandleFlickerEffect.bind(this);
this.startAmbientEffects = this.startAmbientEffects.bind(this);
this.stopAmbientEffects = this.stopAmbientEffects.bind(this);
this.applyEffect = this.applyEffect.bind(this);
this.applyShakeEffect = this.applyShakeEffect.bind(this);
this.applyFlashEffect = this.applyFlashEffect.bind(this);
this.applyTextEmphasis = this.applyTextEmphasis.bind(this);
this.processCommand = this.processCommand.bind(this);
// Store a bound version of dispatchEvent for use in methods
this._dispatchModuleEvent = (name, detail) => {
document.dispatchEvent(new CustomEvent(name, {
detail: { moduleId: this.id, ...detail },
bubbles: true
}));
};
// Use bindMethods from parent class
this.bindMethods([
'updateCandleEffect',
'setupEffectElements',
'createEffectsOverlay',
'createCandleEffect',
'createLightingElement',
'setupAmbientEffects',
'setupCandleFlickerEffect',
'startAmbientEffects',
'stopAmbientEffects',
'applyEffect',
'applyShakeEffect',
'applyFlashEffect',
'applyTextEmphasis',
'processCommand'
]);
console.log('UIEffects: Constructor initialized');
}
@@ -72,8 +64,8 @@ class UIEffects extends BaseModule {
this.reportProgress(100, 'UI Effects ready');
// Use the DOM event API directly instead of this.dispatchEvent
this._dispatchModuleEvent('ui:effects:ready', {});
// Use the parent's dispatchEvent method
this.dispatchEvent('ui:effects:ready', {});
return true;
} catch (error) {
@@ -124,7 +116,7 @@ class UIEffects extends BaseModule {
setupAmbientEffects() {
// Initialize candle flicker effect
if (this.candleEffectElement && this.effectsConfig.candleFlicker.enabled !== false) {
if (this.candleEffectElement && this.config.candleFlicker.enabled !== false) {
this.setupCandleFlickerEffect();
}
}
@@ -137,7 +129,7 @@ class UIEffects extends BaseModule {
updateCandleEffect() {
if (!this.candleEffectElement || !this.ambientEffectsActive) return;
const { intensity, speed } = this.effectsConfig.candleFlicker;
const { intensity, speed } = this.config.candleFlicker;
// Create subtle random flickering effect
const flickerAmount = Math.random() * intensity;
+27 -77
View File
@@ -1,106 +1,58 @@
import { BaseModule } from './base-module.js';
import { moduleRegistry } from './module-registry.js';
import { ModuleEvent } from './base-module.js';
class UIInputHandler extends BaseModule {
constructor() {
super('ui-input-handler');
super('ui-input-handler', 'UI Input Handler');
// Explicitly declare ui-display-handler as a dependency
this.dependencies = ['ui-display-handler'];
// Reference to display handler
this.displayHandler = null;
// Input elements
this.inputArea = null;
this.playerInput = null;
this.cursor = null;
this.commandHistoryElement = null; // Changed: renamed to avoid conflict
this.commandHistoryElement = null;
// Input state
this.inputEnabled = true;
this.historyIndex = -1;
this.commandHistory = []; // Now this is clearly the array of previous commands
this.commandHistory = [];
this.inputBuffer = '';
// Add this method to properly dispatch custom events
this._dispatchModuleEvent = (name, detail) => {
document.dispatchEvent(new CustomEvent(name, {
detail: { moduleId: this.id, ...detail },
bubbles: true
}));
};
// Bind method contexts
this.setupInputElements = this.setupInputElements.bind(this);
this.handlePlayerInput = this.handlePlayerInput.bind(this);
this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
this.positionCursor = this.positionCursor.bind(this);
this.handleKeyboardInput = this.handleKeyboardInput.bind(this);
// Bind methods using the parent class bindMethods utility
this.bindMethods([
'setupInputElements',
'handlePlayerInput',
'handleInputKeyDown',
'positionCursor',
'handleKeyboardInput',
'submitCommand',
'addToHistory',
'resetCursorPosition'
]);
console.log('UIInputHandler: Constructor initialized');
}
/**
* Wait for dependencies before initializing
* This ensures displayHandler is ready before we try to use it
*/
async waitForDependencies() {
try {
// Explicitly wait for the display handler to be ready
console.log('UIInputHandler: Waiting for display handler to be ready');
// Get reference to the display handler
this.displayHandler = moduleRegistry.getModule('ui-display-handler');
if (!this.displayHandler) {
console.error('UIInputHandler: Display handler dependency not found');
return false;
}
// Wait for display handler to reach FINISHED state
const displayHandlerReady = await moduleRegistry.waitForModule('ui-display-handler');
if (!displayHandlerReady) {
console.error('UIInputHandler: Display handler not ready after waiting');
return false;
}
console.log('UIInputHandler: Display handler is ready');
return true;
} catch (error) {
console.error('UIInputHandler: Error waiting for dependencies:', error);
return false;
}
}
/**
* Initialize input handler
*/
async initialize() {
this.reportProgress(0, 'Initializing UI Input Handler');
try {
// Double-check display handler reference
this.reportProgress(0, 'Initializing UI Input Handler');
// Get display handler reference through the parent's getModule method
this.displayHandler = this.getModule('ui-display-handler');
if (!this.displayHandler) {
this.displayHandler = moduleRegistry.getModule('ui-display-handler');
if (!this.displayHandler) {
console.error('UIInputHandler: Display handler still not available');
return false;
}
console.error('UIInputHandler: Display handler module not found');
return false;
}
this.reportProgress(30, 'Setting up keyboard listeners');
// Set up keyboard event listeners
document.addEventListener('keydown', (event) => {
this.handleKeyboardInput(event);
});
// Use the parent's addEventListener for automatic cleanup
this.addEventListener(document, 'keydown', this.handleKeyboardInput);
this.reportProgress(60, 'Setting up input elements');
// Set up input elements
this.setupInputElements();
this.reportProgress(100, 'UI Input Handler ready');
@@ -156,9 +108,9 @@ class UIInputHandler extends BaseModule {
commandHistory = document.createElement('div');
commandHistory.id = 'command_history';
choicesContainer.appendChild(commandHistory);
this.commandHistoryElement = commandHistory; // Changed: store in renamed property
this.commandHistoryElement = commandHistory;
} else {
this.commandHistoryElement = commandHistory; // Changed: store in renamed property
this.commandHistoryElement = commandHistory;
}
// Create input container if needed
@@ -246,8 +198,8 @@ class UIInputHandler extends BaseModule {
this.positionCursor(this.playerInput, this.cursor);
}
// Dispatch event using the properly defined method
this._dispatchModuleEvent('ui:input:change', {
// Use the parent class dispatchEvent method instead of custom _dispatchModuleEvent
this.dispatchEvent('ui:input:change', {
text: this.playerInput.value
});
}
@@ -280,11 +232,9 @@ class UIInputHandler extends BaseModule {
const command = this.playerInput.value.trim();
console.log(`UIInputHandler: Submitting command: "${command}"`);
// Add command to history
this.addToHistory(command);
// Dispatch command event
this._dispatchModuleEvent('ui:command', {
this.dispatchEvent('ui:command', {
type: 'input',
text: command
});