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:
@@ -1 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# windsurf rules
|
||||||
|
.windsurfrules
|
||||||
|
|||||||
Vendored
+10
@@ -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 };
|
||||||
Vendored
+197
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"dev": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts'",
|
"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: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'",
|
"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",
|
"build": "tsc",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"lint": "eslint --ext .ts src/",
|
"lint": "eslint --ext .ts src/",
|
||||||
|
|||||||
@@ -393,6 +393,10 @@ ol.choice {
|
|||||||
|
|
||||||
#story {
|
#story {
|
||||||
overflow-x: visible;
|
overflow-x: visible;
|
||||||
|
text-align: justify;
|
||||||
|
text-justify: inter-word;
|
||||||
|
margin-bottom: 1.2em;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* #story p span {
|
/* #story p span {
|
||||||
@@ -442,6 +446,12 @@ ol.choice {
|
|||||||
-ms-animation: fadeIn ease 1s;
|
-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 {
|
@keyframes fadeIn {
|
||||||
0% {opacity:0;}
|
0% {opacity:0;}
|
||||||
100% {opacity:1;}
|
100% {opacity:1;}
|
||||||
@@ -655,3 +665,59 @@ ol.choice {
|
|||||||
.fade-in-input {
|
.fade-in-input {
|
||||||
animation: fadeInInput 0.5s ease forwards;
|
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
@@ -5,53 +5,54 @@
|
|||||||
*/
|
*/
|
||||||
import { BaseModule } from './base-module.js';
|
import { BaseModule } from './base-module.js';
|
||||||
import { moduleRegistry } from './module-registry.js';
|
import { moduleRegistry } from './module-registry.js';
|
||||||
import { ModuleEvent } from './base-module.js'; // Add this import
|
|
||||||
|
|
||||||
class AnimationQueueModule extends BaseModule {
|
class AnimationQueueModule extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('animation-queue', 'Animation Queue');
|
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
|
// 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
|
this.tts = null; // TTS module reference
|
||||||
|
|
||||||
// Fast-forwarding state
|
// Bind methods using parent's bindMethods utility
|
||||||
this.isFastForwarding = false;
|
this.bindMethods([
|
||||||
|
'schedule',
|
||||||
// Bind methods
|
'fastForward',
|
||||||
this.schedule = this.schedule.bind(this);
|
'clearAll',
|
||||||
this.fastForward = this.fastForward.bind(this);
|
'setSpeed',
|
||||||
this.clearAll = this.clearAll.bind(this);
|
'beginFastForward',
|
||||||
this.setSpeed = this.setSpeed.bind(this);
|
'endFastForward',
|
||||||
}
|
'emitAnimationComplete',
|
||||||
|
'cleanupStaleTasks',
|
||||||
async waitForDependencies() {
|
'isAnyTtsSpeaking'
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
try {
|
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");
|
this.reportProgress(100, "Animation Queue ready");
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} 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 {Function} func - Function to execute
|
||||||
* @param {number} delay - Delay in milliseconds
|
* @param {number} delay - Delay in milliseconds
|
||||||
* @param {...any} args - Arguments to pass to the function
|
* @param {Object} options - Optional parameters including TTS text
|
||||||
* @returns {Object} - Timeout object that can be used to cancel
|
* @returns {number} - Timeout ID for cancellation
|
||||||
*/
|
*/
|
||||||
schedule(func, delay, ...args) {
|
schedule(func, delay, options = {}) {
|
||||||
if (typeof func !== 'function') {
|
if (typeof func !== 'function') {
|
||||||
console.error('Animation Queue: Not a function passed to schedule');
|
console.error('AnimationQueue: Invalid function provided to schedule');
|
||||||
return null;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create timeout object with execute method
|
// Adjust delay based on fast-forward or speed settings
|
||||||
const timeoutObject = {
|
const actualDelay = this.config.fastForwardEnabled ? 0 : Math.max(0, delay * this.config.speed);
|
||||||
execute: () => {
|
|
||||||
try {
|
// Record the delay for tracking
|
||||||
func(...args);
|
this.delay = Math.max(this.delay, delay);
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing scheduled function:', error);
|
// 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,
|
timeoutId: null,
|
||||||
createdAt: Date.now(),
|
executed: false,
|
||||||
delay: delay
|
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
|
// Add to queue
|
||||||
const adjustedDelay = delay * this.speed;
|
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(() => {
|
timeoutObject.timeoutId = setTimeout(() => {
|
||||||
// Execute the function
|
// Execute the function
|
||||||
timeoutObject.execute();
|
timeoutObject.execute();
|
||||||
|
|
||||||
// Remove from queue
|
// Remove from queue
|
||||||
const index = this.queue.indexOf(timeoutObject);
|
const index = this.timeoutQueue.indexOf(timeoutObject);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
this.queue.splice(index, 1);
|
this.timeoutQueue.splice(index, 1);
|
||||||
}
|
}
|
||||||
}, adjustedDelay);
|
|
||||||
|
|
||||||
// Add to queue
|
// If queue is empty and no TTS is speaking, emit animation complete
|
||||||
this.queue.push(timeoutObject);
|
if (this.timeoutQueue.length === 0 && !this.isAnyTtsSpeaking()) {
|
||||||
|
this.emitAnimationComplete();
|
||||||
|
}
|
||||||
|
|
||||||
// Update current total delay
|
}, actualDelay);
|
||||||
this.delay = adjustedDelay + delay;
|
|
||||||
|
|
||||||
return timeoutObject;
|
return timeoutObject.timeoutId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fast-forward all pending animations
|
* Emit an animation complete event
|
||||||
|
*/
|
||||||
|
emitAnimationComplete() {
|
||||||
|
// Only emit if queue is empty and no TTS is speaking
|
||||||
|
if (this.timeoutQueue.length === 0 && !this.isAnyTtsSpeaking()) {
|
||||||
|
// Use parent's dispatchEvent method
|
||||||
|
this.dispatchEvent('ui:animation:complete', {
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up any animation tasks that might have been missed
|
||||||
|
* (e.g. due to browser tab being inactive)
|
||||||
|
*/
|
||||||
|
cleanupStaleTasks() {
|
||||||
|
const now = Date.now();
|
||||||
|
const staleTasks = [];
|
||||||
|
|
||||||
|
// Find stale tasks
|
||||||
|
this.timeoutQueue.forEach(task => {
|
||||||
|
// If task has been running for more than 10 seconds, consider it stale
|
||||||
|
if (now - task.startTime > 10000 && !task.executed) {
|
||||||
|
staleTasks.push(task);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute and remove stale tasks
|
||||||
|
staleTasks.forEach(task => {
|
||||||
|
console.log('AnimationQueue: Cleaning up stale task');
|
||||||
|
|
||||||
|
// Clear the timeout
|
||||||
|
if (task.timeoutId !== null) {
|
||||||
|
clearTimeout(task.timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the task
|
||||||
|
task.execute();
|
||||||
|
|
||||||
|
// Remove from queue
|
||||||
|
const index = this.timeoutQueue.indexOf(task);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.timeoutQueue.splice(index, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any TTS is currently speaking
|
||||||
|
* @returns {boolean} - True if TTS is speaking
|
||||||
|
*/
|
||||||
|
isAnyTtsSpeaking() {
|
||||||
|
// If we don't have a reference to the TTS module yet, try to get it
|
||||||
|
if (!this.tts) {
|
||||||
|
this.tts = this.getModule('tts-player');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if TTS is speaking
|
||||||
|
if (this.tts && typeof this.tts.isSpeaking === 'function') {
|
||||||
|
return this.tts.isSpeaking();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to false if TTS module is not available
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast forward all pending animations and stop TTS
|
||||||
*/
|
*/
|
||||||
fastForward() {
|
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
|
console.log(`AnimationQueue: Fast forwarding ${this.timeoutQueue.length} pending items`);
|
||||||
if (this.tts) {
|
|
||||||
|
// 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();
|
this.tts.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute and clear all timeouts
|
// Execute all pending animations immediately
|
||||||
const queueCopy = [...this.queue]; // Make a copy to avoid modification during iteration
|
this.timeoutQueue.forEach(timeout => {
|
||||||
|
// Clear the timeout
|
||||||
queueCopy.forEach(timeoutObject => {
|
if (timeout.timeoutId !== null) {
|
||||||
// Clear timeout
|
clearTimeout(timeout.timeoutId);
|
||||||
if (timeoutObject.timeoutId !== null) {
|
timeout.timeoutId = null;
|
||||||
clearTimeout(timeoutObject.timeoutId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute immediately
|
// Clear TTS flag
|
||||||
timeoutObject.execute();
|
timeout.ttsSpeaking = false;
|
||||||
|
|
||||||
|
// Execute the function immediately
|
||||||
|
timeout.execute();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear queue
|
// Clear the queue
|
||||||
this.queue = [];
|
this.timeoutQueue = [];
|
||||||
|
|
||||||
// Reset delay
|
// Reset delay
|
||||||
this.delay = 0;
|
this.delay = 0;
|
||||||
|
|
||||||
// Use direct DOM event dispatch instead of this.dispatchEvent
|
// Update config using parent's updateConfig method
|
||||||
document.dispatchEvent(new CustomEvent('animations:fastForwarded', {
|
this.updateConfig({ fastForwardEnabled: false });
|
||||||
detail: { moduleId: this.id }
|
|
||||||
}));
|
// 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
|
* Clear all scheduled animations without executing them
|
||||||
*/
|
*/
|
||||||
clearAll() {
|
clearAll() {
|
||||||
console.log(`Animation Queue: Clearing ${this.queue.length} pending items`);
|
console.log(`Animation Queue: Clearing ${this.timeoutQueue.length} pending items`);
|
||||||
|
|
||||||
// Clear all timeouts
|
// Clear all timeouts
|
||||||
this.queue.forEach(timeoutObject => {
|
this.timeoutQueue.forEach(timeoutObject => {
|
||||||
if (timeoutObject.timeoutId !== null) {
|
if (timeoutObject.timeoutId !== null) {
|
||||||
clearTimeout(timeoutObject.timeoutId);
|
clearTimeout(timeoutObject.timeoutId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear queue
|
// Clear queue
|
||||||
this.queue = [];
|
this.timeoutQueue = [];
|
||||||
|
|
||||||
// Reset delay
|
// Reset delay
|
||||||
this.delay = 0;
|
this.delay = 0;
|
||||||
@@ -177,16 +344,33 @@ class AnimationQueueModule extends BaseModule {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.speed = speed;
|
// Update config using parent's updateConfig method
|
||||||
|
this.updateConfig({ speed });
|
||||||
console.log(`Animation Queue: Speed set to ${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
|
* Get current queue length
|
||||||
* @returns {number} - Number of items in the queue
|
* @returns {number} - Number of items in the queue
|
||||||
*/
|
*/
|
||||||
getQueueLength() {
|
getQueueLength() {
|
||||||
return this.queue.length;
|
return this.timeoutQueue.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,6 +390,3 @@ moduleRegistry.register(AnimationQueue);
|
|||||||
|
|
||||||
// Export the module
|
// Export the module
|
||||||
export { AnimationQueue };
|
export { AnimationQueue };
|
||||||
|
|
||||||
// Keep a reference in window for loader system
|
|
||||||
window.AnimationQueue = AnimationQueue;
|
|
||||||
|
|||||||
+613
-186
@@ -1,215 +1,688 @@
|
|||||||
/**
|
/**
|
||||||
* ApiTTSHandler for AI Interactive Fiction
|
* API TTS Handler
|
||||||
* Implementation using external TTS APIs like ElevenLabs
|
* Provides TTS via external APIs (e.g., ElevenLabs)
|
||||||
*/
|
*/
|
||||||
import { TTSHandler } from './tts-handler.js';
|
import { TTSHandler } from './tts-handler.js';
|
||||||
|
import { moduleRegistry } from './module-registry.js';
|
||||||
|
|
||||||
export class ApiTTSHandler extends TTSHandler {
|
export class ApiTTSHandler extends TTSHandler {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(); // Initialize the base TTSHandler
|
super();
|
||||||
this.isReady = false;
|
this.id = 'api';
|
||||||
this.enabled = false; // Disabled by default until options panel is implemented
|
this.name = 'API TTS Handler';
|
||||||
this.audioElement = null;
|
|
||||||
// Set voice options through base class
|
// Voice options
|
||||||
this.voiceOptions = {
|
this.voiceOptions = {
|
||||||
voice: '8JNqTOY3RaSYcHTVJZ0G', // Default ElevenLabs voice ID
|
voice: 'pNInz6obpgDQGcFmaJgB', // Default German voice ID for ElevenLabs
|
||||||
model: 'eleven_multilingual_v1',
|
model: 'eleven_multilingual_v2', // Use the multilingual model for better German
|
||||||
stability: 0,
|
speed: 1.0
|
||||||
similarityBoost: 0,
|
|
||||||
style: 0.5,
|
|
||||||
useSpeakerBoost: true
|
|
||||||
};
|
};
|
||||||
this.apiKey = 'd191e27c2e5b07573b39fe70f0783f48'; // From speech.js
|
|
||||||
this.apiUrl = 'https://api.elevenlabs.io/v1/text-to-speech';
|
// State
|
||||||
this.voicesApiUrl = 'https://api.elevenlabs.io/v1/voices'; // Separate URL for voices endpoint
|
this.available = false;
|
||||||
this.cache = new Map();
|
this.isReady = false;
|
||||||
this.currentCallback = null;
|
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
|
* Get a module from the registry
|
||||||
* @returns {string} - Provider ID
|
* @param {string} moduleId - ID of the module to get
|
||||||
|
* @returns {Object|null} - The module or null if not found
|
||||||
*/
|
*/
|
||||||
getId() {
|
getModule(moduleId) {
|
||||||
return 'api';
|
return moduleRegistry.getModule(moduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the API TTS system
|
* Initialize the API TTS handler
|
||||||
* @param {Function} progressCallback - Optional callback for progress updates
|
* @param {Function} progressCallback - Callback for progress updates
|
||||||
* @returns {Promise<boolean>} - Resolves to true if initialization was successful
|
* @returns {Promise<boolean>} - Resolves with success status
|
||||||
*/
|
*/
|
||||||
async initialize(progressCallback = null) {
|
async initialize(progressCallback = null) {
|
||||||
try {
|
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();
|
this.audioElement = new Audio();
|
||||||
|
|
||||||
// Set up audio event listeners
|
if (progressCallback) {
|
||||||
this.audioElement.onended = () => {
|
progressCallback(30, "Loading voices");
|
||||||
if (this.currentCallback) {
|
|
||||||
const callback = this.currentCallback;
|
|
||||||
this.currentCallback = null;
|
|
||||||
callback();
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
this.audioElement.onerror = (error) => {
|
// Load available voices
|
||||||
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
|
|
||||||
try {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'xi-api-key': this.apiKey
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (testResponse.ok) {
|
if (!response.ok) {
|
||||||
this.isReady = true;
|
console.warn(`API TTS: API endpoint not available (${response.status} ${response.statusText}). Will use fallback.`);
|
||||||
console.log('API TTS initialized successfully');
|
this.available = false;
|
||||||
} else {
|
this.isReady = true; // Still mark as ready, just not available
|
||||||
console.warn('API TTS initialized but API may not be accessible');
|
|
||||||
}
|
if (progressCallback) {
|
||||||
} catch (apiError) {
|
progressCallback(100, "API TTS unavailable, using fallback");
|
||||||
console.warn('Could not verify API access, but continuing:', apiError);
|
|
||||||
// We'll still mark as ready and try when speak is called
|
|
||||||
this.isReady = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('API TTS is disabled by default. Enable via options panel when implemented.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progressCallback) progressCallback(100, 'API TTS initialization complete');
|
// Return 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) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if API TTS is available
|
* Select a voice for the given locale
|
||||||
* @returns {boolean} - True if API TTS is ready to use
|
* @param {string} locale - Locale code
|
||||||
|
* @returns {boolean} - Success status
|
||||||
*/
|
*/
|
||||||
isAvailable() {
|
selectVoiceForLocale(locale) {
|
||||||
return this.isReady && this.enabled;
|
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
|
* Select a default voice
|
||||||
* @param {string} text - Text to hash
|
* @returns {boolean} - Success status
|
||||||
* @returns {string} - MD5 hash
|
|
||||||
*/
|
*/
|
||||||
generateHash(text) {
|
selectDefaultVoice() {
|
||||||
// Simple hash function for client-side use
|
if (this.voices.length === 0) {
|
||||||
// For production, consider using a proper hashing library
|
console.warn("API TTS: No voices available for default selection");
|
||||||
let hash = 0;
|
return false;
|
||||||
if (text.length === 0) return hash.toString();
|
|
||||||
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
const char = text.charCodeAt(i);
|
|
||||||
hash = ((hash << 5) - hash) + char;
|
|
||||||
hash = hash & hash; // Convert to 32bit integer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.abs(hash).toString(16);
|
// 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
|
* Preload speech for a text
|
||||||
* @param {string} text - Text to speak
|
* @param {string} text - Text to preload
|
||||||
* @param {Function} callback - Called when speech completes
|
* @returns {Promise<Object>} - Preloaded audio data
|
||||||
*/
|
*/
|
||||||
async speak(text, callback = null) {
|
async preloadSpeech(text) {
|
||||||
if (!this.isAvailable() || !text) {
|
if (!this.available || !text) {
|
||||||
if (callback) callback();
|
return null;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop any current speech
|
|
||||||
this.stop();
|
|
||||||
|
|
||||||
// Set new callback
|
|
||||||
this.currentCallback = callback;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check cache first
|
// Process text for TTS
|
||||||
const cacheKey = this.generateHash(text + JSON.stringify(this.voiceOptions));
|
const processedText = this.preprocessText(text);
|
||||||
let audioUrl = this.cache.get(cacheKey);
|
|
||||||
|
|
||||||
if (!audioUrl) {
|
console.log(`API TTS: Preloading speech for: "${processedText.substring(0, 50)}${processedText.length > 50 ? '...' : ''}"`);
|
||||||
// Make API request to get audio
|
|
||||||
const response = await fetch(`${this.apiUrl}/${this.voiceOptions.voice}`, {
|
// Make API request to generate speech
|
||||||
|
const response = await fetch(this.apiEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json'
|
||||||
'xi-api-key': this.apiKey
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
text: text,
|
text: processedText,
|
||||||
|
voice_id: this.voiceOptions.voice,
|
||||||
model_id: this.voiceOptions.model,
|
model_id: this.voiceOptions.model,
|
||||||
voice_settings: {
|
speed: this.voiceOptions.speed
|
||||||
stability: this.voiceOptions.stability,
|
|
||||||
similarity_boost: this.voiceOptions.similarityBoost,
|
|
||||||
style: this.voiceOptions.style,
|
|
||||||
use_speaker_boost: this.voiceOptions.useSpeakerBoost
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
const audioBlob = await response.blob();
|
||||||
audioUrl = URL.createObjectURL(audioBlob);
|
|
||||||
|
|
||||||
// Store in cache
|
// Create audio element but don't play it
|
||||||
this.cache.set(cacheKey, audioUrl);
|
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
|
// Play the audio
|
||||||
this.audioElement.src = audioUrl;
|
audio.play();
|
||||||
await this.audioElement.play();
|
|
||||||
|
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error speaking with API TTS:', error);
|
console.error("API TTS: Error playing preloaded speech:", error);
|
||||||
if (this.currentCallback) {
|
|
||||||
const callback = this.currentCallback;
|
// Dispatch error event
|
||||||
this.currentCallback = null;
|
this.dispatchEvent('tts:speak:error', {
|
||||||
callback();
|
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() {
|
isAvailable() {
|
||||||
if (this.audioElement) {
|
return this.available;
|
||||||
this.audioElement.pause();
|
|
||||||
this.audioElement.currentTime = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currentCallback) {
|
/**
|
||||||
const callback = this.currentCallback;
|
* Get handler ID
|
||||||
this.currentCallback = null;
|
* @returns {string} - Handler ID
|
||||||
callback();
|
*/
|
||||||
|
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
|
* @param {Object} options - Voice options
|
||||||
*/
|
*/
|
||||||
setVoiceOptions(options = {}) {
|
setVoiceOptions(options = {}) {
|
||||||
if (options.voice !== undefined) this.voiceOptions.voice = options.voice;
|
if (options.voice) {
|
||||||
if (options.model !== undefined) this.voiceOptions.model = options.model;
|
this.voiceOptions.voice = options.voice;
|
||||||
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.model) {
|
||||||
* Get available voices from the API
|
this.voiceOptions.model = options.model;
|
||||||
* @returns {Promise<Array>} - Array of available voices
|
|
||||||
*/
|
|
||||||
async getVoices() {
|
|
||||||
if (!this.enabled) {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (typeof options.speed === 'number') {
|
||||||
const response = await fetch(this.voicesApiUrl, {
|
// Clamp speed between 0.5 and 2.0
|
||||||
method: 'GET',
|
this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed));
|
||||||
headers: {
|
|
||||||
'xi-api-key': this.apiKey
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
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
@@ -9,6 +9,24 @@ export class BaseModule {
|
|||||||
this.state = 'PENDING';
|
this.state = 'PENDING';
|
||||||
this.progress = 0;
|
this.progress = 0;
|
||||||
this.progressCallback = null;
|
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.changeState('LOADING');
|
||||||
this.reportProgress(10, "Starting initialization");
|
this.reportProgress(10, "Starting initialization");
|
||||||
|
|
||||||
// Load dependencies
|
// Skip loadDependencies() call - now handled automatically
|
||||||
const depsLoaded = await this.loadDependencies();
|
|
||||||
if (!depsLoaded) {
|
|
||||||
this.changeState('ERROR');
|
|
||||||
this.reportProgress(100, "Failed to load dependencies");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const depStatus = await this.waitForDependencies();
|
// Wait for dependencies
|
||||||
|
const depStatus = await this._waitForModuleDependencies();
|
||||||
if (!depStatus) {
|
if (!depStatus) {
|
||||||
// If dependencies aren't available, report waiting
|
// If dependencies aren't available, report waiting
|
||||||
this.changeState('WAITING');
|
this.changeState('WAITING');
|
||||||
@@ -59,24 +72,100 @@ export class BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load module dependencies - Override this in child classes
|
* Wait for module dependencies
|
||||||
* @returns {Promise} - Resolves when dependencies are loaded
|
* @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() {
|
async loadDependencies() {
|
||||||
|
// This is now handled by _waitForModuleDependencies
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for dependencies to be ready - Override this in child classes
|
* Legacy method for backwards compatibility
|
||||||
* @returns {Promise} - Resolves when dependencies are ready
|
* @deprecated No longer needed as waitForDependencies is handled automatically
|
||||||
|
* @returns {Promise<boolean>} - Resolves when dependencies are ready
|
||||||
*/
|
*/
|
||||||
async waitForDependencies() {
|
async waitForDependencies() {
|
||||||
|
// This is now handled by _waitForModuleDependencies
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the module - Override this in child classes
|
* 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() {
|
async initialize() {
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
@@ -116,6 +205,360 @@ export class BaseModule {
|
|||||||
getState() {
|
getState() {
|
||||||
return this.state;
|
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
@@ -3,196 +3,587 @@
|
|||||||
* Implementation using the browser's Web Speech API
|
* Implementation using the browser's Web Speech API
|
||||||
*/
|
*/
|
||||||
import { TTSHandler } from './tts-handler.js';
|
import { TTSHandler } from './tts-handler.js';
|
||||||
|
import { moduleRegistry } from './module-registry.js';
|
||||||
|
|
||||||
export class BrowserTTSHandler extends TTSHandler {
|
export class BrowserTTSHandler extends TTSHandler {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(); // Initialize the base TTSHandler
|
super();
|
||||||
this.synth = window.speechSynthesis;
|
this.id = 'browser';
|
||||||
this.utterance = null;
|
this.name = 'Browser TTS Handler';
|
||||||
this.voices = [];
|
|
||||||
this.isReady = false;
|
// Voice options
|
||||||
// Initialize voice options through base class
|
|
||||||
this.voiceOptions = {
|
this.voiceOptions = {
|
||||||
voice: '',
|
voice: null, // Will be set during initialization
|
||||||
rate: 1.0,
|
rate: 1.0,
|
||||||
pitch: 1.0,
|
pitch: 1.0,
|
||||||
volume: 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
|
* Get a module from the registry
|
||||||
* @returns {boolean} - True if speaking
|
* @param {string} moduleId - ID of the module to get
|
||||||
|
* @returns {Object|null} - The module or null if not found
|
||||||
*/
|
*/
|
||||||
isSpeaking() {
|
getModule(moduleId) {
|
||||||
return this.synth && this.synth.speaking;
|
return moduleRegistry.getModule(moduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the ID of this provider
|
* Initialize the browser TTS handler
|
||||||
* @returns {string} - Provider ID
|
* @param {Function} progressCallback - Callback for progress updates
|
||||||
*/
|
* @returns {Promise<boolean>} - Resolves with success status
|
||||||
getId() {
|
|
||||||
return 'browser';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the browser's speech synthesis
|
|
||||||
* @param {Function} progressCallback - Optional callback for progress updates
|
|
||||||
* @returns {Promise<boolean>} - Resolves to true if initialization was successful
|
|
||||||
*/
|
*/
|
||||||
async initialize(progressCallback = null) {
|
async initialize(progressCallback = null) {
|
||||||
if (!this.synth) {
|
try {
|
||||||
console.warn('Web Speech API not supported in this browser');
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback(30, "Loading voices");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (progressCallback) progressCallback(20, 'Loading speech synthesis');
|
// Load available voices
|
||||||
|
await this.loadVoices();
|
||||||
|
|
||||||
// Get available voices
|
if (progressCallback) {
|
||||||
this.voices = await this.getVoices();
|
progressCallback(70, "Setting up voice");
|
||||||
|
|
||||||
if (progressCallback) progressCallback(80, 'Speech synthesis loaded');
|
|
||||||
|
|
||||||
// If we have voices, we're ready
|
|
||||||
this.isReady = this.voices && this.voices.length > 0;
|
|
||||||
|
|
||||||
if (this.isReady) {
|
|
||||||
console.log('Browser TTS initialized with', this.voices.length, 'voices');
|
|
||||||
} else {
|
|
||||||
console.warn('Browser TTS initialized but no voices available');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progressCallback) progressCallback(100, 'Browser TTS ready');
|
// 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) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available voices
|
* Handle voices changed event
|
||||||
* @returns {Promise<Array>} - Array of available voices
|
|
||||||
*/
|
*/
|
||||||
async getVoices() {
|
async onVoicesChanged() {
|
||||||
return new Promise((resolve) => {
|
await this.loadVoices();
|
||||||
// Some browsers get voices immediately, others need an event
|
const localization = this.getModule('localization');
|
||||||
const voices = this.synth.getVoices();
|
const persistenceManager = this.getModule('persistence-manager');
|
||||||
|
let currentLocale = 'en-us';
|
||||||
if (voices && voices.length > 0) {
|
let preferredVoice = '';
|
||||||
resolve(voices);
|
if (localization) {
|
||||||
} else {
|
currentLocale = localization.getLocale();
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
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
|
* Set voice based on locale
|
||||||
* @returns {boolean} - True if browser TTS is ready to use
|
* @param {string} locale - Locale code (e.g., 'en-us', 'de', 'fr')
|
||||||
|
* @param {string} preferredVoice - Optional preferred voice name
|
||||||
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
isAvailable() {
|
async selectVoiceForLocale(locale = 'en-us', preferredVoice = '') {
|
||||||
return this.isReady && this.synth;
|
// Normalize locale for comparison
|
||||||
|
const normalizedLocale = locale.toLowerCase().split('-')[0];
|
||||||
|
|
||||||
|
// If we have a preferred voice, try to use it first
|
||||||
|
if (preferredVoice) {
|
||||||
|
const matchingVoice = this.voices.find(voice =>
|
||||||
|
voice.name === preferredVoice ||
|
||||||
|
voice.voiceURI === preferredVoice
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingVoice) {
|
||||||
|
this.voiceOptions.voice = matchingVoice;
|
||||||
|
console.log(`Browser TTS: Using preferred voice: ${matchingVoice.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Find voices matching the locale
|
||||||
* Speak text using browser TTS
|
const localeVoices = this.voices.filter(voice => {
|
||||||
* @param {string} text - The text to speak
|
const voiceLocale = voice.lang.toLowerCase();
|
||||||
* @param {Function} callback - Called when speech completes
|
return voiceLocale.startsWith(normalizedLocale) ||
|
||||||
*/
|
voice.name.toLowerCase().includes(normalizedLocale);
|
||||||
speak(text, callback = null) {
|
});
|
||||||
if (!this.isAvailable() || !text) {
|
|
||||||
if (callback) callback();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop any current speech
|
// If no matching voice found, try to find any voice
|
||||||
this.stop();
|
if (this.voices.length > 0) {
|
||||||
|
// Look for a preferred language voice (English)
|
||||||
try {
|
const englishVoices = this.voices.filter(voice =>
|
||||||
// Create a new utterance
|
voice.lang.toLowerCase().startsWith('en')
|
||||||
this.utterance = new SpeechSynthesisUtterance(text);
|
|
||||||
|
|
||||||
// Apply voice options
|
|
||||||
if (this.voiceOptions.voice) {
|
|
||||||
// Find the voice by name or URI
|
|
||||||
const selectedVoice = this.voices.find(v =>
|
|
||||||
v.name === this.voiceOptions.voice ||
|
|
||||||
v.voiceURI === this.voiceOptions.voice
|
|
||||||
);
|
);
|
||||||
if (selectedVoice) {
|
|
||||||
this.utterance.voice = selectedVoice;
|
if (englishVoices.length > 0) {
|
||||||
|
this.voiceOptions.voice = englishVoices[0];
|
||||||
|
console.log(`Browser TTS: No ${normalizedLocale} voice found, using English voice: ${this.voiceOptions.voice.name}`);
|
||||||
|
} else {
|
||||||
|
// Use the first available voice
|
||||||
|
this.voiceOptions.voice = this.voices[0];
|
||||||
|
console.log(`Browser TTS: No ${normalizedLocale} or English voice found, using: ${this.voiceOptions.voice.name}`);
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
console.log("Browser TTS: No voices available");
|
||||||
// Apply other options
|
|
||||||
this.utterance.rate = this.voiceOptions.rate;
|
|
||||||
this.utterance.pitch = this.voiceOptions.pitch;
|
|
||||||
this.utterance.volume = this.voiceOptions.volume;
|
|
||||||
|
|
||||||
// Handle end of speech
|
|
||||||
this.utterance.onend = () => {
|
|
||||||
if (callback) callback();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle errors
|
|
||||||
this.utterance.onerror = (e) => {
|
|
||||||
console.error('Speech synthesis error:', e);
|
|
||||||
if (callback) callback();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start speaking
|
|
||||||
this.synth.speak(this.utterance);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error speaking with browser TTS:', error);
|
|
||||||
if (callback) callback();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop any ongoing speech
|
* 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() {
|
stop() {
|
||||||
if (this.synth) {
|
if (speechSynthesis) {
|
||||||
this.synth.cancel();
|
speechSynthesis.cancel();
|
||||||
this.utterance = null;
|
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
|
* Set voice options
|
||||||
* @param {Object} options - Voice options
|
* @param {Object} options - Voice options
|
||||||
*/
|
*/
|
||||||
setVoiceOptions(options = {}) {
|
setVoiceOptions(options = {}) {
|
||||||
if (options.voice !== undefined) this.voiceOptions.voice = options.voice;
|
if (options.voice) {
|
||||||
if (options.rate !== undefined) this.voiceOptions.rate = options.rate;
|
// Find the voice by ID or name
|
||||||
if (options.pitch !== undefined) this.voiceOptions.pitch = options.pitch;
|
const voice = this.voices.find(v =>
|
||||||
if (options.volume !== undefined) this.voiceOptions.volume = options.volume;
|
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
@@ -8,11 +8,11 @@ import { moduleRegistry } from './module-registry.js';
|
|||||||
class GameLoopModule extends BaseModule {
|
class GameLoopModule extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('game-loop', 'Game Loop');
|
super('game-loop', 'Game Loop');
|
||||||
this.uiController = null;
|
|
||||||
this.socketClient = null;
|
// Dependencies
|
||||||
this.ttsPlayer = null;
|
this.dependencies = ['ui-controller', 'socket-client', 'tts-player', 'text-buffer'];
|
||||||
this.textBuffer = null;
|
|
||||||
this.isRunning = false;
|
// Game state
|
||||||
this.gameState = {
|
this.gameState = {
|
||||||
started: false,
|
started: false,
|
||||||
canLoad: false,
|
canLoad: false,
|
||||||
@@ -20,78 +20,33 @@ class GameLoopModule extends BaseModule {
|
|||||||
inventory: [],
|
inventory: [],
|
||||||
commandHistory: []
|
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() {
|
async initialize() {
|
||||||
this.reportProgress(100, "Game loop initialized");
|
this.reportProgress(100, "Game loop initialized");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the game loop
|
|
||||||
*/
|
|
||||||
start() {
|
start() {
|
||||||
console.log("GameLoop: Starting game sequence...");
|
console.log("GameLoop: Starting game sequence...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update UI with initial game state
|
// The dependencies are now automatically available via getModule
|
||||||
if (this.uiController && this.ttsPlayer) {
|
|
||||||
this.updateUIState();
|
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...");
|
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() {
|
setupSocketEventListeners() {
|
||||||
// Get the socket client module
|
// Get the socket client module using parent's getModule method
|
||||||
this.socketClient = moduleRegistry.getModule('socket-client');
|
this.socketClient = this.getModule('socket-client');
|
||||||
|
|
||||||
if (!this.socketClient) {
|
if (!this.socketClient) {
|
||||||
console.error("Socket client module not found");
|
console.error("Socket client module not found");
|
||||||
@@ -118,6 +70,8 @@ class GameLoopModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Connect UI controller to socket client for command handling
|
// Connect UI controller to socket client for command handling
|
||||||
|
this.uiController = this.getModule('ui-controller');
|
||||||
|
|
||||||
if (this.uiController) {
|
if (this.uiController) {
|
||||||
this.uiController.socketClient = this.socketClient;
|
this.uiController.socketClient = this.socketClient;
|
||||||
} else {
|
} else {
|
||||||
@@ -128,9 +82,14 @@ class GameLoopModule extends BaseModule {
|
|||||||
this.socketClient.on('connect', () => {
|
this.socketClient.on('connect', () => {
|
||||||
console.log("GameLoop: Socket connected event received.");
|
console.log("GameLoop: Socket connected event received.");
|
||||||
|
|
||||||
// Request a new game start when we (re)connect
|
// Request a new game start when we connect
|
||||||
console.log("GameLoop: Requesting start game on (re)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();
|
this.requestStartGame();
|
||||||
|
} else {
|
||||||
|
console.log("GameLoop: Game already started, skipping duplicate start request.");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for game state updates
|
// Listen for game state updates
|
||||||
@@ -157,8 +116,6 @@ class GameLoopModule extends BaseModule {
|
|||||||
this.socketClient.connect().then(success => {
|
this.socketClient.connect().then(success => {
|
||||||
if (success) {
|
if (success) {
|
||||||
console.log("GameLoop: Socket connection established successfully.");
|
console.log("GameLoop: Socket connection established successfully.");
|
||||||
console.log("GameLoop: Requesting to start a new game");
|
|
||||||
this.requestStartGame();
|
|
||||||
} else {
|
} else {
|
||||||
console.error("GameLoop: Failed to connect to socket server");
|
console.error("GameLoop: Failed to connect to socket server");
|
||||||
}
|
}
|
||||||
@@ -229,12 +186,15 @@ class GameLoopModule extends BaseModule {
|
|||||||
* @param {string} text - Text to add
|
* @param {string} text - Text to add
|
||||||
*/
|
*/
|
||||||
addText(text) {
|
addText(text) {
|
||||||
if (!this.textBuffer) {
|
// Use parent's getModule method
|
||||||
|
const textBuffer = this.getModule('text-buffer');
|
||||||
|
|
||||||
|
if (!textBuffer) {
|
||||||
console.warn("Text buffer not available");
|
console.warn("Text buffer not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.textBuffer.addText(text);
|
textBuffer.addText(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+666
-581
File diff suppressed because it is too large
Load Diff
+33
-30
@@ -23,7 +23,7 @@ self.onmessage = function(e) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'generate':
|
case 'generate':
|
||||||
if (!message.data || !message.data.text) {
|
if (!message.text) {
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
error: 'No text provided for generation'
|
error: 'No text provided for generation'
|
||||||
@@ -32,11 +32,17 @@ self.onmessage = function(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store voice options
|
// Store voice options
|
||||||
if (message.data.voice) voiceOptions.voice = message.data.voice;
|
if (message.voice) voiceOptions.voice = message.voice;
|
||||||
if (message.data.speed) voiceOptions.speed = message.data.speed;
|
if (message.speed) voiceOptions.speed = message.speed;
|
||||||
|
|
||||||
// Generate speech
|
// Generate speech
|
||||||
generateSpeech(message.data.text)
|
generateSpeech(message.text)
|
||||||
|
.then(result => {
|
||||||
|
self.postMessage({
|
||||||
|
type: 'generated',
|
||||||
|
result: result
|
||||||
|
}, [result.audio.buffer]);
|
||||||
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@@ -73,46 +79,43 @@ async function generateSpeech(text) {
|
|||||||
try {
|
try {
|
||||||
// Load Kokoro if not already loaded
|
// Load Kokoro if not already loaded
|
||||||
if (!kokoroLoaded) {
|
if (!kokoroLoaded) {
|
||||||
|
try {
|
||||||
// Load the Kokoro script
|
// 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');
|
throw new Error('Kokoro failed to load correctly');
|
||||||
}
|
}
|
||||||
|
|
||||||
kokoroLoaded = true;
|
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
|
// Generate speech using Kokoro
|
||||||
// We can't easily transfer the instance from the main thread, so we create it here
|
const result = await self.Kokoro(text, {
|
||||||
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, {
|
|
||||||
voice: voiceOptions.voice,
|
voice: voiceOptions.voice,
|
||||||
speed: voiceOptions.speed
|
speed: voiceOptions.speed,
|
||||||
|
autoPlay: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send the result back to the main thread
|
// Extract audio data
|
||||||
// We can't transfer the Float32Array directly, so let's transfer the buffer
|
const audioContext = new (self.AudioContext || self.webkitAudioContext)();
|
||||||
const audioBuffer = result.audio.buffer;
|
const audioBuffer = await audioContext.decodeAudioData(result.buffer);
|
||||||
|
|
||||||
self.postMessage({
|
// Get audio data as Float32Array
|
||||||
type: 'generated',
|
const audioData = audioBuffer.getChannelData(0);
|
||||||
result: {
|
|
||||||
audio: audioBuffer,
|
|
||||||
sampling_rate: result.sampling_rate
|
|
||||||
}
|
|
||||||
}, [audioBuffer]); // Transfer the buffer for better performance
|
|
||||||
|
|
||||||
|
// Return the result
|
||||||
|
return {
|
||||||
|
audio: audioData,
|
||||||
|
sampling_rate: audioBuffer.sampleRate
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error generating speech in worker:', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
|
|||||||
+281
-221
@@ -1,251 +1,311 @@
|
|||||||
/**
|
/**
|
||||||
* LayoutRenderer Module
|
* Layout Renderer Module
|
||||||
* Translates the abstract layout data into concrete visual elements (DOM nodes).
|
* Renders calculated paragraph layouts into the DOM with proper animations
|
||||||
*/
|
*/
|
||||||
export class LayoutRenderer {
|
import { BaseModule } from './base-module.js';
|
||||||
/**
|
import { moduleRegistry } from './module-registry.js';
|
||||||
* Create a new LayoutRenderer
|
|
||||||
* @param {Object} animationQueue - The AnimationQueue instance
|
class LayoutRendererModule extends BaseModule {
|
||||||
*/
|
constructor() {
|
||||||
constructor(animationQueue) {
|
super('layout-renderer', 'Layout Renderer');
|
||||||
this.animationQueue = animationQueue;
|
|
||||||
this.fastForwardingAll = false;
|
// 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
|
* Initialize the module
|
||||||
* @param {string} tagType - The type of tag (IMAGE, BACKGROUND, etc.)
|
* @returns {Promise<boolean>} - Resolves with success status
|
||||||
* @param {string} tagValue - The value of the tag
|
|
||||||
* @param {HTMLElement} container - The container to append to
|
|
||||||
* @param {number} delay - The delay in milliseconds
|
|
||||||
* @returns {HTMLElement|null} The created element or null
|
|
||||||
*/
|
*/
|
||||||
renderVisualTag(tagType, tagValue, container, delay = 0) {
|
async initialize() {
|
||||||
switch (tagType) {
|
try {
|
||||||
case "IMAGE":
|
this.reportProgress(10, "Initializing Layout Renderer");
|
||||||
const imageElement = document.createElement('img');
|
|
||||||
imageElement.src = tagValue;
|
|
||||||
container.appendChild(imageElement);
|
|
||||||
this.showAfter(delay, imageElement);
|
|
||||||
return imageElement;
|
|
||||||
|
|
||||||
case "BACKGROUND":
|
// Get animation queue from module registry
|
||||||
const outerScrollContainer = document.querySelector('#book');
|
this.animationQueue = this.getModule('animation-queue');
|
||||||
outerScrollContainer.style.backgroundImage = 'url(' + tagValue + ')';
|
if (!this.animationQueue) {
|
||||||
return null;
|
console.warn("Layout Renderer: Animation Queue module not found in registry");
|
||||||
|
}
|
||||||
|
|
||||||
case "CHAPTER":
|
// We'll try to get the TTS module, but it's not a hard dependency
|
||||||
const h = document.createElement('H2');
|
// We'll check for it again at runtime when needed
|
||||||
h.appendChild(document.createTextNode(tagValue));
|
setTimeout(() => {
|
||||||
h.classList.add("chapter-heading");
|
// Try to get TTS module after a delay to allow it to initialize
|
||||||
h.classList.add("fade-in");
|
this.ttsPlayer = this.getModule('tts-player');
|
||||||
container.appendChild(h);
|
if (!this.ttsPlayer) {
|
||||||
return h;
|
console.log("Layout Renderer: TTS Player module not found yet, will try again when needed");
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
case "SEPARATOR":
|
this.reportProgress(100, "Layout Renderer ready");
|
||||||
const d = document.createElement('double');
|
return true;
|
||||||
d.appendChild(document.createTextNode('\u2766'));
|
} catch (error) {
|
||||||
d.classList.add("fade-in");
|
console.error("Error initializing Layout Renderer:", error);
|
||||||
d.classList.add("separator");
|
return false;
|
||||||
container.appendChild(d);
|
}
|
||||||
return d;
|
}
|
||||||
|
|
||||||
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;
|
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
|
* Render a single word
|
||||||
* @param {boolean} state - The fast forwarding state
|
* @param {string} word - Word to render
|
||||||
|
* @param {boolean} animate - Whether to prepare for animation
|
||||||
|
* @returns {HTMLElement} - The created word element
|
||||||
*/
|
*/
|
||||||
setFastForwardingAll(state) {
|
renderWord(word, animate = true) {
|
||||||
this.fastForwardingAll = state;
|
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
|
* Create a word element
|
||||||
* @returns {boolean} The fast forwarding state
|
* @param {string} word - Word to render
|
||||||
|
* @returns {HTMLElement} - The created word element
|
||||||
*/
|
*/
|
||||||
getFastForwardingAll() {
|
createWordElement(word) {
|
||||||
return this.fastForwardingAll;
|
const wordElement = document.createElement('span');
|
||||||
|
wordElement.className = 'word';
|
||||||
|
wordElement.textContent = word;
|
||||||
|
return wordElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Smooth scroll to an element
|
* Schedule a word animation with the animation queue
|
||||||
* @param {HTMLElement} target - The target element to scroll to
|
* @param {HTMLElement} wordElement - Word element to animate
|
||||||
* @param {number} duration - The duration of the scroll animation
|
* @param {number} delay - Delay before animation starts
|
||||||
|
* @param {number} speed - Animation speed factor
|
||||||
*/
|
*/
|
||||||
smoothScroll(target, duration) {
|
scheduleWordAnimation(wordElement, delay, speed) {
|
||||||
const display = document.getElementById('page_right');
|
if (!this.animationQueue) return;
|
||||||
const targetPosition = target.getBoundingClientRect().top;
|
|
||||||
const startPosition = display.scrollTop;
|
|
||||||
const distance = targetPosition;
|
|
||||||
let startTime = null;
|
|
||||||
|
|
||||||
if (duration < 5) {
|
const actualDelay = delay * speed;
|
||||||
display.scrollTo(0, targetPosition);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function animation(currentTime) {
|
this.animationQueue.schedule(() => {
|
||||||
if (startTime === null) startTime = currentTime;
|
wordElement.style.opacity = '1';
|
||||||
const timeElapsed = currentTime - startTime;
|
wordElement.style.transform = 'translateY(0)';
|
||||||
const run = ease(timeElapsed, startPosition, distance, duration);
|
wordElement.style.transition = `opacity 0.2s ease-out, transform 0.3s ease-out`;
|
||||||
display.scrollTo(0, run);
|
}, actualDelay);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
import { moduleRegistry } from './module-registry.js';
|
import { moduleRegistry } from './module-registry.js';
|
||||||
import { ModuleEvent } from './base-module.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
|
* Module States
|
||||||
*/
|
*/
|
||||||
@@ -45,6 +49,20 @@ const ModuleLoader = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Module Loader: Initialization started');
|
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
|
// Create the loading overlay
|
||||||
createLoadingOverlay();
|
createLoadingOverlay();
|
||||||
|
|
||||||
@@ -77,6 +95,7 @@ const ModuleLoader = (function() {
|
|||||||
* @returns {Promise} - Resolves when all module scripts are loaded
|
* @returns {Promise} - Resolves when all module scripts are loaded
|
||||||
*/
|
*/
|
||||||
async function loadModuleScripts() {
|
async function loadModuleScripts() {
|
||||||
|
|
||||||
// Define modules with their weights
|
// Define modules with their weights
|
||||||
const modulesToLoad = [
|
const modulesToLoad = [
|
||||||
// Core functionality modules
|
// Core functionality modules
|
||||||
@@ -84,10 +103,12 @@ const ModuleLoader = (function() {
|
|||||||
{ id: 'localization', script: '/js/localization.js', weight: 40 },
|
{ id: 'localization', script: '/js/localization.js', weight: 40 },
|
||||||
{ id: 'text-processor', script: '/js/text-processor.js', weight: 40 },
|
{ id: 'text-processor', script: '/js/text-processor.js', weight: 40 },
|
||||||
{ id: 'paragraph-layout', script: '/js/paragraph-layout.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 },
|
{ id: 'animation-queue', script: '/js/animation-queue.js', weight: 50 },
|
||||||
|
|
||||||
// Audio and TTS modules
|
// Audio and TTS modules
|
||||||
{ id: 'audio-manager', script: '/js/audio-manager.js', weight: 60 },
|
{ 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 },
|
{ id: 'tts', script: '/js/tts-player.js', weight: 75 },
|
||||||
|
|
||||||
// UI and interaction modules
|
// UI and interaction modules
|
||||||
@@ -143,6 +164,13 @@ const ModuleLoader = (function() {
|
|||||||
// Find the game loop module instance
|
// Find the game loop module instance
|
||||||
gameLoopModule = moduleRegistry.getModule('game-loop');
|
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
|
// For each registered module, start initialization
|
||||||
Object.values(modules).forEach(async (module) => {
|
Object.values(modules).forEach(async (module) => {
|
||||||
try {
|
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
|
// Initialize the module with progress callback
|
||||||
await module.initializeInterface(progressCallback);
|
await module.initializeInterface(progressCallback);
|
||||||
|
|
||||||
|
// Log completion of initialization
|
||||||
|
console.log(`Completed initialization of module: ${module.id}`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error initializing module ${module.id}:`, error);
|
console.error(`Error initializing module ${module.id}:`, error);
|
||||||
}
|
}
|
||||||
@@ -307,7 +341,18 @@ const ModuleLoader = (function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (allFinished && !isLoadingComplete) {
|
if (allFinished && !isLoadingComplete) {
|
||||||
|
console.log('All modules finished loading. Proceeding to finalization...');
|
||||||
finalizeLoading();
|
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() {
|
function finalizeLoading() {
|
||||||
console.log('Loading completed. Finalizing...');
|
console.log('Loading completed. Finalizing...');
|
||||||
|
try {
|
||||||
completeFinalization();
|
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
|
// Hide the overlay first, then start the game loop
|
||||||
hideOverlay(() => {
|
hideOverlay(() => {
|
||||||
console.log("Loader: Overlay hidden, starting Game Loop.");
|
console.log("Loader: Overlay hidden, starting Game Loop.");
|
||||||
|
try {
|
||||||
gameLoopModule.start();
|
gameLoopModule.start();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting Game Loop:", error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error("Loader: Game Loop module not found or start method missing.");
|
console.error("Loader: Game Loop module not found or start method missing.");
|
||||||
|
|||||||
+180
-175
@@ -1,16 +1,52 @@
|
|||||||
/**
|
/**
|
||||||
* Localization Module
|
* Localization Module for AI Interactive Fiction
|
||||||
* Manages translations and locale settings for the application
|
* Handles translations and locale settings
|
||||||
*/
|
*/
|
||||||
import { BaseModule } from './base-module.js';
|
import { BaseModule } from './base-module.js';
|
||||||
import { moduleRegistry } from './module-registry.js';
|
import { moduleRegistry } from './module-registry.js';
|
||||||
|
|
||||||
class LocalizationModule extends BaseModule {
|
class LocalizationModule extends BaseModule {
|
||||||
|
/**
|
||||||
|
* Create a new localization module
|
||||||
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
super('localization', 'Localization');
|
super('localization', 'Localization');
|
||||||
this.currentLocale = 'en-us'; // Default locale
|
|
||||||
|
// Current locale
|
||||||
|
this.currentLocale = 'en-us';
|
||||||
|
|
||||||
|
// Available translations
|
||||||
this.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() {
|
async initialize() {
|
||||||
try {
|
try {
|
||||||
// Load translations
|
this.reportProgress(10, "Initializing localization");
|
||||||
this.loadTranslations();
|
|
||||||
|
|
||||||
// Set global locale for SmartyPants
|
// Load default translations
|
||||||
window.locale = this.currentLocale;
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error initializing localization module:", error);
|
console.error("Error initializing localization:", error);
|
||||||
|
this.reportProgress(100, "Localization failed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all translations
|
* Load translations for a locale
|
||||||
|
* @param {string} locale - Locale to load
|
||||||
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
loadTranslations() {
|
async loadTranslations(locale) {
|
||||||
// Add English translations (default)
|
if (this.translations[locale]) {
|
||||||
this.addTranslations('en-us', {
|
return; // Already loaded
|
||||||
// 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.'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
try {
|
||||||
* Add translations for a specific locale
|
// Normalize locale
|
||||||
* @param {string} locale - Locale code
|
const normalizedLocale = locale.toLowerCase();
|
||||||
* @param {Object} translations - Translation key-value pairs
|
|
||||||
*/
|
// Try to load the exact locale
|
||||||
addTranslations(locale, translations) {
|
const response = await fetch(`/locales/${normalizedLocale}.json`);
|
||||||
if (!this.translations[locale]) {
|
|
||||||
this.translations[locale] = {};
|
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];
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
// Return default value or key if no translation found
|
console.error(`Error loading translations for ${locale}:`, error);
|
||||||
return defaultValue || key;
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the current locale
|
* Set the current locale
|
||||||
* @param {string} locale - Locale code
|
* @param {string} locale - Locale to set
|
||||||
|
* @returns {Promise<boolean>} - Success status
|
||||||
*/
|
*/
|
||||||
setLocale(locale) {
|
async setLocale(locale) {
|
||||||
if (this.translations[locale]) {
|
if (!locale) return false;
|
||||||
this.currentLocale = locale;
|
|
||||||
|
|
||||||
// Update global locale for SmartyPants
|
try {
|
||||||
window.locale = locale;
|
// Normalize locale
|
||||||
|
const normalizedLocale = locale.toLowerCase();
|
||||||
|
|
||||||
// Notify observers of locale change
|
// Load translations if not already loaded
|
||||||
this.notifyObservers();
|
if (!this.translations[normalizedLocale]) {
|
||||||
|
await this.loadTranslations(normalizedLocale);
|
||||||
console.log(`Localization: Locale set to ${locale}`);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current locale
|
* Get the current locale
|
||||||
* @returns {string} - Current locale code
|
* @returns {string} - Current locale
|
||||||
*/
|
*/
|
||||||
getLocale() {
|
getLocale() {
|
||||||
return this.currentLocale;
|
return this.currentLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a module to be notified of locale changes
|
* Get the language part of the current locale (e.g., 'en' from 'en-us')
|
||||||
* @param {Object} module - Module to register
|
* @returns {string} - Language code
|
||||||
* @param {Function} updateMethod - Method to call on locale change
|
|
||||||
*/
|
*/
|
||||||
registerObserver(module, updateMethod) {
|
getLanguage() {
|
||||||
if (typeof updateMethod !== 'function') {
|
return this.currentLocale.split('-')[0];
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,34 +190,68 @@ class LocalizationModule extends BaseModule {
|
|||||||
* @returns {Array<string>} - Array of locale codes
|
* @returns {Array<string>} - Array of locale codes
|
||||||
*/
|
*/
|
||||||
getAvailableLocales() {
|
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
|
* @param {string} locale - Locale code
|
||||||
* @returns {Object} - Translations for the locale
|
* @returns {string} - Language name
|
||||||
*/
|
*/
|
||||||
getTranslationsForLocale(locale) {
|
getLanguageName(locale) {
|
||||||
return this.translations[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)
|
* Translate a key
|
||||||
* @returns {string} - Text direction ('ltr' or 'rtl')
|
* @param {string} key - Translation key
|
||||||
|
* @param {Object} params - Parameters for interpolation
|
||||||
|
* @returns {string} - Translated text
|
||||||
*/
|
*/
|
||||||
getTextDirection() {
|
translate(key, params = {}) {
|
||||||
// List of RTL languages
|
if (!key) return '';
|
||||||
const rtlLocales = ['ar', 'he', 'fa', 'ur'];
|
|
||||||
|
|
||||||
// Check if current locale starts with any RTL language code
|
// Get translations for current locale
|
||||||
for (const rtl of rtlLocales) {
|
const translations = this.translations[this.currentLocale] || {};
|
||||||
if (this.currentLocale.startsWith(rtl)) {
|
|
||||||
return 'rtl';
|
// 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 the module
|
||||||
export { Localization };
|
export { Localization };
|
||||||
|
|
||||||
// Keep a reference in window for loader system
|
|
||||||
window.Localization = Localization;
|
|
||||||
|
|||||||
@@ -6,20 +6,52 @@ export class ModuleRegistry {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.modules = {};
|
this.modules = {};
|
||||||
this.readyPromises = {};
|
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
|
* Register a module
|
||||||
* @param {BaseModule} module - Module to register
|
* @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) {
|
if (!module || !module.id) {
|
||||||
console.error('Invalid module - must have an id property');
|
console.error('Invalid module - must have an id property');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the module
|
||||||
this.modules[module.id] = 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
|
// Create a promise that will resolve when this module is ready
|
||||||
this.readyPromises[module.id] = new Promise((resolve) => {
|
this.readyPromises[module.id] = new Promise((resolve) => {
|
||||||
// Set up a state change listener for this module
|
// 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
|
* Get a module by id
|
||||||
* @param {string} id - Module id
|
* @param {string} id - Module id
|
||||||
@@ -56,6 +158,33 @@ export class ModuleRegistry {
|
|||||||
return this.modules;
|
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)
|
* Wait for a module to be ready (in FINISHED state)
|
||||||
* @param {string} id - Module id to wait for
|
* @param {string} id - Module id to wait for
|
||||||
@@ -92,3 +221,6 @@ export class ModuleRegistry {
|
|||||||
|
|
||||||
// Create and export a singleton instance
|
// Create and export a singleton instance
|
||||||
export const moduleRegistry = new ModuleRegistry();
|
export const moduleRegistry = new ModuleRegistry();
|
||||||
|
|
||||||
|
// Make registry accessible globally
|
||||||
|
window.moduleRegistry = moduleRegistry;
|
||||||
|
|||||||
+328
-207
@@ -11,10 +11,15 @@ class OptionsUIModule extends BaseModule {
|
|||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
super('options-ui', 'Options UI');
|
super('options-ui', 'Options UI');
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
this.dependencies = ['persistence-manager', 'localization'];
|
||||||
|
|
||||||
this.persistenceManager = null;
|
this.persistenceManager = null;
|
||||||
this.ttsPlayer = null;
|
this.ttsPlayer = null;
|
||||||
this.audioManager = null;
|
this.audioManager = null;
|
||||||
this.ttsFactory = null;
|
this.ttsFactory = null;
|
||||||
|
this.localization = null;
|
||||||
this.modal = null;
|
this.modal = null;
|
||||||
this.isOpen = false;
|
this.isOpen = false;
|
||||||
|
|
||||||
@@ -25,8 +30,19 @@ class OptionsUIModule extends BaseModule {
|
|||||||
backdrop: true
|
backdrop: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Elements reference
|
||||||
|
this.elements = null;
|
||||||
|
|
||||||
// Bound event handlers for proper this context
|
// 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
|
* Wait for dependencies to be ready
|
||||||
* @returns {Promise<boolean>} - Resolves when dependencies are ready
|
* @returns {Promise<boolean>} - Resolves when dependencies are ready
|
||||||
*/
|
*/
|
||||||
async waitForDependencies() {
|
async waitForDependencies() {
|
||||||
try {
|
try {
|
||||||
// Wait for the persistence manager if available
|
// Get required modules
|
||||||
this.persistenceManager = moduleRegistry.getModule('persistence-manager');
|
this.persistenceManager = this.getModule('persistence-manager');
|
||||||
this.ttsPlayer = moduleRegistry.getModule('tts');
|
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
|
// 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;
|
return true;
|
||||||
} catch (error) {
|
} 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
|
* 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() {
|
handleTtsSystemChanged(event) {
|
||||||
if (!this.persistenceManager || !this.elements) return;
|
console.log("TTS system changed:", event.detail);
|
||||||
|
|
||||||
const prefs = this.persistenceManager.getAllPreferences();
|
if (this.isOpen) {
|
||||||
|
// Refresh the voices list if the options UI is currently open
|
||||||
// Animation speed
|
this.populateVoices();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -860,19 +964,36 @@ class OptionsUIModule extends BaseModule {
|
|||||||
const ttsVolume = this.persistenceManager.getPreference('tts', 'volume', 1.0);
|
const ttsVolume = this.persistenceManager.getPreference('tts', 'volume', 1.0);
|
||||||
const ttsRate = this.persistenceManager.getPreference('tts', 'rate', 1.0);
|
const ttsRate = this.persistenceManager.getPreference('tts', 'rate', 1.0);
|
||||||
|
|
||||||
if (this.ttsPlayer) {
|
if (this.ttsFactory) {
|
||||||
// Set TTS system
|
// Set TTS provider if it's available
|
||||||
if (ttsProvider) {
|
const availableHandlers = this.ttsFactory.getAvailableHandlers();
|
||||||
this.ttsPlayer.switchTTS(ttsProvider);
|
if (ttsProvider && availableHandlers[ttsProvider]) {
|
||||||
|
this.ttsFactory.setActiveHandler(ttsProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply voice options
|
// Get the active handler
|
||||||
this.ttsPlayer.setVoiceOptions({
|
const activeHandler = this.ttsFactory.getActiveHandler();
|
||||||
voice: ttsVoice,
|
if (activeHandler) {
|
||||||
|
// Set voice if specified
|
||||||
|
if (ttsVoice) {
|
||||||
|
activeHandler.setVoice(ttsVoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set options
|
||||||
|
activeHandler.setOptions({
|
||||||
volume: ttsVolume,
|
volume: ttsVolume,
|
||||||
rate: ttsRate
|
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
|
// Apply audio volume settings
|
||||||
const masterVolume = this.persistenceManager.getPreference('audio', 'masterVolume', 1.0);
|
const masterVolume = this.persistenceManager.getPreference('audio', 'masterVolume', 1.0);
|
||||||
|
|||||||
+256
-64
@@ -1,108 +1,300 @@
|
|||||||
/**
|
/**
|
||||||
* ParagraphLayout Module
|
* Paragraph Layout Module
|
||||||
* Interfaces with the Knuth-Plass line breaking algorithm to calculate optimal line breaks.
|
* 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 { BaseModule } from './base-module.js';
|
||||||
import { moduleRegistry } from './module-registry.js';
|
import { moduleRegistry } from './module-registry.js';
|
||||||
|
|
||||||
class ParagraphLayoutModule extends BaseModule {
|
class ParagraphLayoutModule extends BaseModule {
|
||||||
/**
|
|
||||||
* Create a new ParagraphLayout
|
|
||||||
*/
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('paragraph-layout', 'Paragraph Layout');
|
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'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async initialize() {
|
||||||
* Load module dependencies
|
|
||||||
* @returns {Promise} - Resolves when dependencies are loaded
|
|
||||||
*/
|
|
||||||
async loadDependencies() {
|
|
||||||
try {
|
try {
|
||||||
// First load linebreak.js if needed
|
this.reportProgress(20, "Initializing paragraph layout");
|
||||||
if (!window.linebreak) {
|
|
||||||
await this.loadScript('/js/linebreak.js');
|
// Get text processor using parent's getModule method
|
||||||
this.reportProgress(40, "Linebreak algorithm loaded");
|
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
|
// Load required dependencies
|
||||||
if (!window.kap) {
|
await this.loadLayoutDependencies();
|
||||||
await this.loadScript('/js/knuth-and-plass.js');
|
|
||||||
this.reportProgress(60, "KAP algorithm loaded");
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading paragraph layout dependencies:", error);
|
console.error("Error initializing Paragraph Layout:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a script dynamically
|
* Load required dependencies for layout calculations
|
||||||
* @param {string} src - Script source URL
|
|
||||||
* @returns {Promise} - Resolves when script is loaded
|
|
||||||
*/
|
*/
|
||||||
loadScript(src) {
|
async loadLayoutDependencies() {
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
const script = document.createElement('script');
|
this.reportProgress(30, "Loading layout dependencies");
|
||||||
script.src = src;
|
|
||||||
script.onload = () => resolve();
|
// Load LinkedList.js first as it's required by linebreak.js
|
||||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
await this.loadScript('/js/linked-list.js');
|
||||||
document.head.appendChild(script);
|
|
||||||
|
// 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
|
* Update the font for text measurements
|
||||||
* @returns {Promise<boolean>} - Resolves with success status
|
* @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 {
|
try {
|
||||||
// The measureText function will be provided by the game controller later
|
// Apply text processor transformations if available
|
||||||
this.reportProgress(100, "Paragraph layout initialized");
|
if (this.textProcessor) {
|
||||||
return true;
|
// 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) {
|
} catch (error) {
|
||||||
console.error("Error initializing paragraph layout:", error);
|
console.error("Error processing text for layout:", error);
|
||||||
return false;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate layout for a paragraph
|
* Calculate layout for a paragraph using Knuth and Plass algorithm
|
||||||
* @param {string} processedText - The pre-processed text (with SmartyPants and hyphenation)
|
* @param {string} text - Text to layout
|
||||||
* @param {Array<number>} measures - Array of line width measurements
|
* @param {Object} options - Layout options
|
||||||
* @param {boolean} hyphenate - Whether to enable hyphenation
|
* @returns {Object} - Layout data with line breaks
|
||||||
* @param {Function} [measureFunc] - Optional specific measurement function for this call
|
|
||||||
* @returns {Object} Layout data with nodes and breaks
|
|
||||||
*/
|
*/
|
||||||
calculateLayout(processedText, measures, hyphenate = true, measureFunc = null) {
|
calculateLayout(text, options = {}) {
|
||||||
const measure = measureFunc || this.measureText; // Use provided func or fallback to instance default
|
if (!text) return null;
|
||||||
if (typeof measure !== 'function') {
|
|
||||||
throw new Error('No text measurement function available');
|
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
|
* Set debug mode
|
||||||
* @param {Function} measureFunc - The new measurement function
|
* @param {boolean} enabled - Whether debug mode should be enabled
|
||||||
*/
|
*/
|
||||||
setMeasureFunction(measureFunc) {
|
setDebugMode(enabled) {
|
||||||
this.measureText = measureFunc;
|
// Use parent's updateConfig method
|
||||||
}
|
this.updateConfig({ debugMode: enabled });
|
||||||
|
console.log(`Paragraph Layout: Debug mode ${enabled ? 'enabled' : 'disabled'}`);
|
||||||
/**
|
|
||||||
* Set a new Knuth and Plass algorithm implementation
|
|
||||||
* @param {Function} kapFunc - The new KAP algorithm function
|
|
||||||
*/
|
|
||||||
setKapAlgorithm(kapFunc) {
|
|
||||||
this.kapAlgorithm = kapFunc;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+402
-226
@@ -11,35 +11,71 @@ class PersistenceManagerModule extends BaseModule {
|
|||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
super('persistence-manager', 'Persistence Manager');
|
super('persistence-manager', 'Persistence Manager');
|
||||||
this.storage = window.localStorage;
|
|
||||||
this.stateKey = 'ai_fiction_state';
|
// Storage keys
|
||||||
this.prefsKey = 'ai_fiction_prefs';
|
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
|
// Default preferences
|
||||||
this.defaultPreferences = {
|
this.defaultPreferences = {
|
||||||
|
animation: {
|
||||||
|
enabled: true,
|
||||||
|
speed: 50 // 0-100 scale, 50 is default
|
||||||
|
},
|
||||||
tts: {
|
tts: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
provider: 'browser', // 'browser', 'kokoro', 'elevenlabs'
|
provider: 'browser', // 'browser', 'api', 'kokoro'
|
||||||
voice: '',
|
voice: '',
|
||||||
volume: 1.0
|
volume: 1.0,
|
||||||
|
rate: 1.0,
|
||||||
|
language: 'en-us' // Default language, will be updated during initialization
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
masterVolume: 1.0,
|
masterVolume: 1.0,
|
||||||
musicVolume: 0.7,
|
musicVolume: 0.7,
|
||||||
sfxVolume: 1.0
|
sfxVolume: 1.0,
|
||||||
},
|
musicEnabled: true,
|
||||||
animation: {
|
sfxEnabled: true
|
||||||
speed: 50, // 0-100 scale
|
|
||||||
fastForwardKey: ' ' // Space key
|
|
||||||
},
|
},
|
||||||
accessibility: {
|
accessibility: {
|
||||||
highContrast: false,
|
highContrast: false,
|
||||||
largerText: false
|
largerText: false
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
locale: 'en-us',
|
||||||
|
theme: 'default'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Current preferences (will be loaded from storage)
|
// Bind methods
|
||||||
this.preferences = { ...this.defaultPreferences };
|
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() {
|
async initialize() {
|
||||||
try {
|
try {
|
||||||
// Test storage availability
|
this.reportProgress(10, "Initializing persistence manager");
|
||||||
this.storage = this.getStorageObject();
|
|
||||||
|
|
||||||
// Load preferences automatically
|
// Load preferences first (with default language settings)
|
||||||
this.loadPreferences();
|
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");
|
this.reportProgress(100, "Persistence manager ready");
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error initializing persistence manager:", error);
|
console.error("Error initializing persistence manager:", error);
|
||||||
// Continue without persistence rather than failing
|
this.reportProgress(100, "Persistence manager failed");
|
||||||
return true;
|
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
|
* 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) {
|
saveGameState(state) {
|
||||||
if (!this.storage) {
|
if (!state) return false;
|
||||||
console.warn('No storage available, game state not saved.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stateString = JSON.stringify(state);
|
this.gameState = state;
|
||||||
this.storage.setItem(this.stateKey, stateString);
|
localStorage.setItem(this.keys.gameState, JSON.stringify(state));
|
||||||
console.log('Game state saved successfully.');
|
|
||||||
|
// Dispatch event
|
||||||
|
this.dispatchEvent('game-state-saved', {
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving game state:', error);
|
console.error("Error saving game state:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the saved game state
|
* Load the current game state
|
||||||
* @returns {Object|null} The loaded state or null if no state exists
|
* @returns {Object|null} - Loaded game state or null if not found
|
||||||
*/
|
*/
|
||||||
loadState() {
|
loadGameState() {
|
||||||
if (!this.storage) {
|
|
||||||
console.warn('No storage available, cannot load game state.');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stateString = this.storage.getItem(this.stateKey);
|
const stateJson = localStorage.getItem(this.keys.gameState);
|
||||||
if (!stateString) {
|
if (!stateJson) return null;
|
||||||
console.info('No saved game state found.');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = JSON.parse(stateString);
|
this.gameState = JSON.parse(stateJson);
|
||||||
console.log('Game state loaded successfully.');
|
return this.gameState;
|
||||||
return state;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading game state:', error);
|
console.error("Error loading game state:", error);
|
||||||
return null;
|
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
|
* Save user preferences
|
||||||
* @param {Object} [preferences] - Preferences to save (defaults to current preferences)
|
* @returns {boolean} - Success status
|
||||||
* @returns {boolean} Whether preferences were successfully saved
|
|
||||||
*/
|
*/
|
||||||
savePreferences(preferences = null) {
|
savePreferences() {
|
||||||
if (!this.storage) {
|
|
||||||
console.warn('No storage available, preferences not saved.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use provided preferences or current preferences
|
|
||||||
const prefsToSave = preferences || this.preferences;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prefsString = JSON.stringify(prefsToSave);
|
localStorage.setItem(this.keys.preferences, JSON.stringify(this.preferences));
|
||||||
this.storage.setItem(this.prefsKey, prefsString);
|
|
||||||
console.log('Preferences saved successfully.');
|
|
||||||
|
|
||||||
// Update current preferences
|
// Dispatch event
|
||||||
if (preferences) {
|
this.dispatchEvent('preferences-saved', {
|
||||||
this.preferences = { ...this.preferences, ...preferences };
|
timestamp: new Date().toISOString()
|
||||||
}
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving preferences:', error);
|
console.error("Error saving preferences:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load user preferences
|
* 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() {
|
loadPreferences() {
|
||||||
if (!this.storage) {
|
|
||||||
console.warn('No storage available, using default preferences.');
|
|
||||||
return { ...this.defaultPreferences };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prefsString = this.storage.getItem(this.prefsKey);
|
const prefsJson = localStorage.getItem(this.keys.preferences);
|
||||||
if (!prefsString) {
|
|
||||||
console.info('No saved preferences found, using defaults.');
|
if (prefsJson) {
|
||||||
this.preferences = { ...this.defaultPreferences };
|
// Parse stored preferences
|
||||||
return this.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;
|
return this.preferences;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading preferences:', error);
|
console.error("Error loading preferences:", error);
|
||||||
this.preferences = { ...this.defaultPreferences };
|
|
||||||
|
// Fall back to default preferences
|
||||||
|
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
|
||||||
return this.preferences;
|
return this.preferences;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge loaded preferences with default values to ensure all fields exist
|
* Merge stored preferences with defaults to ensure all keys exist
|
||||||
* @param {Object} loaded - The loaded preferences
|
* @param {Object} stored - Stored preferences
|
||||||
* @param {Object} defaults - The default preferences
|
* @param {Object} defaults - Default preferences
|
||||||
* @returns {Object} Merged preferences
|
* @returns {Object} - Merged preferences
|
||||||
* @private
|
|
||||||
*/
|
*/
|
||||||
mergeWithDefaults(loaded, defaults) {
|
mergeWithDefaults(stored, defaults) {
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|
||||||
// Start with defaults
|
// For each category in defaults
|
||||||
for (const key in defaults) {
|
for (const category in defaults) {
|
||||||
if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) {
|
result[category] = {};
|
||||||
// Recurse for nested objects
|
|
||||||
if (loaded && loaded[key]) {
|
// Copy all settings from defaults for this category
|
||||||
result[key] = this.mergeWithDefaults(loaded[key], defaults[key]);
|
for (const setting in defaults[category]) {
|
||||||
} else {
|
// Use stored value if it exists, otherwise use default
|
||||||
result[key] = { ...defaults[key] };
|
result[category][setting] = (stored[category] && stored[category][setting] !== undefined)
|
||||||
|
? stored[category][setting]
|
||||||
|
: defaults[category][setting];
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Use loaded value if available, otherwise default
|
// Copy any additional settings from stored that aren't in defaults
|
||||||
result[key] = (loaded && loaded[key] !== undefined) ? loaded[key] : defaults[key];
|
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
|
* Get a specific preference
|
||||||
* @param {string} category - The preference category (e.g., 'tts', 'audio')
|
* @param {string} category - Preference category
|
||||||
* @param {string} setting - The specific setting name
|
* @param {string} setting - Preference setting
|
||||||
* @param {any} value - The new value
|
* @param {*} defaultValue - Default value if preference not found
|
||||||
* @param {boolean} [saveImmediately=true] - Whether to save immediately
|
* @returns {*} - Preference value
|
||||||
*/
|
|
||||||
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
|
|
||||||
*/
|
*/
|
||||||
getPreference(category, setting, defaultValue = null) {
|
getPreference(category, setting, defaultValue = null) {
|
||||||
// Check if category exists
|
if (!this.preferences) {
|
||||||
if (!this.preferences[category]) {
|
this.loadPreferences();
|
||||||
return defaultValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if setting exists in category
|
if (this.preferences[category] && this.preferences[category][setting] !== undefined) {
|
||||||
if (this.preferences[category].hasOwnProperty(setting)) {
|
|
||||||
return this.preferences[category][setting];
|
return this.preferences[category][setting];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If default value provided, use it
|
||||||
|
if (defaultValue !== null) {
|
||||||
return defaultValue;
|
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
|
* Reset preferences to defaults
|
||||||
* @param {string} [category] - Optional category to reset (resets all if not specified)
|
* @returns {boolean} - Success status
|
||||||
* @param {boolean} [saveImmediately=true] - Whether to save immediately
|
|
||||||
*/
|
*/
|
||||||
resetPreferences(category = null, saveImmediately = true) {
|
resetPreferences() {
|
||||||
if (category) {
|
try {
|
||||||
// Reset only specified category
|
// Clone default preferences
|
||||||
if (this.defaultPreferences[category]) {
|
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
|
||||||
this.preferences[category] = { ...this.defaultPreferences[category] };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Reset all preferences
|
|
||||||
this.preferences = { ...this.defaultPreferences };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save if requested
|
// Save preferences
|
||||||
if (saveImmediately) {
|
this.savePreferences();
|
||||||
return this.savePreferences();
|
|
||||||
}
|
// Dispatch event
|
||||||
|
this.dispatchEvent('preferences-reset', {
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error resetting preferences:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all preferences
|
* Get all preferences
|
||||||
* @returns {Object} The current preferences
|
* @returns {Object} - All preferences
|
||||||
*/
|
*/
|
||||||
getAllPreferences() {
|
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 the module
|
||||||
export { PersistenceManager };
|
export { PersistenceManager };
|
||||||
|
|
||||||
// Keep a reference in window for loader system
|
|
||||||
window.PersistenceManager = PersistenceManager;
|
|
||||||
|
|||||||
+44
-73
@@ -8,38 +8,38 @@ import { moduleRegistry } from './module-registry.js';
|
|||||||
class SocketClientModule extends BaseModule {
|
class SocketClientModule extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('socket-client', 'Socket Client');
|
super('socket-client', 'Socket Client');
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
this.dependencies = ['text-buffer'];
|
||||||
|
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
this.textBuffer = null;
|
this.textBuffer = null;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.maxReconnectAttempts = 5;
|
this.maxReconnectAttempts = 5;
|
||||||
this.reconnectDelay = 2000; // 2 seconds
|
this.reconnectDelay = 2000;
|
||||||
this.url = null;
|
this.url = null;
|
||||||
this.eventListeners = {};
|
this.eventListeners = {};
|
||||||
this.defaultHost = 'localhost:3000'; // Default to localhost:3000 if not running in same origin
|
this.defaultHost = 'localhost:3000';
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Bind methods using parent's bindMethods utility
|
||||||
* Load module dependencies
|
this.bindMethods([
|
||||||
* @returns {Promise} - Resolves when dependencies are loaded
|
'connect',
|
||||||
*/
|
'disconnect',
|
||||||
async loadDependencies() {
|
'send',
|
||||||
try {
|
'sendCommand',
|
||||||
// We depend on the text-buffer module
|
'requestStartGame',
|
||||||
this.reportProgress(30, "Waiting for text buffer");
|
'requestSaveGame',
|
||||||
|
'requestLoadGame',
|
||||||
// Dynamically load Socket.IO client if not already loaded
|
'on',
|
||||||
if (!window.io) {
|
'off',
|
||||||
this.reportProgress(40, "Loading Socket.IO client");
|
'emitEvent',
|
||||||
await this.loadSocketIO();
|
'setupGameEventHandlers',
|
||||||
this.reportProgress(45, "Socket.IO client loaded");
|
'processTextFragment',
|
||||||
}
|
'attemptReconnect',
|
||||||
|
'getConnectionStatus',
|
||||||
return true;
|
'loadSocketIO'
|
||||||
} catch (error) {
|
]);
|
||||||
console.error("Error loading Socket Client dependencies:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,54 +47,8 @@ class SocketClientModule extends BaseModule {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
loadSocketIO() {
|
loadSocketIO() {
|
||||||
return new Promise((resolve, reject) => {
|
// Use parent's loadScript method
|
||||||
// Check if Socket.IO is already loaded
|
return this.loadScript('/socket.io/socket.io.js');
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,8 +57,25 @@ class SocketClientModule extends BaseModule {
|
|||||||
*/
|
*/
|
||||||
async initialize() {
|
async initialize() {
|
||||||
try {
|
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
|
// Use the current origin for the socket connection
|
||||||
// This automatically handles the Docker port mapping situation
|
|
||||||
const currentUrl = window.location.origin;
|
const currentUrl = window.location.origin;
|
||||||
|
|
||||||
console.log(`Socket Client: Using origin for connection: ${currentUrl}`);
|
console.log(`Socket Client: Using origin for connection: ${currentUrl}`);
|
||||||
|
|||||||
+124
-12
@@ -9,9 +9,23 @@ class TextBufferModule extends BaseModule {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super('text-buffer', 'Text Buffer');
|
super('text-buffer', 'Text Buffer');
|
||||||
this.buffer = '';
|
this.buffer = '';
|
||||||
this.sentenceEndRegex = /[.!?]\s+/g; // Detect sentence endings
|
this.sentenceEndRegex = /[.!?]\s+/g;
|
||||||
this.onSentenceReadyCallback = null; // Callback for complete sentences
|
this.onSentenceReadyCallback = null;
|
||||||
this.processingLock = false; // Lock to prevent concurrent processing
|
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() {
|
async initialize() {
|
||||||
try {
|
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");
|
this.reportProgress(100, "Text buffer ready");
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -36,6 +66,11 @@ class TextBufferModule extends BaseModule {
|
|||||||
if (typeof callback === 'function') {
|
if (typeof callback === 'function') {
|
||||||
this.onSentenceReadyCallback = callback;
|
this.onSentenceReadyCallback = callback;
|
||||||
console.log("Text Buffer: Sentence ready callback set");
|
console.log("Text Buffer: Sentence ready callback set");
|
||||||
|
|
||||||
|
// Process any queued text immediately
|
||||||
|
if (this.processingQueue.length > 0 && !this.isProcessingActive) {
|
||||||
|
this.processNextFromQueue();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn("Text Buffer: Invalid sentence ready callback provided");
|
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 ? '...' : ''}"`);
|
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
|
// Add text to buffer
|
||||||
this.buffer += text;
|
this.buffer += text;
|
||||||
|
|
||||||
@@ -89,14 +148,22 @@ class TextBufferModule extends BaseModule {
|
|||||||
if (!foundSentence) {
|
if (!foundSentence) {
|
||||||
// No complete sentences yet
|
// No complete sentences yet
|
||||||
this.processingLock = false;
|
this.processingLock = false;
|
||||||
|
this.isProcessingActive = false;
|
||||||
|
|
||||||
|
// Use parent's dispatchEvent method
|
||||||
|
super.dispatchEvent('buffer:waiting', {
|
||||||
|
remainingText: this.buffer,
|
||||||
|
queueLength: this.processingQueue.length
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each complete sentence
|
// Process the next complete sentence
|
||||||
this.processNextSentence();
|
this.processNextSentence();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing sentences:", error);
|
console.error("Error processing sentences:", error);
|
||||||
this.processingLock = false;
|
this.processingLock = false;
|
||||||
|
this.isProcessingActive = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +188,7 @@ class TextBufferModule extends BaseModule {
|
|||||||
if (endIndex === -1) {
|
if (endIndex === -1) {
|
||||||
// No complete sentence found
|
// No complete sentence found
|
||||||
this.processingLock = false;
|
this.processingLock = false;
|
||||||
|
this.isProcessingActive = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,24 +199,52 @@ class TextBufferModule extends BaseModule {
|
|||||||
|
|
||||||
console.log(`TextBuffer: Processing sentence: "${sentence.trim()}"`);
|
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
|
// Call the callback if set
|
||||||
if (this.onSentenceReadyCallback) {
|
if (this.onSentenceReadyCallback) {
|
||||||
this.onSentenceReadyCallback(sentence, () => {
|
this.onSentenceReadyCallback(sentence, () => {
|
||||||
// After processing is complete, check for more sentences
|
// 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) {
|
if (this.buffer.length > 0) {
|
||||||
|
// Use requestAnimationFrame to prevent stack overflow and ensure UI update between sentences
|
||||||
|
requestAnimationFrame(() => {
|
||||||
this.processSentences();
|
this.processSentences();
|
||||||
} else {
|
});
|
||||||
this.processingLock = false;
|
} else if (this.processingQueue.length > 0) {
|
||||||
}
|
// No more sentences in buffer but we have more text in the queue
|
||||||
}, 0);
|
requestAnimationFrame(() => {
|
||||||
|
this.isProcessingActive = false;
|
||||||
|
this.processNextFromQueue();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// No callback set, just process the next sentence
|
// All processed
|
||||||
if (this.buffer.length > 0) {
|
this.isProcessingActive = false;
|
||||||
this.processSentences();
|
super.dispatchEvent('buffer:empty', {});
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// No callback set, just release lock and continue processing
|
||||||
this.processingLock = false;
|
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() {
|
clear() {
|
||||||
this.buffer = '';
|
this.buffer = '';
|
||||||
|
this.processingQueue = [];
|
||||||
|
this.isProcessingActive = false;
|
||||||
|
this.processingLock = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,6 +266,19 @@ class TextBufferModule extends BaseModule {
|
|||||||
getBuffer() {
|
getBuffer() {
|
||||||
return this.buffer;
|
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
|
// Create the singleton instance
|
||||||
|
|||||||
+207
-199
@@ -4,40 +4,118 @@
|
|||||||
*/
|
*/
|
||||||
import { BaseModule } from './base-module.js';
|
import { BaseModule } from './base-module.js';
|
||||||
import { moduleRegistry } from './module-registry.js';
|
import { moduleRegistry } from './module-registry.js';
|
||||||
|
import Hyphenopoly from './hyphenopoly.module.js';
|
||||||
|
|
||||||
class TextProcessorModule extends BaseModule {
|
class TextProcessorModule extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('text-processor', 'Text Processor');
|
super('text-processor', 'Text Processor');
|
||||||
this.smartyPants = null; // Store the function reference here
|
this.smartyPants = null;
|
||||||
this.smartypantsu = null; // Store the function reference here
|
this.smartypantsu = null;
|
||||||
this.hyphenator = null; // For hyphenation function
|
this.hyphenator = null;
|
||||||
this.hyphenatorReady = false;
|
this.hyphenatorReady = false;
|
||||||
this.locale = 'en-us';
|
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
|
* @returns {Promise<boolean>} - Resolves with success status
|
||||||
*/
|
*/
|
||||||
async loadDependencies() {
|
async initialize() {
|
||||||
try {
|
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();
|
await this.loadSmartyPantsScript();
|
||||||
this.reportProgress(50, "SmartyPants loaded");
|
|
||||||
|
|
||||||
// Initialize hyphenation in the background, but don't wait for it
|
// Verify SmartyPants is properly loaded
|
||||||
this.initializeHyphenation();
|
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;
|
return true;
|
||||||
} catch (error) {
|
} 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;
|
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
|
* Load the SmartyPants script dynamically and wait for it to be ready
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
@@ -53,229 +131,142 @@ class TextProcessorModule extends BaseModule {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the script using a script tag
|
// Create script element
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = '/js/smartypants.js';
|
script.type = 'text/javascript';
|
||||||
script.async = false; // Load synchronously relative to other scripts
|
script.src = '/js/smartypants.js'; // Use relative URL
|
||||||
|
script.async = true;
|
||||||
|
|
||||||
|
// Set up load and error handlers
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
// Use a microtask to ensure the script has executed
|
|
||||||
Promise.resolve().then(() => {
|
|
||||||
if (typeof window.SmartyPants === 'object' && typeof window.SmartyPants.smartypants === 'function') {
|
if (typeof window.SmartyPants === 'object' && typeof window.SmartyPants.smartypants === 'function') {
|
||||||
this.smartyPants = window.SmartyPants.smartypants;
|
this.smartyPants = window.SmartyPants.smartypants;
|
||||||
this.smartypantsu = window.SmartyPants.smartypantsu;
|
this.smartypantsu = window.SmartyPants.smartypantsu;
|
||||||
console.log("SmartyPants loaded successfully via script tag");
|
console.log("SmartyPants loaded successfully");
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
console.error("SmartyPants script loaded but functions not found on window.SmartyPants");
|
const error = new Error('SmartyPants loaded but functions not found');
|
||||||
reject(new Error('SmartyPants functions not found after loading'));
|
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) => {
|
script.onerror = (error) => {
|
||||||
console.error('Failed to load Hyphenopoly:', error);
|
console.error("Error loading SmartyPants script:", error);
|
||||||
document.dispatchEvent(new CustomEvent('hyphenation-error', {
|
reject(new Error('Failed to load SmartyPants script'));
|
||||||
detail: { error: 'Failed to load Hyphenopoly script' }
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add script to document
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
// Set up configuration for Hyphenopoly
|
|
||||||
window.Hyphenopoly = {
|
|
||||||
require: {
|
|
||||||
'en-us': 'FORCEHYPHENATION'
|
|
||||||
},
|
|
||||||
paths: {
|
|
||||||
maindir: '/js/',
|
|
||||||
patterndir: '/js/patterns/'
|
|
||||||
},
|
|
||||||
setup: {
|
|
||||||
selectors: {
|
|
||||||
'.hyphenate': {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up Hyphenopoly when it's available
|
* Initialize hyphenation using Hyphenopoly module
|
||||||
|
* @returns {Promise<boolean>} - Resolves when hyphenation is initialized
|
||||||
*/
|
*/
|
||||||
setupHyphenopoly() {
|
initializeHyphenation() {
|
||||||
// Wait for hyphenator to be available
|
return new Promise((resolve, reject) => {
|
||||||
if (window.Hyphenopoly && window.Hyphenopoly.hyphenators) {
|
try {
|
||||||
// Get hyphenator for English
|
console.log("Initializing hyphenation with Hyphenopoly module");
|
||||||
window.Hyphenopoly.hyphenators['en-us'].then((hyphenator) => {
|
|
||||||
console.log('Hyphenator ready');
|
// 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.hyphenator = hyphenator;
|
||||||
this.hyphenatorReady = true;
|
this.hyphenatorReady = true;
|
||||||
|
console.log(`Hyphenator ready for ${this.locale}`);
|
||||||
|
|
||||||
// Dispatch event that hyphenation is ready
|
// Dispatch event that hyphenation is ready
|
||||||
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
|
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
|
||||||
}).catch(err => {
|
resolve(true); // Successfully initialized
|
||||||
console.error('Error loading hyphenator:', err);
|
})
|
||||||
});
|
.catch(error => {
|
||||||
|
console.error(`Failed to initialize hyphenator for ${this.locale}:`, error);
|
||||||
|
// Try to fall back to en-us if the current locale failed
|
||||||
|
if (this.locale !== 'en-us') {
|
||||||
|
console.log("Falling back to en-us hyphenation");
|
||||||
|
return hyphenatorPromise.get('en-us');
|
||||||
}
|
}
|
||||||
}
|
throw error;
|
||||||
|
})
|
||||||
/**
|
.then(fallbackHyphenator => {
|
||||||
* Set the hyphenator function
|
if (fallbackHyphenator) {
|
||||||
* @param {Function} hyphenatorFunc - The hyphenator function
|
this.hyphenator = fallbackHyphenator;
|
||||||
*/
|
|
||||||
setHyphenator(hyphenatorFunc) {
|
|
||||||
if (typeof hyphenatorFunc === 'function') {
|
|
||||||
this.hyphenator = hyphenatorFunc;
|
|
||||||
this.hyphenatorReady = true;
|
this.hyphenatorReady = true;
|
||||||
console.log("Hyphenator function set explicitly");
|
console.log("Using fallback en-us hyphenator");
|
||||||
} else {
|
|
||||||
console.warn("Invalid hyphenator provided");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Dispatch event that hyphenation is ready
|
||||||
* Process text with SmartyPants and optional hyphenation
|
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
|
||||||
* @param {string} text - The text to process
|
resolve(true); // Successfully initialized with fallback
|
||||||
* @param {boolean} useHyphenation - Whether to apply hyphenation
|
|
||||||
* @returns {string} - The processed text
|
|
||||||
*/
|
|
||||||
process(text, useHyphenation = false) {
|
|
||||||
if (!text) return '';
|
|
||||||
|
|
||||||
let processed = text;
|
|
||||||
|
|
||||||
// Apply SmartyPants for typographic punctuation using stored references
|
|
||||||
try {
|
|
||||||
if (typeof this.smartyPants === 'function') {
|
|
||||||
processed = this.smartyPants(processed);
|
|
||||||
} else {
|
|
||||||
console.warn("SmartyPants function not available for processing");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert HTML entities to UTF-8 characters
|
|
||||||
if (typeof this.smartypantsu === 'function') {
|
|
||||||
processed = this.smartypantsu(processed);
|
|
||||||
} else {
|
|
||||||
console.warn("smartypantsu function not available for processing");
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error("Failed to initialize hyphenation even with fallback:", error);
|
||||||
|
reject(error); // Failed to initialize
|
||||||
|
});
|
||||||
} catch (error) {
|
} 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
|
* Check if hyphenation is available
|
||||||
* @returns {boolean} - Whether hyphenation is available
|
* @returns {boolean} - True if hyphenation is available
|
||||||
*/
|
*/
|
||||||
isHyphenationAvailable() {
|
isHyphenationAvailable() {
|
||||||
return this.hyphenatorReady && this.hyphenator !== null;
|
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
|
* @param {string} text - The text to hyphenate
|
||||||
* @returns {string} - The hyphenated text
|
* @returns {string} - The hyphenated text
|
||||||
*/
|
*/
|
||||||
hyphenate(text) {
|
hyphenate(text) {
|
||||||
if (!text || !this.hyphenatorReady || !this.hyphenator) {
|
if (!this.isHyphenationAvailable()) {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,16 +279,33 @@ class TextProcessorModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the locale for text processing
|
* Process text with typography enhancements
|
||||||
* @param {string} locale - The locale code (e.g., 'en-us', 'de')
|
* @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) {
|
process(text, options = {}) {
|
||||||
if (locale && typeof locale === 'string') {
|
const opts = {
|
||||||
this.locale = locale.toLowerCase();
|
smartypants: true,
|
||||||
// Update global locale for SmartyPants
|
hyphenate: true,
|
||||||
window.locale = this.locale;
|
...options
|
||||||
console.log(`TextProcessor: Locale set to ${locale}`);
|
};
|
||||||
|
|
||||||
|
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
@@ -1,555 +1,401 @@
|
|||||||
/**
|
/**
|
||||||
* TTS Factory for AI Interactive Fiction
|
* TTS Factory Module
|
||||||
* Manages different TTS implementations with a common interface
|
* 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() {
|
constructor() {
|
||||||
this.ttsHandler = null;
|
super('tts-factory', 'TTS Factory');
|
||||||
|
|
||||||
|
// Available TTS handlers
|
||||||
this.handlers = {};
|
this.handlers = {};
|
||||||
this.initializationAttempted = false;
|
|
||||||
this.initializationPromise = null;
|
|
||||||
this.ttsEnabled = true;
|
|
||||||
this.progressCallback = null;
|
|
||||||
this.persistenceManager = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Current active handler
|
||||||
* Initialize the TTS Factory - Static method for the module loader
|
this.activeHandler = null;
|
||||||
* @param {Function} reportProgress - Function to report loading progress to the loader
|
|
||||||
* @returns {Promise} - Resolves when TTS is initialized
|
|
||||||
*/
|
|
||||||
static async initializeInterface(reportProgress = null) {
|
|
||||||
console.log('TTS Factory: Initializing interface');
|
|
||||||
|
|
||||||
// Create singleton instance if needed
|
// Handler initialization status
|
||||||
if (!window.ttsFactory) {
|
this.initStatus = {
|
||||||
window.ttsFactory = new TTSFactory();
|
browser: false,
|
||||||
}
|
api: false,
|
||||||
|
kokoro: false
|
||||||
// Initialize TTS with the progress callback
|
|
||||||
window.ttsFactory.progressCallback = reportProgress;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Start initialization process
|
|
||||||
await window.ttsFactory.initialize();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing TTS Factory:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the TTS Factory
|
|
||||||
* This will load and initialize all available TTS handlers
|
|
||||||
* @returns {Promise} - Resolves when initialization is complete
|
|
||||||
*/
|
|
||||||
async initialize() {
|
|
||||||
if (this.initializationPromise) {
|
|
||||||
return this.initializationPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.initializationPromise = new Promise(async (resolve) => {
|
|
||||||
this.initializationAttempted = true;
|
|
||||||
|
|
||||||
const reportProgress = (percent, message) => {
|
|
||||||
console.log(`TTS progress: ${percent}% - ${message}`);
|
|
||||||
if (this.progressCallback && typeof this.progressCallback === 'function') {
|
|
||||||
this.progressCallback(percent, message);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
// TTS availability flag
|
||||||
// Report starting initialization
|
this.ttsAvailable = false;
|
||||||
reportProgress(10, 'Loading TTS modules');
|
|
||||||
|
|
||||||
// Get persistence manager if available
|
// Bind methods
|
||||||
if (window.PersistenceManager) {
|
this.bindMethods([
|
||||||
this.persistenceManager = window.PersistenceManager;
|
'registerHandler',
|
||||||
reportProgress(15, 'Persistence manager found, loading preferences');
|
'initializeHandler',
|
||||||
|
'getHandler',
|
||||||
// Load preferences to determine TTS enabled state and preferred provider
|
'setActiveHandler',
|
||||||
const prefs = this.persistenceManager.getAllPreferences();
|
'getActiveHandler',
|
||||||
if (prefs && prefs.tts) {
|
'getAvailableHandlers',
|
||||||
this.ttsEnabled = prefs.tts.enabled;
|
'speak',
|
||||||
console.log(`TTS Factory: Setting initial TTS enabled state to ${this.ttsEnabled ? 'enabled' : 'disabled'} from preferences`);
|
'stop',
|
||||||
}
|
'pause',
|
||||||
}
|
'resume',
|
||||||
|
'getVoices',
|
||||||
// Import needed modules dynamically
|
'getPreference'
|
||||||
const [{ BrowserTTSHandler }, { KokoroHandler }, { ApiTTSHandler }] = await Promise.all([
|
|
||||||
import('./browser-tts-handler.js'),
|
|
||||||
import('./kokoro-handler.js'),
|
|
||||||
import('./api-tts-handler.js')
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
reportProgress(20, 'TTS modules loaded');
|
// Add dependencies
|
||||||
|
this.dependencies = ['persistence-manager', 'localization'];
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply stored voice settings from preferences
|
* Initialize the module
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
applyVoiceSettingsFromPreferences() {
|
|
||||||
if (!this.ttsHandler || !this.persistenceManager) return;
|
|
||||||
|
|
||||||
const prefs = this.persistenceManager.getAllPreferences();
|
|
||||||
if (prefs && prefs.tts) {
|
|
||||||
if (prefs.tts.voice) {
|
|
||||||
console.log(`TTS Factory: Setting voice to ${prefs.tts.voice} from preferences`);
|
|
||||||
// Check if setVoice exists, otherwise try setting through voiceOptions
|
|
||||||
if (typeof this.ttsHandler.setVoice === 'function') {
|
|
||||||
this.ttsHandler.setVoice(prefs.tts.voice);
|
|
||||||
} else if (typeof this.ttsHandler.setVoiceOptions === 'function') {
|
|
||||||
this.ttsHandler.setVoiceOptions({ voice: prefs.tts.voice });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prefs.tts.rate !== undefined) {
|
|
||||||
console.log(`TTS Factory: Setting speech rate to ${prefs.tts.rate} from preferences`);
|
|
||||||
// Check if setSpeed exists, otherwise try setting through voiceOptions
|
|
||||||
if (typeof this.ttsHandler.setSpeed === 'function') {
|
|
||||||
this.ttsHandler.setSpeed(prefs.tts.rate);
|
|
||||||
} else if (typeof this.ttsHandler.setVoiceOptions === 'function') {
|
|
||||||
this.ttsHandler.setVoiceOptions({ rate: prefs.tts.rate });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prefs.tts.volume !== undefined && typeof this.ttsHandler.setVolume === 'function') {
|
|
||||||
console.log(`TTS Factory: Setting volume to ${prefs.tts.volume} from preferences`);
|
|
||||||
this.ttsHandler.setVolume(prefs.tts.volume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize browser TTS
|
|
||||||
* @param {BrowserTTSHandler} handler - The browser TTS handler
|
|
||||||
* @param {Function} reportProgress - Progress reporting function
|
|
||||||
* @returns {Promise<boolean>} - Resolves with availability status
|
|
||||||
*/
|
|
||||||
async initializeBrowserTTS(handler, reportProgress) {
|
|
||||||
reportProgress(30, 'Initializing browser TTS');
|
|
||||||
const browserAvailable = await handler.initialize();
|
|
||||||
|
|
||||||
if (browserAvailable) {
|
|
||||||
this.ttsHandler = handler;
|
|
||||||
this.dispatchTTSReadyEvent(true, 'browser', handler);
|
|
||||||
reportProgress(40, 'Browser TTS ready');
|
|
||||||
} else {
|
|
||||||
reportProgress(40, 'Browser TTS not available');
|
|
||||||
}
|
|
||||||
|
|
||||||
return browserAvailable;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize API TTS
|
|
||||||
* @param {ApiTTSHandler} handler - The API TTS handler
|
|
||||||
* @param {Function} reportProgress - Progress reporting function
|
|
||||||
* @returns {Promise<boolean>} - Resolves with availability status
|
|
||||||
*/
|
|
||||||
async initializeApiTTS(handler, reportProgress) {
|
|
||||||
reportProgress(50, 'Initializing API TTS');
|
|
||||||
const apiAvailable = await handler.initialize();
|
|
||||||
|
|
||||||
if (apiAvailable) {
|
|
||||||
this.ttsHandler = handler;
|
|
||||||
this.dispatchTTSReadyEvent(true, 'api', handler);
|
|
||||||
reportProgress(70, 'API TTS ready');
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiAvailable;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get preferred TTS mode from storage
|
|
||||||
* @returns {string|null} - Preferred TTS mode or null if not set
|
|
||||||
*/
|
|
||||||
getPreferredTTSMode() {
|
|
||||||
// First check persistent settings if available
|
|
||||||
if (this.persistenceManager) {
|
|
||||||
const prefs = this.persistenceManager.getAllPreferences();
|
|
||||||
if (prefs && prefs.tts && prefs.tts.provider) {
|
|
||||||
console.log(`TTS Factory: Using preferred TTS mode '${prefs.tts.provider}' from persistence manager`);
|
|
||||||
return prefs.tts.provider;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to localStorage if persistence manager is not available
|
|
||||||
try {
|
|
||||||
const savedMode = localStorage.getItem('preferred-tts-mode');
|
|
||||||
if (savedMode) {
|
|
||||||
console.log(`TTS Factory: Using preferred TTS mode '${savedMode}' from localStorage`);
|
|
||||||
return savedMode;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Could not read TTS preference from localStorage');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to Kokoro if no preference is found
|
|
||||||
return "kokoro";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set preferred TTS mode in storage
|
|
||||||
* @param {string} mode - The TTS mode to save as preferred
|
|
||||||
*/
|
|
||||||
setPreferredTTSMode(mode) {
|
|
||||||
// Update in persistence manager if available
|
|
||||||
if (this.persistenceManager) {
|
|
||||||
this.persistenceManager.updatePreference('tts', 'provider', mode);
|
|
||||||
console.log(`TTS Factory: Saved preferred TTS mode '${mode}' to persistence manager`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also save to localStorage as backup
|
|
||||||
try {
|
|
||||||
localStorage.setItem('preferred-tts-mode', mode);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Could not save TTS preference to localStorage');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule Kokoro initialization during idle time
|
|
||||||
* @param {Object} kokoroHandler - The Kokoro handler instance
|
|
||||||
* @param {Function} reportProgress - Progress reporting function
|
|
||||||
* @returns {Promise<boolean>} - Resolves with success status
|
* @returns {Promise<boolean>} - Resolves with success status
|
||||||
*/
|
*/
|
||||||
scheduleKokoroInitialization(kokoroHandler, reportProgress) {
|
async initialize() {
|
||||||
// Immediately dispatch the loading started event so tts-player can catch it
|
|
||||||
window.dispatchEvent(new CustomEvent('kokoro-loading-started'));
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
// Create the initialization function
|
|
||||||
const startKokoroInit = async () => {
|
|
||||||
try {
|
try {
|
||||||
// Initialize Kokoro with progress callback
|
this.reportProgress(10, "Initializing TTS factory");
|
||||||
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}%`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark completion
|
// Get dependencies
|
||||||
if (kokoroAvailable) {
|
const persistenceManager = this.getModule('persistence-manager');
|
||||||
reportProgress(95, "Kokoro TTS initialized successfully");
|
const localization = this.getModule('localization');
|
||||||
} else {
|
|
||||||
reportProgress(95, "Kokoro TTS unavailable - using fallback");
|
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
|
// Register available handlers
|
||||||
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
|
this.registerHandler('browser', new BrowserTTSHandler());
|
||||||
detail: { success: kokoroAvailable }
|
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) {
|
} catch (error) {
|
||||||
console.error('Error initializing Kokoro:', error);
|
console.error("Error initializing TTS factory:", error);
|
||||||
reportProgress(95, 'Kokoro TTS failed to initialize - using fallback');
|
this.reportProgress(100, "TTS factory failed");
|
||||||
|
|
||||||
// Dispatch completion event with error information
|
// Set TTS availability to false and dispatch event
|
||||||
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
|
this.ttsAvailable = false;
|
||||||
detail: { success: false, error: error.message }
|
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(() => {
|
* Register a TTS handler
|
||||||
reportProgress(95, 'Kokoro initialization timed out - using fallback');
|
* @param {string} id - Handler ID
|
||||||
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
|
* @param {Object} handler - TTS handler instance
|
||||||
detail: { success: false, error: "Timeout" }
|
*/
|
||||||
}));
|
registerHandler(id, handler) {
|
||||||
resolve(false);
|
if (!id || !handler) return;
|
||||||
}, 30000); // Increased timeout to 30 seconds since model loading is resource intensive
|
this.handlers[id] = handler;
|
||||||
|
}
|
||||||
|
|
||||||
// Use requestIdleCallback to start initialization during idle time
|
/**
|
||||||
if (window.requestIdleCallback) {
|
* Initialize a specific TTS handler
|
||||||
reportProgress(75, 'Scheduling Kokoro TTS for background loading');
|
* @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(() => {
|
try {
|
||||||
startKokoroInit().then(() => clearTimeout(timeoutId));
|
this.reportProgress(0, `Initializing ${id} TTS handler`);
|
||||||
}, { timeout: 10000 });
|
|
||||||
|
// 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 {
|
} else {
|
||||||
reportProgress(75, 'Background loading not available, loading Kokoro normally');
|
console.error(`TTS Factory: Failed to initialize ${id} TTS handler`);
|
||||||
|
}
|
||||||
// Use a microtask to avoid blocking the UI thread
|
|
||||||
Promise.resolve().then(() => startKokoroInit().then(() => clearTimeout(timeoutId)));
|
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
|
* Get a TTS handler by ID
|
||||||
* @param {boolean} available - Whether TTS is available
|
* @param {string} id - Handler ID
|
||||||
* @param {string} type - The type of TTS
|
* @returns {Object|null} - TTS handler instance or null if not found
|
||||||
* @param {Object} handler - The TTS handler object
|
|
||||||
*/
|
*/
|
||||||
dispatchTTSReadyEvent(available, type = null, handler = null) {
|
getHandler(id) {
|
||||||
const event = new CustomEvent('tts-ready', {
|
if (!id || !this.handlers[id]) return null;
|
||||||
detail: {
|
return this.handlers[id];
|
||||||
available,
|
|
||||||
type,
|
|
||||||
handler,
|
|
||||||
enabled: this.ttsEnabled
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get information about the active TTS system
|
* Set the active TTS handler
|
||||||
* @returns {Object} - TTS system info
|
* @param {string} id - Handler ID
|
||||||
*/
|
|
||||||
getActiveTTSInfo() {
|
|
||||||
if (!this.ttsHandler) {
|
|
||||||
return { available: false, type: 'none', name: 'None' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = this.ttsHandler.getId();
|
|
||||||
const name = {
|
|
||||||
'browser': 'Browser TTS',
|
|
||||||
'kokoro': 'Kokoro Neural TTS',
|
|
||||||
'api': 'ElevenLabs API TTS'
|
|
||||||
}[id] || 'Unknown TTS';
|
|
||||||
|
|
||||||
return {
|
|
||||||
available: true,
|
|
||||||
type: id,
|
|
||||||
name: name
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch to a specific TTS handler
|
|
||||||
* @param {string} type - The handler ID to use
|
|
||||||
* @returns {boolean} - Success status
|
* @returns {boolean} - Success status
|
||||||
*/
|
*/
|
||||||
switchTTS(type) {
|
setActiveHandler(id) {
|
||||||
if (!this.handlers[type] || !this.handlers[type].isAvailable()) {
|
if (!id || !this.handlers[id] || !this.initStatus[id]) {
|
||||||
|
console.warn(`Cannot set active handler to ${id}: handler not found or not initialized`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ttsHandler = this.handlers[type];
|
// Stop current handler if active
|
||||||
this.dispatchTTSReadyEvent(true, type, this.ttsHandler);
|
if (this.activeHandler) {
|
||||||
|
this.handlers[this.activeHandler].stop();
|
||||||
// Update preferred TTS mode
|
|
||||||
this.setPreferredTTSMode(type);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Set new active handler
|
||||||
* Speak text using the active TTS handler
|
this.activeHandler = id;
|
||||||
* @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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlerType = this.ttsHandler.getId();
|
// Update preference
|
||||||
console.log(`TTSFactory: Using ${handlerType} handler to speak "${text}"`);
|
this.getModule('persistence-manager').updatePreference('tts', 'provider', id);
|
||||||
|
|
||||||
try {
|
// Dispatch event
|
||||||
this.ttsHandler.speak(text, (result) => {
|
this.dispatchEvent('tts-handler-changed', {
|
||||||
console.log(`TTSFactory: Speech completed using ${handlerType}`, result);
|
handler: id
|
||||||
if (callback) callback(result);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
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() {
|
getActiveHandler() {
|
||||||
if (this.ttsHandler) {
|
if (!this.activeHandler) return null;
|
||||||
this.ttsHandler.stop();
|
return this.handlers[this.activeHandler];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set voice options for the active handler
|
* Get all available TTS handlers
|
||||||
* @param {Object} options - Voice options
|
* @returns {Object} - Map of handler IDs to initialization status
|
||||||
*/
|
|
||||||
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
|
|
||||||
*/
|
*/
|
||||||
getAvailableHandlers() {
|
getAvailableHandlers() {
|
||||||
const available = {};
|
const available = {};
|
||||||
|
|
||||||
Object.entries(this.handlers).forEach(([id, handler]) => {
|
for (const id in this.handlers) {
|
||||||
if (handler.isAvailable()) {
|
available[id] = this.initStatus[id];
|
||||||
available[id] = handler;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return available;
|
return available;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available voices from active handler
|
* Speak text using the active TTS handler
|
||||||
* @returns {Promise<Array>} - Array of available voices
|
* @param {string} text - Text to speak
|
||||||
|
* @param {Object} options - TTS options
|
||||||
|
* @returns {Promise<boolean>} - Success status
|
||||||
*/
|
*/
|
||||||
async getVoices() {
|
async speak(text, options = {}) {
|
||||||
if (!this.ttsHandler || typeof this.ttsHandler.getVoices !== 'function') {
|
if (!this.activeHandler) {
|
||||||
return [];
|
console.warn("No active TTS handler");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.ttsHandler.getVoices();
|
return await this.handlers[this.activeHandler].speak(text, options);
|
||||||
} catch (error) {
|
} 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 [];
|
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
|
// Create the singleton instance
|
||||||
const ttsFactory = new TTSFactory();
|
const TTSFactory = new TTSFactoryModule();
|
||||||
|
|
||||||
// Export the factory
|
// Register with the module registry
|
||||||
export { ttsFactory };
|
moduleRegistry.register(TTSFactory);
|
||||||
|
|
||||||
// Keep global reference
|
// Export the module
|
||||||
window.ttsFactory = ttsFactory;
|
export { TTSFactory };
|
||||||
@@ -107,4 +107,18 @@ export class TTSHandler {
|
|||||||
removeEventListener(eventName, callback) {
|
removeEventListener(eventName, callback) {
|
||||||
this.eventTarget.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
@@ -1,59 +1,41 @@
|
|||||||
/**
|
/**
|
||||||
* TTS Player Module for AI Interactive Fiction
|
* TTS Player Module
|
||||||
* Handles Text-to-Speech functionality with resource-aware loading and progress reporting
|
* 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';
|
import { moduleRegistry } from './module-registry.js';
|
||||||
|
|
||||||
class TTSPlayerModule extends BaseModule {
|
class TTSPlayerModule extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('tts', 'Text-to-Speech');
|
super('tts-player', 'TTS Player');
|
||||||
this.ttsFactory = null;
|
|
||||||
this.isInitialized = false;
|
|
||||||
this.kokoroLoadingPromise = null;
|
|
||||||
this.kokoroLoadingStarted = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Module dependencies
|
||||||
* Load module dependencies
|
this.dependencies = ['tts-factory'];
|
||||||
* @returns {Promise} - Resolves when dependencies are loaded
|
|
||||||
*/
|
|
||||||
async loadDependencies() {
|
|
||||||
try {
|
|
||||||
// Import the TTS Factory module
|
|
||||||
const { ttsFactory } = await import('./tts-factory.js');
|
|
||||||
this.ttsFactory = ttsFactory;
|
|
||||||
this.reportProgress(20, "TTS Factory loaded");
|
|
||||||
|
|
||||||
// Set up event listeners
|
// TTS state
|
||||||
window.addEventListener('tts-ready', this.handleTTSReadyEvent.bind(this));
|
this.enabled = true;
|
||||||
|
this.currentSpeech = null;
|
||||||
|
this.pendingCallback = null;
|
||||||
|
|
||||||
// Create a Promise that resolves when Kokoro is loaded
|
// Preloading mechanism
|
||||||
this.kokoroLoadingPromise = new Promise(resolve => {
|
this.preloadQueue = [];
|
||||||
// Listen for when Kokoro starts loading
|
this.preloadedAudio = new Map(); // Cache for preloaded TTS
|
||||||
window.addEventListener('kokoro-loading-started', () => {
|
this.isPreloading = false;
|
||||||
this.kokoroLoadingStarted = true;
|
|
||||||
this.reportProgress(50, "Loading Kokoro TTS");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for when Kokoro completes loading
|
// Bind methods using parent's bindMethods utility
|
||||||
window.addEventListener('kokoro-loading-complete', (event) => {
|
this.bindMethods([
|
||||||
// Check if loading was successful from the event details
|
'speak',
|
||||||
if (event.detail && event.detail.success === false) {
|
'preloadSpeech',
|
||||||
this.reportProgress(95, "Kokoro TTS failed to load - using fallback");
|
'processPreloadQueue',
|
||||||
console.warn("Kokoro failed to load:", event.detail?.error || "unknown error");
|
'stop',
|
||||||
} else {
|
'enable',
|
||||||
this.reportProgress(95, "Kokoro TTS loaded");
|
'isEnabled',
|
||||||
}
|
'isSpeaking',
|
||||||
resolve();
|
'setVoice',
|
||||||
});
|
'setSpeed',
|
||||||
});
|
'getVoices',
|
||||||
|
'toggle'
|
||||||
return true;
|
]);
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading TTS dependencies:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,197 +44,291 @@ class TTSPlayerModule extends BaseModule {
|
|||||||
*/
|
*/
|
||||||
async initialize() {
|
async initialize() {
|
||||||
try {
|
try {
|
||||||
// Initialize TTS Factory
|
this.reportProgress(20, "Initializing TTS Player");
|
||||||
await this.ttsFactory.constructor.initializeInterface((percent, message) => {
|
|
||||||
// Scale to 20-90% of our progress range
|
// Get TTS Factory dependency
|
||||||
const scaledPercent = 20 + (percent * 0.7);
|
const ttsFactory = this.getModule('tts-factory');
|
||||||
this.reportProgress(scaledPercent, message);
|
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
|
// Listen for TTS availability changes
|
||||||
this.reportProgress(90, "Waiting for Kokoro TTS to complete loading");
|
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
|
// If TTS becomes unavailable, disable it
|
||||||
try {
|
if (!available) {
|
||||||
// Add a timeout to prevent waiting forever
|
this.enabled = false;
|
||||||
const timeoutPromise = new Promise(resolve => setTimeout(() => {
|
// Notify UI that TTS is disabled
|
||||||
console.log("TTS Player: Kokoro loading timed out, continuing without Kokoro");
|
document.dispatchEvent(new CustomEvent('tts:stateChange', {
|
||||||
resolve(false);
|
detail: { enabled: false, available: 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");
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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
|
// Listen for sentence ready events to preload TTS
|
||||||
const ttsInfo = this.ttsFactory.getActiveTTSInfo();
|
this.addEventListener(document, 'buffer:sentence', (event) => {
|
||||||
if (ttsInfo.available) {
|
if (event.detail && event.detail.sentence && this.enabled) {
|
||||||
this.reportProgress(100, `TTS Player initialized using ${ttsInfo.name}`);
|
// 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;
|
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) {
|
} catch (error) {
|
||||||
console.error("Error initializing TTS Player:", error);
|
console.error("Error initializing TTS Player:", error);
|
||||||
this.reportProgress(100, "TTS initialization failed, continuing without TTS");
|
return false;
|
||||||
this.isInitialized = true; // Mark as initialized anyway to not block other modules
|
|
||||||
return true; // Return true to not block the application
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle TTS ready event from the factory
|
* Preload speech for a sentence
|
||||||
* @param {CustomEvent} event - The TTS ready event
|
* @param {string} text - Text to preload
|
||||||
*/
|
*/
|
||||||
handleTTSReadyEvent(event) {
|
preloadSpeech(text) {
|
||||||
const { available, type } = event.detail;
|
if (!text || !this.enabled) return;
|
||||||
|
|
||||||
if (available && type) {
|
// Don't preload if already in cache
|
||||||
this.reportProgress(95, `TTS system ready: ${type}`);
|
if (this.preloadedAudio.has(text)) return;
|
||||||
} else {
|
|
||||||
this.reportProgress(95, "No TTS system available");
|
// 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
|
* Process the preload queue
|
||||||
* @returns {boolean} - New TTS enabled state
|
|
||||||
*/
|
*/
|
||||||
toggle() {
|
async processPreloadQueue() {
|
||||||
if (!this.ttsFactory) return false;
|
if (this.preloadQueue.length === 0 || this.isPreloading) return;
|
||||||
return this.ttsFactory.toggle();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
this.isPreloading = true;
|
||||||
* Speak text using the active TTS system
|
const text = this.preloadQueue.shift();
|
||||||
* @param {string} text - Text to speak
|
|
||||||
* @param {Function} callback - Called when speech completes
|
try {
|
||||||
*/
|
// Get TTSFactory from module registry
|
||||||
speak(text, callback) {
|
const ttsFactory = this.getModule('tts-factory');
|
||||||
if (!this.ttsFactory) {
|
if (!ttsFactory) {
|
||||||
console.warn("TTS Factory not available for speak");
|
console.error("TTS Player: TTSFactory module not found in registry");
|
||||||
if (callback) callback("TTS not available");
|
this.isPreloading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`TTS Player speaking: "${text}"`);
|
// Only preload if we're not currently speaking or the text is different from current speech
|
||||||
this.ttsFactory.speak(text, (result) => {
|
if (!this.isSpeaking() || (this.currentSpeech && this.currentSpeech !== text)) {
|
||||||
console.log("TTS Player speak complete", result);
|
console.log(`TTS Player: Preloading speech for: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||||
if (callback) callback(result);
|
|
||||||
|
// 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() {
|
stop() {
|
||||||
if (this.ttsFactory) {
|
const ttsFactory = this.getModule('tts-factory');
|
||||||
this.ttsFactory.stop();
|
if (ttsFactory) {
|
||||||
|
ttsFactory.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.currentSpeech = null;
|
||||||
|
this.pendingCallback = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set voice options for the active TTS system
|
* Toggle TTS enabled state
|
||||||
* @param {Object} options - Voice options
|
|
||||||
*/
|
*/
|
||||||
setVoiceOptions(options) {
|
toggle() {
|
||||||
if (this.ttsFactory) {
|
this.enabled = !this.enabled;
|
||||||
this.ttsFactory.setVoiceOptions(options);
|
this.enable(this.enabled);
|
||||||
}
|
return this.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set speech rate/speed
|
* Enable or disable TTS
|
||||||
* @param {number} speed - Speech rate (0.5-2.0)
|
* @param {boolean} enabled - Whether TTS should be enabled
|
||||||
*/
|
*/
|
||||||
setSpeed(speed) {
|
enable(enabled) {
|
||||||
this.setVoiceOptions({ rate: speed });
|
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
|
* Check if TTS is enabled
|
||||||
* @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
|
|
||||||
* @returns {boolean} - Whether TTS is enabled
|
* @returns {boolean} - Whether TTS is enabled
|
||||||
*/
|
*/
|
||||||
isEnabled() {
|
isEnabled() {
|
||||||
if (!this.ttsFactory) return false;
|
return this.enabled;
|
||||||
return this.ttsFactory.isEnabled();
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 the module
|
||||||
export { TTSPlayer };
|
export { TTSPlayer };
|
||||||
|
|
||||||
// Keep a reference in window for loader system
|
|
||||||
window.TTSPlayer = TTSPlayer;
|
|
||||||
|
|||||||
+161
-158
@@ -4,10 +4,11 @@ import { ModuleEvent } from './base-module.js';
|
|||||||
|
|
||||||
class UIController extends BaseModule {
|
class UIController extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('ui-controller');
|
super('ui-controller', 'UI Controller');
|
||||||
|
|
||||||
// Declare dependencies on TTS, animation-queue, and our new UI modules
|
// Remove 'tts' from direct dependencies to break circular dependency
|
||||||
this.dependencies = ['tts', 'animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects'];
|
// 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
|
// References to sub-modules
|
||||||
this.displayHandler = null;
|
this.displayHandler = null;
|
||||||
@@ -32,55 +33,74 @@ class UIController extends BaseModule {
|
|||||||
|
|
||||||
// Add TTS toggle state
|
// Add TTS toggle state
|
||||||
this.ttsEnabled = false;
|
this.ttsEnabled = false;
|
||||||
|
this.ttsAvailable = true; // Add TTS availability state
|
||||||
|
|
||||||
// Bind methods that use 'this' internally or are used as callbacks/event handlers
|
// Bind methods using the parent class bindMethods utility
|
||||||
this.initialize = this.initialize.bind(this); // Bind initialize as it calls dispatchEvent
|
this.bindMethods([
|
||||||
this.handleCommand = this.handleCommand.bind(this); // Bind event handler
|
'initialize',
|
||||||
this.displayText = this.displayText.bind(this); // Bind if passed as callback
|
'handleCommand',
|
||||||
this.setupBookInterface = this.setupBookInterface.bind(this);
|
'displayText',
|
||||||
this.applyBookSizing = this.applyBookSizing.bind(this);
|
'setupBookInterface',
|
||||||
this.setupEventListeners = this.setupEventListeners.bind(this);
|
'applyBookSizing',
|
||||||
this.setupMainUI = this.setupMainUI.bind(this);
|
'setupEventListeners',
|
||||||
this.initializeTextBuffer = this.initializeTextBuffer.bind(this);
|
'setupMainUI',
|
||||||
this.showUI = this.showUI.bind(this);
|
'initializeTextBuffer',
|
||||||
this.hideUI = this.hideUI.bind(this);
|
'showUI',
|
||||||
this.clearDisplay = this.clearDisplay.bind(this);
|
'hideUI',
|
||||||
this.sendCommand = this.sendCommand.bind(this);
|
'clearDisplay',
|
||||||
this.updateButtonStates = this.updateButtonStates.bind(this);
|
'sendCommand',
|
||||||
|
'updateButtonStates'
|
||||||
// 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
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
try {
|
||||||
this.reportProgress(0, 'Initializing UI Controller');
|
this.reportProgress(0, 'Initializing UI Controller');
|
||||||
|
|
||||||
try {
|
|
||||||
this.reportProgress(20, 'Setting up book interface');
|
this.reportProgress(20, 'Setting up book interface');
|
||||||
|
|
||||||
// Set up book interface
|
// Set up book interface
|
||||||
this.setupBookInterface();
|
this.setupBookInterface();
|
||||||
|
|
||||||
this.reportProgress(30, 'Setting up UI components');
|
this.reportProgress(30, 'Getting module dependencies');
|
||||||
|
|
||||||
// Get module references
|
// Get module references using parent's getModule method
|
||||||
this.displayHandler = moduleRegistry.getModule('ui-display-handler');
|
this.displayHandler = this.getModule('ui-display-handler');
|
||||||
this.inputHandler = moduleRegistry.getModule('ui-input-handler');
|
this.inputHandler = this.getModule('ui-input-handler');
|
||||||
this.effects = moduleRegistry.getModule('ui-effects');
|
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
|
// Check for required UI modules
|
||||||
this.textBuffer = moduleRegistry.getModule('text-buffer');
|
if (!this.displayHandler) {
|
||||||
this.ttsHandler = moduleRegistry.getModule('tts');
|
console.error('UI Controller: Display handler module not found');
|
||||||
this.socketClient = moduleRegistry.getModule('socket-client');
|
return false;
|
||||||
this.animationQueue = moduleRegistry.getModule('animation-queue');
|
}
|
||||||
|
|
||||||
if (!this.displayHandler || !this.inputHandler || !this.effects) {
|
if (!this.inputHandler) {
|
||||||
console.error('UI Controller: Required UI modules not found');
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,24 +109,25 @@ class UIController extends BaseModule {
|
|||||||
// Set up event listeners between components
|
// Set up event listeners between components
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
|
|
||||||
this.reportProgress(80, 'Finalizing UI initialization');
|
this.reportProgress(70, 'Setting up main UI');
|
||||||
|
|
||||||
// Initialize main UI container
|
// Initialize main UI container
|
||||||
await this.setupMainUI();
|
await this.setupMainUI();
|
||||||
|
|
||||||
|
this.reportProgress(80, 'Initializing text buffer');
|
||||||
|
|
||||||
// Initialize text buffer handler
|
// Initialize text buffer handler
|
||||||
this.initializeTextBuffer();
|
this.initializeTextBuffer();
|
||||||
|
|
||||||
|
this.reportProgress(100, 'UI Controller ready');
|
||||||
|
|
||||||
this.isReady = true;
|
this.isReady = true;
|
||||||
this.isVisible = true;
|
this.isVisible = true;
|
||||||
this.reportProgress(100, 'UI Controller ready');
|
this.dispatchEvent(new ModuleEvent('ui:ready', { controller: this }));
|
||||||
|
|
||||||
// Start ambient effects
|
// Start ambient effects
|
||||||
this.effects.startAmbientEffects();
|
this.effects.startAmbientEffects();
|
||||||
|
|
||||||
// Use the DOM event API directly instead of this.dispatchEvent
|
|
||||||
this._dispatchModuleEvent('ui:ready', { controller: this });
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing UI Controller:', 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
|
// Listen for text display events - use arrow function to preserve context
|
||||||
document.addEventListener('ui:text:complete', () => {
|
document.addEventListener('ui:text:complete', (event) => {
|
||||||
// Use the DOM event API directly
|
console.log('UIController: Text complete event received, ready for next text');
|
||||||
this._dispatchModuleEvent('ui:ready:for:next', {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for socket connection events
|
// Listen for socket connection events
|
||||||
document.addEventListener('socket:connected', () => {
|
document.addEventListener('socket:connected', () => {
|
||||||
console.log('UI Controller: Socket connected');
|
console.log('UIController: Socket connected');
|
||||||
|
this.updateButtonStates();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('socket:disconnected', () => {
|
document.addEventListener('socket:disconnected', () => {
|
||||||
console.log('UI Controller: Socket disconnected');
|
console.log('UIController: Socket disconnected');
|
||||||
|
this.updateButtonStates();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle speed reset
|
// Listen for TTS state change events
|
||||||
const speedReset = document.getElementById('speed_reset');
|
document.addEventListener('tts:stateChange', (event) => {
|
||||||
if (speedReset) {
|
if (event.detail) {
|
||||||
speedReset.addEventListener('click', (e) => {
|
if (typeof event.detail.enabled === 'boolean') {
|
||||||
e.preventDefault();
|
this.ttsEnabled = event.detail.enabled;
|
||||||
const speedSlider = document.getElementById('speed');
|
|
||||||
if (speedSlider) {
|
|
||||||
speedSlider.value = 50;
|
|
||||||
if (this.animationQueue) {
|
|
||||||
this.animationQueue.setSpeed(1.0);
|
|
||||||
}
|
}
|
||||||
|
if (typeof event.detail.available === 'boolean') {
|
||||||
|
this.ttsAvailable = event.detail.available;
|
||||||
}
|
}
|
||||||
});
|
this.updateButtonStates();
|
||||||
}
|
|
||||||
|
|
||||||
// Handle speed slider change for animation speed
|
|
||||||
const speedSlider = document.getElementById('speed');
|
|
||||||
if (speedSlider) {
|
|
||||||
speedSlider.addEventListener('input', (e) => {
|
|
||||||
if (this.animationQueue) {
|
|
||||||
// Convert slider value (0-100) to animation speed
|
|
||||||
// Using formula from Documentation.md: lower values = slower speed
|
|
||||||
const value = parseInt(e.target.value);
|
|
||||||
const speed = Math.pow(100.0 - value, 3) / 10000 * 10 + 0.01;
|
|
||||||
this.animationQueue.setSpeed(speed);
|
|
||||||
console.log(`UI Controller: Animation speed set to ${speed.toFixed(3)}`);
|
|
||||||
|
|
||||||
// Save to persistence manager if available
|
|
||||||
if (window.PersistenceManager) {
|
|
||||||
window.PersistenceManager.updatePreference('animation', 'speed', value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set initial speed from persistence manager if available
|
// Listen for TTS availability events
|
||||||
if (window.PersistenceManager) {
|
document.addEventListener('tts:availability', (event) => {
|
||||||
const savedSpeed = window.PersistenceManager.getPreference('animation', 'speed', 50);
|
if (event.detail && typeof event.detail.available === 'boolean') {
|
||||||
speedSlider.value = savedSpeed;
|
this.ttsAvailable = event.detail.available;
|
||||||
// Apply initial speed
|
this.updateButtonStates();
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Add options button to controls section
|
// Add options button to controls section
|
||||||
const controlsSection = document.getElementById('controls');
|
const controlsSection = document.getElementById('controls');
|
||||||
@@ -251,53 +218,35 @@ class UIController extends BaseModule {
|
|||||||
optionsButton.href = '#';
|
optionsButton.href = '#';
|
||||||
optionsButton.textContent = 'options';
|
optionsButton.textContent = 'options';
|
||||||
optionsButton.title = 'Show game options';
|
optionsButton.title = 'Show game options';
|
||||||
|
optionsButton.className = 'control-button';
|
||||||
// Add event listener
|
|
||||||
optionsButton.addEventListener('click', (e) => {
|
optionsButton.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const optionsUI = moduleRegistry.getModule('options-ui');
|
document.dispatchEvent(new CustomEvent('ui:showOptions'));
|
||||||
if (optionsUI && optionsUI.toggle) {
|
|
||||||
optionsUI.toggle();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to controls
|
|
||||||
controlsSection.appendChild(document.createTextNode(' | '));
|
|
||||||
controlsSection.appendChild(optionsButton);
|
controlsSection.appendChild(optionsButton);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Enable all controls buttons
|
// Add speech toggle button
|
||||||
const controlButtons = document.querySelectorAll('#controls a');
|
const speechToggle = document.getElementById('speech-toggle');
|
||||||
controlButtons.forEach(button => {
|
if (speechToggle) {
|
||||||
button.removeAttribute('disabled');
|
speechToggle.addEventListener('click', (e) => {
|
||||||
});
|
e.preventDefault();
|
||||||
|
// Dispatch an event for the TTS module to handle instead of calling directly
|
||||||
// Book click for fast-forwarding - make sure it triggers the animation queue
|
document.dispatchEvent(new CustomEvent('tts:toggle'));
|
||||||
if (this.bookElement) {
|
|
||||||
this.bookElement.addEventListener('click', (event) => {
|
|
||||||
// Only if not clicking on a link or control
|
|
||||||
if (event.target.tagName !== 'A' &&
|
|
||||||
!event.target.closest('#controls') &&
|
|
||||||
!event.target.closest('#command_input')) {
|
|
||||||
if (this.animationQueue) {
|
|
||||||
console.log('UI Controller: Fast-forwarding animations');
|
|
||||||
this.animationQueue.fastForward();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Space key for fast-forwarding
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === ' ' &&
|
|
||||||
document.activeElement.tagName !== 'TEXTAREA' &&
|
|
||||||
document.activeElement.tagName !== 'INPUT') {
|
|
||||||
if (this.animationQueue) {
|
|
||||||
console.log('UI Controller: Fast-forwarding animations (space key)');
|
|
||||||
this.animationQueue.fastForward();
|
|
||||||
e.preventDefault(); // Prevent page scrolling
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Listen for 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() {
|
initializeTextBuffer() {
|
||||||
// Initialize text buffer handling
|
// Initialize text buffer handling
|
||||||
if (this.textBuffer) {
|
if (this.textBuffer) {
|
||||||
|
console.log('UIController: Setting up text buffer callback');
|
||||||
this.textBuffer.setOnSentenceReady((text, callback) => {
|
this.textBuffer.setOnSentenceReady((text, callback) => {
|
||||||
console.log('UI Controller: Displaying sentence');
|
console.log('UIController: Received sentence from text buffer, displaying');
|
||||||
this.displayText(text).then(callback);
|
|
||||||
|
// 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;
|
break;
|
||||||
case 'input':
|
case 'input':
|
||||||
if (this.socketClient) {
|
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;
|
break;
|
||||||
case 'menu':
|
case 'menu':
|
||||||
@@ -354,7 +336,7 @@ class UIController extends BaseModule {
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Handle general UI commands or pass to game logic
|
// 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 saveButton = document.getElementById('save');
|
||||||
const loadButton = document.getElementById('reload');
|
const loadButton = document.getElementById('reload');
|
||||||
const restartButton = document.getElementById('rewind');
|
const restartButton = document.getElementById('rewind');
|
||||||
|
const speechToggle = document.getElementById('speech-toggle');
|
||||||
|
|
||||||
// Update save button state
|
// Update save button state
|
||||||
if (saveButton) {
|
if (saveButton) {
|
||||||
@@ -396,6 +379,26 @@ class UIController extends BaseModule {
|
|||||||
restartButton.setAttribute('disabled', 'disabled');
|
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
|
// Public API methods
|
||||||
|
|||||||
+429
-468
File diff suppressed because it is too large
Load Diff
+25
-33
@@ -1,10 +1,9 @@
|
|||||||
import { BaseModule } from './base-module.js';
|
import { BaseModule } from './base-module.js';
|
||||||
import { moduleRegistry } from './module-registry.js';
|
import { moduleRegistry } from './module-registry.js';
|
||||||
import { ModuleEvent } from './base-module.js';
|
|
||||||
|
|
||||||
class UIEffects extends BaseModule {
|
class UIEffects extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('ui-effects');
|
super('ui-effects', 'UI Effects');
|
||||||
|
|
||||||
// No external dependencies
|
// No external dependencies
|
||||||
this.dependencies = [];
|
this.dependencies = [];
|
||||||
@@ -13,8 +12,8 @@ class UIEffects extends BaseModule {
|
|||||||
this.activeEffects = new Map();
|
this.activeEffects = new Map();
|
||||||
this.ambientEffectsActive = false;
|
this.ambientEffectsActive = false;
|
||||||
|
|
||||||
// Effects configuration
|
// Effects configuration - use the config object from BaseModule
|
||||||
this.effectsConfig = {
|
this.updateConfig({
|
||||||
candleFlicker: {
|
candleFlicker: {
|
||||||
intensity: 0.5,
|
intensity: 0.5,
|
||||||
speed: 0.8
|
speed: 0.8
|
||||||
@@ -26,32 +25,25 @@ class UIEffects extends BaseModule {
|
|||||||
backgroundEffects: {
|
backgroundEffects: {
|
||||||
enabled: true
|
enabled: true
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
// Bind methods that use 'this' internally or are used as callbacks/event handlers
|
// Use bindMethods from parent class
|
||||||
this.initialize = this.initialize.bind(this); // Bind initialize as it calls dispatchEvent
|
this.bindMethods([
|
||||||
this.updateCandleEffect = this.updateCandleEffect.bind(this); // Used with requestAnimationFrame
|
'updateCandleEffect',
|
||||||
this.setupEffectElements = this.setupEffectElements.bind(this);
|
'setupEffectElements',
|
||||||
this.createEffectsOverlay = this.createEffectsOverlay.bind(this);
|
'createEffectsOverlay',
|
||||||
this.createCandleEffect = this.createCandleEffect.bind(this);
|
'createCandleEffect',
|
||||||
this.createLightingElement = this.createLightingElement.bind(this);
|
'createLightingElement',
|
||||||
this.setupAmbientEffects = this.setupAmbientEffects.bind(this);
|
'setupAmbientEffects',
|
||||||
this.setupCandleFlickerEffect = this.setupCandleFlickerEffect.bind(this);
|
'setupCandleFlickerEffect',
|
||||||
this.startAmbientEffects = this.startAmbientEffects.bind(this);
|
'startAmbientEffects',
|
||||||
this.stopAmbientEffects = this.stopAmbientEffects.bind(this);
|
'stopAmbientEffects',
|
||||||
this.applyEffect = this.applyEffect.bind(this);
|
'applyEffect',
|
||||||
this.applyShakeEffect = this.applyShakeEffect.bind(this);
|
'applyShakeEffect',
|
||||||
this.applyFlashEffect = this.applyFlashEffect.bind(this);
|
'applyFlashEffect',
|
||||||
this.applyTextEmphasis = this.applyTextEmphasis.bind(this);
|
'applyTextEmphasis',
|
||||||
this.processCommand = this.processCommand.bind(this);
|
'processCommand'
|
||||||
|
]);
|
||||||
// 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
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('UIEffects: Constructor initialized');
|
console.log('UIEffects: Constructor initialized');
|
||||||
}
|
}
|
||||||
@@ -72,8 +64,8 @@ class UIEffects extends BaseModule {
|
|||||||
|
|
||||||
this.reportProgress(100, 'UI Effects ready');
|
this.reportProgress(100, 'UI Effects ready');
|
||||||
|
|
||||||
// Use the DOM event API directly instead of this.dispatchEvent
|
// Use the parent's dispatchEvent method
|
||||||
this._dispatchModuleEvent('ui:effects:ready', {});
|
this.dispatchEvent('ui:effects:ready', {});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -124,7 +116,7 @@ class UIEffects extends BaseModule {
|
|||||||
|
|
||||||
setupAmbientEffects() {
|
setupAmbientEffects() {
|
||||||
// Initialize candle flicker effect
|
// Initialize candle flicker effect
|
||||||
if (this.candleEffectElement && this.effectsConfig.candleFlicker.enabled !== false) {
|
if (this.candleEffectElement && this.config.candleFlicker.enabled !== false) {
|
||||||
this.setupCandleFlickerEffect();
|
this.setupCandleFlickerEffect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +129,7 @@ class UIEffects extends BaseModule {
|
|||||||
updateCandleEffect() {
|
updateCandleEffect() {
|
||||||
if (!this.candleEffectElement || !this.ambientEffectsActive) return;
|
if (!this.candleEffectElement || !this.ambientEffectsActive) return;
|
||||||
|
|
||||||
const { intensity, speed } = this.effectsConfig.candleFlicker;
|
const { intensity, speed } = this.config.candleFlicker;
|
||||||
|
|
||||||
// Create subtle random flickering effect
|
// Create subtle random flickering effect
|
||||||
const flickerAmount = Math.random() * intensity;
|
const flickerAmount = Math.random() * intensity;
|
||||||
|
|||||||
@@ -1,106 +1,58 @@
|
|||||||
import { BaseModule } from './base-module.js';
|
import { BaseModule } from './base-module.js';
|
||||||
import { moduleRegistry } from './module-registry.js';
|
import { moduleRegistry } from './module-registry.js';
|
||||||
import { ModuleEvent } from './base-module.js';
|
|
||||||
|
|
||||||
class UIInputHandler extends BaseModule {
|
class UIInputHandler extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('ui-input-handler');
|
super('ui-input-handler', 'UI Input Handler');
|
||||||
|
|
||||||
// Explicitly declare ui-display-handler as a dependency
|
// Explicitly declare ui-display-handler as a dependency
|
||||||
this.dependencies = ['ui-display-handler'];
|
this.dependencies = ['ui-display-handler'];
|
||||||
|
|
||||||
// Reference to display handler
|
|
||||||
this.displayHandler = null;
|
|
||||||
|
|
||||||
// Input elements
|
// Input elements
|
||||||
this.inputArea = null;
|
this.inputArea = null;
|
||||||
this.playerInput = null;
|
this.playerInput = null;
|
||||||
this.cursor = null;
|
this.cursor = null;
|
||||||
this.commandHistoryElement = null; // Changed: renamed to avoid conflict
|
this.commandHistoryElement = null;
|
||||||
|
|
||||||
// Input state
|
// Input state
|
||||||
this.inputEnabled = true;
|
this.inputEnabled = true;
|
||||||
this.historyIndex = -1;
|
this.historyIndex = -1;
|
||||||
this.commandHistory = []; // Now this is clearly the array of previous commands
|
this.commandHistory = [];
|
||||||
this.inputBuffer = '';
|
this.inputBuffer = '';
|
||||||
|
|
||||||
// Add this method to properly dispatch custom events
|
// Bind methods using the parent class bindMethods utility
|
||||||
this._dispatchModuleEvent = (name, detail) => {
|
this.bindMethods([
|
||||||
document.dispatchEvent(new CustomEvent(name, {
|
'setupInputElements',
|
||||||
detail: { moduleId: this.id, ...detail },
|
'handlePlayerInput',
|
||||||
bubbles: true
|
'handleInputKeyDown',
|
||||||
}));
|
'positionCursor',
|
||||||
};
|
'handleKeyboardInput',
|
||||||
|
'submitCommand',
|
||||||
// Bind method contexts
|
'addToHistory',
|
||||||
this.setupInputElements = this.setupInputElements.bind(this);
|
'resetCursorPosition'
|
||||||
this.handlePlayerInput = this.handlePlayerInput.bind(this);
|
]);
|
||||||
this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
|
|
||||||
this.positionCursor = this.positionCursor.bind(this);
|
|
||||||
this.handleKeyboardInput = this.handleKeyboardInput.bind(this);
|
|
||||||
|
|
||||||
console.log('UIInputHandler: Constructor initialized');
|
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() {
|
async initialize() {
|
||||||
|
try {
|
||||||
this.reportProgress(0, 'Initializing UI Input Handler');
|
this.reportProgress(0, 'Initializing UI Input Handler');
|
||||||
|
|
||||||
try {
|
// Get display handler reference through the parent's getModule method
|
||||||
// Double-check display handler reference
|
this.displayHandler = this.getModule('ui-display-handler');
|
||||||
if (!this.displayHandler) {
|
if (!this.displayHandler) {
|
||||||
this.displayHandler = moduleRegistry.getModule('ui-display-handler');
|
console.error('UIInputHandler: Display handler module not found');
|
||||||
|
|
||||||
if (!this.displayHandler) {
|
|
||||||
console.error('UIInputHandler: Display handler still not available');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.reportProgress(30, 'Setting up keyboard listeners');
|
this.reportProgress(30, 'Setting up keyboard listeners');
|
||||||
|
|
||||||
// Set up keyboard event listeners
|
// Use the parent's addEventListener for automatic cleanup
|
||||||
document.addEventListener('keydown', (event) => {
|
this.addEventListener(document, 'keydown', this.handleKeyboardInput);
|
||||||
this.handleKeyboardInput(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.reportProgress(60, 'Setting up input elements');
|
this.reportProgress(60, 'Setting up input elements');
|
||||||
|
|
||||||
// Set up input elements
|
|
||||||
this.setupInputElements();
|
this.setupInputElements();
|
||||||
|
|
||||||
this.reportProgress(100, 'UI Input Handler ready');
|
this.reportProgress(100, 'UI Input Handler ready');
|
||||||
@@ -156,9 +108,9 @@ class UIInputHandler extends BaseModule {
|
|||||||
commandHistory = document.createElement('div');
|
commandHistory = document.createElement('div');
|
||||||
commandHistory.id = 'command_history';
|
commandHistory.id = 'command_history';
|
||||||
choicesContainer.appendChild(commandHistory);
|
choicesContainer.appendChild(commandHistory);
|
||||||
this.commandHistoryElement = commandHistory; // Changed: store in renamed property
|
this.commandHistoryElement = commandHistory;
|
||||||
} else {
|
} else {
|
||||||
this.commandHistoryElement = commandHistory; // Changed: store in renamed property
|
this.commandHistoryElement = commandHistory;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create input container if needed
|
// Create input container if needed
|
||||||
@@ -246,8 +198,8 @@ class UIInputHandler extends BaseModule {
|
|||||||
this.positionCursor(this.playerInput, this.cursor);
|
this.positionCursor(this.playerInput, this.cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch event using the properly defined method
|
// Use the parent class dispatchEvent method instead of custom _dispatchModuleEvent
|
||||||
this._dispatchModuleEvent('ui:input:change', {
|
this.dispatchEvent('ui:input:change', {
|
||||||
text: this.playerInput.value
|
text: this.playerInput.value
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -280,11 +232,9 @@ class UIInputHandler extends BaseModule {
|
|||||||
const command = this.playerInput.value.trim();
|
const command = this.playerInput.value.trim();
|
||||||
console.log(`UIInputHandler: Submitting command: "${command}"`);
|
console.log(`UIInputHandler: Submitting command: "${command}"`);
|
||||||
|
|
||||||
// Add command to history
|
|
||||||
this.addToHistory(command);
|
this.addToHistory(command);
|
||||||
|
|
||||||
// Dispatch command event
|
this.dispatchEvent('ui:command', {
|
||||||
this._dispatchModuleEvent('ui:command', {
|
|
||||||
type: 'input',
|
type: 'input',
|
||||||
text: command
|
text: command
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -257,3 +257,342 @@ The overlay fades away as the first scheduled animation.
|
|||||||
- All settings are persisted via the persistence-manager
|
- 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.
|
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.
|
||||||
@@ -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
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user