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
+3
View File
@@ -1 +1,4 @@
node_modules
# windsurf rules
.windsurfrules
+10
View File
@@ -0,0 +1,10 @@
/**
* Test Server for AI Interactive Fiction
* Simplified version that sends test paragraphs instead of using LLM
*/
import http from 'http';
import { Server as SocketIOServer } from 'socket.io';
declare const app: import("express-serve-static-core").Express;
declare const server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
declare const io: SocketIOServer<import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, any>;
export { app, server, io };
+197
View File
@@ -0,0 +1,197 @@
"use strict";
/**
* Test Server for AI Interactive Fiction
* Simplified version that sends test paragraphs instead of using LLM
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.io = exports.server = exports.app = void 0;
const path_1 = __importDefault(require("path"));
const express_1 = __importDefault(require("express"));
const http_1 = __importDefault(require("http"));
const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const fs_1 = require("fs");
// Load environment variables
dotenv.config();
// Create Express application
const app = (0, express_1.default)();
exports.app = app;
const server = http_1.default.createServer(app);
exports.server = server;
const io = new socket_io_1.Server(server);
exports.io = io;
// Get port from environment variables or use default
const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
// Serve static files from the public directory
app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
// Test paragraphs to send to the client
const TEST_PARAGRAPHS = [
"You stand at the entrance of a mysterious cave. The air is cool and damp, carrying the scent of earth and ancient stone. Shadows dance on the walls as your torch flickers in the gentle breeze.",
"As you venture deeper, the passage narrows. Stalactites hang from the ceiling like stone daggers, their surfaces glistening with moisture. The sound of dripping water echoes through the silence.",
"Suddenly, the passage opens into a vast chamber. Crystal formations catch the light of your torch, sending rainbow reflections across the walls. In the center of the room stands an ancient stone pedestal, its surface carved with symbols from a forgotten language."
];
// Handle socket connections
io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
let currentParagraphIndex = 0;
// Start a new game
socket.on('startGame', async () => {
try {
console.log('Starting test game session');
// Send introduction to client
socket.emit('gameIntroduction', {
introduction: "Welcome to the Interactive Fiction Test. This is a simplified version that sends predefined paragraphs instead of using an LLM.",
initialRoomDescription: TEST_PARAGRAPHS[0],
currentRoomId: "test-room"
});
}
catch (error) {
console.error('Error starting game:', error);
socket.emit('error', { message: 'Failed to start game. Please try again.' });
}
});
// Process player command
socket.on('playerCommand', async (data) => {
try {
console.log(`Received command: ${data.command}`);
// Move to the next paragraph
currentParagraphIndex = (currentParagraphIndex + 1) % TEST_PARAGRAPHS.length;
// Send narrative response to client
socket.emit('narrativeResponse', {
text: TEST_PARAGRAPHS[currentParagraphIndex],
gameState: {
currentRoomId: "test-room"
},
suggestions: ["look around", "examine pedestal", "touch crystals"]
});
}
catch (error) {
console.error('Error processing command:', error);
socket.emit('error', { message: 'Failed to process command. Please try again.' });
}
});
// Handle disconnection
socket.on('disconnect', () => {
console.log(`Client disconnected: ${socket.id}`);
});
});
// Ensure required asset folders exist
function ensureDirectories() {
const dirs = [
path_1.default.join(__dirname, '../public'),
path_1.default.join(__dirname, '../public/js'),
path_1.default.join(__dirname, '../public/css'),
path_1.default.join(__dirname, '../public/images'),
path_1.default.join(__dirname, '../public/fonts')
];
for (const dir of dirs) {
if (!(0, fs_1.existsSync)(dir)) {
(0, fs_1.mkdirSync)(dir, { recursive: true });
}
}
}
// Copy kokoro-js library from node_modules if not already present
function ensureKokoroJs() {
const source = path_1.default.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
const destination = path_1.default.join(__dirname, '../public/js/kokoro-js.js');
if ((0, fs_1.existsSync)(source) && !(0, fs_1.existsSync)(destination)) {
(0, fs_1.copyFileSync)(source, destination);
console.log(`Copied kokoro-js from ${source} to ${destination}`);
}
}
// Start the server with port fallback
async function startServer(initialPort, range) {
let currentPort = initialPort;
const maxPort = initialPort + range;
// Try ports in the specified range
while (currentPort < maxPort) {
try {
// Ensure directories exist
ensureDirectories();
// Ensure kokoro-js is copied
try {
ensureKokoroJs();
}
catch (error) {
console.error('Error copying kokoro-js:', error);
}
// Try to start the server on the current port
await new Promise((resolve, reject) => {
server.listen(currentPort, () => {
console.log(`AI Interactive Fiction TEST SERVER running on http://localhost:${currentPort}`);
console.log('This server is sending predefined test paragraphs instead of using an LLM');
resolve();
});
server.on('error', (error) => {
// If port is in use, try next port
if (error.code === 'EADDRINUSE') {
console.log(`Port ${currentPort} is in use, trying next port...`);
server.close();
currentPort++;
reject();
}
else {
// For other errors, log and reject
console.error('Server error:', error);
reject(error);
}
});
});
// If we reach here, server started successfully
return;
}
catch (error) {
// If we reach the max port and still fail, throw an error
if (currentPort >= maxPort - 1) {
throw new Error(`Failed to start server on ports ${initialPort} to ${maxPort - 1}`);
}
// Otherwise try the next port
}
}
}
// Start the server when this module is run directly
if (require.main === module) {
startServer(PORT, PORT_RANGE).catch(error => {
console.error('Failed to start server:', error);
process.exit(1);
});
}
//# sourceMappingURL=test-server.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"test-server.js","sourceRoot":"","sources":["../src/test-server.ts"],"names":[],"mappings":";AAAA;;;GAGG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAEH,gDAAwB;AACxB,sDAA8B;AAC9B,gDAAwB;AACxB,yCAAqD;AACrD,+CAAiC;AACjC,2BAAyD;AAEzD,6BAA6B;AAC7B,MAAM,CAAC,MAAM,EAAE,CAAC;AAEhB,6BAA6B;AAC7B,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;AAkKb,kBAAG;AAjKZ,MAAM,MAAM,GAAG,cAAI,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC;AAiKxB,wBAAM;AAhKpB,MAAM,EAAE,GAAG,IAAI,kBAAc,CAAC,MAAM,CAAC,CAAC;AAgKhB,gBAAE;AA9JxB,qDAAqD;AACrD,MAAM,YAAY,GAAG,IAAI,CAAC;AAC1B,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC;AAC1E,MAAM,UAAU,GAAG,EAAE,CAAC,CAAC,+CAA+C;AAEtE,+CAA+C;AAC/C,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,MAAM,CAAC,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC;AAE3D,wCAAwC;AACxC,MAAM,eAAe,GAAG;IACtB,kMAAkM;IAClM,oMAAoM;IACpM,yQAAyQ;CAC1Q,CAAC;AAEF,4BAA4B;AAC5B,EAAE,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,MAAM,EAAE,EAAE;IAC7B,OAAO,CAAC,GAAG,CAAC,yBAAyB,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC;IAClD,IAAI,qBAAqB,GAAG,CAAC,CAAC;IAE9B,mBAAmB;IACnB,MAAM,CAAC,EAAE,CAAC,WAAW,EAAE,KAAK,IAAI,EAAE;QAChC,IAAI,CAAC;YACH,OAAO,CAAC,GAAG,CAAC,4BAA4B,CAAC,CAAC;YAE1C,8BAA8B;YAC9B,MAAM,CAAC,IAAI,CAAC,kBAAkB,EAAE;gBAC9B,YAAY,EAAE,iIAAiI;gBAC/I,sBAAsB,EAAE,eAAe,CAAC,CAAC,CAAC;gBAC1C,aAAa,EAAE,WAAW;aAC3B,CAAC,CAAC;QAEL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,sBAAsB,EAAE,KAAK,CAAC,CAAC;YAC7C,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,OAAO,EAAE,yCAAyC,EAAE,CAAC,CAAC;QAC/E,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,yBAAyB;IACzB,MAAM,CAAC,EAAE,CAAC,eAAe,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;QACxC,IAAI,CAAC;YACH,OAAO,CAAC,GAAG,CAAC,qBAAqB,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC;YAEjD,6BAA6B;YAC7B,qBAAqB,GAAG,CAAC,qBAAqB,GAAG,CAAC,CAAC,GAAG,eAAe,CAAC,MAAM,CAAC;YAE7E,oCAAoC;YACpC,MAAM,CAAC,IAAI,CAAC,mBAAmB,EAAE;gBAC/B,IAAI,EAAE,eAAe,CAAC,qBAAqB,CAAC;gBAC5C,SAAS,EAAE;oBACT,aAAa,EAAE,WAAW;iBAC3B;gBACD,WAAW,EAAE,CAAC,aAAa,EAAE,kBAAkB,EAAE,gBAAgB,CAAC;aACnE,CAAC,CAAC;QAEL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,2BAA2B,EAAE,KAAK,CAAC,CAAC;YAClD,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,OAAO,EAAE,8CAA8C,EAAE,CAAC,CAAC;QACpF,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,uBAAuB;IACvB,MAAM,CAAC,EAAE,CAAC,YAAY,EAAE,GAAG,EAAE;QAC3B,OAAO,CAAC,GAAG,CAAC,wBAAwB,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC;IACnD,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,sCAAsC;AACtC,SAAS,iBAAiB;IACxB,MAAM,IAAI,GAAG;QACX,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,WAAW,CAAC;QACjC,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC;QACpC,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,eAAe,CAAC;QACrC,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,kBAAkB,CAAC;QACxC,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,iBAAiB,CAAC;KACxC,CAAC;IAEF,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,IAAI,CAAC,IAAA,eAAU,EAAC,GAAG,CAAC,EAAE,CAAC;YACrB,IAAA,cAAS,EAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACtC,CAAC;IACH,CAAC;AACH,CAAC;AAED,kEAAkE;AAClE,SAAS,cAAc;IACrB,MAAM,MAAM,GAAG,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,yCAAyC,CAAC,CAAC;IAC/E,MAAM,WAAW,GAAG,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,2BAA2B,CAAC,CAAC;IAEtE,IAAI,IAAA,eAAU,EAAC,MAAM,CAAC,IAAI,CAAC,IAAA,eAAU,EAAC,WAAW,CAAC,EAAE,CAAC;QACnD,IAAA,iBAAY,EAAC,MAAM,EAAE,WAAW,CAAC,CAAC;QAClC,OAAO,CAAC,GAAG,CAAC,yBAAyB,MAAM,OAAO,WAAW,EAAE,CAAC,CAAC;IACnE,CAAC;AACH,CAAC;AAED,sCAAsC;AACtC,KAAK,UAAU,WAAW,CAAC,WAAmB,EAAE,KAAa;IAC3D,IAAI,WAAW,GAAG,WAAW,CAAC;IAC9B,MAAM,OAAO,GAAG,WAAW,GAAG,KAAK,CAAC;IAEpC,mCAAmC;IACnC,OAAO,WAAW,GAAG,OAAO,EAAE,CAAC;QAC7B,IAAI,CAAC;YACH,2BAA2B;YAC3B,iBAAiB,EAAE,CAAC;YAEpB,6BAA6B;YAC7B,IAAI,CAAC;gBACH,cAAc,EAAE,CAAC;YACnB,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,OAAO,CAAC,KAAK,CAAC,0BAA0B,EAAE,KAAK,CAAC,CAAC;YACnD,CAAC;YAED,8CAA8C;YAC9C,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;gBAC1C,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,GAAG,EAAE;oBAC9B,OAAO,CAAC,GAAG,CAAC,kEAAkE,WAAW,EAAE,CAAC,CAAC;oBAC7F,OAAO,CAAC,GAAG,CAAC,2EAA2E,CAAC,CAAC;oBACzF,OAAO,EAAE,CAAC;gBACZ,CAAC,CAAC,CAAC;gBAEH,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAA4B,EAAE,EAAE;oBAClD,mCAAmC;oBACnC,IAAI,KAAK,CAAC,IAAI,KAAK,YAAY,EAAE,CAAC;wBAChC,OAAO,CAAC,GAAG,CAAC,QAAQ,WAAW,iCAAiC,CAAC,CAAC;wBAClE,MAAM,CAAC,KAAK,EAAE,CAAC;wBACf,WAAW,EAAE,CAAC;wBACd,MAAM,EAAE,CAAC;oBACX,CAAC;yBAAM,CAAC;wBACN,mCAAmC;wBACnC,OAAO,CAAC,KAAK,CAAC,eAAe,EAAE,KAAK,CAAC,CAAC;wBACtC,MAAM,CAAC,KAAK,CAAC,CAAC;oBAChB,CAAC;gBACH,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YAEH,gDAAgD;YAChD,OAAO;QAET,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,0DAA0D;YAC1D,IAAI,WAAW,IAAI,OAAO,GAAG,CAAC,EAAE,CAAC;gBAC/B,MAAM,IAAI,KAAK,CAAC,mCAAmC,WAAW,OAAO,OAAO,GAAG,CAAC,EAAE,CAAC,CAAC;YACtF,CAAC;YAED,8BAA8B;QAChC,CAAC;IACH,CAAC;AACH,CAAC;AAED,oDAAoD;AACpD,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;IAC5B,WAAW,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE;QAC1C,OAAO,CAAC,KAAK,CAAC,yBAAyB,EAAE,KAAK,CAAC,CAAC;QAChD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;AACL,CAAC"}
+1
View File
@@ -10,6 +10,7 @@
"dev": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts'",
"dev:web": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts'",
"dev:cli": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts --cli'",
"test-server": "ts-node src/test-server.ts",
"build": "tsc",
"test": "jest",
"lint": "eslint --ext .ts src/",
+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;
}
+270 -89
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.dependencies = [];
// Queue of scheduled animations/functions
this.timeoutQueue = [];
// 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
// Fast-forwarding state
this.isFastForwarding = false;
// 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;
}
// 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
const timeoutObject = {
execute: () => {
try {
func(...args);
} catch (error) {
console.error('Error executing scheduled function:', error);
// 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 = {
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);
// Add to queue
this.queue.push(timeoutObject);
// If queue is empty and no TTS is speaking, emit animation complete
if (this.timeoutQueue.length === 0 && !this.isAnyTtsSpeaking()) {
this.emitAnimationComplete();
}
// Update current total delay
this.delay = adjustedDelay + delay;
}, actualDelay);
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;
+613 -186
View File
@@ -1,215 +1,688 @@
/**
* 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
super();
this.id = 'api';
this.name = 'API TTS Handler';
// Voice options
this.voiceOptions = {
voice: '8JNqTOY3RaSYcHTVJZ0G', // Default ElevenLabs voice ID
model: 'eleven_multilingual_v1',
stability: 0,
similarityBoost: 0,
style: 0.5,
useSpeakerBoost: true
voice: 'pNInz6obpgDQGcFmaJgB', // Default German voice ID for ElevenLabs
model: 'eleven_multilingual_v2', // Use the multilingual model for better German
speed: 1.0
};
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;
// 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 the ID of this provider
* @returns {string} - Provider ID
* 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
*/
getId() {
return 'api';
getModule(moduleId) {
return moduleRegistry.getModule(moduleId);
}
/**
* Initialize the API TTS system
* @param {Function} progressCallback - Optional callback for progress updates
* @returns {Promise<boolean>} - Resolves to true if initialization was successful
* Initialize the API TTS handler
* @param {Function} progressCallback - Callback for progress updates
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize(progressCallback = null) {
try {
if (progressCallback) progressCallback(20, 'Setting up API TTS');
if (progressCallback) {
progressCallback(10, "Initializing API TTS Handler");
}
// Create audio element for playback
// 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();
// Set up audio event listeners
this.audioElement.onended = () => {
if (this.currentCallback) {
const callback = this.currentCallback;
this.currentCallback = null;
callback();
if (progressCallback) {
progressCallback(30, "Loading voices");
}
};
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
// Load available voices
try {
const testResponse = await fetch(this.voicesApiUrl, {
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: {
'xi-api-key': this.apiKey
'Content-Type': 'application/json'
}
});
if (testResponse.ok) {
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 (!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");
}
if (progressCallback) progressCallback(100, 'API TTS initialization complete');
// Return true to indicate the module initialized successfully
// even though the API is not available
return true;
}
return this.isReady;
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.error('Error initializing API TTS:', 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;
if (progressCallback) {
progressCallback(100, "API TTS initialization failed");
}
// Return true to indicate the module initialized successfully
// even though there was an error
return true;
}
}
/**
* 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();
}
}
/**
* 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;
}
}
/**
* Check if API TTS is available
* @returns {boolean} - True if API TTS is ready to use
* Select a voice for the given locale
* @param {string} locale - Locale code
* @returns {boolean} - Success status
*/
isAvailable() {
return this.isReady && this.enabled;
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;
}
/**
* Generate an MD5 hash for text caching
* @param {string} text - Text to hash
* @returns {string} - MD5 hash
* Select a default voice
* @returns {boolean} - Success status
*/
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
selectDefaultVoice() {
if (this.voices.length === 0) {
console.warn("API TTS: No voices available for default selection");
return false;
}
return Math.abs(hash).toString(16);
// 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;
}
/**
* Convert text to speech via API and play it
* @param {string} text - Text to speak
* @param {Function} callback - Called when speech completes
* Preload speech for a text
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Preloaded audio data
*/
async speak(text, callback = null) {
if (!this.isAvailable() || !text) {
if (callback) callback();
return;
async preloadSpeech(text) {
if (!this.available || !text) {
return null;
}
// 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);
// Process text for TTS
const processedText = this.preprocessText(text);
if (!audioUrl) {
// Make API request to get audio
const response = await fetch(`${this.apiUrl}/${this.voiceOptions.voice}`, {
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',
'xi-api-key': this.apiKey
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: text,
text: processedText,
voice_id: this.voiceOptions.voice,
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
}
speed: this.voiceOptions.speed
})
});
if (!response.ok) {
throw new Error(`API returned ${response.status}: ${response.statusText}`);
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
// Get the audio data as blob
// Get audio blob
const audioBlob = await response.blob();
audioUrl = URL.createObjectURL(audioBlob);
// Store in cache
this.cache.set(cacheKey, audioUrl);
// 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
this.audioElement.src = audioUrl;
await this.audioElement.play();
audio.play();
return true;
} catch (error) {
console.error('Error speaking with API TTS:', error);
if (this.currentCallback) {
const callback = this.currentCallback;
this.currentCallback = null;
callback();
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);
}
}
}
/**
* Stop any ongoing speech
* Check if TTS is available
* @returns {boolean} - True if TTS is available
*/
stop() {
if (this.audioElement) {
this.audioElement.pause();
this.audioElement.currentTime = 0;
isAvailable() {
return this.available;
}
if (this.currentCallback) {
const callback = this.currentCallback;
this.currentCallback = null;
callback();
/**
* 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 [];
}
}
@@ -218,63 +691,17 @@ export class ApiTTSHandler extends TTSHandler {
* @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;
if (options.voice) {
this.voiceOptions.voice = options.voice;
}
/**
* Get available voices from the API
* @returns {Promise<Array>} - Array of available voices
*/
async getVoices() {
if (!this.enabled) {
return [];
if (options.model) {
this.voiceOptions.model = options.model;
}
try {
const response = await fetch(this.voicesApiUrl, {
method: 'GET',
headers: {
'xi-api-key': this.apiKey
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));
}
});
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();
}
}
/**
* 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();
}
}
/**
+522 -131
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
super();
this.id = 'browser';
this.name = 'Browser TTS Handler';
// Voice options
this.voiceOptions = {
voice: '',
voice: null, // Will be set during initialization
rate: 1.0,
pitch: 1.0,
volume: 1.0
};
// State
this.available = false;
this.voices = [];
this.currentUtterance = null;
this.preloadCache = new Map();
// Add dependencies
this.dependencies = ['localization', 'persistence-manager'];
// Bind methods
this.bindMethods([
'initialize',
'speak',
'speakPreloaded',
'preloadSpeech',
'stop',
'isAvailable',
'getId',
'getVoices',
'setVoiceOptions',
'onVoicesChanged',
'getModule'
]);
}
/**
* Check if speech is currently playing
* @returns {boolean} - True if speaking
* 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
*/
isSpeaking() {
return this.synth && this.synth.speaking;
getModule(moduleId) {
return moduleRegistry.getModule(moduleId);
}
/**
* 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
* Initialize the browser TTS handler
* @param {Function} progressCallback - Callback for progress updates
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize(progressCallback = null) {
if (!this.synth) {
console.warn('Web Speech API not supported in this browser');
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 {
if (progressCallback) progressCallback(20, 'Loading speech synthesis');
// Load available voices
await this.loadVoices();
// 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(70, "Setting up voice");
}
if (progressCallback) progressCallback(100, 'Browser TTS ready');
// Get localization module
const localization = this.getModule('localization');
const persistenceManager = this.getModule('persistence-manager');
return this.isReady;
// 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('Error initializing browser TTS:', 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;
}
}
/**
* Get available voices
* @returns {Promise<Array>} - Array of available voices
* Handle voices changed event
*/
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());
};
this.synth.addEventListener('voiceschanged', voicesChangedHandler);
// 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);
};
// Use requestIdleCallback if available, otherwise requestAnimationFrame
if (window.requestIdleCallback) {
window.requestIdleCallback(safetyCheckVoices, { timeout: 3000 });
} else {
// Schedule for next frame, but with longer delay
setTimeout(safetyCheckVoices, 3000);
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();
};
}
});
}
/**
* Check if browser TTS is available
* @returns {boolean} - True if browser TTS is ready to use
* 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>}
*/
isAvailable() {
return this.isReady && this.synth;
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;
}
}
/**
* 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();
// 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;
}
// 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 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 (selectedVoice) {
this.utterance.voice = selectedVoice;
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}`);
}
}
// 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();
} else {
console.log("Browser TTS: No voices available");
}
}
/**
* Stop any ongoing speech
* 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;
}
}
/**
* 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 (this.synth) {
this.synth.cancel();
this.utterance = null;
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 !== 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;
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));
}
}
}
+36 -76
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) {
// The dependencies are now automatically available via getModule
this.updateUIState();
} else {
console.warn("GameLoop: UI Controller or TTS Player not ready for status update.");
}
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.");
// 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
+33 -30
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) {
try {
// Load the Kokoro script
self.importScripts('/js/kokoro-js.js');
self.importScripts('/js/kokoro.js');
if (!self.kokoro || !self.kokoro.KokoroTTS) {
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}`);
}
}
// 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;
+281 -221
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'
}
/**
* 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
*/
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 = "";
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;
if (!this.fastForwardingAll) {
this.insertAfter(delay, stack[stack.length-1], word);
}
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();
}
}
}
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);
});
// Bind methods
this.bindMethods([
'renderParagraph',
'renderWord',
'scheduleWordAnimation'
]);
}
/**
* 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
* Initialize the module
* @returns {Promise<boolean>} - Resolves with success status
*/
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;
async initialize() {
try {
this.reportProgress(10, "Initializing Layout Renderer");
case "BACKGROUND":
const outerScrollContainer = document.querySelector('#book');
outerScrollContainer.style.backgroundImage = 'url(' + tagValue + ')';
return null;
// 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");
}
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;
// 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);
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;
this.reportProgress(100, "Layout Renderer ready");
return true;
} catch (error) {
console.error("Error initializing Layout Renderer:", error);
return false;
}
}
default:
/**
* 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 (ratio > 0) {
// Stretch space
spaceWidth += ratio * node.stretch;
} else if (ratio < 0) {
// Shrink space
spaceWidth += ratio * node.shrink;
}
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;
}
}
}
// Add the paragraph to the container
container.appendChild(paragraphElement);
// 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);
}
return paragraphElement;
}
/**
* Set the fast forwarding state
* @param {boolean} state - The fast forwarding state
* Render a single word
* @param {string} word - Word to render
* @param {boolean} animate - Whether to prepare for animation
* @returns {HTMLElement} - The created word element
*/
setFastForwardingAll(state) {
this.fastForwardingAll = state;
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';
}
return wordElement;
}
/**
* Get the fast forwarding state
* @returns {boolean} The fast forwarding state
* Create a word element
* @param {string} word - Word to render
* @returns {HTMLElement} - The created word element
*/
getFastForwardingAll() {
return this.fastForwardingAll;
createWordElement(word) {
const wordElement = document.createElement('span');
wordElement.className = 'word';
wordElement.textContent = word;
return wordElement;
}
/**
* Smooth scroll to an element
* @param {HTMLElement} target - The target element to scroll to
* @param {number} duration - The duration of the scroll animation
* 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
*/
smoothScroll(target, duration) {
const display = document.getElementById('page_right');
const targetPosition = target.getBoundingClientRect().top;
const startPosition = display.scrollTop;
const distance = targetPosition;
let startTime = null;
scheduleWordAnimation(wordElement, delay, speed) {
if (!this.animationQueue) return;
if (duration < 5) {
display.scrollTo(0, targetPosition);
return;
}
const actualDelay = delay * speed;
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);
}
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;
}
requestAnimationFrame(animation);
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;
+55
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...');
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.");
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.");
+180 -175
View File
@@ -1,16 +1,52 @@
/**
* 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'
]);
}
/**
@@ -19,196 +55,134 @@ class LocalizationModule extends BaseModule {
*/
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
* Load translations for a locale
* @param {string} locale - Locale to load
* @returns {Promise<void>}
*/
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.'
});
async loadTranslations(locale) {
if (this.translations[locale]) {
return; // Already loaded
}
/**
* Add translations for a specific locale
* @param {string} locale - Locale code
* @param {Object} translations - Translation key-value pairs
*/
addTranslations(locale, translations) {
if (!this.translations[locale]) {
this.translations[locale] = {};
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] = {};
}
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];
}
// 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;
} catch (error) {
console.error(`Error loading translations for ${locale}:`, error);
throw error;
}
}
/**
* 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;
async setLocale(locale) {
if (!locale) return false;
// Update global locale for SmartyPants
window.locale = locale;
try {
// Normalize locale
const normalizedLocale = locale.toLowerCase();
// Notify observers of locale change
this.notifyObservers();
console.log(`Localization: Locale set to ${locale}`);
return true;
// Load translations if not already loaded
if (!this.translations[normalizedLocale]) {
await this.loadTranslations(normalizedLocale);
}
console.warn(`Localization: Locale ${locale} not available`);
// 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;
+328 -207
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,19 +964,36 @@ 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,
// 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
const masterVolume = this.persistenceManager.getPreference('audio', 'masterVolume', 1.0);
+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() {
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`);
}
}
/**
* Measure text width using canvas context
* @param {string} text - Text to measure
* @returns {number} - Text width in pixels
*/
measureText(text) {
if (!this.textMeasureCtx) {
this.initializeTextMeasurement();
}
if (!text) return 0;
return this.textMeasureCtx.measureText(text).width;
}
/**
* Process text for layout (apply hyphenation and smartypants)
* @param {string} text - Text to process
* @returns {string} - Processed text
*/
processTextForLayout(text) {
if (!text) return '';
// Remove extra whitespace
text = text.trim().replace(/\s+/g, ' ');
try {
// The measureText function will be provided by the game controller later
this.reportProgress(100, "Paragraph layout initialized");
return true;
// 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 initializing paragraph layout:", error);
return false;
console.error("Error processing text for layout:", error);
return text;
}
}
/**
* 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
* 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
*/
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');
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;
}
return this.kapAlgorithm(processedText, measure, measures, hyphenate);
// 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 a new text measurement function
* @param {Function} measureFunc - The new measurement function
* Set debug mode
* @param {boolean} enabled - Whether debug mode should be enabled
*/
setMeasureFunction(measureFunc) {
this.measureText = measureFunc;
}
/**
* Set a new Knuth and Plass algorithm implementation
* @param {Function} kapFunc - The new KAP algorithm function
*/
setKapAlgorithm(kapFunc) {
this.kapAlgorithm = kapFunc;
setDebugMode(enabled) {
// Use parent's updateConfig method
this.updateConfig({ debugMode: enabled });
console.log(`Paragraph Layout: Debug mode ${enabled ? 'enabled' : 'disabled'}`);
}
}
+402 -226
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.');
this.gameState = state;
localStorage.setItem(this.keys.gameState, JSON.stringify(state));
// Dispatch event
this.dispatchEvent('game-state-saved', {
timestamp: new Date().toISOString()
});
return true;
} catch (error) {
console.error('Error saving game state:', 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
* Load the current game state
* @returns {Object|null} - Loaded game state or null if not found
*/
loadState() {
if (!this.storage) {
console.warn('No storage available, cannot load game state.');
return null;
}
loadGameState() {
try {
const stateString = this.storage.getItem(this.stateKey);
if (!stateString) {
console.info('No saved game state found.');
return null;
}
const stateJson = localStorage.getItem(this.keys.gameState);
if (!stateJson) return null;
const state = JSON.parse(stateString);
console.log('Game state loaded successfully.');
return state;
this.gameState = JSON.parse(stateJson);
return this.gameState;
} catch (error) {
console.error('Error loading game state:', error);
console.error("Error loading game state:", error);
return null;
}
}
/**
* Check if a saved game state exists
* @returns {boolean} Whether a saved state exists
*/
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;
try {
this.storage.removeItem(this.stateKey);
console.log('Game state cleared.');
return true;
} catch (error) {
console.error('Error clearing game state:', error);
return false;
}
}
/**
* 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];
}
} else {
// Use loaded value if available, otherwise default
result[key] = (loaded && loaded[key] !== undefined) ? loaded[key] : defaults[key];
// 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];
}
}
}
}
// 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];
}
// 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 if requested
if (saveImmediately) {
return this.savePreferences();
}
// 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;
}
}
/**
* 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;
+44 -73
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
}
this.defaultHost = 'localhost:3000';
/**
* 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;
}
// 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}`);
+124 -12
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(() => {
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 {
// No callback set, just process the next sentence
if (this.buffer.length > 0) {
this.processSentences();
// All processed
this.isProcessingActive = false;
super.dispatchEvent('buffer:empty', {});
}
});
} else {
// No callback set, just release lock and continue processing
this.processingLock = false;
if (this.buffer.length > 0) {
// 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.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
+207 -199
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
// 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}`);
// Ensure global locale is set for SmartyPants
window.locale = this.locale;
// Load SmartyPants - critical dependency
this.reportProgress(40, "Loading SmartyPants");
try {
await this.loadSmartyPantsScript();
this.reportProgress(50, "SmartyPants loaded");
// Initialize hyphenation in the background, but don't wait for it
this.initializeHyphenation();
// Verify SmartyPants is properly loaded
if (!this.smartyPants || typeof this.smartyPants !== 'function') {
throw new Error('SmartyPants not properly loaded');
}
this.reportProgress(90, "Dependencies 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.error("Error loading Text Processor dependencies:", 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 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>}
@@ -53,229 +131,142 @@ class TextProcessorModule extends BaseModule {
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");
console.log("SmartyPants loaded successfully");
resolve();
} else {
console.error("SmartyPants script loaded but functions not found on window.SmartyPants");
reject(new Error('SmartyPants functions not found after loading'));
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'));
};
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
*/
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' }
}));
console.error("Error loading SmartyPants script:", error);
reject(new Error('Failed to load SmartyPants script'));
};
// Add script to document
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
* Initialize hyphenation using Hyphenopoly module
* @returns {Promise<boolean>} - Resolves when hyphenation is initialized
*/
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');
initializeHyphenation() {
return new Promise((resolve, reject) => {
try {
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'));
}).catch(err => {
console.error('Error loading hyphenator:', err);
});
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');
}
}
/**
* Set the hyphenator function
* @param {Function} hyphenatorFunc - The hyphenator function
*/
setHyphenator(hyphenatorFunc) {
if (typeof hyphenatorFunc === 'function') {
this.hyphenator = hyphenatorFunc;
throw error;
})
.then(fallbackHyphenator => {
if (fallbackHyphenator) {
this.hyphenator = fallbackHyphenator;
this.hyphenatorReady = true;
console.log("Hyphenator function set explicitly");
} else {
console.warn("Invalid hyphenator provided");
}
}
console.log("Using fallback en-us hyphenator");
/**
* 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");
// 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 SmartyPants:", error);
console.error("Error setting up hyphenation:", error);
reject(error); // Failed to initialize
}
// Apply hyphenation if enabled and available
if (useHyphenation && this.hyphenatorReady && this.hyphenator) {
try {
processed = this.hyphenator(processed);
} catch (error) {
console.error("Error applying hyphenation:", error);
}
}
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;
}
@@ -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;
}
}
+322 -476
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
*/
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';
class TTSFactoryModule extends BaseModule {
/**
* Create a new TTS factory
*/
class TTSFactory {
constructor() {
this.ttsHandler = null;
super('tts-factory', 'TTS Factory');
// Available TTS handlers
this.handlers = {};
this.initializationAttempted = false;
this.initializationPromise = null;
this.ttsEnabled = true;
this.progressCallback = null;
this.persistenceManager = null;
}
/**
* 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');
// Current active handler
this.activeHandler = null;
// 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);
}
// Handler initialization status
this.initStatus = {
browser: false,
api: false,
kokoro: false
};
try {
// Report starting initialization
reportProgress(10, 'Loading TTS modules');
// TTS availability flag
this.ttsAvailable = false;
// 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`);
}
}
// 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')
// 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');
}
});
}
// 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;
// Add dependencies
this.dependencies = ['persistence-manager', 'localization'];
}
/**
* 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
* Initialize the module
* @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 () => {
async initialize() {
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}%`);
});
this.reportProgress(10, "Initializing TTS factory");
// Mark completion
if (kokoroAvailable) {
reportProgress(95, "Kokoro TTS initialized successfully");
} else {
reportProgress(95, "Kokoro TTS unavailable - using fallback");
// 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;
}
// Always dispatch event to indicate completion status
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
detail: { success: kokoroAvailable }
// 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);
if (initSuccess) {
this.setActiveHandler(preferredProvider);
} else {
// 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");
// Initialize Kokoro and Browser handlers in parallel (not API as it requires configuration)
const initPromises = [
this.initializeHandler('kokoro'),
this.initializeHandler('browser')
];
// Wait for all handlers to initialize
await Promise.allSettled(initPromises);
// Check if any handler initialized successfully
initSuccess = this.initStatus.kokoro || this.initStatus.browser;
}
// 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(kokoroAvailable);
this.reportProgress(100, initSuccess ? "TTS factory ready" : "TTS factory ready (no handlers available)");
// Always return true since TTS is optional for the application
return true;
} catch (error) {
console.error('Error initializing Kokoro:', error);
reportProgress(95, 'Kokoro TTS failed to initialize - using fallback');
console.error("Error initializing TTS factory:", error);
this.reportProgress(100, "TTS factory failed");
// Dispatch completion event with error information
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
detail: { success: false, error: error.message }
// Set TTS availability to false and dispatch event
this.ttsAvailable = false;
document.dispatchEvent(new CustomEvent('tts:availability', {
detail: { available: false }
}));
resolve(false);
// Still return true since TTS is optional
return true;
}
}
};
// 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" }
}));
resolve(false);
}, 30000); // Increased timeout to 30 seconds since model loading is resource intensive
/**
* 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;
}
// Use requestIdleCallback to start initialization during idle time
if (window.requestIdleCallback) {
reportProgress(75, 'Scheduling Kokoro TTS for background loading');
/**
* 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;
}
window.requestIdleCallback(() => {
startKokoroInit().then(() => clearTimeout(timeoutId));
}, { timeout: 10000 });
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 {
reportProgress(75, 'Background loading not available, loading Kokoro normally');
// Use a microtask to avoid blocking the UI thread
Promise.resolve().then(() => startKokoroInit().then(() => clearTimeout(timeoutId)));
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;
}
});
}
/**
* 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
* Get a TTS handler by ID
* @param {string} id - Handler ID
* @returns {Object|null} - TTS handler instance or null if not found
*/
dispatchTTSReadyEvent(available, type = null, handler = null) {
const event = new CustomEvent('tts-ready', {
detail: {
available,
type,
handler,
enabled: this.ttsEnabled
}
});
window.dispatchEvent(event);
getHandler(id) {
if (!id || !this.handlers[id]) return null;
return this.handlers[id];
}
/**
* 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
* Set the active TTS handler
* @param {string} id - Handler ID
* @returns {boolean} - Success status
*/
switchTTS(type) {
if (!this.handlers[type] || !this.handlers[type].isAvailable()) {
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;
}
this.ttsHandler = this.handlers[type];
this.dispatchTTSReadyEvent(true, type, this.ttsHandler);
// Update preferred TTS mode
this.setPreferredTTSMode(type);
return true;
// Stop current handler if active
if (this.activeHandler) {
this.handlers[this.activeHandler].stop();
}
/**
* 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;
}
// Set new active handler
this.activeHandler = id;
const handlerType = this.ttsHandler.getId();
console.log(`TTSFactory: Using ${handlerType} handler to speak "${text}"`);
// Update preference
this.getModule('persistence-manager').updatePreference('tts', 'provider', id);
try {
this.ttsHandler.speak(text, (result) => {
console.log(`TTSFactory: Speech completed using ${handlerType}`, result);
if (callback) callback(result);
// Dispatch event
this.dispatchEvent('tts-handler-changed', {
handler: id
});
return true;
} catch (error) {
console.error('Error speaking:', error);
if (callback) callback(error);
return false;
}
}
/**
* Stop any ongoing speech
* Get the active TTS handler
* @returns {Object|null} - Active TTS handler instance or null if none active
*/
stop() {
if (this.ttsHandler) {
this.ttsHandler.stop();
}
getActiveHandler() {
if (!this.activeHandler) return null;
return this.handlers[this.activeHandler];
}
/**
* 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);
}
if (options.rate !== undefined) {
this.persistenceManager.updatePreference('tts', 'rate', options.rate, false);
}
if (options.volume !== undefined) {
this.persistenceManager.updatePreference('tts', 'volume', options.volume, 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();
}
// 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`);
}
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
* Get all available TTS handlers
* @returns {Object} - Map of handler IDs to initialization status
*/
getAvailableHandlers() {
const available = {};
Object.entries(this.handlers).forEach(([id, handler]) => {
if (handler.isAvailable()) {
available[id] = handler;
for (const id in this.handlers) {
available[id] = this.initStatus[id];
}
});
return available;
}
/**
* Get available voices from active handler
* @returns {Promise<Array>} - Array of available voices
* Speak text using the active TTS handler
* @param {string} text - Text to speak
* @param {Object} options - TTS options
* @returns {Promise<boolean>} - Success status
*/
async getVoices() {
if (!this.ttsHandler || typeof this.ttsHandler.getVoices !== 'function') {
return [];
async speak(text, options = {}) {
if (!this.activeHandler) {
console.warn("No active TTS handler");
return false;
}
try {
return await this.ttsHandler.getVoices();
return await this.handlers[this.activeHandler].speak(text, options);
} catch (error) {
console.error('Error getting voices:', 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);
}
});
}
}
+268 -195
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;
}
super('tts-player', 'TTS Player');
/**
* 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");
// Module dependencies
this.dependencies = ['tts-factory'];
// Set up event listeners
window.addEventListener('tts-ready', this.handleTTSReadyEvent.bind(this));
// TTS state
this.enabled = true;
this.currentSpeech = null;
this.pendingCallback = null;
// 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");
});
// Preloading mechanism
this.preloadQueue = [];
this.preloadedAudio = new Map(); // Cache for preloaded TTS
this.isPreloading = false;
// 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;
}
// 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'}`);
// 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");
// 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 }
}));
}
}
});
this.isInitialized = true;
// 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 }
}));
});
// Final status check
const ttsInfo = this.ttsFactory.getActiveTTSInfo();
if (ttsInfo.available) {
this.reportProgress(100, `TTS Player initialized using ${ttsInfo.name}`);
// 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);
}
});
// 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;
} else {
this.reportProgress(100, "TTS initialization complete but no voices available");
return true; // Still consider this a success, just with no voices
}
} 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
*/
getTTSInfo() {
if (!this.ttsFactory) return { available: false, type: 'none', name: 'None' };
return this.ttsFactory.getActiveTTSInfo();
}
/**
* Toggle TTS functionality on/off
* @returns {boolean} - New TTS enabled state
* Process the preload queue
*/
toggle() {
if (!this.ttsFactory) return false;
return this.ttsFactory.toggle();
}
async processPreloadQueue() {
if (this.preloadQueue.length === 0 || this.isPreloading) return;
/**
* Speak text using the active TTS system
* @param {string} text - Text to speak
* @param {Function} callback - Called when speech completes
*/
speak(text, callback) {
if (!this.ttsFactory) {
console.warn("TTS Factory not available for speak");
if (callback) callback("TTS not available");
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;
}
console.log(`TTS Player speaking: "${text}"`);
this.ttsFactory.speak(text, (result) => {
console.log("TTS Player speak complete", result);
if (callback) callback(result);
// 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());
}
}
}
/**
* Stop any ongoing speech
* Speak text
* @param {string} text - Text to speak
* @param {Function} callback - Optional callback for when speech completes
* @returns {boolean} - True if speech started successfully
*/
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;
}
// 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 speaking
*/
stop() {
if (this.ttsFactory) {
this.ttsFactory.stop();
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
ttsFactory.stop();
}
this.currentSpeech = null;
this.pendingCallback = null;
}
/**
* Set voice options for the active TTS system
* @param {Object} options - Voice options
* Toggle TTS enabled state
*/
setVoiceOptions(options) {
if (this.ttsFactory) {
this.ttsFactory.setVoiceOptions(options);
}
toggle() {
this.enabled = !this.enabled;
this.enable(this.enabled);
return this.enabled;
}
/**
* Set speech rate/speed
* @param {number} speed - Speech rate (0.5-2.0)
* Enable or disable TTS
* @param {boolean} enabled - Whether TTS should be enabled
*/
setSpeed(speed) {
this.setVoiceOptions({ rate: speed });
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);
}
}
/**
* 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()
}
}));
}
return result;
}
/**
* Get available TTS systems
* @returns {Array<string>} - Array of available TTS system IDs
*/
getAvailableSystems() {
if (!this.ttsFactory) return [];
const handlers = this.ttsFactory.getAvailableHandlers();
return Object.keys(handlers);
}
/**
* Get available voices for the active TTS system
* @returns {Promise<Array>} - Array of voice objects
*/
async getVoices() {
if (!this.ttsFactory) return [];
return this.ttsFactory.getVoices();
}
/**
* 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;
+161 -158
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() {
try {
this.reportProgress(0, 'Initializing UI Controller');
try {
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;
}
if (typeof event.detail.available === 'boolean') {
this.ttsAvailable = event.detail.available;
}
});
}
// 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);
}
this.updateButtonStates();
}
});
// 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);
}
}
}
// 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);
}
}
// Enable all controls buttons
const controlButtons = document.querySelectorAll('#controls a');
controlButtons.forEach(button => {
button.removeAttribute('disabled');
});
// 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();
}
}
// 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'));
});
}
// 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 window resize events
window.addEventListener('resize', () => {
this.applyBookSizing();
});
// 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;
+25 -75
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() {
try {
this.reportProgress(0, 'Initializing UI Input Handler');
try {
// Double-check display handler reference
// 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');
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
});
+339
View File
@@ -257,3 +257,342 @@ The overlay fades away as the first scheduled animation.
- All settings are persisted via the persistence-manager
This synchronized approach ensures that text animations and speech work together seamlessly, creating a more immersive storytelling experience while maintaining smooth performance.
# Text Output Pipeline Architecture
The text output pipeline manages the flow of text from server reception to visual display and audio playback, with a focus on performance and synchronization.
## Core Components
1. **Socket Client**: Receives raw text fragments from the server.
2. **TextBuffer**: Accumulates fragments and identifies complete sentences.
- Collects all incoming text regardless of fragment size
- Identifies and extracts complete sentences
- Maintains partial sentences until completion
3. **SentenceQueue**: Manages the preparation pipeline for sentences.
- Receives complete sentences from TextBuffer
- Orchestrates parallel processing of TTS generation and text layout
- Ensures sentences are fully prepared before playback
- Maintains a queue of sentences ready for playback
4. **TTS Generation System**: Prepares audio for sentences.
- Generates audio in the background without blocking UI
- Provides audio duration information for synchronization
- Can be cancelled for fast-forward operations
- Falls back to character count duration calculation when disabled
5. **Typography Processor**: Enhances text presentation quality.
- Applies smart typography (quotes, em-dashes, etc.)
- Handles hyphenation for line breaks
- Preserves special formatting
6. **ParagraphLayout**: Calculates optimal text presentation.
- Computes line breaks using Knuth-Plass algorithm
- Determines word positioning and timing
- Adjusts animation duration to match audio length
7. **AnimationPlayerQueue**: Manages the playback pipeline.
- Maintains a playlist of ready-to-play sentences
- Inserts DOM elements for prepared sentences
- Coordinates CSS-based animations
- Monitors animation completion
- Automatically advances to next sentence
## Process Flow
1. **Preparation Pipeline**:
- Socket client receives text and feeds it to TextBuffer
- TextBuffer identifies complete sentences
- SentenceQueue receives complete sentences
- TTS generation and layout processing happen in parallel
- When both TTS and layout are complete, sentence is marked "ready"
- Ready sentences are added to AnimationPlayerQueue
2. **Playback Pipeline**:
- AnimationPlayerQueue plays the first ready sentence
- DOM elements are inserted and CSS animations begin
- TTS audio plays simultaneously with animations
- AnimationPlayerQueue monitors complete animation duration
- When playback completes, the next ready sentence immediately begins
3. **Fast-Forward Handling**:
- Can interrupt at any stage of the pipeline
- Currently playing animations are immediately completed
- Currently playing audio is faded out and stopped
- Any in-progress sentence preparation is cancelled
- System advances to the next sentence in queue
## Speed Synchronization
1. **Audio-Driven Timing**: Animation speed is determined by audio duration
- TTS audio length dictates animation duration
- Without TTS, duration is calculated from character count and speed setting
2. **Seamless Transitions**: Next sentence begins immediately after current completes
- No gap between sentence playbacks
- Preparation happens during playback of previous sentence
3. **Feedback Loop**: Animation system provides timing data back to preparation pipeline
- Helps optimize future sentence preparation
- Allows runtime adjustment of timing parameters
This architecture separates preparation from playback, creating a buffer of ready content that enables smooth presentation while handling the computational overhead of text processing and TTS generation in the background.
# Text Processing & Layout Architecture
The text processing and layout system transforms raw text input into visually appealing, typographically correct, and elegantly animated content through several specialized components.
## Component Interactions
### Core Components
1. **text-processor.js**: Enhances typography and applies hyphenation
- Entry point for text processing pipeline
- Manages SmartyPants for typographic enhancements
- Controls Hyphenopoly for language-aware hyphenation
- Serves as the central coordinator for text transformation
2. **smartypants.js**: Provides typographic punctuation conversion
- Transforms straight quotes to curly quotes
- Converts hyphens to em-dashes and en-dashes
- Handles ellipses and other typographic niceties
- Operates as a pure function with no dependencies
3. **paragraph-layout.js**: Manages paragraph structure and word metrics
- Breaks text into words and calculates their dimensions
- Manages paragraph-level styling and layout properties
- Prepares text for the line-breaking algorithm
- Connects text-processor output to the layout engine
4. **knuth-plass.js**: Implementation of the optimal line-breaking algorithm
- Calculates aesthetically pleasing line breaks
- Minimizes "raggedness" across paragraph lines
- Implements the core Knuth-Plass algorithm
- Uses linked-list.js for internal data structures
5. **linked-list.js**: Provides data structures for the line-breaking algorithm
- Implements doubly-linked list for efficient node insertion/removal
- Supports the complex data relationships in the Knuth-Plass algorithm
- Pure utility with no direct interaction with other components
6. **hyphenopoly.module.js**: Performs language-aware hyphenation
- Contains language-specific hyphenation patterns
- Provides functions to insert soft hyphens at valid breaking points
- Loaded dynamically when needed by text-processor.js
7. **layout-renderer.js**: Translates calculated layout into DOM elements
- Takes the output from paragraph-layout.js
- Generates DOM structure for the text display
- Creates CSS classes and styles for animations
- Prepares text for display and animation
## Process Flow
1. **Text Input → Typography Enhancement**
```
Raw Text → text-processor.js → smartypants.js → Enhanced Text
```
- Raw text enters the text-processor
- SmartyPants functions transform quotation marks, dashes, etc.
- Typography-enhanced text is produced
2. **Typography-Enhanced Text → Hyphenation**
```
Enhanced Text → text-processor.js → hyphenopoly.module.js → Hyphenated Text
```
- Enhanced text is passed to the hyphenation system
- Language-specific rules determine valid hyphenation points
- Soft hyphens are inserted at appropriate positions
3. **Hyphenated Text → Layout Calculation**
```
Hyphenated Text → paragraph-layout.js → knuth-plass.js → Optimized Layout
```
- Paragraph layout breaks text into words and calculates metrics
- Knuth-Plass algorithm calculates optimal line breaks
- linked-list.js provides the data structures for this process
- An optimized layout structure is produced
4. **Layout → Rendering**
```
Optimized Layout → layout-renderer.js → DOM Elements
```
- Layout renderer converts the abstract layout to concrete DOM
- CSS classes and styles are applied for animation
- Words are positioned according to the calculated layout
5. **Rendering → Animation**
```
DOM Elements → AnimationQueue → Visual Display
```
- The rendered DOM elements are passed to the animation system
- Words are animated according to timing and styling parameters
- Visual presentation occurs synchronized with audio if applicable
## Implementation Dependencies
```
text-processor.js
├── smartypants.js
└── hyphenopoly.module.js
└── [language pattern files]
paragraph-layout.js
├── knuth-plass.js
│ └── linked-list.js
└── [font metrics]
layout-renderer.js
└── [CSS styling]
```
## Integration Points
1. **Text Buffer → Text Processor**
- Text buffer passes complete sentences to the processing pipeline
- Text processor enhances typography and applies hyphenation
2. **Text Processor → Paragraph Layout**
- Enhanced text flows to paragraph layout for structure analysis
- Word metrics and paragraph properties are calculated
3. **Paragraph Layout → Layout Renderer**
- Optimized layout information is passed to the renderer
- Renderer creates DOM elements with appropriate styling
4. **Layout Renderer → Animation Queue**
- Rendered elements are scheduled for animation
- Animation timing is synchronized with TTS if enabled
This architecture ensures typographically beautiful text with optimal line breaks, proper hyphenation, and smooth animation, creating a professional reading experience.
# TTS Integration with Localization
## Architecture Overview
The Text-to-Speech (TTS) system has been refactored to seamlessly integrate with the localization module, ensuring a cohesive user experience across different languages. This integration follows these key architectural principles:
1. **Base TTS Handler Pattern**: All TTS handlers extend a common `TTSHandler` class that inherits from `BaseModule`, ensuring consistent interface and behavior.
2. **Dependency Injection**: TTS handlers access the localization and persistence modules through the dependency system rather than direct global references.
3. **Locale-Aware Voice Selection**: TTS handlers automatically select appropriate voices based on the current locale.
4. **Preference Persistence**: User preferences for TTS settings are stored and retrieved through the persistence manager.
5. **Optional Functionality**: TTS is treated as an optional feature that can be unavailable without breaking the application.
## Core Components
1. **TTSFactory**: Central coordinator for TTS functionality
- Manages initialization of all TTS handlers
- Implements fallback mechanisms when preferred TTS systems are unavailable
- Provides access to the active TTS handler
- Integrates with localization module for language-aware voice selection
- Reports TTS availability to the UI
2. **TTSHandler**: Abstract base class for all TTS handlers
- Defines common interface methods (speak, stop, getVoices, etc.)
- Provides shared utility functions for voice selection and preference handling
- Extends BaseModule for dependency management and event handling
3. **TTS Handlers**: Concrete implementations for different TTS approaches
- **BrowserTTSHandler**: Uses the Web Speech API
- **ApiTTSHandler**: Communicates with a remote TTS API
- **KokoroHandler**: Provides neural TTS via Kokoro.js
4. **OptionsUI**: User interface for TTS configuration
- Allows selection of TTS system (Browser, API, Kokoro)
- Provides voice selection based on available voices for current locale
- Includes controls for volume, rate, and pitch
- Persists user preferences via PersistenceManager
## Localization Integration
1. **Locale-Based Voice Selection**:
- Each TTS handler implements `setupVoiceFromPreferences()` to select voices based on:
- User's explicitly saved voice preference
- Current locale from the localization module
- Fallback to language-matching voice if exact locale match not found
- Default voice (typically English) as final fallback
2. **Voice Filtering**:
- TTS handlers filter available voices to prioritize those matching the current locale
- Voice lists in the UI are sorted to show locale-matching voices first
3. **Preference Persistence**:
- TTS settings (system, voice, volume, rate) are saved per-user
- Settings are automatically applied when the application loads
- Changes in the localization settings trigger voice re-selection
## Initialization Flow
1. **TTSFactory Initialization**:
```
TTSFactory.initialize()
├── Loads user preferences via PersistenceManager
├── Initializes all available TTS handlers
│ ├── KokoroHandler.initialize()
│ └── BrowserTTSHandler.initialize()
├── Selects active TTS handler based on preferences and availability
├── Sets up event listeners for locale changes
└── Dispatches TTS availability event
```
2. **TTS Handler Initialization**:
```
TTSHandler.initialize()
├── Loads system-specific resources
├── Retrieves available voices
├── Gets dependencies (localization, persistenceManager)
└── Sets up voice based on preferences and locale
```
3. **Voice Setup Process**:
```
setupVoiceFromPreferences()
├── Gets user's preferred voice from persistenceManager
├── If preferred voice exists and is available:
│ └── Use preferred voice
├── Otherwise:
│ ├── Get current locale from localization module
│ ├── Find voice matching current locale
│ ├── If no match, find voice matching language part
│ └── If still no match, use default voice
└── Update preference with selected voice
```
## Event Handling
1. **Locale Change Events**:
- When user changes locale in the UI, the localization module emits a 'locale-changed' event
- TTSFactory listens for this event and triggers voice re-selection in the active TTS handler
2. **TTS Preference Events**:
- Changes to TTS settings in the options UI trigger preference updates
- These updates are persisted and immediately applied to the active TTS handler
3. **TTS Availability Events**:
- TTSFactory dispatches 'tts:availability' events to notify the UI about TTS availability
- UI Controller listens for these events and updates the speech toggle button accordingly
## Error Handling and Fallbacks
1. **TTS System Fallbacks**:
- If the preferred TTS system fails to initialize, TTSFactory falls back to the next available system
- Priority order: Kokoro > Browser > None (with None being acceptable)
- API TTS is not used as a fallback as it requires manual configuration
2. **Voice Selection Fallbacks**:
- If preferred voice is unavailable, fall back to locale-matching voice
- If no locale match, fall back to language match
- If no language match, fall back to default (typically English)
3. **TTS Unavailability Handling**:
- If no TTS handlers are available, the system continues to function without TTS
- The speech toggle button is disabled in the UI
- The application remains fully functional for text-only interaction
This architecture ensures that the TTS system seamlessly adapts to the user's language preferences while maintaining a consistent and intuitive user experience across different locales, even when TTS is unavailable.
+210
View File
@@ -0,0 +1,210 @@
onpage-dialog.preload.js:121 Uncaught ReferenceError: browser is not defined
at start (onpage-dialog.preload.js:121:5)
at onpage-dialog.preload.js:135:1
at onpage-dialog.preload.js:393:12
start @ onpage-dialog.preload.js:121
(anonymous) @ onpage-dialog.preload.js:135
(anonymous) @ onpage-dialog.preload.js:393
loader.js:12 Module registry initialized and assigned to window.moduleRegistry
loader.js:51 Module Loader: Initialization started
module-registry.js:111 Module Registry: Tracking potential circular dependency with unregistered module: localization
module-registry.js:111 Module Registry: Tracking potential circular dependency with unregistered module: text-processor
module-registry.js:111 Module Registry: Tracking potential circular dependency with unregistered module: tts-factory
ui-input-handler.js:35 UIInputHandler: Constructor initialized
module-registry.js:111 Module Registry: Tracking potential circular dependency with unregistered module: ui-display-handler
ui-input-handler.js:398 UIInputHandler: Registering with window
ui-effects.js:48 UIEffects: Constructor initialized
ui-effects.js:310 UIEffects: Registering with window
ui-display-handler.js:61 UIDisplayHandler: Constructor initialized
module-registry.js:111 Module Registry: Tracking potential circular dependency with unregistered module: text-processor
ui-display-handler.js:581 UIDisplayHandler: Registering with window
module-registry.js:111 Module Registry: Tracking potential circular dependency with unregistered module: text-processor
module-registry.js:111 Module Registry: Tracking potential circular dependency with unregistered module: socket-client
module-registry.js:111 Module Registry: Tracking potential circular dependency with unregistered module: text-processor
module-registry.js:111 Module Registry: Tracking potential circular dependency with unregistered module: tts
loader.js:168 Module dependencies:
loader.js:171 persistence-manager depends on: localization
loader.js:171 localization depends on: none
loader.js:171 paragraph-layout depends on: text-processor
loader.js:171 animation-queue depends on: none
loader.js:171 layout-renderer depends on: animation-queue
loader.js:171 audio-manager depends on: none
loader.js:171 text-buffer depends on: none
loader.js:171 tts-player depends on: tts-factory
loader.js:171 ui-input-handler depends on: ui-display-handler
loader.js:171 ui-effects depends on: none
loader.js:171 ui-display-handler depends on: paragraph-layout, layout-renderer, animation-queue
loader.js:171 ui-controller depends on: animation-queue, ui-display-handler, ui-input-handler, ui-effects, text-buffer, socket-client
socket-client depends on: text-buffer
options-ui depends on: persistence-manager, localization
game-loop depends on: ui-controller, socket-client, tts, text-buffer
text-processor depends on: localization
tts-factory depends on: persistence-manager, localization
Starting initialization of module: persistence-manager
Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (PENDING)', 'paragraph-layout (PENDING)', 'animation-queue (PENDING)', 'layout-renderer (PENDING)', 'audio-manager (PENDING)', 'text-buffer (PENDING)', 'tts-player (PENDING)', 'ui-input-handler (PENDING)', 'ui-effects (PENDING)', 'ui-display-handler (PENDING)', 'ui-controller (PENDING)', 'socket-client (PENDING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
Starting initialization of module: localization
Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (PENDING)', 'animation-queue (PENDING)', 'layout-renderer (PENDING)', 'audio-manager (PENDING)', 'text-buffer (PENDING)', 'tts-player (PENDING)', 'ui-input-handler (PENDING)', 'ui-effects (PENDING)', 'ui-display-handler (PENDING)', 'ui-controller (PENDING)', 'socket-client (PENDING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
Starting initialization of module: paragraph-layout
Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (PENDING)', 'layout-renderer (PENDING)', 'audio-manager (PENDING)', 'text-buffer (PENDING)', 'tts-player (PENDING)', 'ui-input-handler (PENDING)', 'ui-effects (PENDING)', 'ui-display-handler (PENDING)', 'ui-controller (PENDING)', 'socket-client (PENDING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: animation-queue
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (PENDING)', 'audio-manager (PENDING)', 'text-buffer (PENDING)', 'tts-player (PENDING)', 'ui-input-handler (PENDING)', 'ui-effects (PENDING)', 'ui-display-handler (PENDING)', 'ui-controller (PENDING)', 'socket-client (PENDING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: layout-renderer
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (PENDING)', 'text-buffer (PENDING)', 'tts-player (PENDING)', 'ui-input-handler (PENDING)', 'ui-effects (PENDING)', 'ui-display-handler (PENDING)', 'ui-controller (PENDING)', 'socket-client (PENDING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: audio-manager
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (PENDING)', 'tts-player (PENDING)', 'ui-input-handler (PENDING)', 'ui-effects (PENDING)', 'ui-display-handler (PENDING)', 'ui-controller (PENDING)', 'socket-client (PENDING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: text-buffer
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (PENDING)', 'ui-input-handler (PENDING)', 'ui-effects (PENDING)', 'ui-display-handler (PENDING)', 'ui-controller (PENDING)', 'socket-client (PENDING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: tts-player
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (PENDING)', 'ui-effects (PENDING)', 'ui-display-handler (PENDING)', 'ui-controller (PENDING)', 'socket-client (PENDING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: ui-input-handler
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (PENDING)', 'ui-display-handler (PENDING)', 'ui-controller (PENDING)', 'socket-client (PENDING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: ui-effects
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (LOADING)', 'ui-display-handler (PENDING)', 'ui-controller (PENDING)', 'socket-client (PENDING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: ui-display-handler
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (PENDING)', 'socket-client (PENDING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: ui-controller
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (PENDING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: socket-client
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (PENDING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: options-ui
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (PENDING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: game-loop
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (PENDING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: text-processor
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (PENDING)']
loader.js:197 Starting initialization of module: tts-factory
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (LOADING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (INITIALIZING)', 'paragraph-layout (LOADING)', 'animation-queue (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (INITIALIZING)', 'paragraph-layout (LOADING)', 'animation-queue (INITIALIZING)', 'layout-renderer (LOADING)', 'audio-manager (LOADING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (INITIALIZING)', 'paragraph-layout (LOADING)', 'animation-queue (INITIALIZING)', 'layout-renderer (LOADING)', 'audio-manager (INITIALIZING)', 'text-buffer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (INITIALIZING)', 'paragraph-layout (LOADING)', 'animation-queue (INITIALIZING)', 'layout-renderer (LOADING)', 'audio-manager (INITIALIZING)', 'text-buffer (INITIALIZING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (17) ['persistence-manager (LOADING)', 'localization (INITIALIZING)', 'paragraph-layout (LOADING)', 'animation-queue (INITIALIZING)', 'layout-renderer (LOADING)', 'audio-manager (INITIALIZING)', 'text-buffer (INITIALIZING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (INITIALIZING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
ui-effects.js:79 UIEffects: Setting up effect elements
loader.js:354 Modules still pending: (16) ['persistence-manager (LOADING)', 'localization (INITIALIZING)', 'paragraph-layout (LOADING)', 'layout-renderer (LOADING)', 'audio-manager (INITIALIZING)', 'text-buffer (INITIALIZING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (INITIALIZING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (15) ['persistence-manager (LOADING)', 'localization (INITIALIZING)', 'paragraph-layout (LOADING)', 'layout-renderer (LOADING)', 'text-buffer (INITIALIZING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (INITIALIZING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (14) ['persistence-manager (LOADING)', 'localization (INITIALIZING)', 'paragraph-layout (LOADING)', 'layout-renderer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-effects (INITIALIZING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (13) ['persistence-manager (LOADING)', 'localization (INITIALIZING)', 'paragraph-layout (LOADING)', 'layout-renderer (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:203 Completed initialization of module: animation-queue
loader.js:203 Completed initialization of module: audio-manager
loader.js:203 Completed initialization of module: text-buffer
loader.js:203 Completed initialization of module: ui-effects
loader.js:354 Modules still pending: (13) ['persistence-manager (LOADING)', 'localization (INITIALIZING)', 'paragraph-layout (LOADING)', 'layout-renderer (INITIALIZING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (LOADING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (13) ['persistence-manager (LOADING)', 'localization (INITIALIZING)', 'paragraph-layout (LOADING)', 'layout-renderer (INITIALIZING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (INITIALIZING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (12) ['persistence-manager (LOADING)', 'localization (INITIALIZING)', 'paragraph-layout (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (INITIALIZING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:203 Completed initialization of module: layout-renderer
localization.js:102
GET http://localhost:3001/locales/en-us.json 404 (Not Found)
loadTranslations @ localization.js:102
initialize @ localization.js:61
initializeInterface @ base-module.js:55
await in initializeInterface
(anonymous) @ loader.js:200
initializeModules @ loader.js:175
(anonymous) @ loader.js:75
Promise.then
init @ loader.js:73
(anonymous) @ loader.js:586
localization.js:111
GET http://localhost:3001/locales/en.json 404 (Not Found)
loadTranslations @ localization.js:111
await in loadTranslations
initialize @ localization.js:61
initializeInterface @ base-module.js:55
await in initializeInterface
(anonymous) @ loader.js:200
initializeModules @ loader.js:175
(anonymous) @ loader.js:75
Promise.then
init @ loader.js:73
(anonymous) @ loader.js:586
localization.js:122 English translations not found, using empty set
loadTranslations @ localization.js:122
await in loadTranslations
initialize @ localization.js:61
initializeInterface @ base-module.js:55
await in initializeInterface
(anonymous) @ loader.js:200
initializeModules @ loader.js:175
(anonymous) @ loader.js:75
Promise.then
init @ loader.js:73
(anonymous) @ loader.js:586
localization.js:102
GET http://localhost:3001/locales/de.json 404 (Not Found)
loadTranslations @ localization.js:102
initialize @ localization.js:68
await in initialize
initializeInterface @ base-module.js:55
await in initializeInterface
(anonymous) @ loader.js:200
initializeModules @ loader.js:175
(anonymous) @ loader.js:75
Promise.then
init @ loader.js:73
(anonymous) @ loader.js:586
loader.js:354 Modules still pending: (11) ['persistence-manager (LOADING)', 'paragraph-layout (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (INITIALIZING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:203 Completed initialization of module: localization
loader.js:354 Modules still pending: (11) ['persistence-manager (INITIALIZING)', 'paragraph-layout (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (INITIALIZING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (LOADING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (11) ['persistence-manager (INITIALIZING)', 'paragraph-layout (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (INITIALIZING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (INITIALIZING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (10) ['paragraph-layout (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (INITIALIZING)', 'options-ui (LOADING)', 'game-loop (LOADING)', 'text-processor (INITIALIZING)', 'tts-factory (LOADING)']
loader.js:203 Completed initialization of module: persistence-manager
loader.js:354 Modules still pending: (10) ['paragraph-layout (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (INITIALIZING)', 'options-ui (INITIALIZING)', 'game-loop (LOADING)', 'text-processor (INITIALIZING)', 'tts-factory (LOADING)']
loader.js:354 Modules still pending: (10) ['paragraph-layout (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (INITIALIZING)', 'options-ui (INITIALIZING)', 'game-loop (LOADING)', 'text-processor (INITIALIZING)', 'tts-factory (INITIALIZING)']
loader.js:354 Modules still pending: (9) ['paragraph-layout (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'socket-client (INITIALIZING)', 'game-loop (LOADING)', 'text-processor (INITIALIZING)', 'tts-factory (INITIALIZING)']
loader.js:203 Completed initialization of module: options-ui
socket-client.js:81 Socket Client: Using origin for connection: http://localhost:3001
loader.js:354 Modules still pending: (8) ['paragraph-layout (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'game-loop (LOADING)', 'text-processor (INITIALIZING)', 'tts-factory (INITIALIZING)']
loader.js:203 Completed initialization of module: socket-client
browser-tts-handler.js:227 Browser TTS: Loaded 23 voices from event
browser-tts-handler.js:269 Browser TTS: Using de voice: Microsoft Hedda - German (Germany)
tts-factory.js:201 TTS Factory: Successfully initialized browser TTS handler
text-processor.js:145 SmartyPants loaded successfully
text-processor.js:171 Initializing hyphenation with Hyphenopoly module
text-processor.js:185 Loading hyphenation pattern: /js/patterns/de.wasm
kokoro-handler.js:104 Kokoro worker is ready
kokoro-handler.js:202 Kokoro worker initialized successfully
kokoro-handler.js:753 Kokoro TTS: Set voice to German (Neural)
tts-factory.js:201 TTS Factory: Successfully initialized kokoro TTS handler
loader.js:354 Modules still pending: (7) ['paragraph-layout (LOADING)', 'tts-player (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'game-loop (LOADING)', 'text-processor (INITIALIZING)']
loader.js:203 Completed initialization of module: tts-factory
loader.js:354 Modules still pending: (7) ['paragraph-layout (LOADING)', 'tts-player (INITIALIZING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'game-loop (LOADING)', 'text-processor (INITIALIZING)']
loader.js:354 Modules still pending: (6) ['paragraph-layout (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'game-loop (LOADING)', 'text-processor (INITIALIZING)']
loader.js:203 Completed initialization of module: tts-player
text-processor.js:208 Hyphenopoly engine ready for de
text-processor.js:218 Hyphenator ready for de
loader.js:354 Modules still pending: (5) ['paragraph-layout (LOADING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'game-loop (LOADING)']
loader.js:203 Completed initialization of module: text-processor
loader.js:354 Modules still pending: (5) ['paragraph-layout (INITIALIZING)', 'ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'game-loop (LOADING)']
loader.js:354 Modules still pending: (4) ['ui-input-handler (LOADING)', 'ui-display-handler (LOADING)', 'ui-controller (LOADING)', 'game-loop (LOADING)']
loader.js:203 Completed initialization of module: paragraph-layout
loader.js:354 Modules still pending: (4) ['ui-input-handler (LOADING)', 'ui-display-handler (INITIALIZING)', 'ui-controller (LOADING)', 'game-loop (LOADING)']
ui-display-handler.js:162 UIDisplayHandler: CSS /css/style.css loaded successfully
ui-display-handler.js:212 UIDisplayHandler: Book container not found, creating it
ui-display-handler.js:221 UIDisplayHandler: Left page not found, creating it
ui-display-handler.js:285 UIDisplayHandler: Right page not found, creating it
ui-display-handler.js:294 UIDisplayHandler: Story container not found, creating it
ui-display-handler.js:304 UIDisplayHandler: Paragraphs container not found, creating it
ui-display-handler.js:326 UIDisplayHandler: All containers initialized
loader.js:354 Modules still pending: (3) ['ui-input-handler (LOADING)', 'ui-controller (LOADING)', 'game-loop (LOADING)']
loader.js:203 Completed initialization of module: ui-display-handler
loader.js:354 Modules still pending: (3) ['ui-input-handler (INITIALIZING)', 'ui-controller (LOADING)', 'game-loop (LOADING)']
ui-input-handler.js:83 UIInputHandler: Setting up input elements in document flow
ui-input-handler.js:182 UIInputHandler: Input elements setup complete
loader.js:354 Modules still pending: (2) ['ui-controller (LOADING)', 'game-loop (LOADING)']
loader.js:203 Completed initialization of module: ui-input-handler
loader.js:354 Modules still pending: (2) ['ui-controller (INITIALIZING)', 'game-loop (LOADING)']0: "ui-controller (INITIALIZING)"1: "game-loop (LOADING)"length: 2[[Prototype]]: Array(0)
ui-controller.js:271 UIController: Setting up text buffer callback
text-buffer.js:68 Text Buffer: Sentence ready callback set
ui-controller.js:292 UIController: Text buffer callback set up
loader.js:354 Modules still pending: ['game-loop (LOADING)']0: "game-loop (LOADING)"length: 1[[Prototype]]: Array(0)
loader.js:203 Completed initialization of module: ui-controller
loader.js:354 Modules still pending: ['game-loop (WAITING)']0: "game-loop (WAITING)"length: 1[[Prototype]]: Array(0)
loader.js:203 Completed initialization of module: game-loop
animation-queue.js:52 Animation Queue: TTS module not found yet, will try again when needed
layout-renderer.js:55 Layout Renderer: TTS Player module not found yet, will try again when needed
+179
View File
@@ -0,0 +1,179 @@
/**
* Test Server for AI Interactive Fiction
* Simplified version that sends test paragraphs instead of using LLM
*/
import path from 'path';
import express from 'express';
import http from 'http';
import { Server as SocketIOServer } from 'socket.io';
import * as dotenv from 'dotenv';
import { existsSync, mkdirSync, copyFileSync } from 'fs';
// Load environment variables
dotenv.config();
// Create Express application
const app = express();
const server = http.createServer(app);
const io = new SocketIOServer(server);
// Get port from environment variables or use default
const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
// Serve static files from the public directory
app.use(express.static(path.join(__dirname, '../public')));
// Test paragraphs to send to the client
const TEST_PARAGRAPHS = [
"You stand at the entrance of a mysterious cave. The air is cool and damp, carrying the scent of earth and ancient stone. Shadows dance on the walls as your torch flickers in the gentle breeze.",
"As you venture deeper, the passage narrows. Stalactites hang from the ceiling like stone daggers, their surfaces glistening with moisture. The sound of dripping water echoes through the silence.",
"Suddenly, the passage opens into a vast chamber. Crystal formations catch the light of your torch, sending rainbow reflections across the walls. In the center of the room stands an ancient stone pedestal, its surface carved with symbols from a forgotten language."
];
// Handle socket connections
io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
let currentParagraphIndex = 0;
// Start a new game
socket.on('startGame', async () => {
try {
console.log('Starting test game session');
// Send introduction to client
socket.emit('gameIntroduction', {
introduction: "Welcome to the Interactive Fiction Test. This is a simplified version that sends predefined paragraphs instead of using an LLM.",
initialRoomDescription: TEST_PARAGRAPHS[0],
currentRoomId: "test-room"
});
} catch (error) {
console.error('Error starting game:', error);
socket.emit('error', { message: 'Failed to start game. Please try again.' });
}
});
// Process player command
socket.on('playerCommand', async (data) => {
try {
console.log(`Received command: ${data.command}`);
// Move to the next paragraph
currentParagraphIndex = (currentParagraphIndex + 1) % TEST_PARAGRAPHS.length;
// Send narrative response to client
socket.emit('narrativeResponse', {
text: TEST_PARAGRAPHS[currentParagraphIndex],
gameState: {
currentRoomId: "test-room"
},
suggestions: ["look around", "examine pedestal", "touch crystals"]
});
} catch (error) {
console.error('Error processing command:', error);
socket.emit('error', { message: 'Failed to process command. Please try again.' });
}
});
// Handle disconnection
socket.on('disconnect', () => {
console.log(`Client disconnected: ${socket.id}`);
});
});
// Ensure required asset folders exist
function ensureDirectories() {
const dirs = [
path.join(__dirname, '../public'),
path.join(__dirname, '../public/js'),
path.join(__dirname, '../public/css'),
path.join(__dirname, '../public/images'),
path.join(__dirname, '../public/fonts')
];
for (const dir of dirs) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
}
// Copy kokoro-js library from node_modules if not already present
function ensureKokoroJs() {
const source = path.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
const destination = path.join(__dirname, '../public/js/kokoro-js.js');
if (existsSync(source) && !existsSync(destination)) {
copyFileSync(source, destination);
console.log(`Copied kokoro-js from ${source} to ${destination}`);
}
}
// Start the server with port fallback
async function startServer(initialPort: number, range: number): Promise<void> {
let currentPort = initialPort;
const maxPort = initialPort + range;
// Try ports in the specified range
while (currentPort < maxPort) {
try {
// Ensure directories exist
ensureDirectories();
// Ensure kokoro-js is copied
try {
ensureKokoroJs();
} catch (error) {
console.error('Error copying kokoro-js:', error);
}
// Try to start the server on the current port
await new Promise<void>((resolve, reject) => {
server.listen(currentPort, () => {
console.log(`AI Interactive Fiction TEST SERVER running on http://localhost:${currentPort}`);
console.log('This server is sending predefined test paragraphs instead of using an LLM');
resolve();
});
server.on('error', (error: NodeJS.ErrnoException) => {
// If port is in use, try next port
if (error.code === 'EADDRINUSE') {
console.log(`Port ${currentPort} is in use, trying next port...`);
server.close();
currentPort++;
reject();
} else {
// For other errors, log and reject
console.error('Server error:', error);
reject(error);
}
});
});
// If we reach here, server started successfully
return;
} catch (error) {
// If we reach the max port and still fail, throw an error
if (currentPort >= maxPort - 1) {
throw new Error(`Failed to start server on ports ${initialPort} to ${maxPort - 1}`);
}
// Otherwise try the next port
}
}
}
// Start the server when this module is run directly
if (require.main === module) {
startServer(PORT, PORT_RANGE).catch(error => {
console.error('Failed to start server:', error);
process.exit(1);
});
}
export { app, server, io };