diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..3c2353d
--- /dev/null
+++ b/public/favicon.ico
@@ -0,0 +1,2 @@
+// This is a binary file. I'm creating an empty placeholder here.
+// Replace with a proper favicon file for production.
diff --git a/public/index.html b/public/index.html
index 90a9191..cfc901d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,93 +1,193 @@
-
-
+
+
-
-
- ai-fiction Book Runtime (Modular Version)
-
-
-
- We are using Node.js ,
- Chromium ,
- and Electron .
-
-
- What do you want to do next?
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
\ No newline at end of file
diff --git a/public/js/ai-fiction.js b/public/js/ai-fiction.js
deleted file mode 100644
index 25808ac..0000000
--- a/public/js/ai-fiction.js
+++ /dev/null
@@ -1,756 +0,0 @@
-/**
- * Animated Fiction - Main Application Integration
- * Integrates all modules to create an interactive fiction experience.
- */
-import { AnimationQueue } from './animation-queue.js';
-import { TextProcessor } from './text-processor.js';
-import { ParagraphLayout } from './paragraph-layout.js';
-import { LayoutRenderer } from './layout-renderer.js';
-import { AudioManager } from './audio-manager.js';
-import { TtsPlayer } from './tts-player.js';
-import { PersistenceManager } from './persistence-manager.js';
-import { InputHandler } from './input-handler.js';
-import { SocketClient } from './socket-client.js';
-import { UiController } from './ui-controller.js';
-import { ttsFactory } from './tts-factory.js';
-
-export class AnimatedFiction {
- /**
- * Create a new AnimatedFiction application
- * @param {Object} config - Configuration options
- * @param {string} config.serverUrl - URL for the Socket.IO server (optional, defaults to window.location.origin)
- * @param {string} config.storyContainerId - ID of the story container element
- * @param {string} config.commandHistoryContainerId - ID of the command history container element
- * @param {string} config.ttsApiKey - API key for TTS service (if applicable)
- * @param {number} config.initialSpeed - Initial animation speed
- * @param {string} config.locale - Locale for translations
- * @param {Object} config.translations - Translations object
- */
- constructor(config = {}) {
- this.config = config;
- this.storyContainer = document.getElementById(config.storyContainerId || 'story');
- this.commandHistoryContainer = document.getElementById(config.commandHistoryContainerId || 'command_history'); // Added for user commands
-
- // Game state
- this.gameState = {
- started: false,
- currentRoomId: '',
- isThinking: false,
- textSpeed: config.initialSpeed || 50 // Keep track of speed locally if needed
- };
- this.currentCommandTimeout = null; // To handle server timeouts
-
- // Initialize core components
- this.initializeComponents();
- this.bindGlobalEvents(); // Add global event bindings like focus management
- }
-
- /**
- * Initialize all components
- */
- initializeComponents() {
- // 1. Core Components
- this.animationQueue = new AnimationQueue();
- // Initialize TextProcessor without hyphenator initially
- this.textProcessor = new TextProcessor(window.SmartyPants);
- // Pass null for measure func initially, it will be provided after DOM measurement setup
- this.paragraphLayout = new ParagraphLayout(window.kap, null);
- this.layoutRenderer = new LayoutRenderer(this.animationQueue);
- this.audioManager = new AudioManager();
- this.ttsPlayer = new TtsPlayer({
- apiKey: this.config.ttsApiKey,
- animationQueue: this.animationQueue
- });
- this.persistenceManager = new PersistenceManager({
- storage: localStorage // Note: Persistence might need rework for socket state
- });
-
- // Initialize the DOM-based text measurement system
- this.initializeTextMeasurement();
-
- // 2. Input, Socket, and UI Controller
- this.inputHandler = new InputHandler('player_input', 'cursor');
- this.socketClient = new SocketClient(this.config.serverUrl); // Pass server URL if provided
-
- this.uiController = new UiController({
- // storyPlayer: this.storyPlayer, // Remove storyPlayer dependency
- animationQueue: this.animationQueue, // Keep for speed control
- ttsPlayer: this.ttsPlayer, // Keep for TTS toggle
- speedSliderElement: document.getElementById('speed'),
- // choiceContainerElement: document.getElementById('choices'), // Choices are now suggestions/input
- commandHistoryContainerElement: this.commandHistoryContainer, // Pass command history
- storyContainerElement: this.storyContainer, // Pass story container
- rewindButtonElement: document.getElementById('rewind'),
- saveButtonElement: document.getElementById('save'),
- loadButtonElement: document.getElementById('reload'),
- speechButtonElement: document.getElementById('speech'),
- speedResetElement: document.getElementById('speed_reset'),
- inputHandler: this.inputHandler, // Pass input handler for suggestion clicks etc.
- socketClient: this.socketClient, // Pass socket client for actions
- translations: this.config.translations,
- locale: this.config.locale || 'en-us'
- });
-
- // Configure TTS Player based on factory readiness
- this.listenForTTSReady();
-
- // Link InputHandler submission to SocketClient
- this.inputHandler.onCommandSubmit((command) => {
- this.submitCommand(command);
- });
-
- // Link UI Controller actions to SocketClient
- this.uiController.onRestartRequest = () => this.socketClient.requestStartGame();
- this.uiController.onSaveRequest = () => this.socketClient.requestSaveGame();
- this.uiController.onLoadRequest = () => this.socketClient.requestLoadGame();
- // TTS toggle is likely handled within UiController using TtsPlayer/ttsHandler
-
- // Initialize Socket Listeners
- this.initializeSocketListeners();
- } // <<< Re-added missing closing brace for initializeComponents
-
- /**
- * Initialize DOM-based text measurement system
- * Recreates the ruler stack system from the original game.js
- */
- initializeTextMeasurement() {
- // Set up ruler DOM element & stack for text measurement
- this.rulerElement = document.getElementById('ruler');
- if (!this.rulerElement) {
- console.error("AnimatedFiction: Ruler element not found! Text measurement will fail.");
- return;
- }
-
- // Reset and initialize ruler stack
- this.rulerStack = [this.rulerElement];
-
- // Create the DOM-based text measurement function
- this.measureText = (str) => {
- // Get current ruler from stack top
- let ruler = this.rulerStack[this.rulerStack.length - 1];
-
- // Handle HTML tags specially
- if (str.substr(0, 2) == '') {
- // End tag: pop the last child off the stack
- let child = this.rulerStack.pop();
- ruler = this.rulerStack[this.rulerStack.length - 1];
- ruler.removeChild(child);
- return 0;
- } else if (str.substr(0, 1) == '<') {
- // Start tag: create and push element onto stack
- let tmp = document.createElement('div');
- tmp.innerHTML = str;
- let word = tmp.firstChild;
- ruler = this.rulerStack[this.rulerStack.length - 1];
- this.rulerStack.push(word);
- ruler.appendChild(word);
- return 0;
- } else if (str === '|') {
- // Pipe character (hyphenation marker): zero width
- return 0;
- } else if (str === ' ') {
- // Non-breaking space for measurement
- str = '\u00A0';
- }
-
- // For normal text, measure width with a text node
- ruler = this.rulerStack[this.rulerStack.length - 1];
- let textNode = document.createTextNode(str);
- ruler.appendChild(textNode);
- let width = ruler.getClientRects()[0].width;
- ruler.removeChild(textNode);
-
- return width;
- };
-
- // Provide the measurement function to ParagraphLayout
- if (this.paragraphLayout) {
- this.paragraphLayout.setMeasureFunction(this.measureText);
- }
- }
-
- // Removed measureDomText method
-
- /**
- * Listen for the tts-ready event from the factory
- */
- listenForTTSReady() {
- window.addEventListener('tts-ready', (event) => {
- console.log('AnimatedFiction received tts-ready event:', event.detail);
- const { available, type, handler } = event.detail;
-
- if (available && handler) {
- console.log(`AnimatedFiction: Using ${type} TTS system with handler:`, handler);
-
- // Store the handler for direct access
- window.ttsHandler = handler;
-
- // Pass the handler to the TtsPlayer if needed
- if (this.ttsPlayer) {
- this.ttsPlayer.setTtsHandler(handler);
- }
-
- // Ensure UI controller knows about it
- if (this.uiController) {
- this.uiController.setTtsHandler(handler);
- this.uiController.updateSpeechButtonAvailability(available, type);
- }
-
- // Set user activation flag once we have user interaction
- document.addEventListener('click', function setUserActivation() {
- if (window.ttsHandler) {
- window.ttsHandler.hasUserActivation = true;
- // If using Kokoro, try to resume the AudioContext
- if (window.ttsHandler.audioContext && window.ttsHandler.audioContext.state === 'suspended') {
- window.ttsHandler.audioContext.resume().catch(err =>
- console.error('Error resuming AudioContext on click:', err));
- }
- }
- // Only need this once
- document.removeEventListener('click', setUserActivation);
- }, { once: false });
-
- } else {
- console.warn("AnimatedFiction: No TTS handler available after initialization.");
- if (this.uiController) {
- this.uiController.updateSpeechButtonAvailability(false);
- }
- }
- });
- }
-
- /**
- * Initialize listeners for SocketClient events.
- */
- initializeSocketListeners() {
- this.socketClient.on('connect', () => {
- console.log('AnimatedFiction: Socket connected.');
- // Automatically start the game once connected
- if (!this.gameState.started) {
- // Don't display any message before starting the game
- this.socketClient.requestStartGame();
- }
- });
-
- this.socketClient.on('connect_error', (error) => {
- console.error('AnimatedFiction: Socket connection error:', error);
- this.displaySystemMessage('Connection error. Please check server and network.');
- });
-
- this.socketClient.on('disconnect', (reason) => {
- console.warn('AnimatedFiction: Socket disconnected.', reason);
- this.displaySystemMessage('Disconnected from server.');
- this.gameState.started = false; // Reset started state on disconnect
- this.uiController.updateButtonStates(this.gameState); // Disable buttons
- });
-
- this.socketClient.on('gameIntroduction', (data) => {
- // Clear any existing content
- this.clearStoryDisplay();
-
- // Display the introduction text with proper animation
- this.displayNarrative(data.introduction);
- this.displayNarrative(data.initialRoomDescription);
-
- this.gameState.started = true;
- this.gameState.currentRoomId = data.currentRoomId;
- this.gameState.isThinking = false;
-
- this.uiController.updateButtonStates(this.gameState); // Enable buttons
- this.inputHandler.enableInput();
- this.inputHandler.focus();
- });
-
- this.socketClient.on('narrativeResponse', (data) => {
- this.handleNarrativeResponse(data);
- });
-
- this.socketClient.on('gameSaved', () => {
- this.displaySystemMessage('Game saved successfully.');
- this.gameState.canLoad = true; // Assuming save enables load
- this.uiController.updateButtonStates(this.gameState);
- });
-
- this.socketClient.on('gameLoaded', (data) => {
- this.clearStoryDisplay();
- this.displaySystemMessage('Game loaded successfully.');
- this.displayNarrative(data.currentRoomDescription); // Display current room after load
-
- this.gameState.started = true; // Ensure game is marked as started
- this.gameState.currentRoomId = data.currentRoomId;
- this.gameState.isThinking = false;
- this.gameState.canLoad = true; // Can still load after loading
-
- this.uiController.updateButtonStates(this.gameState);
- this.inputHandler.enableInput();
- this.inputHandler.focus();
- });
-
- this.socketClient.on('error', (data) => {
- this.handleNarrativeResponse({ text: '' }); // Clear thinking indicator on error
- this.displaySystemMessage(`Server Error: ${data.message}`);
- this.inputHandler.enableInput(); // Re-enable input on server error
- });
- }
-
- /**
- * Handles the narrative response from the server.
- * @param {object} data - The data received from the server.
- * @param {string} data.text - The narrative text.
- * @param {string[]} [data.suggestions] - Optional suggestions.
- * @param {object} [data.gameState] - Optional updated game state.
- */
- handleNarrativeResponse(data) {
- // Clear thinking indicator and timeout
- if (this.currentCommandTimeout) {
- clearTimeout(this.currentCommandTimeout);
- this.currentCommandTimeout = null;
- }
- this.removeThinkingIndicator();
- this.gameState.isThinking = false;
-
- // Display narrative using the proper text processing pipeline
- if (data.text) {
- this.displayNarrative(data.text);
- }
-
- // Display suggestions
- if (data.suggestions && data.suggestions.length > 0) {
- this.displaySuggestions(data.suggestions);
- }
-
- // Update game state if provided
- if (data.gameState) {
- this.gameState.currentRoomId = data.gameState.currentRoomId;
- // Update other relevant state parts if needed
- }
-
- // Re-enable input and focus
- this.inputHandler.enableInput();
- this.scrollToBottom(); // Ensure view is scrolled down
- }
-
- /**
- * Submits a command entered by the user.
- * @param {string} command - The command text.
- */
- submitCommand(command) {
- if (!this.gameState.started || this.gameState.isThinking) return;
-
- this.displayUserCommand(command); // Show command in history
- this.displayThinkingIndicator(); // Show thinking message
- this.gameState.isThinking = true;
-
- this.socketClient.sendCommand(command);
-
- // Failsafe timeout to re-enable input if server doesn't respond
- this.currentCommandTimeout = setTimeout(() => {
- if (this.gameState.isThinking) { // Only if still thinking
- console.warn("Server response timeout.");
- this.removeThinkingIndicator();
- this.displaySystemMessage('The server is taking too long to respond.');
- this.gameState.isThinking = false;
- this.inputHandler.enableInput();
- }
- }, 15000); // 15 seconds timeout
- }
-
- /**
- * Displays a narrative paragraph using the rendering pipeline.
- * @param {string} text - The narrative text.
- */
- displayNarrative(text) {
- if (!text) return;
-
- console.log("AnimatedFiction: Displaying narrative text:", text);
-
- try {
- // 1. Process the text with TextProcessor (SmartyPants, hyphenation)
- const processed = this.textProcessor.process(text);
-
- // 2. Get container width for line measure calculation
- const containerWidth = this.storyContainer.clientWidth;
-
- // 3. Setup measures array for the Knuth-Plass algorithm
- // Calculate indent width for the measures array
- const indentWidth = 2 * parseFloat(window.getComputedStyle(document.querySelector("#indent")).lineHeight);
- const measures = [
- containerWidth, // Full width
- containerWidth - indentWidth, // Indented width
- containerWidth - indentWidth * 0.9 // Slightly less indented width
- ];
-
- // 4. Calculate Layout using the Knuth-Plass algorithm with DOM-based measurement
- console.log("AnimatedFiction: Calculating paragraph layout with DOM ruler");
- // We'll use our DOM-based measureText function that we set up in initializeTextMeasurement
- // Will use reversed measures array as in the original game.js implementation
- const layout = this.paragraphLayout.calculateLayout(processed, measures.slice().reverse(), true);
-
- // 5. Render paragraph using the LayoutRenderer
- console.log("AnimatedFiction: Rendering paragraph with layout data");
- // Also pass reversed measures array to renderer as in the original
- const renderResult = this.layoutRenderer.renderParagraph(layout, this.animationQueue.getDelay(), measures.slice().reverse());
-
- if (!Array.isArray(renderResult) || renderResult.length < 2) {
- throw new Error("renderParagraph did not return the expected array [element, delay]");
- }
-
- const [paragraphElement, finalDelay] = renderResult;
-
- // Update the animation queue's delay
- this.animationQueue.setDelay(finalDelay);
-
- // 6. Append the paragraph element to the story container
- this.storyContainer.appendChild(paragraphElement);
-
- // 7. Speak text if TTS is enabled
- // First check our ttsPlayer
- if (this.ttsPlayer && this.ttsPlayer.isEnabled()) {
- console.log("AnimatedFiction: Speaking text with TTS via ttsPlayer");
- this.ttsPlayer.speak(text);
- }
- // Also try the global TTS handler in case ttsPlayer isn't properly configured
- else if (window.ttsHandler && typeof window.ttsHandler.isEnabled === 'function' && window.ttsHandler.isEnabled()) {
- console.log("AnimatedFiction: Speaking text with global TTS handler");
- window.ttsHandler.speak(text);
- }
-
- } catch (error) {
- console.error("Error during paragraph layout or rendering:", error);
- console.error(error.stack);
- // Display raw text as fallback with simple fade-in
- const fallbackPara = document.createElement('p');
- fallbackPara.textContent = text;
- fallbackPara.classList.add("fallback");
- fallbackPara.classList.add("fade-in");
- this.storyContainer.appendChild(fallbackPara);
-
- // Still try to speak the text if TTS is enabled
- if (this.ttsPlayer && this.ttsPlayer.isEnabled()) {
- this.ttsPlayer.speak(text);
- } else if (window.ttsHandler && typeof window.ttsHandler.isEnabled === 'function' && window.ttsHandler.isEnabled()) {
- window.ttsHandler.speak(text);
- }
- }
-
- this.scrollToBottom();
- }
-
- /**
- * Displays the user's command in the history.
- * @param {string} command - The command text.
- */
- displayUserCommand(command) {
- const element = document.createElement('p');
- element.className = 'user-input';
- element.textContent = `> ${command}`;
- this.commandHistoryContainer.appendChild(element);
- this.scrollToBottom();
- }
-
- /**
- * Displays a system message (e.g., errors, confirmations).
- * @param {string} message - The message text.
- */
- displaySystemMessage(message) {
- const element = document.createElement('p');
- element.className = 'system-message';
- element.textContent = message;
- this.storyContainer.appendChild(element); // Add to main story flow
- this.scrollToBottom();
- }
-
- /**
- * Displays clickable suggestions.
- * @param {string[]} suggestions - An array of suggestion strings.
- */
- displaySuggestions(suggestions) {
- // Remove previous suggestions if any
- const existingSuggestions = this.storyContainer.querySelector('.suggestions');
- if (existingSuggestions) {
- existingSuggestions.remove();
- }
-
- const element = document.createElement('div');
- element.className = 'suggestions';
-
- const heading = document.createElement('p');
- heading.textContent = 'Suggestions:'; // TODO: Localize
- heading.style.fontStyle = 'italic';
- heading.style.marginTop = '1em';
- element.appendChild(heading);
-
- const list = document.createElement('ul');
- suggestions.forEach(suggestion => {
- const item = document.createElement('li');
- item.textContent = suggestion;
- item.style.cursor = 'pointer';
- item.addEventListener('click', () => {
- this.inputHandler.setValue(suggestion); // Set input value
- this.submitCommand(suggestion); // Submit immediately
- });
- list.appendChild(item);
- });
- element.appendChild(list);
-
- this.storyContainer.appendChild(element);
- this.scrollToBottom();
- }
-
- /** Displays a "Thinking..." indicator. */
- displayThinkingIndicator() {
- this.removeThinkingIndicator(); // Ensure only one exists
- const id = 'thinking-' + Date.now();
- const element = document.createElement('div');
- element.id = id;
- element.className = 'thinking';
- element.innerHTML = 'Thinking...
'; // TODO: Localize
- this.storyContainer.appendChild(element);
- this.scrollToBottom();
- }
-
- /** Removes the "Thinking..." indicator. */
- removeThinkingIndicator() {
- const thinkingElement = this.storyContainer.querySelector('.thinking');
- if (thinkingElement) {
- thinkingElement.remove();
- }
- }
-
- /** Clears the main story display area. */
- clearStoryDisplay() {
- // Clear story container
- while (this.storyContainer.firstChild) {
- this.storyContainer.removeChild(this.storyContainer.firstChild);
- }
- // Optionally clear command history as well, or keep it
- // while (this.commandHistoryContainer.firstChild) {
- // this.commandHistoryContainer.removeChild(this.commandHistoryContainer.firstChild);
- // }
- }
-
- /** Scrolls the main content area to the bottom. */
- scrollToBottom() {
- // Scroll the right page (story container's parent)
- const rightPage = document.getElementById('page_right');
- if (rightPage) {
- // Use setTimeout to ensure rendering is complete before scrolling
- setTimeout(() => {
- rightPage.scrollTop = rightPage.scrollHeight;
- }, 50); // Small delay
- }
- }
-
- /** Binds global event listeners like focus management. */
- bindGlobalEvents() {
- // Basic focus management: click anywhere focuses input unless on interactive element
- document.addEventListener('click', (e) => {
- if (
- e.target.tagName !== 'BUTTON' &&
- e.target.tagName !== 'A' &&
- e.target.tagName !== 'INPUT' && // Allow range slider interaction
- !e.target.closest('.suggestions li') && // Allow clicking suggestions
- e.target !== this.inputHandler.playerInput // Don't refocus if already clicking input
- ) {
- this.inputHandler.focus();
- }
- });
-
- // Refocus on window visibility change
- window.addEventListener('focus', () => this.inputHandler.focus());
- window.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible') {
- setTimeout(() => this.inputHandler.focus(), 100);
- }
- });
- }
-
-
- /**
- * Start the application: Connect socket and set up UI.
- */
- start() {
- // Set up UI event listeners (speed, TTS, etc.)
- this.uiController.setupEventListeners();
-
- // Force initial UI layout calculation immediately to ensure visibility
- this.uiController.updateBookDimensions();
- this.uiController.updateParagraphHeight();
-
- // Set initial speed in AnimationQueue
- const initialQueueSpeed = Math.pow(100.0 - (this.config.initialSpeed || 50), 3) / 10000 * 10 + 0.01;
- this.animationQueue.setSpeed(initialQueueSpeed);
- this.uiController.updateSpeedDisplay(this.config.initialSpeed || 50); // Update slider visually
-
- // Connect the socket to the server without showing any loading messages
- console.log("AnimatedFiction: Connecting to server...");
- this.socketClient.connect();
-
- this.uiController.handleWindowResize();
-
- // Initial focus on input
- this.inputHandler.focus();
- }
-}
-
-/**
- * Initialize the application when the window loads
- * Using addEventListener instead of window.onload to prevent overriding other handlers
- */
-window.addEventListener('load', async () => {
- // Define translations first
- const translations = {
- 'en-us': {
- by: "powered by Generative AI", // Updated
- speed: "speed*", // Simpler superscript
- title_speed: "Set speed of text animation",
- restart: "restart",
- title_restart: "Restart game from beginning", // Clarified
- save: "save",
- title_save: "Save progress",
- load: "load",
- title_load: "Reload from save point",
- prompt: "What do you want to do next?", // Changed from italic
- remark: "*click on page or press spacebar to fast forward text animation", // Simplified
- end: "The End", // Keep for potential future use
- // Action prompts might not be needed for socket version, keep for now
- action_examine: "Examine",
- action_comment: "Comment",
- action_ask: "Ask",
- action_interact: "Interact",
- action_reflect: "Reflect",
- action_inventory: "Inventory",
- speech: "speech", // Lowercase to match button style
- title_speech: "Toggle text to speech",
- system_error: "Error", // Added
- system_connecting: "Connecting...", // Added
- system_thinking: "Thinking", // Added
- system_suggestions: "Suggestions:", // Added
- system_save_ok: "Game saved.", // Added
- system_load_ok: "Game loaded.", // Added
- system_connection_lost: "Connection lost.", // Added
- system_restarting: "Restarting game..." // Added
- },
- 'de': { // Keep German translations, update/add as needed
- by: "powered by Generative AI",
- speed: "Geschwindigkeit*",
- title_speed: "Geschwindigkeit der Textanimation einstellen",
- restart: "Neustart",
- title_restart: "Spiel von vorne beginnen",
- save: "Speichern",
- title_save: "Fortschritt speichern",
- load: "Laden",
- title_load: "Gespeicherten Spielstand laden",
- prompt: "Was möchtest du als Nächstes tun?",
- remark: "*Klicke auf die Seite oder drücke die Leertaste, um die Textanimation zu beschleunigen",
- end: "Ende",
- action_examine: "Untersuchen",
- action_comment: "Kommentieren",
- action_ask: "Fragen",
- action_interact: "Interagieren",
- action_reflect: "Nachdenken",
- action_inventory: "Inventar",
- speech: "Sprache",
- title_speech: "Sprachausgabe umschalten",
- system_error: "Fehler",
- system_connecting: "Verbinde...",
- system_thinking: "Denke nach",
- system_suggestions: "Vorschläge:",
- system_save_ok: "Spiel gespeichert.",
- system_load_ok: "Spiel geladen.",
- system_connection_lost: "Verbindung verloren.",
- system_restarting: "Starte Spiel neu..."
- }
- };
-
- // Configure Hyphenopoly before creating the application
- window.Hyphenopoly = window.Hyphenopoly || {};
- window.Hyphenopoly.config({
- require: {
- "en-us": "FORCEHYPHENOPOLY"
- },
- paths: {
- maindir: "./js/",
- patterndir: "./js/patterns/"
- },
- setup: {
- selectors: {
- ".hyphenate": { // Default selector with soft hyphen
- hyphen: "\u00AD"
- },
- ".hyphenatePipe": { // Selector for Knuth-Plass with pipe hyphen
- hyphen: "|",
- // Explicitly add minWordLength here as a potential fix
- // Although it should derive from language, this might help
- minWordLength: 4
- }
- }
- },
- handleEvent: {
- error: function(e) {
- console.error("Hyphenopoly error:", e);
- },
- hyphenopolyEnd: function(e) {
- console.log("Hyphenopoly fully initialized (hyphenopolyEnd event).");
- // --- Move hyphenator setup logic inside this event handler ---
- try {
- if (window.Hyphenopoly && window.Hyphenopoly.hyphenators) {
- const hyphenatorPromise = window.Hyphenopoly.hyphenators["en-us"];
-
- hyphenatorPromise.then(hyphenatorFunction => {
- console.log("Hyphenator function obtained after hyphenopolyEnd:", hyphenatorFunction);
-
- if (window.app && window.app.textProcessor) {
- const hyphenationSelector = ".hyphenatePipe";
- console.log("Using hyphenation selector:", hyphenationSelector);
-
- const wrappedHyphenator = (text) => {
- try {
- // Don't strip the dot - Hyphenopoly expects the full selector with dot
- const result = hyphenatorFunction(text, hyphenationSelector);
- return result;
- } catch (error) {
- console.error("Error during hyphenation call:", error);
- console.error("Text being hyphenated:", text);
- console.error("Selector used:", hyphenationSelector);
- return text; // Fallback
- }
- };
-
- window.app.textProcessor.setHyphenator(wrappedHyphenator);
- console.log("Hyphenator successfully configured on TextProcessor using selector after hyphenopolyEnd.");
-
- } else {
- console.error("Failed to set hyphenator post-hyphenopolyEnd: window.app or window.app.textProcessor not found.");
- }
-
- }).catch(err => {
- console.error("Failed to get hyphenator function from promise post-hyphenopolyEnd:", err);
- });
- } else {
- console.error("Hyphenopoly.hyphenators not found post-hyphenopolyEnd.");
- }
- } catch (error) {
- console.error("General error setting up hyphenator post-hyphenopolyEnd:", error);
- }
- // --- End of moved logic ---
- }
- }
- });
-
- // Create and initialize the application
- window.app = new AnimatedFiction({
- // storyUrl: 'Herrenhaus.ink.json', // No longer needed for socket version
- storyContainerId: 'story',
- commandHistoryContainerId: 'command_history', // Specify history container
- initialSpeed: 50,
- locale: window.locale || 'en-us',
- translations: translations
- // ttsApiKey: 'YOUR_API_KEY' // Add if needed for specific TTS service via TtsPlayer
- });
-
- // Start the application
- window.app.start();
-
- // Force another UI update once DOM is completely rendered
-});
diff --git a/public/js/animation-queue.js b/public/js/animation-queue.js
index c83b3cc..7f7999b 100644
--- a/public/js/animation-queue.js
+++ b/public/js/animation-queue.js
@@ -1,117 +1,211 @@
/**
- * AnimationQueue Module
- * Manages the timing and execution queue for all scheduled animations (primarily text reveal).
+ * Animation Queue Module
+ * Handles scheduling and executing animations with proper resource management
+ * and synchronization with TTS
*/
-export class AnimationQueue {
- constructor() {
- this.queue = [];
- this.delay = 0;
- this.speed = 0.05; // Default speed
- }
+import { BaseModule } from './base-module.js';
+import { moduleRegistry } from './module-registry.js';
+import { ModuleEvent } from './base-module.js'; // Add this import
+class AnimationQueueModule extends BaseModule {
+ constructor() {
+ super('animation-queue', 'Animation Queue');
+
+ // Queue of scheduled animations/functions
+ this.queue = [];
+
+ // Animation timing properties
+ this.speed = 0.05; // Base animation speed (seconds per character)
+ this.delay = 0; // Current accumulated delay
+
+ // Module dependencies
+ this.dependencies = ['tts'];
+ this.tts = null; // TTS module reference
+
+ // Fast-forwarding state
+ this.isFastForwarding = false;
+
+ // Bind methods
+ this.schedule = this.schedule.bind(this);
+ this.fastForward = this.fastForward.bind(this);
+ this.clearAll = this.clearAll.bind(this);
+ this.setSpeed = this.setSpeed.bind(this);
+ }
+
+ async waitForDependencies() {
+ try {
+ // Wait for TTS module to be available
+ this.tts = moduleRegistry.getModule('tts');
+
+ if (!this.tts) {
+ console.warn("TTS module not ready, Animation Queue will have limited functionality");
+ return true; // Continue anyway
+ }
+
+ return true;
+ } catch (error) {
+ console.error("Error waiting for Animation Queue dependencies:", error);
+ return false;
+ }
+ }
+
+ async initialize() {
+ try {
+ // Nothing special to initialize here
+ this.reportProgress(100, "Animation Queue ready");
+ return true;
+ } catch (error) {
+ console.error("Error initializing Animation Queue:", error);
+ return false;
+ }
+ }
+
/**
- * Schedule a function to be executed after a delay
- * @param {Function} func - The function to execute
- * @param {number} delay - The delay in milliseconds
+ * Schedule a function to execute after a delay
+ * @param {Function} func - Function to execute
+ * @param {number} delay - Delay in milliseconds
* @param {...any} args - Arguments to pass to the function
- * @returns {number} The timeout ID
+ * @returns {Object} - Timeout object that can be used to cancel
*/
schedule(func, delay, ...args) {
+ if (typeof func !== 'function') {
+ console.error('Animation Queue: Not a function passed to schedule');
+ return null;
+ }
+
+ // Create timeout object with execute method
const timeoutObject = {
- execute: () => func(...args),
- timeoutId: null
+ execute: () => {
+ try {
+ func(...args);
+ } catch (error) {
+ console.error('Error executing scheduled function:', error);
+ }
+ },
+ timeoutId: null,
+ createdAt: Date.now(),
+ delay: delay
};
-
+
+ // Apply speed factor to the delay
+ const adjustedDelay = delay * this.speed;
+
+ // Schedule execution
timeoutObject.timeoutId = setTimeout(() => {
+ // Execute the function
timeoutObject.execute();
- this.queue = this.queue.filter(t => t !== timeoutObject);
- if (this.queue.length <= 0) {
- let event = new CustomEvent("allWordsSetEvent", {
- detail: { messages: "All scheduled word fade in animations were played." },
- bubbles: true,
- cancelable: false
- });
- document.dispatchEvent(event);
+
+ // Remove from queue
+ const index = this.queue.indexOf(timeoutObject);
+ if (index !== -1) {
+ this.queue.splice(index, 1);
}
- }, delay);
-
+ }, adjustedDelay);
+
+ // Add to queue
this.queue.push(timeoutObject);
- return timeoutObject.timeoutId;
+
+ // Update current total delay
+ this.delay = adjustedDelay + delay;
+
+ return timeoutObject;
}
-
+
/**
- * Fast forward all scheduled animations
+ * Fast-forward all pending animations
*/
fastForward() {
- this.delay = 0.0;
- // Sort the queue based on timeoutId (assuming that smaller ids are scheduled earlier)
- this.queue.sort((a, b) => a.timeoutId - b.timeoutId);
- // Clear and execute all timeouts
- this.queue.forEach(timeoutObject => {
- clearTimeout(timeoutObject.timeoutId);
+ console.log(`Animation Queue: Fast-forwarding ${this.queue.length} pending items`);
+
+ // Stop TTS if playing
+ if (this.tts) {
+ this.tts.stop();
+ }
+
+ // Execute and clear all timeouts
+ const queueCopy = [...this.queue]; // Make a copy to avoid modification during iteration
+
+ queueCopy.forEach(timeoutObject => {
+ // Clear timeout
+ if (timeoutObject.timeoutId !== null) {
+ clearTimeout(timeoutObject.timeoutId);
+ }
+
+ // Execute immediately
timeoutObject.execute();
});
+
+ // Clear queue
this.queue = [];
- let event = new CustomEvent("allWordsSetEvent", {
- detail: { messages: "All scheduled word fade in animations were played." },
- bubbles: true,
- cancelable: false
- });
- document.dispatchEvent(event);
- document.getElementById("page_right").scrollTo({
- top: document.getElementById("page_right").scrollHeight,
- behavior: 'smooth'
- });
+
+ // Reset delay
+ this.delay = 0;
+
+ // Use direct DOM event dispatch instead of this.dispatchEvent
+ document.dispatchEvent(new CustomEvent('animations:fastForwarded', {
+ detail: { moduleId: this.id }
+ }));
}
-
+
/**
- * Stop all scheduled animations
+ * Clear all scheduled animations without executing them
*/
- stop() {
+ clearAll() {
+ console.log(`Animation Queue: Clearing ${this.queue.length} pending items`);
+
+ // Clear all timeouts
this.queue.forEach(timeoutObject => {
- clearTimeout(timeoutObject.timeoutId);
+ if (timeoutObject.timeoutId !== null) {
+ clearTimeout(timeoutObject.timeoutId);
+ }
});
+
+ // Clear queue
this.queue = [];
+
+ // Reset delay
this.delay = 0;
}
-
+
/**
* Set the animation speed
- * @param {number} value - The speed value
+ * @param {number} speed - Animation speed factor (lower is faster)
*/
- setSpeed(value) {
- this.speed = value;
+ setSpeed(speed) {
+ if (typeof speed !== 'number' || speed <= 0) {
+ console.error('Animation Queue: Invalid speed value');
+ return;
+ }
+
+ this.speed = speed;
+ console.log(`Animation Queue: Speed set to ${speed}`);
}
-
+
/**
- * Get the current animation speed
- * @returns {number} The current speed
+ * Get current queue length
+ * @returns {number} - Number of items in the queue
*/
- getSpeed() {
- return this.speed;
+ getQueueLength() {
+ return this.queue.length;
}
-
+
/**
- * Get the current accumulated delay
- * @returns {number} The current delay
+ * Get current accumulated delay
+ * @returns {number} - Current delay in milliseconds
*/
- getDelay() {
+ getCurrentDelay() {
return this.delay;
}
-
- /**
- * Set the accumulated delay
- * @param {number} value - The delay value
- */
- setDelay(value) {
- this.delay = value;
- }
-
- /**
- * Increment the accumulated delay
- * @param {number} value - The amount to increment
- */
- incrementDelay(value) {
- this.delay += value;
- }
}
+
+// Create the singleton instance
+const AnimationQueue = new AnimationQueueModule();
+
+// Register with the module registry
+moduleRegistry.register(AnimationQueue);
+
+// Export the module
+export { AnimationQueue };
+
+// Keep a reference in window for loader system
+window.AnimationQueue = AnimationQueue;
diff --git a/public/js/api-tts-handler.js b/public/js/api-tts-handler.js
new file mode 100644
index 0000000..fa3584a
--- /dev/null
+++ b/public/js/api-tts-handler.js
@@ -0,0 +1,280 @@
+/**
+ * ApiTTSHandler for AI Interactive Fiction
+ * Implementation using external TTS APIs like ElevenLabs
+ */
+import { TTSHandler } from './tts-handler.js';
+
+export class ApiTTSHandler extends TTSHandler {
+ constructor() {
+ super(); // Initialize the base TTSHandler
+ this.isReady = false;
+ this.enabled = false; // Disabled by default until options panel is implemented
+ this.audioElement = null;
+ // Set voice options through base class
+ this.voiceOptions = {
+ voice: '8JNqTOY3RaSYcHTVJZ0G', // Default ElevenLabs voice ID
+ model: 'eleven_multilingual_v1',
+ stability: 0,
+ similarityBoost: 0,
+ style: 0.5,
+ useSpeakerBoost: true
+ };
+ this.apiKey = 'd191e27c2e5b07573b39fe70f0783f48'; // From speech.js
+ this.apiUrl = 'https://api.elevenlabs.io/v1/text-to-speech';
+ this.voicesApiUrl = 'https://api.elevenlabs.io/v1/voices'; // Separate URL for voices endpoint
+ this.cache = new Map();
+ this.currentCallback = null;
+ }
+
+ /**
+ * Get the ID of this provider
+ * @returns {string} - Provider ID
+ */
+ getId() {
+ return 'api';
+ }
+
+ /**
+ * Initialize the API TTS system
+ * @param {Function} progressCallback - Optional callback for progress updates
+ * @returns {Promise} - Resolves to true if initialization was successful
+ */
+ async initialize(progressCallback = null) {
+ try {
+ if (progressCallback) progressCallback(20, 'Setting up API TTS');
+
+ // Create audio element for playback
+ this.audioElement = new Audio();
+
+ // Set up audio event listeners
+ this.audioElement.onended = () => {
+ if (this.currentCallback) {
+ const callback = this.currentCallback;
+ this.currentCallback = null;
+ callback();
+ }
+ };
+
+ this.audioElement.onerror = (error) => {
+ console.error('Audio playback error:', error);
+ if (this.currentCallback) {
+ const callback = this.currentCallback;
+ this.currentCallback = null;
+ callback();
+ }
+ };
+
+ if (progressCallback) progressCallback(80, 'API TTS ready');
+
+ // Only check API if enabled
+ if (this.enabled) {
+ // Check if the API is reachable with a simple request
+ try {
+ const testResponse = await fetch(this.voicesApiUrl, {
+ method: 'GET',
+ headers: {
+ 'xi-api-key': this.apiKey
+ }
+ });
+
+ if (testResponse.ok) {
+ this.isReady = true;
+ console.log('API TTS initialized successfully');
+ } else {
+ console.warn('API TTS initialized but API may not be accessible');
+ }
+ } catch (apiError) {
+ console.warn('Could not verify API access, but continuing:', apiError);
+ // We'll still mark as ready and try when speak is called
+ this.isReady = true;
+ }
+ } else {
+ console.log('API TTS is disabled by default. Enable via options panel when implemented.');
+ }
+
+ if (progressCallback) progressCallback(100, 'API TTS initialization complete');
+
+ return this.isReady;
+ } catch (error) {
+ console.error('Error initializing API TTS:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Check if API TTS is available
+ * @returns {boolean} - True if API TTS is ready to use
+ */
+ isAvailable() {
+ return this.isReady && this.enabled;
+ }
+
+ /**
+ * Generate an MD5 hash for text caching
+ * @param {string} text - Text to hash
+ * @returns {string} - MD5 hash
+ */
+ generateHash(text) {
+ // Simple hash function for client-side use
+ // For production, consider using a proper hashing library
+ let hash = 0;
+ if (text.length === 0) return hash.toString();
+
+ for (let i = 0; i < text.length; i++) {
+ const char = text.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash = hash & hash; // Convert to 32bit integer
+ }
+
+ return Math.abs(hash).toString(16);
+ }
+
+ /**
+ * Convert text to speech via API and play it
+ * @param {string} text - Text to speak
+ * @param {Function} callback - Called when speech completes
+ */
+ async speak(text, callback = null) {
+ if (!this.isAvailable() || !text) {
+ if (callback) callback();
+ return;
+ }
+
+ // Stop any current speech
+ this.stop();
+
+ // Set new callback
+ this.currentCallback = callback;
+
+ try {
+ // Check cache first
+ const cacheKey = this.generateHash(text + JSON.stringify(this.voiceOptions));
+ let audioUrl = this.cache.get(cacheKey);
+
+ if (!audioUrl) {
+ // Make API request to get audio
+ const response = await fetch(`${this.apiUrl}/${this.voiceOptions.voice}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'xi-api-key': this.apiKey
+ },
+ body: JSON.stringify({
+ text: text,
+ model_id: this.voiceOptions.model,
+ voice_settings: {
+ stability: this.voiceOptions.stability,
+ similarity_boost: this.voiceOptions.similarityBoost,
+ style: this.voiceOptions.style,
+ use_speaker_boost: this.voiceOptions.useSpeakerBoost
+ }
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error(`API returned ${response.status}: ${response.statusText}`);
+ }
+
+ // Get the audio data as blob
+ const audioBlob = await response.blob();
+ audioUrl = URL.createObjectURL(audioBlob);
+
+ // Store in cache
+ this.cache.set(cacheKey, audioUrl);
+ }
+
+ // Play the audio
+ this.audioElement.src = audioUrl;
+ await this.audioElement.play();
+
+ } catch (error) {
+ console.error('Error speaking with API TTS:', error);
+ if (this.currentCallback) {
+ const callback = this.currentCallback;
+ this.currentCallback = null;
+ callback();
+ }
+ }
+ }
+
+ /**
+ * Stop any ongoing speech
+ */
+ stop() {
+ if (this.audioElement) {
+ this.audioElement.pause();
+ this.audioElement.currentTime = 0;
+ }
+
+ if (this.currentCallback) {
+ const callback = this.currentCallback;
+ this.currentCallback = null;
+ callback();
+ }
+ }
+
+ /**
+ * Set voice options
+ * @param {Object} options - Voice options
+ */
+ setVoiceOptions(options = {}) {
+ if (options.voice !== undefined) this.voiceOptions.voice = options.voice;
+ if (options.model !== undefined) this.voiceOptions.model = options.model;
+ if (options.stability !== undefined) this.voiceOptions.stability = options.stability;
+ if (options.similarityBoost !== undefined) this.voiceOptions.similarityBoost = options.similarityBoost;
+ if (options.style !== undefined) this.voiceOptions.style = options.style;
+ if (options.useSpeakerBoost !== undefined) this.voiceOptions.useSpeakerBoost = options.useSpeakerBoost;
+ }
+
+ /**
+ * Get available voices from the API
+ * @returns {Promise} - Array of available voices
+ */
+ async getVoices() {
+ if (!this.enabled) {
+ return [];
+ }
+
+ try {
+ const response = await fetch(this.voicesApiUrl, {
+ method: 'GET',
+ 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;
+ }
+}
diff --git a/public/js/audio-manager.js b/public/js/audio-manager.js
index 89ce37a..2ad697c 100644
--- a/public/js/audio-manager.js
+++ b/public/js/audio-manager.js
@@ -2,11 +2,63 @@
* AudioManager Module
* Manages loading and playback of non-TTS audio effects triggered by tags.
*/
-export class AudioManager {
+import { BaseModule } from './base-module.js';
+import { moduleRegistry } from './module-registry.js';
+
+class AudioManagerModule extends BaseModule {
constructor() {
+ super('audio-manager', 'Audio Manager');
this.sounds = new Map();
this.currentAudio = null;
this.currentLoop = null;
+ this.masterVolume = 1.0;
+ this.musicVolume = 1.0;
+ this.sfxVolume = 1.0;
+ }
+
+ /**
+ * Load module dependencies
+ * @returns {Promise} - Resolves when dependencies are loaded
+ */
+ async loadDependencies() {
+ try {
+ this.reportProgress(40, "Initializing audio system");
+ return true;
+ } catch (error) {
+ console.error("Error loading AudioManager dependencies:", error);
+ return false;
+ }
+ }
+
+ /**
+ * Initialize the module
+ * @returns {Promise} - Resolves with success status
+ */
+ async initialize() {
+ try {
+ // Set up audio context if needed
+ this.setupAudioContext();
+
+ // Load some basic sound effects
+ this.reportProgress(80, "Loading sound effects");
+
+ this.reportProgress(100, "Audio system ready");
+ return true;
+ } catch (error) {
+ console.error("Error initializing AudioManager:", error);
+ return false;
+ }
+ }
+
+ /**
+ * Set up Web Audio API context if needed
+ */
+ setupAudioContext() {
+ // Only create if needed for advanced audio features
+ if (typeof AudioContext !== 'undefined' || typeof webkitAudioContext !== 'undefined') {
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
+ this.audioContext = new AudioContextClass();
+ }
}
/**
@@ -132,20 +184,53 @@ export class AudioManager {
}
/**
- * Set the volume for all sounds
+ * Set the master volume for all sounds
* @param {number} volume - The volume level (0.0 to 1.0)
*/
- setVolume(volume) {
+ setMasterVolume(volume) {
+ this.masterVolume = Math.max(0, Math.min(1, volume));
+ this.updateVolumes();
+ }
+
+ /**
+ * Set the music volume
+ * @param {number} volume - The volume level (0.0 to 1.0)
+ */
+ setMusicVolume(volume) {
+ this.musicVolume = Math.max(0, Math.min(1, volume));
+ // Apply to current loop if it exists
+ if (this.currentLoop) {
+ this.currentLoop.volume = this.masterVolume * this.musicVolume;
+ }
+ }
+
+ /**
+ * Set the sound effects volume
+ * @param {number} volume - The volume level (0.0 to 1.0)
+ */
+ setSfxVolume(volume) {
+ this.sfxVolume = Math.max(0, Math.min(1, volume));
+ // Apply to current non-loop audio if it exists
+ if (this.currentAudio) {
+ this.currentAudio.volume = this.masterVolume * this.sfxVolume;
+ }
+ }
+
+ /**
+ * Update all volume levels based on current settings
+ */
+ updateVolumes() {
this.sounds.forEach(audio => {
- audio.volume = volume;
+ const isMusic = audio.loop;
+ audio.volume = this.masterVolume * (isMusic ? this.musicVolume : this.sfxVolume);
});
if (this.currentAudio) {
- this.currentAudio.volume = volume;
+ this.currentAudio.volume = this.masterVolume * this.sfxVolume;
}
if (this.currentLoop) {
- this.currentLoop.volume = volume;
+ this.currentLoop.volume = this.masterVolume * this.musicVolume;
}
}
@@ -191,3 +276,15 @@ export class AudioManager {
});
}
}
+
+// Create the singleton instance
+const AudioManager = new AudioManagerModule();
+
+// Register with the module registry
+moduleRegistry.register(AudioManager);
+
+// Export the module
+export { AudioManager };
+
+// Keep a reference in window for loader system
+window.AudioManager = AudioManager;
diff --git a/public/js/base-module.js b/public/js/base-module.js
new file mode 100644
index 0000000..b0b636a
--- /dev/null
+++ b/public/js/base-module.js
@@ -0,0 +1,134 @@
+/**
+ * Base Module Class
+ * Provides common functionality and enforces a consistent interface for all modules
+ */
+export class BaseModule {
+ constructor(id, name) {
+ this.id = id;
+ this.name = name;
+ this.state = 'PENDING';
+ this.progress = 0;
+ this.progressCallback = null;
+ }
+
+ /**
+ * Initialize the module interface
+ * @param {Function} progressCallback - Function to report progress
+ * @returns {Promise} - Resolves when initialization is complete
+ */
+ async initializeInterface(progressCallback) {
+ this.progressCallback = progressCallback;
+
+ try {
+ this.changeState('LOADING');
+ this.reportProgress(10, "Starting initialization");
+
+ // Load dependencies
+ const depsLoaded = await this.loadDependencies();
+ if (!depsLoaded) {
+ this.changeState('ERROR');
+ this.reportProgress(100, "Failed to load dependencies");
+ return false;
+ }
+
+ const depStatus = await this.waitForDependencies();
+ if (!depStatus) {
+ // If dependencies aren't available, report waiting
+ this.changeState('WAITING');
+ return Promise.resolve(false);
+ }
+
+ this.changeState('INITIALIZING');
+ const initResult = await this.initialize();
+
+ if (initResult) {
+ this.changeState('FINISHED');
+ this.reportProgress(100, "Initialization complete");
+ } else {
+ this.changeState('ERROR');
+ this.reportProgress(100, "Initialization failed");
+ }
+
+ return initResult;
+ } catch (error) {
+ console.error(`Error in module ${this.id}:`, error);
+ this.changeState('ERROR');
+ this.reportProgress(100, "Error during initialization");
+ return Promise.resolve(false);
+ }
+ }
+
+ /**
+ * Load module dependencies - Override this in child classes
+ * @returns {Promise} - Resolves when dependencies are loaded
+ */
+ async loadDependencies() {
+ return Promise.resolve(true);
+ }
+
+ /**
+ * Wait for dependencies to be ready - Override this in child classes
+ * @returns {Promise} - Resolves when dependencies are ready
+ */
+ async waitForDependencies() {
+ return Promise.resolve(true);
+ }
+
+ /**
+ * Initialize the module - Override this in child classes
+ * @returns {Promise} - Resolves when initialization is complete
+ */
+ async initialize() {
+ return Promise.resolve(true);
+ }
+
+ /**
+ * Change the module state and dispatch an event
+ * @param {string} state - The new state
+ */
+ changeState(state) {
+ this.state = state;
+ document.dispatchEvent(new ModuleEvent('stateChange', this.id, { state }));
+ }
+
+ /**
+ * Report progress to the module loader
+ * @param {number} percent - Progress percentage (0-100)
+ * @param {string} message - Status message
+ */
+ reportProgress(percent, message) {
+ this.progress = percent;
+
+ if (this.progressCallback && typeof this.progressCallback === 'function') {
+ this.progressCallback(percent, message);
+ } else {
+ document.dispatchEvent(new ModuleEvent('progress', this.id, { progress: percent }));
+ if (message) {
+ document.dispatchEvent(new ModuleEvent('message', this.id, { message }));
+ }
+ }
+ }
+
+ /**
+ * Get the current module state
+ * @returns {string} - Current state
+ */
+ getState() {
+ return this.state;
+ }
+}
+
+/**
+ * Module Events - Used for communication between modules and the loader
+ */
+export class ModuleEvent extends CustomEvent {
+ constructor(type, moduleId, data = {}) {
+ super(`module:${type}`, {
+ detail: {
+ moduleId,
+ ...data
+ },
+ bubbles: true
+ });
+ }
+}
diff --git a/public/js/browser-tts-handler.js b/public/js/browser-tts-handler.js
new file mode 100644
index 0000000..b00573a
--- /dev/null
+++ b/public/js/browser-tts-handler.js
@@ -0,0 +1,198 @@
+/**
+ * BrowserTTSHandler for AI Interactive Fiction
+ * Implementation using the browser's Web Speech API
+ */
+import { TTSHandler } from './tts-handler.js';
+
+export class BrowserTTSHandler extends TTSHandler {
+ constructor() {
+ super(); // Initialize the base TTSHandler
+ this.synth = window.speechSynthesis;
+ this.utterance = null;
+ this.voices = [];
+ this.isReady = false;
+ // Initialize voice options through base class
+ this.voiceOptions = {
+ voice: '',
+ rate: 1.0,
+ pitch: 1.0,
+ volume: 1.0
+ };
+ }
+
+ /**
+ * Check if speech is currently playing
+ * @returns {boolean} - True if speaking
+ */
+ isSpeaking() {
+ return this.synth && this.synth.speaking;
+ }
+
+ /**
+ * Get the ID of this provider
+ * @returns {string} - Provider ID
+ */
+ getId() {
+ return 'browser';
+ }
+
+ /**
+ * Initialize the browser's speech synthesis
+ * @param {Function} progressCallback - Optional callback for progress updates
+ * @returns {Promise} - Resolves to true if initialization was successful
+ */
+ async initialize(progressCallback = null) {
+ if (!this.synth) {
+ console.warn('Web Speech API not supported in this browser');
+ return false;
+ }
+
+ try {
+ if (progressCallback) progressCallback(20, 'Loading speech synthesis');
+
+ // Get available voices
+ this.voices = await this.getVoices();
+
+ if (progressCallback) progressCallback(80, 'Speech synthesis loaded');
+
+ // If we have voices, we're ready
+ this.isReady = this.voices && this.voices.length > 0;
+
+ if (this.isReady) {
+ console.log('Browser TTS initialized with', this.voices.length, 'voices');
+ } else {
+ console.warn('Browser TTS initialized but no voices available');
+ }
+
+ if (progressCallback) progressCallback(100, 'Browser TTS ready');
+
+ return this.isReady;
+ } catch (error) {
+ console.error('Error initializing browser TTS:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Get available voices
+ * @returns {Promise} - Array of available voices
+ */
+ async getVoices() {
+ return new Promise((resolve) => {
+ // Some browsers get voices immediately, others need an event
+ const voices = this.synth.getVoices();
+
+ if (voices && voices.length > 0) {
+ resolve(voices);
+ } else {
+ // Wait for voiceschanged event
+ const voicesChangedHandler = () => {
+ this.synth.removeEventListener('voiceschanged', voicesChangedHandler);
+ resolve(this.synth.getVoices());
+ };
+
+ 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);
+ }
+ }
+ });
+ }
+
+ /**
+ * Check if browser TTS is available
+ * @returns {boolean} - True if browser TTS is ready to use
+ */
+ isAvailable() {
+ return this.isReady && this.synth;
+ }
+
+ /**
+ * Speak text using browser TTS
+ * @param {string} text - The text to speak
+ * @param {Function} callback - Called when speech completes
+ */
+ speak(text, callback = null) {
+ if (!this.isAvailable() || !text) {
+ if (callback) callback();
+ return;
+ }
+
+ // Stop any current speech
+ this.stop();
+
+ try {
+ // Create a new utterance
+ this.utterance = new SpeechSynthesisUtterance(text);
+
+ // Apply voice options
+ if (this.voiceOptions.voice) {
+ // Find the voice by name or URI
+ const selectedVoice = this.voices.find(v =>
+ v.name === this.voiceOptions.voice ||
+ v.voiceURI === this.voiceOptions.voice
+ );
+ if (selectedVoice) {
+ this.utterance.voice = selectedVoice;
+ }
+ }
+
+ // Apply other options
+ this.utterance.rate = this.voiceOptions.rate;
+ this.utterance.pitch = this.voiceOptions.pitch;
+ this.utterance.volume = this.voiceOptions.volume;
+
+ // Handle end of speech
+ this.utterance.onend = () => {
+ if (callback) callback();
+ };
+
+ // Handle errors
+ this.utterance.onerror = (e) => {
+ console.error('Speech synthesis error:', e);
+ if (callback) callback();
+ };
+
+ // Start speaking
+ this.synth.speak(this.utterance);
+ } catch (error) {
+ console.error('Error speaking with browser TTS:', error);
+ if (callback) callback();
+ }
+ }
+
+ /**
+ * Stop any ongoing speech
+ */
+ stop() {
+ if (this.synth) {
+ this.synth.cancel();
+ this.utterance = null;
+ }
+ }
+
+ /**
+ * Set voice options
+ * @param {Object} options - Voice options
+ */
+ setVoiceOptions(options = {}) {
+ if (options.voice !== undefined) this.voiceOptions.voice = options.voice;
+ if (options.rate !== undefined) this.voiceOptions.rate = options.rate;
+ if (options.pitch !== undefined) this.voiceOptions.pitch = options.pitch;
+ if (options.volume !== undefined) this.voiceOptions.volume = options.volume;
+ }
+}
diff --git a/public/js/debug-utils.js b/public/js/debug-utils.js
new file mode 100644
index 0000000..bc2ced6
--- /dev/null
+++ b/public/js/debug-utils.js
@@ -0,0 +1,152 @@
+/**
+ * Debug Utilities for AI Interactive Fiction
+ * Provides debugging and testing tools
+ */
+
+class DebugUtils {
+ /**
+ * Test the text processing pipeline with sample text
+ * @param {string} text - Test text to process
+ */
+ static testTextPipeline(text = "This is a test sentence. Let's see if it displays correctly!") {
+ console.log("Debug: Testing text pipeline with:", text);
+
+ // Find the text buffer
+ const textBuffer = window.TextBuffer || window.moduleRegistry?.getModule('text-buffer');
+ if (textBuffer) {
+ textBuffer.addText(text);
+ console.log("Debug: Text added to buffer");
+ return true;
+ } else {
+ console.error("Debug: TextBuffer not found");
+ return false;
+ }
+ }
+
+ /**
+ * Test the socket connection
+ */
+ static testSocketConnection() {
+ console.log("Debug: Testing socket connection");
+
+ // Find the socket client
+ const socketClient = window.SocketClient || window.moduleRegistry?.getModule('socket-client');
+ if (socketClient) {
+ if (socketClient.isConnected) {
+ console.log("Debug: Socket is connected");
+ return true;
+ } else {
+ console.log("Debug: Socket is not connected, attempting connection");
+ socketClient.connect();
+ return false;
+ }
+ } else {
+ console.error("Debug: SocketClient not found");
+ return false;
+ }
+ }
+
+ /**
+ * Test the TTS system
+ * @param {string} text - Test text to speak
+ */
+ static testTTS(text = "This is a test of the text to speech system.") {
+ console.log("Debug: Testing TTS with:", text);
+
+ // Find the TTS player
+ const ttsPlayer = window.TTSPlayer || window.moduleRegistry?.getModule('tts');
+ if (ttsPlayer) {
+ const wasEnabled = ttsPlayer.isEnabled();
+
+ // Enable TTS temporarily if it was disabled
+ if (!wasEnabled && ttsPlayer.toggle) {
+ ttsPlayer.toggle();
+ }
+
+ // Speak the text
+ ttsPlayer.speak(text, (result) => {
+ console.log("Debug: TTS completed with result:", result);
+
+ // Restore previous enabled state
+ if (!wasEnabled && ttsPlayer.toggle) {
+ ttsPlayer.toggle();
+ }
+ });
+
+ return true;
+ } else {
+ console.error("Debug: TTSPlayer not found");
+ return false;
+ }
+ }
+
+ /**
+ * Force all modules to reconnect
+ */
+ static forceReconnect() {
+ console.log("Debug: Forcing module reconnection");
+
+ // Get all modules
+ const registry = window.moduleRegistry;
+ if (!registry) {
+ console.error("Debug: Module registry not found");
+ return false;
+ }
+
+ const modules = registry.getAllModules();
+
+ // UI Controller
+ const uiController = modules['ui-controller'];
+ if (uiController) {
+ if (uiController.textBuffer === null) {
+ uiController.textBuffer = modules['text-buffer'];
+ console.log("Debug: Reconnected UI Controller to Text Buffer");
+
+ // Reinitialize text buffer
+ if (uiController.initializeTextBuffer) {
+ uiController.initializeTextBuffer();
+ }
+ }
+
+ if (uiController.ttsHandler === null) {
+ uiController.ttsHandler = modules['tts'];
+ console.log("Debug: Reconnected UI Controller to TTS Player");
+ }
+ }
+
+ // Socket Client
+ const socketClient = modules['socket-client'];
+ if (socketClient) {
+ if (socketClient.textBuffer === null) {
+ socketClient.textBuffer = modules['text-buffer'];
+ console.log("Debug: Reconnected Socket Client to Text Buffer");
+ }
+ }
+
+ // Game Loop
+ const gameLoop = modules['game-loop'];
+ if (gameLoop) {
+ if (gameLoop.uiController === null) {
+ gameLoop.uiController = modules['ui-controller'];
+ console.log("Debug: Reconnected Game Loop to UI Controller");
+ }
+
+ if (gameLoop.socketClient === null) {
+ gameLoop.socketClient = modules['socket-client'];
+ console.log("Debug: Reconnected Game Loop to Socket Client");
+ }
+
+ if (gameLoop.textBuffer === null) {
+ gameLoop.textBuffer = modules['text-buffer'];
+ console.log("Debug: Reconnected Game Loop to Text Buffer");
+ }
+ }
+
+ return true;
+ }
+}
+
+// Export as global for easy console access
+window.DebugUtils = DebugUtils;
+
+export { DebugUtils };
diff --git a/public/js/game-loop.js b/public/js/game-loop.js
new file mode 100644
index 0000000..31c47be
--- /dev/null
+++ b/public/js/game-loop.js
@@ -0,0 +1,251 @@
+/**
+ * Game Loop Module for AI Interactive Fiction
+ * Manages the main game logic and connects various modules
+ */
+import { BaseModule } from './base-module.js';
+import { moduleRegistry } from './module-registry.js';
+
+class GameLoopModule extends BaseModule {
+ constructor() {
+ super('game-loop', 'Game Loop');
+ this.uiController = null;
+ this.socketClient = null;
+ this.ttsPlayer = null;
+ this.textBuffer = null;
+ this.isRunning = false;
+ this.gameState = {
+ started: false,
+ canLoad: false,
+ currentRoom: null,
+ inventory: [],
+ commandHistory: []
+ };
+ }
+
+ /**
+ * 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} - Resolves with success status
+ */
+ async initialize() {
+ this.reportProgress(100, "Game loop initialized");
+ return true;
+ }
+
+ /**
+ * Start the game loop
+ */
+ start() {
+ console.log("GameLoop: Starting game sequence...");
+
+ try {
+ // Update UI with initial game state
+ if (this.uiController && this.ttsPlayer) {
+ this.updateUIState();
+ } else {
+ console.warn("GameLoop: UI Controller or TTS Player not ready for status update.");
+ }
+
+ console.log("GameLoop: Setting up socket listeners and connecting...");
+
+ // Set up socket event listeners and connect
+ this.setupSocketEventListeners();
+
+ // Set the game loop as running
+ this.isRunning = true;
+ } catch (error) {
+ console.error("Error starting game loop:", error);
+ }
+ }
+
+ /**
+ * Set up socket event listeners and connect to server
+ */
+ setupSocketEventListeners() {
+ // Get the socket client module
+ this.socketClient = moduleRegistry.getModule('socket-client');
+
+ if (!this.socketClient) {
+ console.error("Socket client module not found");
+ return;
+ }
+
+ // Connect UI controller to socket client for command handling
+ if (this.uiController) {
+ this.uiController.socketClient = this.socketClient;
+ } else {
+ console.warn("GameLoop: UI Controller not ready for Socket Client assignment.");
+ }
+
+ // Listen for socket connection event
+ this.socketClient.on('connect', () => {
+ console.log("GameLoop: Socket connected event received.");
+
+ // Request a new game start when we (re)connect
+ console.log("GameLoop: Requesting start game on (re)connect.");
+ this.requestStartGame();
+ });
+
+ // Listen for game state updates
+ this.socketClient.on('gameStateUpdate', (data) => {
+ console.log("GameLoop: Game state update received", data);
+ this.updateGameState(data);
+ });
+
+ // Listen for narrative responses
+ this.socketClient.on('narrativeResponse', (data) => {
+ console.log("GameLoop: Narrative response received", data);
+ // Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline
+ });
+
+ // Listen for game introduction
+ this.socketClient.on('gameIntroduction', (data) => {
+ console.log("GameLoop: Received gameIntroduction");
+ this.gameState.started = true;
+ this.updateUIState();
+ // Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline
+ });
+
+ // Connect to the socket server
+ this.socketClient.connect().then(success => {
+ if (success) {
+ console.log("GameLoop: Socket connection established successfully.");
+ console.log("GameLoop: Requesting to start a new game");
+ this.requestStartGame();
+ } else {
+ console.error("GameLoop: Failed to connect to socket server");
+ }
+ });
+ }
+
+ /**
+ * Update the game state
+ * @param {Object} data - New game state data
+ */
+ updateGameState(data) {
+ if (!data) return;
+
+ // Update game state
+ if (data.currentRoom) {
+ this.gameState.currentRoom = data.currentRoom;
+ }
+
+ if (data.inventory) {
+ this.gameState.inventory = data.inventory;
+ }
+
+ // Update UI with new game state
+ this.updateUIState();
+ }
+
+ /**
+ * Update UI with current game state
+ */
+ updateUIState() {
+ if (!this.uiController) return;
+
+ // Update UI components based on game state
+ this.uiController.updateButtonStates(this.gameState);
+ }
+
+ /**
+ * Request to start a new game
+ */
+ requestStartGame() {
+ if (!this.socketClient) return;
+
+ this.socketClient.requestStartGame();
+ this.gameState.started = true;
+ }
+
+ /**
+ * Request to save the current game
+ */
+ requestSaveGame() {
+ if (!this.socketClient) return;
+
+ this.socketClient.requestSaveGame();
+ }
+
+ /**
+ * Request to load a saved game
+ */
+ requestLoadGame() {
+ if (!this.socketClient) return;
+
+ this.socketClient.requestLoadGame();
+ }
+
+ /**
+ * Manually add text to the buffer
+ * Useful for testing or adding local messages
+ * @param {string} text - Text to add
+ */
+ addText(text) {
+ if (!this.textBuffer) {
+ console.warn("Text buffer not available");
+ return;
+ }
+
+ this.textBuffer.addText(text);
+ }
+}
+
+// Create the singleton instance
+const GameLoop = new GameLoopModule();
+
+// Register with the module registry
+moduleRegistry.register(GameLoop);
+
+// Export the module
+export { GameLoop };
+
+// Keep a reference in window for loader system
+window.GameLoop = GameLoop;
diff --git a/public/js/hyphenopoly.module.js b/public/js/hyphenopoly.module.js
new file mode 100644
index 0000000..dda742c
--- /dev/null
+++ b/public/js/hyphenopoly.module.js
@@ -0,0 +1,677 @@
+/**
+ * @license Hyphenopoly.module.js 6.0.0 - hyphenation for node
+ * ©2024 Mathias Nater, Güttingen (mathiasnater at gmail dot com)
+ * https://github.com/mnater/Hyphenopoly
+ *
+ * Released under the MIT license
+ * http://mnater.github.io/Hyphenopoly/LICENSE
+ */
+
+/* eslint-env node */
+
+const decode = (() => {
+ const utf16ledecoder = new TextDecoder("utf-16le");
+ return (ui16) => {
+ return utf16ledecoder.decode(ui16);
+ };
+})();
+
+/**
+ * Create Object without standard Object-prototype
+ * @returns {Object} empty object
+ */
+const empty = () => {
+ return Object.create(null);
+};
+
+const H = empty();
+
+H.supportedLanguages = [
+ "af",
+ "as",
+ "be",
+ "bg",
+ "bn",
+ "ca",
+ "cs",
+ "cy",
+ "da",
+ "de",
+ "de-x-syllable",
+ "el-monoton",
+ "el-polyton",
+ "en-gb",
+ "en-us",
+ "eo",
+ "es",
+ "et",
+ "eu",
+ "fi",
+ "fo",
+ "fr",
+ "fur",
+ "ga",
+ "gl",
+ "gu",
+ "hi",
+ "hr",
+ "hsb",
+ "hu",
+ "hy",
+ "ia",
+ "id",
+ "is",
+ "it",
+ "ka",
+ "kmr",
+ "kn",
+ "la",
+ "lt",
+ "lv",
+ "mk",
+ "ml",
+ "mn-cyrl",
+ "mr",
+ "nb",
+ "nl",
+ "nn",
+ "no",
+ "oc",
+ "or",
+ "pa",
+ "pi",
+ "pl",
+ "pms",
+ "pt",
+ "rm",
+ "ro",
+ "ru",
+ "sh-cyrl",
+ "sh-latn",
+ "sk",
+ "sl",
+ "sq",
+ "sr-cyrl",
+ "sv",
+ "ta",
+ "te",
+ "th",
+ "tk",
+ "tr",
+ "uk",
+ "zh-latn-pinyin"
+];
+
+H.languages = new Map();
+
+/**
+ * Create lang Object
+ * @param {string} lang The language
+ * @returns {Object} The newly created lang object
+ */
+function createLangObj(lang) {
+ if (!H.languages.has(lang)) {
+ H.languages.set(lang, empty());
+ }
+ return H.languages.get(lang);
+}
+
+/**
+ * Setup a language object (lo) and dispatch "engineReady"
+ * @param {string} lang The language
+ * @param {function} hyphenateFunction The hyphenateFunction
+ * @param {string} alphabet List of used characters
+ * @param {number} patternLeftmin leftmin as defined in patterns
+ * @param {number} patternRightmin rightmin as defined in patterns
+ * @returns {undefined}
+ */
+function prepareLanguagesObj(
+ lang,
+ hyphenateFunction,
+ alphabet,
+ patternLeftmin,
+ patternRightmin
+) {
+ alphabet = alphabet.replace(/\\*-/g, "\\-");
+ const lo = createLangObj(lang);
+ if (!lo.engineReady) {
+ lo.cache = new Map();
+ const exc = [];
+ if (H.c.exceptions.has(lang)) {
+ exc.push(...H.c.exceptions.get(lang).split(", "));
+ }
+ if (H.c.exceptions.has("global")) {
+ exc.push(...H.c.exceptions.get("global").split(", "));
+ }
+ lo.exceptions = new Map(exc.map((e) => {
+ return [e.replace(/-/g, ""), e];
+ }));
+
+ lo.alphabet = alphabet;
+ lo.reNotAlphabet = RegExp(`[^${alphabet}]`, "i");
+ lo.lm = Math.max(
+ patternLeftmin,
+ H.c.leftmin,
+ H.c.leftminPerLang.get(lang) || 0
+ );
+ lo.rm = Math.max(
+ patternRightmin,
+ H.c.rightmin,
+ H.c.rightminPerLang.get(lang) || 0
+ );
+ lo.hyphenate = hyphenateFunction;
+ lo.engineReady = true;
+ }
+ H.events.dispatch("engineReady", {"msg": lang});
+}
+
+/**
+ * Setup env for hyphenateFunction
+ * @param {Object} baseData baseData
+ * @param {function} hyphenateFunc hyphenateFunction
+ * @returns {function} hyphenateFunction with closured environment
+ */
+function encloseHyphenateFunction(buf, hyphenateFunc) {
+ const wordStore = new Uint16Array(buf, 0, 64);
+
+ /**
+ * The hyphenateFunction that encloses the env above
+ * Copies the word to wasm-Memory, calls wasm.hyphenateFunc and reads
+ * the hyphenated word from wasm-Memory (eventually replacing hyphenchar)
+ * @param {String} word - the word that has to be hyphenated
+ * @param {String} hyphenchar - the hyphenate character
+ * @param {Number} leftmin - min number of chars to remain on line
+ * @param {Number} rightmin - min number of chars to go to new line
+ * @returns {String} the hyphenated word
+ */
+ return ((word, hyphencc, leftmin, rightmin) => {
+ wordStore.set([
+ ...[...word].map((c) => {
+ return c.charCodeAt(0);
+ }),
+ 0
+ ]);
+ const len = hyphenateFunc(leftmin, rightmin, hyphencc);
+ if (len > 0) {
+ word = decode(new Uint16Array(buf, 0, len));
+ }
+ return word;
+ });
+}
+
+/**
+ * Instantiate Wasm Engine
+ * @param {string} lang The language
+ * @returns {undefined}
+ */
+function instantiateWasmEngine(lang, wasmdata) {
+ /**
+ * Register character substitutions in the .wasm-hyphenEngine
+ * @param {number} alphalen - The length of the alphabet
+ * @param {object} exp - Export-object of the hyphenEngine
+ */
+ function registerSubstitutions(alphalen, exp) {
+ if (H.c.substitute.has(lang)) {
+ const subst = H.c.substitute.get(lang);
+ subst.forEach((substituer, substituted) => {
+ const substitutedU = substituted.toUpperCase();
+ const substitutedUcc = (substitutedU === substituted)
+ ? 0
+ : substitutedU.charCodeAt(0);
+ alphalen = exp.subst(
+ substituted.charCodeAt(0),
+ substitutedUcc,
+ substituer.charCodeAt(0)
+ );
+ });
+ }
+ return alphalen;
+ }
+
+ /**
+ * Instantiate the hyphenEngine
+ * @param {object} res - The fetched ressource
+ */
+ function handleWasm(inst) {
+ const exp = inst.exports;
+ let alphalen = exp.lct.value;
+ alphalen = registerSubstitutions(alphalen, exp);
+ prepareLanguagesObj(
+ lang,
+ encloseHyphenateFunction(
+ exp.mem.buffer,
+ exp.hyphenate
+ ),
+ decode(new Uint16Array(exp.mem.buffer, 1664, alphalen)),
+ exp.lmi.value,
+ exp.rmi.value
+ );
+ }
+ if (H.c.sync) {
+ const heInstance = new WebAssembly.Instance(
+ new WebAssembly.Module(wasmdata)
+ );
+ handleWasm(heInstance);
+ } else {
+ WebAssembly.instantiate(wasmdata).then((res) => {
+ handleWasm(res.instance);
+ });
+ }
+}
+
+/**
+ * Read a .wasm file and call instantiateWasmEngine on success
+ * @param {string} lang - The language
+ * @returns {undefined}
+ */
+function loadHyphenEngine(lang) {
+ const file = `${lang}.wasm`;
+ // eslint-disable-next-line require-jsdoc
+ const cb = (err, data) => {
+ if (err) {
+ H.events.dispatch("error", {
+ "key": lang,
+ "msg": `${lang}.wasm not found.`
+ });
+ } else {
+ instantiateWasmEngine(lang, new Uint8Array(data).buffer);
+ }
+ };
+
+ if (typeof H.c.loader !== "function") {
+ H.events.dispatch("error", {
+ "msg": "Loader must be a function."
+ });
+ return;
+ }
+
+ if (H.c.sync) {
+ cb(null, H.c.loaderSync(file, new URL('./patterns/', import.meta.url)));
+ } else {
+ H.c.loader(file, new URL('./patterns/', import.meta.url)).then(
+ (res) => {
+ cb(null, res);
+ },
+ (err) => {
+ cb(err, null);
+ }
+ );
+ }
+}
+
+const wordHyphenatorPool = new Map();
+
+/**
+ * Factory for hyphenatorFunctions for a specific language and class
+ * @param {Object} lo Language-Object
+ * @param {string} lang The language
+ * @returns {function} The hyphenate function
+ */
+function createWordHyphenator(lo, lang) {
+ if (wordHyphenatorPool.has(lang)) {
+ return wordHyphenatorPool.get(lang);
+ }
+
+ /**
+ * HyphenateFunction for non-compound words
+ * @param {string} word The word
+ * @returns {string} The hyphenated word
+ */
+ function hyphenateNormal(word) {
+ if (word.length > 61) {
+ H.events.dispatch("error", {"msg": "found word longer than 61 characters"});
+ } else if (!lo.reNotAlphabet.test(word)) {
+ return lo.hyphenate(
+ word,
+ H.c.hyphen.charCodeAt(0),
+ lo.lm,
+ lo.rm
+ );
+ }
+ return word;
+ }
+
+ /**
+ * HyphenateFunction for compound words
+ * @param {string} word The word
+ * @returns {string} The hyphenated compound word
+ */
+ function hyphenateCompound(word) {
+ let joiner = "-";
+ const parts = word.split(joiner).map((p) => {
+ if (H.c.compound !== "hyphen" &&
+ p.length >= H.c.minWordLength) {
+ return createWordHyphenator(lo, lang)(p);
+ }
+ return p;
+ });
+ if (H.c.compound !== "auto") {
+ // Add Zero Width Space
+ joiner += "\u200B";
+ }
+ return parts.join(joiner);
+ }
+
+ /**
+ * Checks if a string is mixed case
+ * @param {string} s The string
+ * @returns {boolean} true if s is mixed case
+ */
+ function isMixedCase(s) {
+ return Array.prototype.map.call(s, (c) => {
+ return (c === c.toLowerCase());
+ }).some((v, i, a) => {
+ return (v !== a[0]);
+ });
+ }
+
+ /**
+ * HyphenateFunction for words (compound or not)
+ * @param {string} word The word
+ * @returns {string} The hyphenated word
+ */
+ function hyphenator(word) {
+ let hw = lo.cache.get(word);
+ if (!hw) {
+ if (lo.exceptions.has(word)) {
+ hw = lo.exceptions.get(word).replace(
+ /-/g,
+ H.c.hyphen
+ );
+ } else if (!H.c.mixedCase && isMixedCase(word)) {
+ hw = word;
+ } else if (word.includes("-")) {
+ hw = hyphenateCompound(word);
+ } else {
+ hw = hyphenateNormal(word);
+ }
+ lo.cache.set(word, hw);
+ }
+ return hw;
+ }
+ wordHyphenatorPool.set(lang, hyphenator);
+ return hyphenator;
+}
+
+const orphanController = (() => {
+ /**
+ * Function template
+ * @param {string} ignore unused result of replace
+ * @param {string} leadingWhiteSpace The leading whiteSpace
+ * @param {string} lastWord The last word
+ * @param {string} trailingWhiteSpace The trailing whiteSpace
+ * @returns {string} Treated end of text
+ */
+ function controlOrphans(
+ ignore,
+ leadingWhiteSpace,
+ lastWord,
+ trailingWhiteSpace
+ ) {
+ let h = H.c.hyphen;
+ if (".\\+*?[^]$(){}=!<>|:-".indexOf(H.c.hyphen) !== -1) {
+ h = `\\${H.c.hyphen}`;
+ }
+ if (H.c.orphanControl === 3 && leadingWhiteSpace === " ") {
+ // \u00A0 = no-break space (nbsp)
+ leadingWhiteSpace = "\u00A0";
+ }
+ /* eslint-disable security/detect-non-literal-regexp */
+ return leadingWhiteSpace + lastWord.replace(new RegExp(h, "g"), "") + trailingWhiteSpace;
+ /* eslint-enable security/detect-non-literal-regexp */
+ }
+ return controlOrphans;
+})();
+
+/**
+ * Encloses hyphenateTextFunction
+ * @param {string} lang - The language
+ * @return {function} The hyphenateText-function
+ */
+function createTextHyphenator(lang) {
+ const lo = H.languages.get(lang);
+ const wordHyphenator = (wordHyphenatorPool.has(lang))
+ ? wordHyphenatorPool.get(lang)
+ : createWordHyphenator(lo, lang);
+
+ /*
+ * Transpiled RegExp of
+ * /[${alphabet}\p{Letter}-]{${minwordlength},}/gui
+ */
+ const reWord = RegExp(
+ `[${lo.alphabet}a-z\u0300-\u036F\u0483-\u0487\u00DF-\u00F6\u00F8-\u00FE\u0101\u0103\u0105\u0107\u0109\u010D\u010F\u0111\u0113\u0117\u0119\u011B\u011D\u011F\u0123\u0125\u012B\u012F\u0131\u0135\u0137\u013C\u013E\u0142\u0144\u0146\u0148\u014D\u0151\u0153\u0155\u0159\u015B\u015D\u015F\u0161\u0165\u016B\u016D\u016F\u0171\u0173\u017A\u017C\u017E\u017F\u01CE\u01D0\u01D2\u01D4\u01D6\u01D8\u01DA\u01DC\u0219\u021B\u02BC\u0390\u03AC-\u03CE\u03D0\u03E3\u03E5\u03E7\u03E9\u03EB\u03ED\u03EF\u03F2\u0430-\u044F\u0451-\u045C\u045E\u045F\u0491\u04AF\u04E9\u0561-\u0585\u0587\u0905-\u090C\u090F\u0910\u0913-\u0928\u092A-\u0930\u0932\u0933\u0935-\u0939\u093D\u0960\u0961\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A85-\u0A8B\u0A8F\u0A90\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AE0\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B60\u0B61\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB5\u0BB7-\u0BB9\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D60\u0D61\u0D7A-\u0D7F\u0E01-\u0E2E\u0E30\u0E32\u0E33\u0E40-\u0E45\u10D0-\u10F0\u1200-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u1E0D\u1E37\u1E41\u1E43\u1E45\u1E47\u1E6D\u1F00-\u1F07\u1F10-\u1F15\u1F20-\u1F27\u1F30-\u1F37\u1F40-\u1F45\u1F50-\u1F57\u1F60-\u1F67\u1F70-\u1F7D\u1F80-\u1F87\u1F90-\u1F97\u1FA0-\u1FA7\u1FB2-\u1FB4\u1FB6\u1FB7\u1FC2-\u1FC4\u1FC6\u1FC7\u1FD2\u1FD3\u1FD6\u1FD7\u1FE2-\u1FE7\u1FF2-\u1FF4\u1FF6\u1FF7\u2C81\u2C83\u2C85\u2C87\u2C89\u2C8D\u2C8F\u2C91\u2C93\u2C95\u2C97\u2C99\u2C9B\u2C9D\u2C9F\u2CA1\u2CA3\u2CA5\u2CA7\u2CA9\u2CAB\u2CAD\u2CAF\u2CB1\u2CC9\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E-]{${H.c.minWordLength},}`, "gui"
+ );
+
+ /**
+ * Hyphenate text
+ * @param {string} text The text
+ * @param {string} lang The language of the text
+ * @returns {string} Hyphenated text
+ */
+ return ((text) => {
+ if (H.c.normalize) {
+ text = text.normalize("NFC");
+ }
+ let tn = text.replace(reWord, wordHyphenator);
+ if (H.c.orphanControl !== 1) {
+ tn = tn.replace(
+ /(\u0020*)(\S+)(\s*)$/,
+ orphanController
+ );
+ }
+ return tn;
+ });
+}
+
+(() => {
+ // Events known to the system
+ const definedEvents = new Map();
+
+ /**
+ * Create Event Object
+ * @param {string} name The Name of the event
+ * @param {function|null} defFunc The default method of the event
+ * @param {boolean} cancellable Is the default cancellable
+ * @returns {undefined}
+ */
+ function define(name, defFunc, cancellable) {
+ definedEvents.set(name, {
+ cancellable,
+ "default": defFunc,
+ "register": []
+ });
+ }
+
+ define(
+ "error",
+ (e) => {
+ // eslint-disable-next-line no-console
+ console.error(e.msg);
+ },
+ true
+ );
+
+ define(
+ "engineReady",
+ null,
+ false
+ );
+
+ /**
+ * Dispatch event with arguments
+ * @param {string} name The name of the event
+ * @param {Object|undefined} data Data of the event
+ * @returns {undefined}
+ */
+ function dispatch(name, data) {
+ data.defaultPrevented = false;
+ data.preventDefault = (() => {
+ data.defaultPrevented = true;
+ });
+ definedEvents.get(name).register.forEach((currentHandler) => {
+ currentHandler(data);
+ });
+ if (!data.defaultPrevented && definedEvents.get(name).default) {
+ definedEvents.get(name).default(data);
+ }
+ }
+
+ /**
+ * Add EventListender to event
+ * @param {string} name The name of the event
+ * @param {function} handler Function to register
+ * @returns {undefined}
+ */
+ function addListener(name, handler) {
+ if (definedEvents.has(name)) {
+ definedEvents.get(name).register.push(handler);
+ } else {
+ H.events.dispatch(
+ "error",
+ {"msg": `unknown Event "${name}" discarded`}
+ );
+ }
+ }
+
+ H.events = empty();
+ H.events.dispatch = dispatch;
+ H.events.addListener = addListener;
+})();
+
+/**
+ * Create a Map with a default Map behind the scenes. This mimics
+ * kind of a prototype chain of an object, but without the object-
+ * injection security risk.
+ *
+ * @param {Map} defaultsMap - A Map with default values
+ * @returns {Proxy} - A Proxy for the Map (dot-notation or get/set)
+ */
+function createMapWithDefaults(defaultsMap) {
+ const userMap = new Map();
+
+ /**
+ * The get-trap: get the value from userMap or else from defaults
+ * @param {Sring} key - The key to retrieve the value for
+ * @returns {*}
+ */
+ function get(key) {
+ return (userMap.has(key))
+ ? userMap.get(key)
+ : defaultsMap.get(key);
+ }
+
+ /**
+ * The set-trap: set the value to userMap and don't touch defaults
+ * @param {Sring} key - The key for the value
+ * @param {*} value - The value
+ * @returns {*}
+ */
+ function set(key, value) {
+ userMap.set(key, value);
+ }
+ return new Proxy(defaultsMap, {
+ "get": (_target, prop) => {
+ if (prop === "set") {
+ return set;
+ }
+ if (prop === "get") {
+ return get;
+ }
+ return get(prop);
+ }
+ });
+}
+
+/**
+ * Default loader emits error
+ * @returns null
+ */
+function defaultLoader() {
+ H.events.dispatch("error", {
+ "msg": "loader/loaderSync has not been configured."
+ });
+ return null;
+}
+
+H.config = ((userConfig) => {
+ const settings = createMapWithDefaults(new Map([
+ ["compound", "hyphen"],
+ ["exceptions", new Map()],
+ ["hyphen", "\u00AD"],
+ ["leftmin", 0],
+ ["leftminPerLang", new Map()],
+ ["loader", defaultLoader],
+ ["loaderSync", defaultLoader],
+ ["minWordLength", 6],
+ ["mixedCase", true],
+ ["normalize", false],
+ ["orphanControl", 1],
+ ["require", []],
+ ["rightmin", 0],
+ ["rightminPerLang", new Map()],
+ ["substitute", new Map()],
+ ["sync", false]
+ ]));
+ Object.entries(userConfig).forEach(([key, value]) => {
+ switch (key) {
+ case "exceptions":
+ case "leftminPerLang":
+ case "paths":
+ case "rightminPerLang":
+ Object.entries(value).forEach(([k, v]) => {
+ settings.get(key).set(k, v);
+ });
+ break;
+ case "substitute":
+ Object.entries(value).forEach(([lang, subst]) => {
+ settings.substitute.set(
+ lang,
+ new Map(Object.entries(subst))
+ );
+ });
+ break;
+ default:
+ settings.set(key, value);
+ }
+ });
+ H.c = settings;
+ if (H.c.handleEvent) {
+ Object.entries(H.c.handleEvent).forEach(([name, fn]) => {
+ H.events.addListener(name, fn);
+ });
+ }
+ const result = new Map();
+ if (H.c.require.length === 0) {
+ H.events.dispatch(
+ "error",
+ {"msg": "No language has been required. Setup config according to documenation."}
+ );
+ }
+ H.c.require.forEach((lang) => {
+ if (H.c.sync) {
+ H.events.addListener("engineReady", (e) => {
+ if (e.msg === lang) {
+ result.set(lang, createTextHyphenator(lang));
+ }
+ });
+ } else {
+ const prom = new Promise((resolve, reject) => {
+ H.events.addListener("engineReady", (e) => {
+ if (e.msg === lang) {
+ resolve(createTextHyphenator(lang));
+ }
+ });
+ H.events.addListener("error", (e) => {
+ if (e.key === lang) {
+ reject(e.msg);
+ }
+ });
+ });
+ result.set(lang, prom);
+ }
+ loadHyphenEngine(lang);
+ });
+ return result;
+});
+
+export default H;
diff --git a/public/js/ink-story-player.js b/public/js/ink-story-player.js
deleted file mode 100644
index c6f5f18..0000000
--- a/public/js/ink-story-player.js
+++ /dev/null
@@ -1,719 +0,0 @@
-/**
- * InkStoryPlayer Module
- * Orchestrates the narrative flow specific to the Ink story.
- */
-export class InkStoryPlayer {
- /**
- * Create a new InkStoryPlayer
- * @param {Object} config - Configuration options
- * @param {Function} config.InkStory - The inkjs.Story constructor
- * @param {Object} config.textProcessor - The TextProcessor instance
- * @param {Object} config.paragraphLayout - The ParagraphLayout instance
- * @param {Object} config.layoutRenderer - The LayoutRenderer instance
- * @param {Object} config.audioManager - The AudioManager instance
- * @param {Object} config.ttsPlayer - The TtsPlayer instance
- * @param {Object} config.persistenceManager - The PersistenceManager instance
- */
- constructor(config = {}) {
- this.InkStory = config.InkStory;
- this.textProcessor = config.textProcessor;
- this.paragraphLayout = config.paragraphLayout;
- this.layoutRenderer = config.layoutRenderer;
- this.audioManager = config.audioManager;
- this.ttsPlayer = config.ttsPlayer;
- this.persistenceManager = config.persistenceManager;
-
- this.story = null;
- this.storyContainer = document.getElementById('story');
- this.choiceContainer = document.getElementById('choices');
- this.savePoint = "";
- this.running = false;
- this.keyEventListener = null;
- this.indentedParagraphs = 0;
- this.chapterBegin = false;
- this.measure = [];
- this.locale = 'en-us';
- this.translations = {};
- }
-
- /**
- * Load a story from JSON content
- * @param {Object} storyContent - The compiled Ink story JSON
- * @param {string} initialState - Optional initial state to load
- */
- loadStory(storyContent, initialState = null) {
- this.story = new this.InkStory(storyContent);
-
- if (initialState) {
- this.story.state.LoadJson(initialState);
- }
-
- this.savePoint = this.story.state.toJson();
-
- // Process global tags
- this.processGlobalTags();
- }
-
- /**
- * Process global tags from the story
- */
- processGlobalTags() {
- if (!this.story || !this.story.globalTags) return;
-
- for (let i = 0; i < this.story.globalTags.length; i++) {
- const globalTag = this.story.globalTags[i];
- const splitTag = this.splitPropertyTag(globalTag);
-
- if (splitTag && splitTag.property == "title") {
- const title = document.querySelector('.title');
- if (title) title.innerHTML = splitTag.val;
- } else if (splitTag && splitTag.property == "author") {
- const byline = document.querySelector('.byline');
- if (byline) byline.textContent += splitTag.val;
- } else if (splitTag && splitTag.property == "subtitle") {
- const subtitle = document.querySelector('.subtitle');
- if (subtitle) subtitle.textContent += splitTag.val;
- }
- }
- }
-
- /**
- * Continue the story
- * @param {boolean} firstTime - Whether this is the first time continuing
- */
- async continueStory(firstTime = true) {
- if (!this.story) {
- console.error('No story loaded');
- return;
- }
-
- this.running = true;
- this.layoutRenderer.setFastForwardingAll(false);
- let chapterBegin = false;
-
- if (this.keyEventListener) {
- window.removeEventListener('keypress', this.keyEventListener);
- this.keyEventListener = null;
- }
-
- document.querySelectorAll('#story p').forEach((p) => {
- p.classList.remove("latest-paragraph");
- });
-
- // Generate story text - loop through available content
- while (this.story.canContinue) {
- if (this.layoutRenderer.getFastForwardingAll()) {
- return;
- }
-
- if (this.layoutRenderer.animationQueue) {
- this.layoutRenderer.animationQueue.setDelay(0.0);
- }
-
- // Get ink to generate the next paragraph
- const paragraphText = this.story.Continue();
- const tags = this.story.currentTags;
-
- // Process tags and get custom classes
- const customClasses = this.processTags(tags);
-
- // Skip empty paragraphs
- if (paragraphText.trim().length === 0) {
- continue;
- }
-
- // Process paragraph text and layout
- await this.processParagraph(paragraphText, customClasses, chapterBegin);
-
- chapterBegin = false;
- }
-
- if (this.layoutRenderer.animationQueue) {
- this.layoutRenderer.animationQueue.setDelay(0.0);
- }
-
- // Process choices
- await this.processChoices();
-
- // Dispatch turn complete event
- const tce = new CustomEvent("turnCompleteEvent", {
- detail: { messages: "All text and choices have been set up." },
- bubbles: true,
- cancelable: false
- });
- document.dispatchEvent(tce);
-
- // Check for end of story
- if (this.story.canContinue === false && this.story.currentChoices.length === 0) {
- const end = document.createElement("p");
- end.style.textTransform = "uppercase";
- end.style.textAlign = "center";
- end.classList.add("fade-in");
- end.classList.add("choice");
-
- const endText = this.translations[this.locale] && this.translations[this.locale]['end']
- ? this.translations[this.locale]['end']
- : "The End";
-
- end.appendChild(document.createTextNode(endText));
- this.choiceContainer.appendChild(end);
- }
- }
-
- /**
- * Process tags for a paragraph
- * @param {Array} tags - The tags for the paragraph
- * @returns {Array} Custom CSS classes to apply
- */
- processTags(tags) {
- if (!tags || tags.length === 0) return [];
-
- const customClasses = [];
- let tagDebug = "";
-
- for (let i = 0; i < tags.length; i++) {
- const tag = tags[i];
- tagDebug += tag + ";";
-
- // Detect tags of the form "X: Y"
- const splitTag = this.splitPropertyTag(tag);
-
- // AUDIO: src
- if (splitTag && splitTag.property == "AUDIO") {
- if (this.audioManager) {
- this.audioManager.playSoundFromUrl(splitTag.val);
- }
- }
-
- // AUDIOLOOP: src
- else if (splitTag && splitTag.property == "AUDIOLOOP") {
- if (this.audioManager) {
- this.audioManager.playSoundFromUrl(splitTag.val, true);
- }
- }
-
- // IMAGE: src
- else if (splitTag && splitTag.property == "IMAGE") {
- if (this.layoutRenderer) {
- const imageElement = this.layoutRenderer.renderVisualTag("IMAGE", splitTag.val, this.storyContainer);
- if (imageElement && this.layoutRenderer.animationQueue) {
- this.layoutRenderer.showAfter(this.layoutRenderer.animationQueue.getDelay(), imageElement);
- this.layoutRenderer.animationQueue.incrementDelay(this.layoutRenderer.animationQueue.getSpeed());
- }
- }
- }
-
- // LINK: url
- else if (splitTag && splitTag.property == "LINK") {
- window.location.href = splitTag.val;
- }
-
- // LINKOPEN: url
- else if (splitTag && splitTag.property == "LINKOPEN") {
- window.open(splitTag.val);
- }
-
- // BACKGROUND: src
- else if (splitTag && splitTag.property == "BACKGROUND") {
- if (this.layoutRenderer) {
- this.layoutRenderer.renderVisualTag("BACKGROUND", splitTag.val);
- }
- }
-
- // CLASS: className
- else if (splitTag && splitTag.property == "CLASS") {
- customClasses.push(splitTag.val);
- }
-
- // CLEAR - removes all existing content
- else if (tag == "CLEAR") {
- this.removeAll("p");
- this.removeAll("img");
- }
-
- // RESTART - clears everything and restarts the story from the beginning
- else if (tag == "RESTART") {
- this.removeAll("p");
- this.removeAll("img");
- this.restart();
- return [];
- }
-
- // CHAPTER: Chapter Heading
- else if (splitTag && splitTag.property == "CHAPTER") {
- if (this.layoutRenderer) {
- this.layoutRenderer.renderVisualTag("CHAPTER", splitTag.val, this.storyContainer);
- }
- this.chapterBegin = true;
- this.indentedParagraphs = 2;
- }
-
- // SEPARATOR
- else if (tag == "SEPARATOR") {
- if (this.layoutRenderer) {
- this.layoutRenderer.renderVisualTag("SEPARATOR", splitTag.val, this.storyContainer);
- }
- this.chapterBegin = true;
- }
- }
-
- return customClasses;
- }
-
- /**
- * Process a paragraph of text
- * @param {string} paragraphText - The paragraph text
- * @param {Array} customClasses - Custom CSS classes to apply
- * @param {boolean} chapterBegin - Whether this is the beginning of a chapter
- */
- async processParagraph(paragraphText, customClasses, chapterBegin) {
- const indentWidth = 2 * parseFloat(window.getComputedStyle(document.querySelector("#indent")).lineHeight);
- let text = paragraphText;
- let dropCap = null;
- let dropQuote = null;
-
- if (this.chapterBegin) {
- this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width));
- this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width) - indentWidth);
- this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width) - indentWidth * 0.9);
-
- const words = paragraphText.split(" ");
- let firstWord = words[0].substr(1, words[0].length);
- let openingQuote = "";
- let firstLetter = words[0].substr(0, 1);
-
- if (firstLetter == "\"" || firstLetter == "'") {
- openingQuote = window.SmartyPants ? window.SmartyPants.smartypantsu(firstLetter, 1) : firstLetter;
- firstLetter = words[0].substr(1, 1);
- firstWord = words[0].substr(2, words[0].length);
- }
-
- if (firstWord.length < 5 && words.length > 1) {
- firstWord += ' ' + words[1];
- }
-
- text = '' + firstWord + ' ' + paragraphText.substr(firstWord.length + 2 + openingQuote.length, paragraphText.length);
-
- dropCap = document.createElement("span");
- dropCap.classList.add("drop-cap");
- dropCap.appendChild(document.createTextNode(firstLetter));
- dropCap.style.left = '0%';
- dropCap.style.top = '0%';
- dropCap.style.position = 'absolute';
-
- if (openingQuote) {
- dropQuote = document.createElement("span");
- dropQuote.classList.add("drop-quote");
- dropQuote.appendChild(document.createTextNode(openingQuote));
- dropQuote.style.left = '-4.45%';
- dropQuote.style.top = '-16%';
- dropQuote.style.position = 'absolute';
- }
- } else {
- if (this.measure.length < 1) {
- this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width));
- this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width) - indentWidth * 0.5);
- }
- }
-
- // Process text and calculate layout
- const processedText = this.textProcessor.process(text);
- const paragraphData = this.paragraphLayout.calculateLayout(processedText, [...this.measure].reverse(), true);
-
- // Render paragraph
- const [p, d] = this.layoutRenderer.renderParagraph(paragraphData, this.layoutRenderer.animationQueue.getDelay(), [...this.measure].reverse());
-
- // Update measures and indented paragraphs
- for (let k = 0; k < parseInt(p.dataset.numberOfLines); k++) {
- this.measure.pop();
- }
-
- this.indentedParagraphs -= p.dataset.numberOfLines;
-
- // Add drop cap and quote if needed
- if (dropQuote) {
- this.layoutRenderer.insertAfter(0, p, dropQuote, true);
- }
-
- if (dropCap) {
- this.layoutRenderer.insertAfter(0, p, dropCap, true);
- }
-
- // Add custom classes
- for (let i = 0; i < customClasses.length; i++) {
- p.classList.add(customClasses[i]);
- }
-
- p.lang = this.locale;
- this.storyContainer.appendChild(p);
-
- // Smooth scroll to the paragraph
- this.layoutRenderer.smoothScroll(p, this.layoutRenderer.animationQueue.getSpeed() * 10 * p.dataset.numberOfLines);
-
- // Wait for animations and speech
- await Promise.all([
- new Promise(resolve => {
- document.addEventListener('allWordsSetEvent', resolve, { once: true });
- }),
- new Promise(async resolve => {
- if (!this.ttsPlayer || !this.ttsPlayer.isEnabled()) {
- resolve();
- return;
- }
-
- await this.ttsPlayer.speak(paragraphText);
- resolve();
- })
- ]);
- }
-
- /**
- * Process choices from the story
- */
- async processChoices() {
- const categoryContainers = { default: null };
- const categoryNumbers = { default: 0, categorized: 0 };
-
- this.story.currentChoices.forEach(choice => {
- if (this.layoutRenderer.getFastForwardingAll()) {
- return;
- }
-
- let tagDebug = "";
- let action = "default";
-
- choice.tags.forEach(tag => {
- tagDebug += tag + ";";
- const splitTag = this.splitPropertyTag(tag);
-
- if (splitTag && splitTag.property === "ACTION") {
- action = splitTag.val;
- }
- });
-
- const prompt = this.translations[this.locale] && this.translations[this.locale][`action_${action}`]
- ? this.translations[this.locale][`action_${action}`]
- : (action === "default" ? "What do you want to do?" : action);
-
- if (action != "default") {
- this.createChoiceContainer(categoryContainers, categoryNumbers, action, prompt, choice, tagDebug);
- } else {
- const defaultPrompt = this.translations[this.locale] && this.translations[this.locale]['prompt']
- ? this.translations[this.locale]['prompt']
- : "What do you want to do?";
-
- this.createChoiceContainer(categoryContainers, categoryNumbers, "default", defaultPrompt, choice, tagDebug, true);
- }
- });
-
- // Set up key event listener
- this.keyEventListener = this.setupKeyEventListener();
- }
-
- /**
- * Create a choice container
- * @param {Object} categoryContainers - Map of category containers
- * @param {Object} categoryNumbers - Map of category numbers
- * @param {string} action - The action category
- * @param {string} prompt - The prompt text
- * @param {Object} choice - The choice object
- * @param {string} tagDebug - Debug information for tags
- * @param {boolean} registerKeys - Whether to register keyboard shortcuts
- * @returns {HTMLElement} The choice container element
- */
- createChoiceContainer(categoryContainers, categoryNumbers, action, prompt, choice, tagDebug, registerKeys = false) {
- let choiceCategoryContainer = categoryContainers[action];
-
- if (!choiceCategoryContainer) {
- choiceCategoryContainer = document.createElement('ol');
- const p = document.createElement('p');
- p.innerHTML = prompt;
- choiceCategoryContainer.appendChild(p);
- choiceCategoryContainer.classList.add("choice");
- choiceCategoryContainer.classList.add("fade-in");
-
- if (!registerKeys) {
- choiceCategoryContainer.classList.add("categorized");
- }
-
- if (this.story.currentChoices.length && !this.layoutRenderer.getFastForwardingAll()) {
- this.choiceContainer.appendChild(choiceCategoryContainer);
- }
- }
-
- categoryContainers[action] = choiceCategoryContainer;
-
- let choiceNumber = categoryNumbers[action];
- if (choiceNumber === undefined) {
- choiceNumber = 0;
- }
-
- choiceNumber++;
-
- const choiceParagraphElement = document.createElement('li');
- choiceParagraphElement.classList.add("choice");
- choiceParagraphElement.lang = this.locale;
- choiceParagraphElement.title = tagDebug;
-
- // Use SmartyPants if available
- const choiceText = window.SmartyPants && typeof window.SmartyPants.smartypantsu === 'function'
- ? window.SmartyPants.smartypantsu(choice.text, 1)
- : choice.text;
-
- choiceParagraphElement.innerHTML = `${choiceText}`;
-
- if (!this.layoutRenderer.getFastForwardingAll()) {
- this.layoutRenderer.insertAfter(this.layoutRenderer.animationQueue.getDelay(), choiceCategoryContainer, choiceParagraphElement, true);
- }
-
- this.layoutRenderer.animationQueue.incrementDelay(this.layoutRenderer.animationQueue.getSpeed());
-
- // Register key shortcuts
- if (registerKeys) {
- choiceParagraphElement.value = choiceNumber;
- this.registerKey('Digit' + choiceNumber, choice.index);
- } else {
- let categorizedNumber = categoryNumbers['categorized'];
- categorizedNumber++;
- const keyLetter = String.fromCharCode(64 + categorizedNumber);
- choiceParagraphElement.value = categorizedNumber;
- this.registerKey('Key' + keyLetter, choice.index);
- categoryNumbers['categorized'] = categorizedNumber;
- }
-
- // Click on choice
- const choiceAnchorEl = choiceParagraphElement.querySelectorAll("a")[0];
- choiceAnchorEl.addEventListener("click", (event) => {
- // Don't follow link
- event.preventDefault();
- this.chooseChoice(choice.index);
- });
-
- categoryNumbers[action] = choiceNumber;
-
- return choiceCategoryContainer;
- }
-
- /**
- * Choose a choice by index
- * @param {number} index - The choice index
- */
- chooseChoice(index) {
- // Remove all existing choices
- this.removeAll(".choice", true);
- this.clearKeyRegistry();
-
- // Tell the story where to go next
- this.story.ChooseChoiceIndex(index);
-
- // This is where the save button will save from
- this.savePoint = this.story.state.toJson();
-
- // Continue the story
- this.continueStory(false);
- }
-
- /**
- * Register a key for keyboard shortcuts
- * @param {string} key - The key code
- * @param {number} choice - The choice index
- */
- registerKey(key, choice) {
- this.keyRegistry[key] = choice;
- }
-
- /**
- * Clear the key registry
- */
- clearKeyRegistry() {
- this.keyRegistry = {};
- }
-
- /**
- * Set up the key event listener for choices
- * @returns {Function} The key event listener
- */
- setupKeyEventListener() {
- const keyEventListener = (event) => {
- for (const key in this.keyRegistry) {
- if (event.code === key) {
- window.removeEventListener('keypress', keyEventListener);
- this.chooseChoice(this.keyRegistry[key]);
- break;
- }
- }
- };
-
- window.addEventListener('keypress', keyEventListener);
-
- return keyEventListener;
- }
-
- /**
- * Restart the story
- */
- restart() {
- if (!this.story) return;
-
- if (this.layoutRenderer.animationQueue) {
- this.layoutRenderer.animationQueue.setDelay(0.0);
- }
-
- this.story.ResetState();
- this.layoutRenderer.setFastForwardingAll(true);
- this.layoutRenderer.animationQueue.fastForward();
-
- this.removeAll("p");
- this.removeAll("img");
- this.removeAll("h2");
- this.removeAll("double");
- this.removeAll(".choice", true);
-
- if (this.keyEventListener) {
- window.removeEventListener('keypress', this.keyEventListener);
- this.keyEventListener = null;
- }
-
- // Set save point to here
- this.savePoint = this.story.state.toJson();
-
- // Continue the story
- this.continueStory();
- }
-
- /**
- * Save the current state
- * @returns {boolean} Whether the save was successful
- */
- saveState() {
- if (!this.persistenceManager || !this.story) return false;
-
- try {
- const history = Array.from(document.querySelectorAll("#story p:not(.latest-paragraph)")).map(p => p.outerHTML);
-
- return this.persistenceManager.saveState({
- inkJson: this.savePoint,
- history: history
- });
- } catch (e) {
- console.warn("Couldn't save state:", e);
- return false;
- }
- }
-
- /**
- * Load a saved state
- * @returns {boolean} Whether the load was successful
- */
- loadState() {
- if (!this.persistenceManager || !this.story) return false;
-
- try {
- const savedState = this.persistenceManager.loadState();
-
- if (!savedState) {
- return false;
- }
-
- this.removeAll("p");
- this.removeAll("img");
- this.removeAll("h2");
- this.removeAll("double");
- this.removeAll(".choice", true);
-
- if (savedState.history) {
- savedState.history.forEach(p => {
- const d = document.createElement('div');
- d.innerHTML = p;
- this.storyContainer.appendChild(d.firstChild);
- });
- }
-
- if (savedState.inkJson) {
- this.story.state.LoadJson(savedState.inkJson);
- this.savePoint = savedState.inkJson;
- }
-
- // Update paragraph heights
- this.updateParagraphHeight();
-
- if (this.keyEventListener) {
- window.removeEventListener('keypress', this.keyEventListener);
- this.keyEventListener = null;
- }
-
- // Continue the story
- this.continueStory();
-
- return true;
- } catch (e) {
- console.warn("Couldn't load state:", e);
- return false;
- }
- }
-
- /**
- * Update paragraph heights based on viewport
- */
- updateParagraphHeight() {
- document.querySelectorAll("#story p").forEach((element) => {
- if (element.dataset.vpc) {
- const pHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height);
- const newHeight = pHeight * element.dataset.vpc / 100 + 'px';
- element.style.height = newHeight;
- }
- });
- }
-
- /**
- * Remove all elements that match the given selector
- * @param {string} selector - The CSS selector
- * @param {boolean} choices - Whether to remove from the choice container
- */
- removeAll(selector, choices = false) {
- const container = choices ? this.choiceContainer : this.storyContainer;
- const allElements = container.querySelectorAll(selector);
-
- for (let i = 0; i < allElements.length; i++) {
- const el = allElements[i];
- el.parentNode.removeChild(el);
- }
- }
-
- /**
- * Helper for parsing out tags of the form: # PROPERTY: value
- * @param {string} tag - The tag to parse
- * @returns {Object|null} The parsed property and value
- */
- splitPropertyTag(tag) {
- const propertySplitIdx = tag.indexOf(":");
-
- if (propertySplitIdx !== -1) {
- const property = tag.substr(0, propertySplitIdx).trim();
- const val = tag.substr(propertySplitIdx + 1).trim();
-
- return {
- property: property,
- val: val
- };
- }
-
- return null;
- }
-
- /**
- * Set the locale for translations
- * @param {string} locale - The locale code
- */
- setLocale(locale) {
- this.locale = locale;
- }
-
- /**
- * Set translations
- * @param {Object} translations - The translations object
- */
- setTranslations(translations) {
- this.translations = translations;
- }
-}
diff --git a/public/js/input-handler.js b/public/js/input-handler.js
deleted file mode 100644
index 9584af3..0000000
--- a/public/js/input-handler.js
+++ /dev/null
@@ -1,290 +0,0 @@
-/**
- * Input Handler Module
- * Manages the multi-line text input field with a custom cursor.
- */
-export class InputHandler {
- constructor(inputId = 'player_input', cursorId = 'cursor') {
- this.playerInput = document.getElementById(inputId);
- this.cursor = document.getElementById(cursorId);
- this.commandInputContainer = document.getElementById('command_input'); // Assuming this container exists
-
- if (!this.playerInput || !this.cursor || !this.commandInputContainer) {
- console.error('InputHandler: Required DOM elements not found.');
- return;
- }
-
- this.commandSubmitCallback = null; // Callback for when a command is submitted
-
- this.bindEvents();
- this.adjustTextareaHeight(); // Initial adjustment
- this.updateCursorPosition(); // Initial position
-
- // Setup handler for window load event to ensure proper initialization
- window.addEventListener('load', () => {
- console.log('InputHandler: Window loaded, adjusting text area height and cursor position');
- this.adjustTextareaHeight();
- this.updateCursorPosition();
- });
- }
-
- /**
- * Register a callback function to be called when a command is submitted.
- * @param {function(string)} callback - The function to call with the command text.
- */
- onCommandSubmit(callback) {
- this.commandSubmitCallback = callback;
- }
-
- /**
- * Bind event handlers to the input element.
- */
- bindEvents() {
- // Submit command on Enter key without Shift
- this.playerInput.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault(); // Prevent default to avoid newline
- this.submitCommand();
- }
- // Allow Shift+Enter for new lines (default behavior)
- });
-
- // Auto-resize textarea and update cursor on input
- this.playerInput.addEventListener('input', () => {
- this.adjustTextareaHeight();
- this.updateCursorPosition();
- });
-
- // Update cursor on various events
- this.playerInput.addEventListener('click', this.updateCursorPosition.bind(this));
- this.playerInput.addEventListener('keyup', this.updateCursorPosition.bind(this));
-
- // Show/hide cursor on focus/blur
- this.playerInput.addEventListener('focus', () => {
- if (this.cursor) this.cursor.style.opacity = '1';
- this.updateCursorPosition();
- });
- this.playerInput.addEventListener('blur', () => {
- if (this.cursor) this.cursor.style.opacity = '0';
- });
-
- // Handle paste events
- this.playerInput.addEventListener('paste', () => {
- // Use setTimeout to let the paste complete before adjusting
- setTimeout(() => {
- this.adjustTextareaHeight();
- this.updateCursorPosition();
- }, 10);
- });
-
- // Handle window resize
- window.addEventListener('resize', () => {
- this.adjustTextareaHeight();
- this.updateCursorPosition();
- });
- }
-
- /**
- * Submit the current command.
- */
- submitCommand() {
- const command = this.playerInput.value.trim();
- if (command === '' || !this.commandSubmitCallback) return;
-
- // Fade out the input field container
- if (this.commandInputContainer) {
- this.commandInputContainer.classList.add('fading');
- }
-
- // Disable input temporarily
- this.playerInput.disabled = true;
-
- // Call the registered callback
- this.commandSubmitCallback(command);
-
- // Clear input
- this.clearInput();
- }
-
- /**
- * Clears the input field and resets its state.
- */
- clearInput() {
- this.playerInput.value = '';
- this.resetCursorPosition();
- this.adjustTextareaHeight();
- }
-
- /**
- * Re-enables the input field after a command submission or response.
- */
- enableInput() {
- if (this.commandInputContainer) {
- // Remove fading class and add fade-in animation
- this.commandInputContainer.classList.remove('fading');
- this.commandInputContainer.classList.add('fade-in-input');
-
- // Remove animation class after it completes
- setTimeout(() => {
- if (this.commandInputContainer) {
- this.commandInputContainer.classList.remove('fade-in-input');
- }
- }, 500); // Match CSS animation duration
- }
-
- this.playerInput.disabled = false;
- this.focus();
- }
-
- /**
- * Focuses the input field.
- */
- focus() {
- this.playerInput.focus();
- // Ensure cursor is visible and positioned correctly after focus
- setTimeout(() => {
- if (this.cursor) this.cursor.style.opacity = '1';
- this.updateCursorPosition();
- }, 10);
- }
-
- /**
- * Gets the current value of the input field.
- * @returns {string} The input text.
- */
- getValue() {
- return this.playerInput.value;
- }
-
- /**
- * Sets the value of the input field.
- * @param {string} value - The text to set.
- */
- setValue(value) {
- this.playerInput.value = value;
- this.adjustTextareaHeight();
- this.updateCursorPosition();
- this.focus(); // Focus after setting value
- }
-
- /**
- * Resets the cursor position to the start.
- */
- resetCursorPosition() {
- if (this.cursor) {
- this.cursor.style.left = '0px';
- // Adjust top based on computed style padding or a default
- const computedStyle = window.getComputedStyle(this.playerInput);
- const paddingTop = parseFloat(computedStyle.paddingTop) || 6;
- this.cursor.style.top = `${paddingTop}px`;
- }
- }
-
- /**
- * Update the custom cursor position based on input text and caret position.
- * Uses a temporary div for accurate measurement.
- */
- updateCursorPosition() {
- if (!this.cursor || !this.playerInput) return;
-
- const input = this.playerInput;
- const cursor = this.cursor;
- const caretPosition = input.selectionStart || 0;
- const inputText = input.value;
-
- // If no text, position cursor at the beginning based on padding
- if (inputText.length === 0 && caretPosition === 0) {
- this.resetCursorPosition();
- return;
- }
-
- // Create a temporary measurement div
- const div = document.createElement('div');
- const style = getComputedStyle(input);
-
- // Apply relevant styles from the textarea to the div
- div.style.position = 'absolute';
- div.style.top = '-9999px';
- div.style.left = '-9999px';
- div.style.width = style.width;
- div.style.height = 'auto';
- div.style.padding = style.padding;
- div.style.border = style.border;
- div.style.fontFamily = style.fontFamily;
- div.style.fontSize = style.fontSize;
- div.style.fontWeight = style.fontWeight;
- div.style.lineHeight = style.lineHeight;
- div.style.whiteSpace = 'pre-wrap';
- div.style.wordWrap = 'break-word';
- div.style.boxSizing = style.boxSizing;
-
- // Create spans for text before and after the caret, and a marker span
- const preCaretText = document.createTextNode(inputText.substring(0, caretPosition));
- const caretMarker = document.createElement('span');
- caretMarker.innerHTML = ' '; // Use non-breaking space for measurement
- const postCaretText = document.createTextNode(inputText.substring(caretPosition));
-
- // Append spans to the div
- div.appendChild(preCaretText);
- div.appendChild(caretMarker);
- div.appendChild(postCaretText);
-
- // Append div to body for measurement
- document.body.appendChild(div);
-
- // Get position relative to the div's content box
- const markerRect = caretMarker.getBoundingClientRect();
- const divRect = div.getBoundingClientRect();
-
- // Calculate position relative to the input's top-left, considering scroll
- const cursorLeft = markerRect.left - divRect.left;
- const cursorTop = markerRect.top - divRect.top - input.scrollTop;
-
- // Set cursor position
- cursor.style.left = `${cursorLeft}px`;
- cursor.style.top = `${cursorTop}px`;
-
- // Clean up the temporary div
- document.body.removeChild(div);
- }
-
- /**
- * Adjust textarea height based on its content.
- */
- adjustTextareaHeight() {
- if (!this.playerInput) return;
- const textarea = this.playerInput;
- // Temporarily reset height to accurately measure scrollHeight
- textarea.style.height = 'auto';
- // Set height to scrollHeight to fit content, adding a small buffer if needed
- textarea.style.height = `${textarea.scrollHeight}px`;
- }
-
- /**
- * Sets up focus management to keep the input field focused.
- * Note: Some parts might be better handled by the main application logic
- * depending on overall focus requirements (e.g., clicking outside input).
- */
- setupFocusManagement() {
- // Focus input field when the handler is initialized
- this.focus();
-
- // Re-focus input when user returns to this browser tab/window
- window.addEventListener('focus', () => this.focus());
- window.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible') {
- setTimeout(() => this.focus(), 100);
- }
- });
-
- // Optional: Add a listener to the document to refocus if needed,
- // but be careful not to interfere with other interactive elements.
- /*
- document.addEventListener('click', (e) => {
- // Example: Refocus if click is not on specific elements
- if (!e.target.closest('button, a, .interactive-ui-element')) {
- this.focus();
- }
- });
- */
- }
-}
diff --git a/public/js/kokoro-handler.js b/public/js/kokoro-handler.js
index 5793041..b4218f8 100644
--- a/public/js/kokoro-handler.js
+++ b/public/js/kokoro-handler.js
@@ -1,597 +1,708 @@
/**
- * Kokoro Text-to-Speech Handler for AI Interactive Fiction
- * Uses the kokoro-js library for high-quality TTS
+ * KokoroHandler for AI Interactive Fiction
+ * Handles neural TTS via Kokoro.js with progress reporting and non-blocking loading
*/
+import { TTSHandler } from './tts-handler.js';
-export class KokoroHandler {
- constructor() {
- this.enabled = false;
- this.speaking = false;
- this.paused = false;
- this.audio = null;
- this.currentSpeed = 1.0; // Note: KokoroTTS might not support speed changes directly
- this.audioQueue = [];
- this.isProcessingQueue = false;
- this.kokoroReady = false;
- this.kokoroInstance = null; // Store the KokoroTTS instance
- this.hasUserActivation = false;
- this.initializationPromise = null;
- this.audioContext = null; // For playing the generated audio
- this.currentVoice = "af_heart"; // Default voice from README
- this.currentAudioSource = null; // To keep track of the playing audio source
-
- // Start initialization process
- this.initializeKokoro();
- }
-
- /**
- * Initialize Kokoro TTS by waiting for the class and then instantiating
- */
- async initializeKokoro() {
- if (this.initializationPromise) {
- return this.initializationPromise;
+export class KokoroHandler extends TTSHandler {
+ constructor() {
+ super(); // Initialize the base TTSHandler
+ this.kokoro = null;
+ this.isReady = false;
+ this.currentUtterance = null;
+ // Set default voice options through the base class
+ this.voiceOptions = {
+ voice: 'bf_alice', // Default to high-quality voice
+ speed: 1.0
+ };
+ this.progressCallback = null;
+ this.scriptLoaded = false;
+ this.useLegacyFormat = false;
+ this.legacySpeak = null;
+ this.availableVoices = [];
+ this.worker = null;
+ this.workerReady = false;
+ this.pendingWorkerRequests = [];
+ this.workerInitialized = false;
}
- this.initializationPromise = new Promise(async (resolve) => {
- try {
- // Wait for the KokoroTTS class to be loaded
- if (typeof window.KokoroTTS === 'undefined') {
- console.log('Kokoro TTS class not found, waiting for it to load...');
+ /**
+ * Get the ID of this provider
+ * @returns {string} - Provider ID
+ */
+ getId() {
+ return 'kokoro';
+ }
- let loadTimeoutId = null; // Variable to hold the timeout ID
+ /**
+ * Initialize the Kokoro TTS system
+ * @param {Function} progressCallback - Optional callback for progress updates
+ * @returns {Promise} - Resolves to true if initialization was successful
+ */
+ async initialize(progressCallback = null) {
+ this.progressCallback = progressCallback;
- const loadHandler = async () => {
- clearTimeout(loadTimeoutId); // <<< Clear the timeout
- window.removeEventListener('kokoro-class-loaded', loadHandler);
- window.removeEventListener('kokoro-class-load-failed', failHandler);
- console.log('KokoroTTS class loaded event received.');
- const success = await this._initKokoroInstance();
- resolve(success);
- };
+ try {
+ // First load the script if not already loaded
+ if (this.progressCallback) this.progressCallback(10, "Loading Kokoro script");
+ await this.loadKokoroScript();
- const failHandler = () => {
- clearTimeout(loadTimeoutId); // <<< Clear the timeout
- window.removeEventListener('kokoro-class-loaded', loadHandler);
- window.removeEventListener('kokoro-class-load-failed', failHandler);
- console.error('KokoroTTS class failed to load.');
- resolve(false);
- };
-
- window.addEventListener('kokoro-class-loaded', loadHandler);
- window.addEventListener('kokoro-class-load-failed', failHandler);
-
- // Timeout if the event never fires
- loadTimeoutId = setTimeout(() => { // <<< Store the timeout ID
- // Check if still waiting (listener might have run but instance not ready yet)
- if (!this.kokoroInstance && !this.kokoroReady) {
- window.removeEventListener('kokoro-class-loaded', loadHandler);
- window.removeEventListener('kokoro-class-load-failed', failHandler);
- console.error('Timed out waiting for KokoroTTS class load event.');
- resolve(false);
+ // Check if the kokoro library is loaded correctly
+ if (!window.kokoro) {
+ console.error("Kokoro TTS library not loaded correctly");
+ this.isReady = false;
+ document.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
+ detail: { success: false, error: "Library not loaded" }
+ }));
+ return false;
}
- }, 15000); // Increased timeout
- return;
+ // Report progress
+ if (this.progressCallback) {
+ this.progressCallback(40, "Creating Kokoro instance");
+ }
+
+ // Initialize Kokoro instance
+ try {
+ const apiFound = await this.checkKokoroApi();
+ if (!apiFound) {
+ console.error("Kokoro TTS API not found in loaded module");
+ this.isReady = false;
+ if (this.progressCallback) this.progressCallback(100, "Kokoro API not found");
+ document.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
+ detail: { success: false, error: "API not found" }
+ }));
+ return false;
+ }
+
+ // Initialize the Web Worker for speech generation
+ await this.initWorker();
+
+ // Set ready state
+ this.isReady = true;
+
+ if (this.isReady) {
+ console.log('Kokoro TTS initialized successfully');
+ if (this.progressCallback) {
+ this.progressCallback(100, "Kokoro TTS ready");
+ }
+ } else {
+ console.warn('Kokoro TTS initialized but not available');
+ if (this.progressCallback) {
+ this.progressCallback(100, "Kokoro TTS unavailable");
+ }
+ }
+ } catch (initError) {
+ console.error("Failed to initialize Kokoro instance:", initError);
+ this.isReady = false;
+ if (this.progressCallback) {
+ this.progressCallback(100, "Kokoro initialization failed");
+ }
+ }
+
+ // Dispatch event indicating completion, with success status
+ document.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
+ detail: { success: this.isReady }
+ }));
+
+ return this.isReady;
+ } catch (error) {
+ console.error('Failed to initialize Kokoro TTS:', error);
+ this.isReady = false;
+
+ // Dispatch event even on error to unblock waiting processes
+ document.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
+ detail: { success: false, error: error.message }
+ }));
+
+ return false;
}
-
- // If we get here, KokoroTTS class is already available
- console.log('KokoroTTS class found directly.');
- const success = await this._initKokoroInstance();
- resolve(success);
-
- } catch (error) {
- console.error('Error during KokoroHandler initialization:', error);
- resolve(false);
- }
- });
-
- return this.initializationPromise;
- }
-
- /**
- * Internal method to create and initialize the KokoroTTS instance
- * @private
- */
- async _initKokoroInstance() {
- if (this.kokoroInstance || this.kokoroReady) return true; // Already initialized or initializing
-
- try {
- console.log('Initializing KokoroTTS instance (GPU Only Attempt)...');
- const model_id = "onnx-community/Kokoro-82M-v1.0-ONNX";
-
- // --- Check for WebGPU Support ---
- const device = await this.getBestDevice();
- if (device !== 'webgpu') {
- console.warn('WebGPU not available or supported. Kokoro TTS (GPU) cannot be initialized.');
- // Explicitly set ready to false and return false to signal failure
- this.kokoroReady = false;
- return false;
- }
- // --- End WebGPU Check ---
-
- // Use fp32 for WebGPU as recommended
- const dtype = 'fp32';
- console.log(`Attempting KokoroTTS init with device: ${device}, dtype: ${dtype}`);
-
- console.log(`Calling KokoroTTS.from_pretrained('${model_id}', { dtype: '${dtype}', device: '${device}' })...`);
-
- // --- Add Timeout Wrapper for from_pretrained ---
- const fromPretrainedPromise = window.KokoroTTS.from_pretrained(model_id, {
- dtype: dtype,
- device: device, // Always 'webgpu' if we reach here
- });
-
- const pretrainedTimeoutPromise = new Promise((_, reject) =>
- setTimeout(() => reject(new Error('KokoroTTS.from_pretrained (WebGPU) timed out after 55 seconds')), 55000) // 55 seconds timeout
- );
-
- try {
- this.kokoroInstance = await Promise.race([
- fromPretrainedPromise,
- pretrainedTimeoutPromise
- ]);
- } catch (timeoutError) {
- console.error(timeoutError.message); // Log the specific timeout error
- throw timeoutError; // Re-throw to be caught by the outer catch block
- }
- // --- End Timeout Wrapper ---
-
- console.log('KokoroTTS.from_pretrained call completed.');
-
- if (!this.kokoroInstance) {
- console.error('KokoroTTS.from_pretrained returned a falsy value.');
- throw new Error('KokoroTTS.from_pretrained returned null or undefined.');
- }
-
- // Defer AudioContext creation until first use
-
- this.kokoroReady = true;
- console.log('Kokoro TTS (WebGPU) instance created successfully (AudioContext deferred).');
- return true;
- } catch (error) {
- console.error('Error during KokoroTTS (WebGPU) initialization:', error);
- if (error.message) {
- console.error('Error message:', error.message);
- }
- if (error.stack) {
- console.error('Error stack:', error.stack);
- }
- this.kokoroInstance = null;
- this.kokoroReady = false;
- return false; // Ensure failure is explicitly returned
- }
- }
-
- /**
- * Determine the best device (webgpu or wasm)
- * Checks for WebGPU support.
- * @private
- */
- async getBestDevice() {
- if (navigator.gpu) {
- try {
- // Request an adapter. If this succeeds, WebGPU is likely available.
- const adapter = await navigator.gpu.requestAdapter();
- if (adapter) {
- console.log('WebGPU supported, selecting webgpu device.');
- return 'webgpu';
- }
- console.warn('WebGPU adapter request returned null.');
- } catch (e) {
- console.warn('WebGPU adapter request failed:', e);
- }
- }
- console.log('WebGPU not supported or available, cannot use GPU for Kokoro.');
- return 'wasm'; // Return wasm indicating GPU is not the best/available option
- }
-
- /**
- * List available voices (delegates to KokoroTTS instance)
- */
- async listVoices() {
- if (!this.kokoroReady || !this.kokoroInstance) {
- console.warn('Kokoro not ready, cannot list voices.');
- return [];
- }
- try {
- // The README uses tts.list_voices(), assuming it's a method on the instance
- if (typeof this.kokoroInstance.list_voices === 'function') {
- return await this.kokoroInstance.list_voices();
- } else {
- console.warn('list_voices method not found on KokoroTTS instance. Returning default.');
- // Fallback based on README examples
- return [{ name: 'af_heart', description: 'Default American Female' }];
- }
- } catch (error) {
- console.error('Error listing Kokoro voices:', error);
- return [];
- }
- }
-
- /**
- * Set the voice to use
- * @param {string} voiceName - Name of the voice (e.g., 'af_heart')
- */
- setVoice(voiceName) {
- this.currentVoice = voiceName;
- console.log(`Kokoro voice set to: ${voiceName}`);
- }
-
- /**
- * Toggle TTS functionality on/off
- * @returns {boolean} New state of TTS (enabled/disabled)
- */
- toggle() {
- // Set user activation flag when toggle is called
- this.hasUserActivation = true;
-
- // --- Create AudioContext on first activation ---
- if (!this.audioContext) {
- try {
- this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
- console.log('AudioContext created on user activation.');
- // Resume if context starts suspended
- if (this.audioContext.state === 'suspended') {
- this.audioContext.resume().catch(err => console.error('Error resuming initial AudioContext:', err));
- }
- } catch (e) {
- console.error('Failed to create AudioContext:', e);
- // If AudioContext fails, Kokoro cannot play audio
- this.kokoroReady = false;
- return false;
- }
- }
- // --- End AudioContext Creation ---
-
- if (!this.kokoroReady) {
- console.warn('Kokoro TTS not ready yet');
- // Optionally, trigger re-initialization or inform user
- return false;
}
- this.enabled = !this.enabled;
- console.log("Kokoro TTS toggled:", this.enabled ? "ON" : "OFF");
-
- // Stop any ongoing speech when disabling
- if (!this.enabled && (this.speaking || this.isProcessingQueue)) {
- this.stop();
- }
-
- return this.enabled;
- }
-
- /**
- * Set the speech rate/speed
- * @param {number} speed - Speed multiplier (0.1 to 2.0)
- */
- setSpeed(speed) {
- this.currentSpeed = Math.max(0.5, Math.min(2.0, speed));
- }
-
- /**
- * Process text for better speech synthesis
- * @param {string} text - Text to process
- * @returns {string} - Processed text
- */
- processTextForSpeech(text) {
- if (!text) return "";
-
- // Remove markdown/formatting that would sound strange when read
- text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); // Bold
- text = text.replace(/\*([^*]+)\*/g, '$1'); // Italic
- text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // Links
-
- // Clean up any HTML tags
- text = text.replace(/<[^>]+>/g, '');
-
- return text;
- }
-
- /**
- * Split text into digestible chunks for better TTS handling
- * @param {string} text - Text to split
- * @returns {string[]} - Array of text chunks
- */
- splitTextIntoChunks(text) {
- if (!text) return [];
-
- // Split by sentence terminators, keeping the terminator with the chunk
- const sentenceRegex = /[^.!?]+[.!?]+/g;
- const sentences = text.match(sentenceRegex) || [text];
-
- // Group sentences into chunks for better performance
- const chunks = [];
- let currentChunk = '';
-
- for (const sentence of sentences) {
- // If adding this sentence would make the chunk too long, start a new chunk
- if (currentChunk.length + sentence.length > 500) {
- if (currentChunk) chunks.push(currentChunk);
- currentChunk = sentence;
- } else {
- currentChunk += sentence;
- }
- }
-
- // Add the last chunk if it's not empty
- if (currentChunk) chunks.push(currentChunk);
-
- return chunks;
- }
-
- /**
- * Process the speech queue using KokoroTTS
- * @private
- */
- async processQueue() {
- // Ensure AudioContext is ready before processing
- if (!this.audioContext) {
- console.warn('AudioContext not available, cannot process Kokoro queue.');
- this.isProcessingQueue = false;
- this.speaking = false;
- return;
- }
- // Ensure AudioContext is running
- if (this.audioContext.state === 'suspended') {
- await this.audioContext.resume().catch(err => console.error('Error resuming AudioContext for queue:', err));
- }
-
- if (this.isProcessingQueue || this.audioQueue.length === 0 || !this.kokoroReady || !this.kokoroInstance) {
- if (this.audioQueue.length === 0) {
- this.speaking = false; // Ensure speaking flag is reset when queue is empty
- }
- // Reset processing flag if we exit early
- if (this.isProcessingQueue && this.audioQueue.length === 0) {
- this.isProcessingQueue = false;
- }
- return;
- }
-
- this.isProcessingQueue = true;
- this.speaking = true; // Set speaking true when processing starts
-
- try {
- const textChunk = this.audioQueue.shift();
-
- if (!textChunk) {
- this.isProcessingQueue = false;
- this.speaking = false;
- return;
- }
-
- console.log(`Kokoro generating chunk (${this.audioQueue.length} remaining):`, textChunk.substring(0, 30) + "...");
-
- try {
- // Use Kokoro instance to generate audio
- const audioResult = await this.kokoroInstance.generate(textChunk, {
- voice: this.currentVoice,
+ /**
+ * Initialize the Web Worker for speech generation
+ * @returns {Promise} - Resolves when the worker is initialized
+ */
+ initWorker() {
+ return new Promise((resolve, reject) => {
+ try {
+ // Create the worker
+ this.worker = new Worker('/js/kokoro-worker.js');
+
+ // Handle messages from the worker
+ this.worker.onmessage = (e) => {
+ const { type, result, error } = e.data;
+
+ switch (type) {
+ case 'ready':
+ console.log('Kokoro worker is ready');
+ this.workerReady = true;
+
+ // Process any pending requests
+ this.processPendingRequests();
+ resolve();
+ break;
+
+ case 'initialized':
+ console.log('Kokoro worker initialized');
+ this.workerInitialized = true;
+ break;
+
+ case 'generated':
+ if (this.currentUtterance && this.currentUtterance.resolveGenerate) {
+ // Convert the ArrayBuffer back to Float32Array
+ const audioData = {
+ audio: new Float32Array(result.audio),
+ sampling_rate: result.sampling_rate
+ };
+ this.currentUtterance.resolveGenerate(audioData);
+ }
+ break;
+
+ case 'error':
+ console.error('Kokoro worker error:', error);
+ if (this.currentUtterance && this.currentUtterance.rejectGenerate) {
+ this.currentUtterance.rejectGenerate(new Error(error));
+ }
+ break;
+
+ default:
+ console.warn('Unknown message type from worker:', type);
+ }
+ };
+
+ // Handle worker errors
+ this.worker.onerror = (error) => {
+ console.error('Kokoro worker error:', error);
+ this.workerReady = false;
+ if (this.currentUtterance && this.currentUtterance.rejectGenerate) {
+ this.currentUtterance.rejectGenerate(error);
+ }
+ reject(error);
+ };
+
+ // Initialize the worker
+ this.worker.postMessage({
+ type: 'init'
+ });
+
+ } catch (error) {
+ console.error('Failed to initialize Kokoro worker:', error);
+ reject(error);
+ }
});
+ }
- // --- Updated Check: Expect Float32Array ---
- if (!audioResult || !audioResult.audio || !(audioResult.audio instanceof Float32Array) || !audioResult.sampling_rate) {
- console.error('Invalid audio data or sampling rate received from KokoroTTS.generate', audioResult);
- throw new Error('Invalid audio data or sampling rate received from KokoroTTS.generate');
+ /**
+ * Process any pending worker requests
+ */
+ processPendingRequests() {
+ if (this.pendingWorkerRequests.length > 0 && this.workerReady) {
+ const request = this.pendingWorkerRequests.shift();
+ this.generateInWorker(request.text, request.options)
+ .then(request.resolve)
+ .catch(request.reject);
+
+ // Process the next request after a small delay to keep UI responsive
+ if (this.pendingWorkerRequests.length > 0) {
+ setTimeout(() => this.processPendingRequests(), 10);
+ }
}
- // --- End Updated Check ---
+ }
- const rawAudioSamples = audioResult.audio;
- const samplingRate = audioResult.sampling_rate;
- console.log(`Received raw audio samples (${rawAudioSamples.length}), sample rate: ${samplingRate}`);
+ /**
+ * Generate speech in the worker
+ * @param {string} text - Text to convert to speech
+ * @param {Object} options - Voice options
+ * @returns {Promise