diff --git a/public/index.html b/public/index.html index cfc901d..ee2784c 100644 --- a/public/index.html +++ b/public/index.html @@ -26,6 +26,20 @@ margin: 0; padding: 0; background-color: #000; + font-family: 'EB Garamond', serif; + color: rgba(0, 0, 0, 0.9); + overflow: hidden; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + html { + height: 100%; + margin: 0; + font-family: 'EB Garamond', sans-serif; + font-feature-settings: 'kern' on, 'liga' on, 'onum' on, 'dlig' on, 'clig' on, 'calt' on, 'hlig' off, 'swsh' on, 'locl' off; + -webkit-font-smoothing: antialiased; } .loading-overlay { font-family: 'EB Garamond', serif; @@ -77,30 +91,48 @@ #modules-list { list-style-type: none; padding: 0; - margin-top: 20px; - max-height: 300px; /* Increased height */ - overflow: hidden; /* Hide scrollbar */ + margin-top: 40px; width: 100%; + overflow: visible; } - .module-item { - display: grid; - grid-template-columns: 1fr auto 1fr; - margin-bottom: 8px; - color: #ccc; + + .module-name, .module-status, .module-status-detail { + font-size: 14px !important; + line-height: 24px !important; + } + + .module-status { + text-align: center !important; + font-size: 14px !important; + grid-column: 2 !important; + min-width: 80px !important; + padding: 0 10px !important; + position: relative !important; + z-index: 1 !important; + } + .module-item::before { + content: ""; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--progress-width, 0%); + background: rgba(76, 175, 80, 0.15); + transition: width 0.3s ease-in-out; + z-index: 0; + pointer-events: none; + border-radius: 4px; + } + /* Fallback for browsers without CSS variable support */ + .module-item[data-progress] { position: relative; - width: 100%; } .module-name { text-align: left; padding-right: 10px; grid-column: 1; - } - .module-status { - text-align: center; - font-size: 12px; - grid-column: 2; - min-width: 80px; /* Ensure status has minimum width */ - padding: 0 10px; + position: relative; + z-index: 1; } .module-status-detail { grid-column: 3; @@ -109,12 +141,17 @@ color: #aaa; font-style: italic; padding-left: 10px; + position: relative; + z-index: 1; } .status-pending { color: #ccc; } .status-loading { - color: #FFC107; + color: #07ffe6; + } + .status-fetching { + color: #ff00dd; } .status-waiting { color: #FF9800; @@ -129,13 +166,43 @@ color: #F44336; } + /* Module fade animations */ + @keyframes fadeInModule { + 0% { opacity: 0; transform: translateY(5px); } + 100% { opacity: 1; transform: translateY(0); } + } + + @keyframes fadeOutModule { + 0% { opacity: 1; height: 24px; margin-bottom: 8px; } + 60% { opacity: 0.4; height: 24px; margin-bottom: 8px; } + 100% { opacity: 0; height: 0; margin-bottom: 0; padding: 0; } + } + + .module-item { + display: grid !important; + grid-template-columns: 1fr auto 1fr !important; + margin-bottom: 8px !important; + color: #ccc !important; + position: relative !important; + width: 100% !important; + height: 24px; + line-height: 24px; + overflow: hidden !important; + font-size: 14px !important; + animation: fadeInModule 0.5s ease-in-out forwards !important; + transition: height 0.5s ease-in-out, margin-bottom 0.5s ease-in-out, opacity 0.5s ease-in-out !important; + } + + .module-finished { + animation: fadeOutModule 1.5s ease-in-out forwards !important; + animation-delay: 1s !important; /* Shorter delay so you can see it happen */ + } + /* Update loader module list scrolling */ .loading-overlay #modules-list { list-style-type: none; padding: 0; margin-top: 20px; - max-height: 300px; - overflow-y: auto; /* Enable vertical scrolling */ width: 100%; scrollbar-width: thin; scrollbar-color: #555 #333; @@ -153,6 +220,29 @@ background-color: #555; border-radius: 4px; } + + /* Add scrollbar styles from main CSS */ + /* ===== Scrollbar CSS ===== */ + /* Firefox */ + * { + scrollbar-width: auto; + scrollbar-color: #000000 rgba(255,255,255,0); + } + + /* Chrome, Edge, and Safari */ + *::-webkit-scrollbar { + width: calc(1rem/4); + } + + *::-webkit-scrollbar-track { + background: rgba(255,255,255,0); + } + + *::-webkit-scrollbar-thumb { + background-color: #000000; + border-radius: 10px; + border: 1px none rgba(255,255,255,0); + } @@ -188,6 +278,21 @@ console.log(message); }; + \ No newline at end of file diff --git a/public/js/Hyphenopoly.js b/public/js/Hyphenopoly.js deleted file mode 100644 index 21e75a5..0000000 --- a/public/js/Hyphenopoly.js +++ /dev/null @@ -1,931 +0,0 @@ -/** - * @license Hyphenopoly 5.2.0-beta.1 - client side hyphenation for webbrowsers - * ©2023 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 - */ - -/* globals Hyphenopoly:readonly */ -((w, o) => { - "use strict"; - const SOFTHYPHEN = "\u00AD"; - - /** - * Event - */ - const event = ((H) => { - const knownEvents = new Map([ - ["afterElementHyphenation", []], - ["beforeElementHyphenation", []], - ["engineReady", []], - [ - "error", [ - (e) => { - if (e.runDefault) { - w.console.warn(e); - } - } - ] - ], - ["hyphenopolyEnd", []], - ["hyphenopolyStart", []] - ]); - if (H.hev) { - const userEvents = new Map(o.entries(H.hev)); - knownEvents.forEach((eventFuncs, eventName) => { - if (userEvents.has(eventName)) { - eventFuncs.unshift(userEvents.get(eventName)); - } - }); - } - return { - "fire": ((eventName, eventData) => { - eventData.runDefault = true; - eventData.preventDefault = () => { - eventData.runDefault = false; - }; - knownEvents.get(eventName).forEach((eventFn) => { - eventFn(eventData); - }); - }) - }; - })(Hyphenopoly); - - /** - * Register copy event on element - * @param {Object} el The element - * @returns {undefined} - */ - function registerOnCopy(el) { - el.addEventListener( - "copy", - (e) => { - e.preventDefault(); - const sel = w.getSelection(); - const div = document.createElement("div"); - div.appendChild(sel.getRangeAt(0).cloneContents()); - e.clipboardData.setData("text/plain", sel.toString().replace(RegExp(SOFTHYPHEN, "g"), "")); - e.clipboardData.setData("text/html", div.innerHTML.replace(RegExp(SOFTHYPHEN, "g"), "")); - }, - true - ); - } - - /** - * Convert settings from H.setup-Object to Map - * This is a IIFE to keep complexity low. - */ - ((H) => { - /** - * 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); - }, - "ownKeys": () => { - return [ - ...new Set( - [...defaultsMap.keys(), ...userMap.keys()] - ) - ]; - } - }); - } - - const settings = createMapWithDefaults(new Map([ - ["defaultLanguage", "en-us"], - [ - "dontHyphenate", (() => { - const list = "abbr,acronym,audio,br,button,code,img,input,kbd,label,math,option,pre,samp,script,style,sub,sup,svg,textarea,var,video"; - return createMapWithDefaults( - new Map(list.split(",").map((val) => { - return [val, true]; - })) - ); - })() - ], - ["dontHyphenateClass", "donthyphenate"], - ["exceptions", new Map()], - ["keepAlive", true], - ["normalize", false], - ["processShadows", false], - ["safeCopy", true], - ["substitute", new Map()], - ["timeout", 1000] - ])); - o.entries(H.s).forEach(([key, value]) => { - switch (key) { - case "selectors": - // Set settings.selectors to array of selectors - settings.set("selectors", o.keys(value)); - - /* - * For each selector add a property to settings with - * selector specific settings - */ - o.entries(value).forEach(([sel, selSettings]) => { - const selectorSettings = createMapWithDefaults(new Map([ - ["compound", "hyphen"], - ["hyphen", SOFTHYPHEN], - ["leftmin", 0], - ["leftminPerLang", 0], - ["minWordLength", 6], - ["mixedCase", true], - ["orphanControl", 1], - ["rightmin", 0], - ["rightminPerLang", 0] - ])); - o.entries(selSettings).forEach( - ([selSetting, setVal]) => { - if (typeof setVal === "object") { - selectorSettings.set( - selSetting, - new Map(o.entries(setVal)) - ); - } else { - selectorSettings.set(selSetting, setVal); - } - } - ); - settings.set(sel, selectorSettings); - }); - break; - case "dontHyphenate": - case "exceptions": - o.entries(value).forEach(([k, v]) => { - settings.get(key).set(k, v); - }); - break; - case "substitute": - o.entries(value).forEach(([lang, subst]) => { - settings.substitute.set( - lang, - new Map(o.entries(subst)) - ); - }); - break; - default: - settings.set(key, value); - } - }); - H.c = settings; - })(Hyphenopoly); - - ((H) => { - const C = H.c; - let mainLanguage = null; - - event.fire( - "hyphenopolyStart", - { - "msg": "hyphenopolyStart" - } - ); - - /** - * Factory for elements - * @returns {Object} elements-object - */ - function makeElementCollection() { - const list = new Map(); - - /* - * Counter counts the elements to be hyphenated. - * Needs to be an object (Pass by reference) - */ - const counter = [0]; - - /** - * Add element to elements - * @param {object} el The element - * @param {string} lang The language of the element - * @param {string} sel The selector of the element - * @returns {Object} An element-object - */ - function add(el, lang, sel) { - const elo = { - "element": el, - "selector": sel - }; - if (!list.has(lang)) { - list.set(lang, []); - } - list.get(lang).push(elo); - counter[0] += 1; - return elo; - } - - /** - * Removes elements from the list and updates the counter - * @param {string} lang - The lang of the elements to remove - */ - function rem(lang) { - let langCount = 0; - if (list.has(lang)) { - langCount = list.get(lang).length; - list.delete(lang); - counter[0] -= langCount; - if (counter[0] === 0) { - event.fire( - "hyphenopolyEnd", - { - "msg": "hyphenopolyEnd" - } - ); - if (!C.keepAlive) { - window.Hyphenopoly = null; - } - } - } - } - - return { - add, - counter, - list, - rem - }; - } - - /** - * Get language of element by searching its parents or fallback - * @param {Object} el The element - * @param {string} parentLang Lang of parent if available - * @param {boolean} fallback Will falback to mainlanguage - * @returns {string|null} The language or null - */ - function getLang(el, parentLang = "", fallback = true) { - // Find closest el with lang attr not empty - el = el.closest("[lang]:not([lang=''])"); - if (el && el.lang) { - return el.lang.toLowerCase(); - } - if (parentLang) { - return parentLang; - } - return (fallback) - ? mainLanguage - : null; - } - - /** - * Collect elements that have a selector defined in C.selectors - * and add them to elements. - * @param {Object} [parent = null] The start point element - * @param {string} [selector = null] The selector matching the parent - * @returns {Object} elements-object - */ - function collectElements(parent = null, selector = null) { - const elements = makeElementCollection(); - - const dontHyphenateSelector = (() => { - let s = "." + C.dontHyphenateClass; - o.getOwnPropertyNames(C.dontHyphenate).forEach((tag) => { - if (C.dontHyphenate.get(tag)) { - s += "," + tag; - } - }); - return s; - })(); - const matchingSelectors = C.selectors.join(",") + "," + dontHyphenateSelector; - - /** - * Recursively walk all elements in el, lending lang and selName - * add them to elements if necessary. - * @param {Object} el The element to scan - * @param {string} pLang The language of the parent element - * @param {string} sel The selector of the parent element - * @param {boolean} isChild If el is a child element - * @returns {undefined} - */ - function processElements(el, pLang, sel, isChild = false) { - const eLang = getLang(el, pLang); - const langDef = H.cf.langs.get(eLang); - if (langDef === "H9Y") { - elements.add(el, eLang, sel); - if (!isChild && C.safeCopy) { - registerOnCopy(el); - } - } else if (!langDef && eLang !== "zxx") { - event.fire( - "error", - Error(`Element with '${eLang}' found, but '${eLang}.wasm' not loaded. Check language tags!`) - ); - } - el.childNodes.forEach((n) => { - if (n.nodeType === 1 && !n.matches(matchingSelectors)) { - processElements(n, eLang, sel, true); - } - }); - } - - /** - * Searches the DOM for each sel - * @param {object} root The DOM root - * @returns {undefined} - */ - function getElems(root) { - C.selectors.forEach((sel) => { - root.querySelectorAll(sel).forEach((n) => { - processElements(n, getLang(n), sel, false); - }); - }); - } - - if (parent === null) { - if (C.processShadows) { - w.document.querySelectorAll("*").forEach((m) => { - if (m.shadowRoot) { - getElems(m.shadowRoot); - } - }); - } - getElems(w.document); - } else { - processElements(parent, getLang(parent), selector); - } - return elements; - } - - const wordHyphenatorPool = new Map(); - - /** - * Factory for hyphenatorFunctions for a specific language and selector - * @param {Object} lo Language-Object - * @param {string} lang The language - * @param {string} sel The selector - * @returns {function} The hyphenate function - */ - function createWordHyphenator(lo, lang, sel) { - const poolKey = lang + "-" + sel; - if (wordHyphenatorPool.has(poolKey)) { - return wordHyphenatorPool.get(poolKey); - } - - const selSettings = C.get(sel); - lo.cache.set(sel, new Map()); - - /** - * HyphenateFunction for non-compound words - * @param {string} word The word - * @returns {string} The hyphenated word - */ - function hyphenateNormal(word) { - if (word.length > 61) { - event.fire( - "error", - Error("Found word longer than 61 characters") - ); - } else if (!lo.reNotAlphabet.test(word)) { - return lo.hyphenate( - word, - selSettings.hyphen.charCodeAt(0), - selSettings.leftminPerLang.get(lang), - selSettings.rightminPerLang.get(lang) - ); - } - return word; - } - - /** - * HyphenateFunction for compound words - * @param {string} word The word - * @returns {string} The hyphenated compound word - */ - function hyphenateCompound(word) { - const zeroWidthSpace = "\u200B"; - let parts = null; - let wordHyphenator = null; - if (selSettings.compound === "auto" || - selSettings.compound === "all") { - wordHyphenator = createWordHyphenator(lo, lang, sel); - parts = word.split("-").map((p) => { - if (p.length >= selSettings.minWordLength) { - return wordHyphenator(p); - } - return p; - }); - if (selSettings.compound === "auto") { - word = parts.join("-"); - } else { - word = parts.join("-" + zeroWidthSpace); - } - } else { - word = word.replace("-", "-" + zeroWidthSpace); - } - return word; - } - - /** - * Checks if a string is mixed case - * @param {string} s The string - * @returns {boolean} true if s is mixed case - */ - function isMixedCase(s) { - return [...s].map((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(sel).get(word); - if (!hw) { - if (lo.exc.has(word)) { - hw = lo.exc.get(word).replace( - /-/g, - selSettings.hyphen - ); - } else if (!selSettings.mixedCase && isMixedCase(word)) { - hw = word; - } else if (word.indexOf("-") === -1) { - hw = hyphenateNormal(word); - } else { - hw = hyphenateCompound(word); - } - lo.cache.get(sel).set(word, hw); - } - return hw; - } - wordHyphenatorPool.set(poolKey, hyphenator); - return hyphenator; - } - - const orphanControllerPool = new Map(); - - /** - * Factory for function that handles orphans - * @param {string} sel The selector - * @returns {function} The function created - */ - function createOrphanController(sel) { - if (orphanControllerPool.has(sel)) { - return orphanControllerPool.get(sel); - } - const selSettings = C.get(sel); - - /** - * 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 - ) { - if (selSettings.orphanControl === 3 && leadingWhiteSpace === " ") { - // \u00A0 = no-break space (nbsp) - leadingWhiteSpace = "\u00A0"; - } - return leadingWhiteSpace + lastWord.replace(RegExp(selSettings.hyphen, "g"), "") + trailingWhiteSpace; - } - orphanControllerPool.set(sel, controlOrphans); - return controlOrphans; - } - - const wordRegExpPool = new Map(); - - /** - * Hyphenate an entitiy (text string or Element-Object) - * @param {string} lang - the language of the string - * @param {string} sel - the selectorName of settings - * @param {string} entity - the entity to be hyphenated - * @returns {string | null} hyphenated str according to setting of sel - */ - function hyphenate(lang, sel, entity) { - const lo = H.languages.get(lang); - const selSettings = C.get(sel); - const minWordLength = selSettings.minWordLength; - - - const regExpWord = (() => { - const key = lang + minWordLength; - if (wordRegExpPool.has(key)) { - return wordRegExpPool.get(key); - } - - /* - * Transpiled RegExp of - * /[${alphabet}\p{Mn}Subset\p{Letter}\00AD-] - * {${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\u00AD\u200B-\u200D-]{${minWordLength},}`, "gui" - ); - wordRegExpPool.set(key, reWord); - return reWord; - })(); - - /** - * Hyphenate text according to setting in sel - * @param {string} text - the strint to be hyphenated - * @returns {string} hyphenated string according to setting of sel - */ - function hyphenateText(text) { - if (C.normalize) { - text = text.normalize("NFC"); - } - let tn = text.replace( - regExpWord, - createWordHyphenator(lo, lang, sel) - ); - if (selSettings.orphanControl !== 1) { - tn = tn.replace( - /(\u0020*)(\S+)(\s*)$/, - createOrphanController(sel) - ); - } - return tn; - } - - /** - * Hyphenate element according to setting in sel - * @param {object} el - the HTMLElement to be hyphenated - * @returns {undefined} - */ - function hyphenateElement(el) { - event.fire( - "beforeElementHyphenation", - { - el, - lang - } - ); - el.childNodes.forEach((n) => { - if ( - n.nodeType === 3 && - (/\S/).test(n.data) && - n.data.length >= minWordLength - ) { - n.data = hyphenateText(n.data); - } - }); - H.res.els.counter[0] -= 1; - event.fire( - "afterElementHyphenation", - { - el, - lang - } - ); - } - let r = null; - if (typeof entity === "string") { - r = hyphenateText(entity); - } else if (entity instanceof HTMLElement) { - hyphenateElement(entity); - } - return r; - } - - /** - * Creates a language-specific string hyphenator - * @param {String} lang - The language this hyphenator hyphenates - */ - function createStringHyphenator(lang) { - return ((entity, sel = ".hyphenate") => { - if (typeof entity !== "string") { - event.fire( - "error", - Error("This use of hyphenators is deprecated. See https://mnater.github.io/Hyphenopoly/Hyphenators.html") - ); - } - return hyphenate(lang, sel, entity); - }); - } - - /** - * Creates a polyglot HTML hyphenator - */ - function createDOMHyphenator() { - return ((entity, sel = ".hyphenate") => { - collectElements(entity, sel).list.forEach((els, l) => { - els.forEach((elo) => { - hyphenate(l, elo.selector, elo.element); - }); - }); - return null; - }); - } - - H.unhyphenate = () => { - H.res.els.list.forEach((els) => { - els.forEach((elo) => { - const n = elo.element.firstChild; - n.data = n.data.replace(RegExp(C[elo.selector].hyphen, "g"), ""); - }); - }); - return Promise.resolve(H.res.els); - }; - - /** - * Hyphenate all elements with a given language - * @param {string} lang The language - * @param {Array} elArr Array of elements - * @returns {undefined} - */ - function hyphenateLangElements(lang, elements) { - const elArr = elements.list.get(lang); - if (elArr) { - elArr.forEach((elo) => { - hyphenate(lang, elo.selector, elo.element); - }); - } else { - event.fire( - "error", - Error(`Engine for language '${lang}' loaded, but no elements found.`) - ); - } - if (elements.counter[0] === 0) { - w.clearTimeout(H.timeOutHandler); - H.hide(0, null); - event.fire( - "hyphenopolyEnd", - { - "msg": "hyphenopolyEnd" - } - ); - if (!C.keepAlive) { - window.Hyphenopoly = null; - } - } - } - - /** - * Convert the exceptions from user input to Map - * @param {string} lang - The language for which the Map is created - * @return {Map} - */ - function createExceptionMap(lang) { - let exc = ""; - if (C.exceptions.has(lang)) { - exc = C.exceptions.get(lang); - } - if (C.exceptions.has("global")) { - if (exc === "") { - exc = C.exceptions.get("global"); - } else { - exc += ", " + C.exceptions.get("global"); - } - } - if (exc === "") { - return new Map(); - } - return new Map(exc.split(", ").map((e) => { - return [e.replace(/-/g, ""), e]; - })); - } - - /** - * Setup lo - * @param {string} lang The language - * @param {function} hyphenateFunction The hyphenateFunction - * @param {string} alphabet List of used characters - * @param {number} leftmin leftmin - * @param {number} rightmin rightmin - * @returns {undefined} - */ - function prepareLanguagesObj( - lang, - hyphenateFunction, - alphabet, - patternLeftmin, - patternRightmin - ) { - C.selectors.forEach((sel) => { - const selSettings = C.get(sel); - if (selSettings.leftminPerLang === 0) { - selSettings.set("leftminPerLang", new Map()); - } - if (selSettings.rightminPerLang === 0) { - selSettings.set("rightminPerLang", new Map()); - } - selSettings.leftminPerLang.set(lang, Math.max( - patternLeftmin, - selSettings.leftmin, - Number(selSettings.leftminPerLang.get(lang)) || 0 - )); - - selSettings.rightminPerLang.set(lang, Math.max( - patternRightmin, - selSettings.rightmin, - Number(selSettings.rightminPerLang.get(lang)) || 0 - )); - }); - if (!H.languages) { - H.languages = new Map(); - } - alphabet = alphabet.replace(/\\*-/g, "\\-"); - H.languages.set(lang, { - alphabet, - "cache": new Map(), - "exc": createExceptionMap(lang), - "hyphenate": hyphenateFunction, - "ready": true, - "reNotAlphabet": RegExp(`[^${alphabet}]`, "i") - }); - H.hy6ors.get(lang).resolve(createStringHyphenator(lang)); - event.fire( - "engineReady", - { - lang - } - ); - if (H.res.els) { - hyphenateLangElements(lang, H.res.els); - } - } - - const decode = (() => { - const utf16ledecoder = new TextDecoder("utf-16le"); - return ((ui16) => { - return utf16ledecoder.decode(ui16); - }); - })(); - - /** - * Setup env for hyphenateFunction - * @param {ArrayBuffer} buf Memory buffer - * @param {function} hyphenateFunc hyphenateFunction - * @returns {function} hyphenateFunction with closured environment - */ - function encloseHyphenateFunction(buf, hyphenateFunc) { - const wordStore = new Uint16Array(buf, 0, 64); - 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(heProm, lang) { - const wa = window.WebAssembly; - - /** - * 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 (C.substitute.has(lang)) { - const subst = 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(res) { - const exp = res.instance.exports; - // eslint-disable-next-line multiline-ternary - let alphalen = (wa.Global) ? exp.lct.value : exp.lct; - alphalen = registerSubstitutions(alphalen, exp); - heProm.l.forEach((l) => { - prepareLanguagesObj( - l, - encloseHyphenateFunction( - exp.mem.buffer, - exp.hyphenate - ), - decode(new Uint16Array(exp.mem.buffer, 1408, alphalen)), - /* eslint-disable multiline-ternary */ - (wa.Global) ? exp.lmi.value : exp.lmi, - (wa.Global) ? exp.rmi.value : exp.rmi - /* eslint-enable multiline-ternary */ - ); - }); - } - heProm.w.then((response) => { - if (response.ok) { - if ( - wa.instantiateStreaming && - (response.headers.get("Content-Type") === "application/wasm") - ) { - return wa.instantiateStreaming(response); - } - return response.arrayBuffer().then((ab) => { - return wa.instantiate(ab); - }); - } - return Promise.reject(Error(`File ${lang}.wasm can't be loaded from ${H.paths.patterndir}`)); - }).then(handleWasm, (e) => { - event.fire("error", e); - H.res.els.rem(lang); - }); - } - - H.main = () => { - H.res.DOM.then(() => { - mainLanguage = getLang(w.document.documentElement, "", false); - if (!mainLanguage && C.defaultLanguage !== "") { - mainLanguage = C.defaultLanguage; - } - const elements = collectElements(); - H.res.els = elements; - elements.list.forEach((ignore, lang) => { - if (H.languages && - H.languages.has(lang) && - H.languages.get(lang).ready - ) { - hyphenateLangElements(lang, elements); - } - }); - }); - - H.res.he.forEach(instantiateWasmEngine); - - Promise.all( - // Make sure all lang specific hyphenators and DOM are ready - [...H.hy6ors.entries()]. - reduce((accumulator, value) => { - if (value[0] !== "HTML") { - return accumulator.concat(value[1]); - } - return accumulator; - }, []). - concat(H.res.DOM) - ).then(() => { - H.hy6ors.get("HTML").resolve(createDOMHyphenator()); - }, (e) => { - event.fire("error", e); - }); - }; - H.main(); - })(Hyphenopoly); -})(window, Object); diff --git a/public/js/Hyphenopoly_Loader.js b/public/js/Hyphenopoly_Loader.js deleted file mode 100644 index f01903f..0000000 --- a/public/js/Hyphenopoly_Loader.js +++ /dev/null @@ -1,347 +0,0 @@ -/** - * @license Hyphenopoly_Loader 5.2.0-beta.1 - client side hyphenation - * ©2023 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 - */ -/* globals Hyphenopoly:readonly */ -window.Hyphenopoly = {}; - -((w, d, H, o) => { - "use strict"; - - /** - * Shortcut for new Map - * @param {any} init - initialiser for new Map - * @returns {Map} - */ - const mp = (init) => { - return new Map(init); - }; - - const scriptName = "Hyphenopoly_Loader.js"; - const thisScript = d.currentScript.src; - const store = sessionStorage; - let mainScriptLoaded = false; - - /** - * The main function runs the feature test and loads Hyphenopoly if - * necessary. - */ - const main = (() => { - const shortcuts = { - "ac": "appendChild", - "ce": "createElement", - "ct": "createTextNode" - }; - - /** - * Create deferred Promise - * - * From http://lea.verou.me/2016/12/resolve-promises-externally-with- - * this-one-weird-trick/ - * @return {promise} - */ - const defProm = () => { - let res = null; - let rej = null; - const promise = new Promise((resolve, reject) => { - res = resolve; - rej = reject; - }); - promise.resolve = res; - promise.reject = rej; - return promise; - }; - - H.ac = new AbortController(); - const fetchOptions = { - "credentials": H.s.CORScredentials, - "signal": H.ac.signal - }; - - let stylesNode = null; - - /** - * Define function H.hide. - * This function hides (state = 1) or unhides (state = 0) - * the whole document (mode == 0) or - * each selected element (mode == 1) or - * text of each selected element (mode == 2) or - * nothing (mode == -1) - * @param {integer} state - State - * @param {integer} mode - Mode - */ - H.hide = (state, mode) => { - if (state) { - let vis = "{visibility:hidden!important}"; - stylesNode = d[shortcuts.ce]("style"); - let myStyle = ""; - if (mode === 0) { - myStyle = "html" + vis; - } else if (mode !== -1) { - if (mode === 2) { - vis = "{color:transparent!important}"; - } - o.keys(H.s.selectors).forEach((sel) => { - myStyle += sel + vis; - }); - } - stylesNode[shortcuts.ac](d[shortcuts.ct](myStyle)); - d.head[shortcuts.ac](stylesNode); - } else if (stylesNode) { - stylesNode.remove(); - } - }; - - const tester = (() => { - let fakeBody = null; - return { - - /** - * Append fakeBody with tests to document - * @returns {Object|null} The body element or null, if no tests - */ - "ap": () => { - if (fakeBody) { - d.documentElement[shortcuts.ac](fakeBody); - return fakeBody; - } - return null; - }, - - /** - * Remove fakeBody - * @returns {undefined} - */ - "cl": () => { - if (fakeBody) { - fakeBody.remove(); - } - }, - - /** - * Create and append div with CSS-hyphenated word - * @param {string} lang Language - * @returns {undefined} - */ - "cr": (lang) => { - if (H.cf.langs.has(lang)) { - return; - } - fakeBody = fakeBody || d[shortcuts.ce]("body"); - const testDiv = d[shortcuts.ce]("div"); - const ha = "hyphens:auto"; - testDiv.lang = lang; - testDiv.style.cssText = `visibility:hidden;-webkit-${ha};-ms-${ha};${ha};width:48px;font-size:12px;line-height:12px;border:none;padding:0;word-wrap:normal`; - testDiv[shortcuts.ac]( - d[shortcuts.ct](H.lrq.get(lang).wo.toLowerCase()) - ); - fakeBody[shortcuts.ac](testDiv); - } - }; - })(); - - /** - * Checks if hyphens (ev.prefixed) is set to auto for the element. - * @param {Object} elm - the element - * @returns {Boolean} result of the check - */ - const checkCSSHyphensSupport = (elmStyle) => { - const h = elmStyle.hyphens || - elmStyle.webkitHyphens || - elmStyle.msHyphens; - return (h === "auto"); - }; - - H.res = { - "he": mp() - }; - - /** - * Load hyphenEngines to H.res.he - * - * Make sure each .wasm is loaded exactly once, even for fallbacks - * Store a list of languages to by hyphenated with each .wasm - * @param {string} lang The language - * @returns {undefined} - */ - const loadhyphenEngine = (lang) => { - const fn = H.lrq.get(lang).fn; - H.cf.pf = true; - H.cf.langs.set(lang, "H9Y"); - if (H.res.he.has(fn)) { - H.res.he.get(fn).l.push(lang); - } else { - H.res.he.set( - fn, - { - "l": [lang], - "w": w.fetch(H.paths.patterndir + fn + ".wasm", fetchOptions) - } - ); - } - }; - H.lrq.forEach((value, lang) => { - if (value.wo === "FORCEHYPHENOPOLY" || H.cf.langs.get(lang) === "H9Y") { - loadhyphenEngine(lang); - } else { - tester.cr(lang); - } - }); - const testContainer = tester.ap(); - if (testContainer) { - testContainer.querySelectorAll("div").forEach((n) => { - if (checkCSSHyphensSupport(n.style) && n.offsetHeight > 12) { - H.cf.langs.set(n.lang, "CSS"); - } else { - loadhyphenEngine(n.lang); - } - }); - tester.cl(); - } - const hev = H.hev; - if (H.cf.pf) { - H.res.DOM = new Promise((res) => { - if (d.readyState === "loading") { - d.addEventListener( - "DOMContentLoaded", - res, - { - "once": true, - "passive": true - } - ); - } else { - res(); - } - }); - H.hide(1, H.s.hide); - H.timeOutHandler = w.setTimeout(() => { - H.hide(0, null); - // eslint-disable-next-line no-bitwise - if (H.s.timeout & 1) { - H.ac.abort(); - } - // eslint-disable-next-line no-console - console.info(scriptName + " timed out."); - }, H.s.timeout); - if (mainScriptLoaded) { - H.main(); - } else { - // Load main script - fetch(H.paths.maindir + "Hyphenopoly.js", fetchOptions). - then((response) => { - if (response.ok) { - response.blob().then((blb) => { - const script = d[shortcuts.ce]("script"); - script.src = URL.createObjectURL(blb); - d.head[shortcuts.ac](script); - mainScriptLoaded = true; - URL.revokeObjectURL(script.src); - }); - } - }); - } - H.hy6ors = mp(); - H.cf.langs.forEach((langDef, lang) => { - if (langDef === "H9Y") { - H.hy6ors.set(lang, defProm()); - } - }); - H.hy6ors.set("HTML", defProm()); - H.hyphenators = new Proxy(H.hy6ors, { - "get": (target, key) => { - return target.get(key); - }, - "set": () => { - // Inhibit setting of hyphenators - return true; - } - }); - (() => { - if (hev && hev.polyfill) { - hev.polyfill(); - } - })(); - } else { - (() => { - if (hev && hev.tearDown) { - hev.tearDown(); - } - w.Hyphenopoly = null; - })(); - } - (() => { - if (H.cft) { - store.setItem(scriptName, JSON.stringify( - { - "langs": [...H.cf.langs.entries()], - "pf": H.cf.pf - } - )); - } - })(); - }); - - H.config = (c) => { - /** - * Sets default properties for an Object - * @param {object} obj - The object to set defaults to - * @param {object} defaults - The defaults to set - * @returns {object} - */ - const setDefaults = (obj, defaults) => { - if (obj) { - o.entries(defaults).forEach(([k, v]) => { - // eslint-disable-next-line security/detect-object-injection - obj[k] = obj[k] || v; - }); - return obj; - } - return defaults; - }; - - H.cft = Boolean(c.cacheFeatureTests); - if (H.cft && store.getItem(scriptName)) { - H.cf = JSON.parse(store.getItem(scriptName)); - H.cf.langs = mp(H.cf.langs); - } else { - H.cf = { - "langs": mp(), - "pf": false - }; - } - - const maindir = thisScript.slice(0, (thisScript.lastIndexOf("/") + 1)); - const patterndir = maindir + "patterns/"; - H.paths = setDefaults(c.paths, { - maindir, - patterndir - }); - H.s = setDefaults(c.setup, { - "CORScredentials": "include", - "hide": "all", - "selectors": {".hyphenate": {}}, - "timeout": 1000 - }); - // Change mode string to mode int - H.s.hide = ["all", "element", "text"].indexOf(H.s.hide); - if (c.handleEvent) { - H.hev = c.handleEvent; - } - - const fallbacks = mp(o.entries(c.fallbacks || {})); - H.lrq = mp(); - o.entries(c.require).forEach(([lang, wo]) => { - H.lrq.set(lang.toLowerCase(), { - "fn": fallbacks.get(lang) || lang, - wo - }); - }); - - main(); - }; -})(window, document, Hyphenopoly, Object); diff --git a/public/js/animation-queue.js b/public/js/animation-queue-module.js similarity index 95% rename from public/js/animation-queue.js rename to public/js/animation-queue-module.js index eba8dda..0fccc85 100644 --- a/public/js/animation-queue.js +++ b/public/js/animation-queue-module.js @@ -4,14 +4,13 @@ * and synchronization with TTS */ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; class AnimationQueueModule extends BaseModule { constructor() { super('animation-queue', 'Animation Queue'); // Module dependencies - this.dependencies = []; + this.dependencies = ['tts-player']; // Queue of scheduled animations/functions this.timeoutQueue = []; @@ -43,15 +42,12 @@ class AnimationQueueModule extends BaseModule { try { this.reportProgress(20, "Initializing Animation Queue"); - // We'll try to get the TTS module, but it's not a hard dependency + // Try to get the TTS module, but it's not a hard dependency // We'll check for it again at runtime when needed - setTimeout(() => { - // Try to get TTS module after a delay to allow it to initialize - this.tts = this.getModule('tts-player'); - if (!this.tts) { - console.log("Animation Queue: TTS Player module not found yet, will try again when needed"); - } - }, 500); + this.tts = this.getModule('tts-player'); + if (!this.tts) { + console.log("Animation Queue: TTS Player module not found yet, will try again when needed"); + } this.reportProgress(100, "Animation Queue ready"); return true; @@ -385,8 +381,5 @@ class AnimationQueueModule extends BaseModule { // Create the singleton instance const AnimationQueue = new AnimationQueueModule(); -// Register with the module registry -moduleRegistry.register(AnimationQueue); - // Export the module export { AnimationQueue }; diff --git a/public/js/api-tts-handler-base.js b/public/js/api-tts-handler-base.js deleted file mode 100644 index dd1bb5d..0000000 --- a/public/js/api-tts-handler-base.js +++ /dev/null @@ -1,506 +0,0 @@ -/** - * API TTS Handler Base Class - * Base class for API-based TTS handlers - */ -import { TTSHandler } from './tts-handler.js'; -import { moduleRegistry } from './module-registry.js'; - -export class ApiTTSHandlerBase extends TTSHandler { - constructor(id, name) { - super(); - this.id = id; - this.name = name; - - // Base voice options - this.voiceOptions = { - speed: 1.0 - }; - - // State - this.available = false; - this.isReady = false; - this.currentAudio = null; - - // Common API settings - this.apiKey = ''; - this.apiBaseUrl = ''; - - // Dependencies - this.dependencies = ['localization', 'persistence-manager']; - } - - /** - * Initialize the API TTS handler - * @param {Function} progressCallback - Callback for progress updates - * @returns {Promise} - Resolves with success status - */ - async initialize(progressCallback = null) { - try { - if (progressCallback) { - progressCallback(10, `Initializing ${this.name}`); - } - - this.changeState('LOADING'); - - // Check for required dependencies - const localization = this.getModule('localization'); - const persistenceManager = this.getModule('persistence-manager'); - - if (!localization) { - console.error(`${this.name}: Required dependency 'localization' not found`); - this.changeState('ERROR'); - return false; - } - - if (!persistenceManager) { - console.error(`${this.name}: Required dependency 'persistence-manager' not found`); - this.changeState('ERROR'); - return false; - } - - if (progressCallback) { - progressCallback(20, `${this.name} dependencies loaded`); - } - - // Set up API key from preferences - should be empty by default - this.apiKey = persistenceManager.getPreference('tts', `${this.id}_api_key`) || ''; - - if (progressCallback) { - progressCallback(30, `${this.name} API key loaded`); - } - - // Get default API URL - const defaultApiUrl = this.getDefaultApiBaseUrl(); - console.log(`${this.name}: Default API URL: ${defaultApiUrl}`); - - // Set up API base URL from preferences or use default - const savedApiUrl = persistenceManager.getPreference('tts', `${this.id}_api_url`); - this.apiBaseUrl = savedApiUrl || defaultApiUrl; - - // If no API URL was saved in preferences, save the default - if (!savedApiUrl && defaultApiUrl) { - console.log(`${this.name}: Saving default API URL to preferences: ${defaultApiUrl}`); - persistenceManager.updatePreference('tts', `${this.id}_api_url`, defaultApiUrl); - } - - // Log the current values for debugging - console.log(`${this.name} API KEY: ${this.apiKey ? '[SET]' : '[EMPTY]'}`); - console.log(`${this.name} API URL: ${this.apiBaseUrl}`); - - if (progressCallback) { - progressCallback(40, `${this.name} API URL set to: ${this.apiBaseUrl}`); - } - - // Set up event listeners for API key and URL changes - this.addEventListener('tts:api:keyChanged', this.handleApiKeyChanged); - this.addEventListener('tts:api:urlChanged', this.handleApiUrlChanged); - - if (progressCallback) { - progressCallback(50, `${this.name} event listeners registered`); - } - - // Load available voices - const voicesLoaded = await this.loadVoices(); - - if (progressCallback) { - progressCallback(70, `${this.name} voices loaded`); - } - - // Set up voice based on preferences - await this.setupVoiceFromPreferences(); - - if (progressCallback) { - progressCallback(90, `${this.name} voice preferences loaded`); - } - - // Set availability based on API key presence - this.available = true; - this.isReady = true; - - if (progressCallback) { - const statusMessage = this.apiKey ? - `${this.name} initialized successfully` : - `${this.name} initialized but unavailable (API key missing)`; - progressCallback(100, statusMessage); - } - - this.changeState(this.available ? 'FINISHED' : 'WAITING'); - return true; - } catch (error) { - console.error(`${this.name}: Initialization error:`, error); - if (progressCallback) { - progressCallback(100, `${this.name} initialization failed - ${error.message}`); - } - this.changeState('ERROR'); - return false; - } - } - - /** - * Get a module from the registry - * @param {string} moduleId - ID of the module to get - * @returns {Object|null} - The module or null if not found - */ - getModule(moduleId) { - return moduleRegistry.getModule(moduleId); - } - - /** - * Get the default API base URL for this provider - * @returns {string} - Default API base URL - */ - getDefaultApiBaseUrl() { - // Should be implemented by subclasses - return ''; - } - - /** - * Set up voice based on preferences and locale - * @returns {Promise} - Resolves with success status - */ - async setupVoiceFromPreferences() { - const persistenceManager = this.getModule('persistence-manager'); - const localization = this.getModule('localization'); - - if (!persistenceManager || !localization) { - return false; - } - - // Get current locale - const locale = localization.getLocale(); - - // Try to get voice preference for this specific provider - const voiceId = persistenceManager.getPreference('tts', `${this.id}_voice`); - - if (voiceId) { - // Set voice from preference - this.voiceOptions.voice = voiceId; - return true; - } - - // If no specific voice preference, try to select a voice for the current locale - return this.selectVoiceForLocale(locale); - } - - /** - * Load available voices from API - * @returns {Promise} - Resolves with success status - */ - async loadVoices() { - // Should be implemented by subclasses - return false; - } - - /** - * Select a voice for the given locale - * @param {string} locale - Locale code - * @returns {boolean} - Success status - */ - selectVoiceForLocale(locale) { - // Should be implemented by subclasses - return this.selectDefaultVoice(); - } - - /** - * Select a default voice - * @returns {boolean} - Success status - */ - selectDefaultVoice() { - // Should be implemented by subclasses - return false; - } - - /** - * Generate speech audio blob for the given text using the API. - * Does not handle caching or playback, returns the Blob directly. - * @param {string} text - The text to synthesize. - * @returns {Promise} - A promise that resolves with the audio Blob, or null on failure. - */ - async generateSpeechAudio(text) { - if (!this.apiKey) { - console.error(`${this.name}: API key is not set.`); - return null; - } - if (!this.isReady || !this.currentVoice) { - console.error(`${this.name}: Handler not ready or no voice selected.`); - return null; - } - - const requestUrl = this.getApiRequestUrl(); - const requestBody = this.getApiRequestBody(text); - const requestHeaders = this.getApiRequestHeaders(); - - console.log(`${this.name}: Requesting speech generation...`); - // Log sensitive info only if debug enabled (assuming a global DEBUG flag or similar) - // if (DEBUG) { - // console.debug(`${this.name}: URL: ${requestUrl}`); - // console.debug(`${this.name}: Headers:`, JSON.stringify(requestHeaders)); - // console.debug(`${this.name}: Body:`, JSON.stringify(requestBody)); - // } - - try { - const response = await fetch(requestUrl, { - method: 'POST', - headers: requestHeaders, - body: JSON.stringify(requestBody) - }); - - if (!response.ok) { - let errorBody = 'Unknown error'; - try { - errorBody = await response.text(); // Try to get text first - const errorJson = JSON.parse(errorBody); // Try to parse as JSON - errorBody = errorJson.error?.message || errorJson.detail || JSON.stringify(errorJson); - } catch (e) { - // If parsing fails or it's not JSON, use the raw text - console.warn(`${this.name}: Could not parse error response as JSON. Raw text: ${errorBody}`); - } - throw new Error(`API Error (${response.status} ${response.statusText}): ${errorBody}`); - } - - // --- Response Handling (Specific to API - Override if necessary) --- - // Default assumes response IS the audio blob - const audioBlob = await response.blob(); - console.log(`${this.name}: Received audio blob, size: ${audioBlob.size}`); - // ------------------------------------------------------------------- - - if (!audioBlob || audioBlob.size === 0) { - throw new Error('Received empty audio blob from API.'); - } - - // Return the audio data blob - return audioBlob; - - } catch (error) { - console.error(`${this.name}: Error generating speech audio:`, error); - this.handleApiError(error); - return null; - } - } - - /** - * Plays preloaded audio data. - * @param {Blob} audioData - The audio data Blob to play. - * @param {Function} [callback=null] - Optional callback function. - */ - speakPreloaded(audioData, callback = null) { - // This method might now be redundant if the factory handles all playback. - // However, keeping it in case direct playback of preloaded data is needed elsewhere. - // Or, it could be simplified to just return the blob if factory always handles play. - // For now, let's keep the playback logic but it might be unused by the factory flow. - console.log(`${this.name}: Playing preloaded audio...`); - const audioManager = this.getModule('audio-manager'); - if (audioManager && audioData) { - // This assumes audioManager.play handles Blobs - audioManager.play(audioData, callback); - } else { - console.error(`${this.name}: AudioManager not found or no audio data to play.`); - if (callback) callback(false, "Playback error"); - } - } - - /** - * Stops the currently playing audio. - */ - stop() { - console.log(`${this.name}: Stop requested.`); - const audioManager = this.getModule('audio-manager'); - if (audioManager) { - audioManager.stop(); - } - // Reset any internal state if needed - this.currentAudio = null; - } - - /** - * Speak the given text using the API. - * This method now primarily calls generateSpeechAudio and returns the result. - * Caching and playback are handled by TTSFactoryModule. - * @param {string} text - The text to speak. - * @returns {Promise} - A promise resolving to the audio Blob or null on failure. - */ - async speak(text) { - console.log(`${this.name}: speak called for text: ${text.substring(0, 30)}...`); - try { - // Generate audio data - const audioData = await this.generateSpeechAudio(text); - - if (!audioData) { - console.error(`${this.name}: Failed to generate audio for speak.`); - return null; - } - - // Return the Blob for the factory to handle - return audioData; - - } catch (error) { - console.error(`${this.name}: Error in speak method:`, error); - return null; - } - } - - /** - * Preloads speech for the given text. - * Generates the audio data but does not play it. - * Returns the generated Blob for the factory to cache. - * @param {string} text - The text to preload. - * @returns {Promise} - A promise resolving to the audio Blob or null on failure. - */ - async preloadSpeech(text) { - console.log(`${this.name}: preloadSpeech called for text: ${text.substring(0, 30)}...`); - try { - // Generate audio data using the main generation method - const audioData = await this.generateSpeechAudio(text); - - if (audioData) { - console.log(`${this.name}: Successfully preloaded speech (blob generated).`); - return audioData; // Return the Blob for the factory - } else { - console.error(`${this.name}: Failed to generate audio for preload.`); - return null; - } - } catch (error) { - console.error(`${this.name}: Error during preloadSpeech:`, error); - return null; - } - } - - /** - * Preprocess text for TTS - * @param {string} text - Text to preprocess - * @returns {string} - Processed text - */ - preprocessText(text) { - if (!text) return ''; - - // Trim whitespace - let processed = text.trim(); - - // Replace multiple spaces with a single space - processed = processed.replace(/\s+/g, ' '); - - // Add a period at the end if there's no punctuation - if (!/[.!?]$/.test(processed)) { - processed += '.'; - } - - return processed; - } - - /** - * Check if TTS is available - * @returns {boolean} - True if TTS is available - */ - isAvailable() { - return this.available; - } - - /** - * Get handler ID - * @returns {string} - Handler ID - */ - getId() { - return this.id; - } - - /** - * Get available voices - * @returns {Promise} - Resolves with array of voice objects - */ - async getVoices() { - // Should be implemented by subclasses - return []; - } - - /** - * Set voice options - * @param {Object} options - Voice options - */ - setVoiceOptions(options = {}) { - if (options.voice) { - this.voiceOptions.voice = options.voice; - - // Save the voice preference - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - persistenceManager.updatePreference('tts', `${this.id}_voice`, options.voice); - } - } - - if (typeof options.speed === 'number') { - // Clamp speed between 0.5 and 2.0 - this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed)); - } - - // Additional provider-specific options should be handled by subclasses - } - - /** - * Handle API key change event - * @param {Event} event - Event object - */ - handleApiKeyChanged(event) { - if (event && event.detail && event.detail.provider === this.id) { - const newKey = event.detail.key || ''; - - // Security check - never use a URL as an API key - if (newKey && newKey.startsWith('http')) { - console.error(`${this.name}: Received URL instead of API key, ignoring it`); - return; // Don't update API key - } - - // Update API key - this.apiKey = newKey; - - // Save to preferences - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - persistenceManager.updatePreference('tts', `${this.id}_api_key`, newKey); - } - - // Update functionality status but don't make it unavailable - // We want it to stay in the dropdown for configuration - const wasFullyFunctional = this.available; - const isFullyFunctional = !!this.apiKey; - - // Only update internal state - don't change availability for UI purposes - if (isFullyFunctional) { - this.changeState('FINISHED'); - } else { - // Not WAITING - we want it to stay in dropdown - this.changeState('CONFIGURING'); - } - - // Log the key change but don't affect availability for UI - console.log(`${this.name}: API key ${newKey ? 'set' : 'cleared'}. Fully functional: ${isFullyFunctional}`); - - // Always stay available in the UI dropdown - this.available = true; - } - } - - /** - * Handle API URL change event - * @param {Event} event - Event object - */ - handleApiUrlChanged(event) { - if (event && event.detail && event.detail.provider === this.id) { - const newUrl = event.detail.url || this.getDefaultApiBaseUrl(); - - // Update API URL - this.apiBaseUrl = newUrl; - - // Save to preferences - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl); - } - - // Log the URL change but don't affect availability - console.log(`${this.name}: API URL updated to ${newUrl}`); - - // Always stay available in the UI dropdown - this.available = true; - } - } -} diff --git a/public/js/api-tts-module-base.js b/public/js/api-tts-module-base.js index 1c74fdc..79ba0be 100644 --- a/public/js/api-tts-module-base.js +++ b/public/js/api-tts-module-base.js @@ -8,6 +8,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule { constructor(id, name) { super(id, name); + // Declare proper dependencies according to architecture principles + this.dependencies = ['persistence-manager', 'localization']; + // Basic voice options this.voiceOptions = { speed: 1.0, @@ -86,8 +89,13 @@ export class ApiTTSModuleBase extends TTSHandlerModule { // Check if we have an API key this.isReady = !!this.apiKey; - // Always mark as available for UI configuration purposes - // (even if not ready due to missing API key) + if (!this.isReady) { + console.error(`${this.name}: Missing API key, initialization failed`); + this.reportProgress(100, `${this.name} initialization failed - missing API key`); + return false; // Properly report failure when API key is missing + } + + // Only mark as complete if we have an API key this.reportProgress(100, `${this.name} initialization complete`); return true; @@ -111,6 +119,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule { const localization = this.getModule('localization'); if (!persistenceManager || !localization) { + console.error(`${this.name}: Required dependencies not found`); return false; } @@ -120,16 +129,13 @@ export class ApiTTSModuleBase extends TTSHandlerModule { // Get current locale const currentLocale = localization.getLocale(); - // If we have a preferred voice and available voices, use it - if (preferredVoiceId && this.voices && this.voices.length > 0) { - const voice = this.voices.find(v => v.id === preferredVoiceId); - if (voice) { - this.voiceOptions.voice = voice; - return true; - } + // If we have a preferred voice ID, use it + if (preferredVoiceId && this.voices.some(v => v.id === preferredVoiceId)) { + this.voiceOptions.voice = this.voices.find(v => v.id === preferredVoiceId); + return true; } - // Otherwise select a voice based on locale + // Otherwise, select voice based on locale if (currentLocale) { return this.selectVoiceForLocale(currentLocale); } @@ -163,7 +169,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule { * @returns {boolean} - Success status */ selectDefaultVoice() { - if (this.voices && this.voices.length > 0) { + if (this.voices.length > 0) { this.voiceOptions.voice = this.voices[0]; return true; } @@ -188,50 +194,42 @@ export class ApiTTSModuleBase extends TTSHandlerModule { */ speakPreloaded(preloadData, callback = null) { if (!preloadData || !preloadData.audioData) { - if (callback) { - callback({ success: false, reason: 'invalid_data' }); - } + console.error(`${this.name}: Invalid preloaded data`); + if (callback) callback({ success: false, reason: 'invalid_data' }); return false; } - // Stop any ongoing speech - this.stop(); - - // Create audio blob + // Create an audio element to play the audio const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' }); const audioUrl = URL.createObjectURL(audioBlob); - - // Create audio element const audio = new Audio(audioUrl); + // Set up state + this.isSpeaking = true; + this.currentAudio = audio; + // Set up event handlers audio.onended = () => { this.isSpeaking = false; - if (callback) { - callback({ success: true }); - } + this.currentAudio = null; URL.revokeObjectURL(audioUrl); + + if (callback) callback({ success: true }); }; audio.onerror = (error) => { + console.error(`${this.name}: Audio playback error:`, error); this.isSpeaking = false; - if (callback) { - callback({ success: false, reason: 'playback_error', error }); - } + this.currentAudio = null; URL.revokeObjectURL(audioUrl); + + if (callback) callback({ success: false, reason: 'playback_error', error }); }; - // Start playback - this.currentAudio = audio; - this.isSpeaking = true; - - // Handle play error + // Play the audio audio.play().catch(error => { - this.isSpeaking = false; - if (callback) { - callback({ success: false, reason: 'playback_error', error }); - } - URL.revokeObjectURL(audioUrl); + console.error(`${this.name}: Failed to play audio:`, error); + if (callback) callback({ success: false, reason: 'playback_error', error }); }); return true; @@ -244,17 +242,21 @@ export class ApiTTSModuleBase extends TTSHandlerModule { stop() { if (this.currentAudio) { try { + // Stop current audio this.currentAudio.pause(); this.currentAudio.currentTime = 0; - this.currentAudio = null; + + // Clean up this.isSpeaking = false; + this.currentAudio = null; + return true; } catch (error) { - console.error(`${this.name}: Error stopping speech:`, error); + console.error(`${this.name}: Error stopping audio:`, error); return false; } } - return true; + return true; // Already stopped } /** @@ -351,6 +353,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule { handleApiKeyChanged(event) { if (event && event.detail && event.detail.provider === this.id) { const newKey = event.detail.key || ''; + const oldKey = this.apiKey; // Security check - never use a URL as an API key if (newKey && newKey.startsWith('http')) { @@ -368,7 +371,33 @@ export class ApiTTSModuleBase extends TTSHandlerModule { } // Update ready state + const wasReady = this.isReady; this.isReady = !!this.apiKey; + + // If state changed (now ready/not-ready), notify the TTS factory + if (wasReady !== this.isReady) { + console.log(`${this.name}: TTS ready state changed to ${this.isReady ? 'ready' : 'not ready'} after API key change`); + + // Find and notify the TTS factory + const ttsFactory = this.getModule('tts-factory'); + if (ttsFactory) { + // If we have a key now (and didn't before), try initializing voices + if (this.isReady && !wasReady) { + // Reload voices with the new API key + this.loadVoices().then(() => { + // Then set up voice from preferences + this.setupVoiceFromPreferences().then(() => { + console.log(`${this.name}: Successfully initialized with new API key`); + // Notify the factory of our readiness change + ttsFactory.updateTTSAvailability(); + }); + }); + } else { + // Just update the availability + ttsFactory.updateTTSAvailability(); + } + } + } } } @@ -378,6 +407,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule { */ handleApiUrlChanged(event) { if (event && event.detail && event.detail.provider === this.id) { + const oldUrl = this.apiBaseUrl; const newUrl = event.detail.url || this.getDefaultApiBaseUrl(); // Update API URL @@ -388,6 +418,25 @@ export class ApiTTSModuleBase extends TTSHandlerModule { if (persistenceManager) { persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl); } + + // Only reinitialize if the URL actually changed and we have an API key + if (oldUrl !== newUrl && this.isReady) { + console.log(`${this.name}: API URL changed, reinitializing`); + + // Reload voices with the new API URL if we're ready + this.loadVoices().then(() => { + // Then set up voice from preferences + this.setupVoiceFromPreferences().then(() => { + console.log(`${this.name}: Successfully reinitialized with new API URL`); + + // Notify the TTS factory + const ttsFactory = this.getModule('tts-factory'); + if (ttsFactory) { + ttsFactory.updateTTSAvailability(); + } + }); + }); + } } } } diff --git a/public/js/audio-manager.js b/public/js/audio-manager-module.js similarity index 98% rename from public/js/audio-manager.js rename to public/js/audio-manager-module.js index 40628f1..1faae4e 100644 --- a/public/js/audio-manager.js +++ b/public/js/audio-manager-module.js @@ -3,7 +3,6 @@ * Manages loading and playback of non-TTS audio effects triggered by tags. */ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; class AudioManagerModule extends BaseModule { constructor() { @@ -374,11 +373,5 @@ class AudioManagerModule extends BaseModule { // 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 index 01bc84d..ca262fb 100644 --- a/public/js/base-module.js +++ b/public/js/base-module.js @@ -2,6 +2,8 @@ * Base Module Class * Provides common functionality and enforces a consistent interface for all modules */ +import { moduleRegistry } from './module-registry.js'; + export class BaseModule { constructor(id, name) { this.id = id; @@ -27,6 +29,9 @@ export class BaseModule { // Dependencies this.dependencies = []; this._loadedDependencies = new Map(); + + // Auto-register with module registry + moduleRegistry.register(this); } /** @@ -84,23 +89,7 @@ export class BaseModule { this.reportProgress(15, "Waiting for dependencies"); // Get moduleRegistry - first try import then fallback to window - const registry = window.moduleRegistry; - if (!registry) { - console.error(`${this.id}: Module registry not found, will retry`); - - // Retry after a short delay to allow registry to be initialized - await new Promise(resolve => setTimeout(resolve, 100)); - - // Try again - const retryRegistry = window.moduleRegistry; - if (!retryRegistry) { - console.error(`${this.id}: Module registry still not found after retry`); - return false; - } - - console.log(`${this.id}: Found module registry after retry`); - return this._continueWaitForDependencies(retryRegistry); - } + const registry = moduleRegistry; return this._continueWaitForDependencies(registry); } catch (error) { @@ -121,17 +110,30 @@ export class BaseModule { const results = await registry.waitForModules(this.dependencies); // Store references to dependencies + let hasErroredDependencies = false; for (let i = 0; i < this.dependencies.length; i++) { const depId = this.dependencies[i]; const depModule = registry.getModule(depId); if (depModule) { this._loadedDependencies.set(depId, depModule); + + // Check if this dependency is in ERROR state + if (depModule.state === 'ERROR') { + hasErroredDependencies = true; + console.warn(`${this.id}: Dependency ${depId} is in ERROR state but will be considered resolved`); + } } } - const allDepsReady = results.every(ready => ready === true); - if (allDepsReady) { - this.reportProgress(20, "Dependencies ready"); + // Check if all dependencies have resolved (either success or error) + // We consider a module with ERROR state as resolved + const allDepsResolved = results.every(result => result === true || result === false); + if (allDepsResolved) { + if (hasErroredDependencies) { + this.reportProgress(20, "Dependencies resolved with some errors"); + } else { + this.reportProgress(20, "Dependencies ready"); + } return true; } else { this.reportProgress(15, "Some dependencies not ready"); @@ -143,26 +145,6 @@ export class BaseModule { } } - /** - * Legacy method for backwards compatibility - * @deprecated Use dependencies array property instead - * @returns {Promise} - Resolves when dependencies are loaded - */ - async loadDependencies() { - // This is now handled by _waitForModuleDependencies - return Promise.resolve(true); - } - - /** - * Legacy method for backwards compatibility - * @deprecated No longer needed as waitForDependencies is handled automatically - * @returns {Promise} - Resolves when dependencies are ready - */ - async waitForDependencies() { - // This is now handled by _waitForModuleDependencies - return Promise.resolve(true); - } - /** * Initialize the module - Override this in child classes * @returns {Promise} - Resolves when initialization is complete @@ -186,7 +168,7 @@ export class BaseModule { * @param {string} message - Status message */ reportProgress(percent, message) { - this.progress = percent; + this.progress = Math.min(100, Math.max(0, percent)); if (this.progressCallback && typeof this.progressCallback === 'function') { this.progressCallback(percent, message); @@ -268,10 +250,6 @@ export class BaseModule { if (this._loadedDependencies.has(moduleId)) { return this._loadedDependencies.get(moduleId); } - - // Then check in the registry - return window.moduleRegistry ? - window.moduleRegistry.getModule(moduleId) : null; } /** @@ -283,7 +261,7 @@ export class BaseModule { if (typeof this[methodName] === 'function') { this[methodName] = this[methodName].bind(this); } else { - console.warn(`Method ${methodName} not found on ${this.id} module`); + console.warn(`Method ${methodName} not found on ${this.id} module.`); } }); } @@ -548,7 +526,13 @@ export class BaseModule { _updateResourceProgress() { if (this._totalResources === 0) return; - const percent = Math.round((this._loadedResources / this._totalResources) * 100); + // Change to FETCHING state when loading resources + if (this.state === 'LOADING' && this._loadedResources === 0) { + this.changeState('FETCHING'); + } + + // Scale resource loading to 10% to 50% range of total module progress + const percent = Math.round((this._loadedResources / this._totalResources) * 40) + 10; this.reportProgress(percent, `Loading resources: ${this._loadedResources}/${this._totalResources}`); } diff --git a/public/js/browser-tts-handler.js b/public/js/browser-tts-handler.js deleted file mode 100644 index e8ce788..0000000 --- a/public/js/browser-tts-handler.js +++ /dev/null @@ -1,789 +0,0 @@ -/** - * BrowserTTSHandler for AI Interactive Fiction - * Implementation using the browser's Web Speech API - */ -import { TTSHandler } from './tts-handler.js'; -import { moduleRegistry } from './module-registry.js'; - -export class BrowserTTSHandler extends TTSHandler { - constructor() { - super(); - this.id = 'browser'; - this.name = 'Browser TTS Handler'; - - // Voice options - this.voiceOptions = { - voice: null, // Will be set during initialization - rate: 1.0, - pitch: 1.0, - volume: 1.0 - }; - - // State - this.available = false; - this.voices = []; - this.currentUtterance = null; - - // Add dependencies - this.dependencies = ['localization', 'persistence-manager']; - - // Bind methods - this.bindMethods([ - 'initialize', - 'speak', - 'speakPreloaded', - 'preloadSpeech', - 'stop', - 'isAvailable', - 'getId', - 'getVoices', - 'setVoiceOptions', - 'onVoicesChanged', - 'getModule' - ]); - } - - /** - * Get a module from the registry - * @param {string} moduleId - ID of the module to get - * @returns {Object|null} - The module or null if not found - */ - getModule(moduleId) { - return moduleRegistry.getModule(moduleId); - } - - /** - * Initialize the browser TTS handler - * @param {Function} progressCallback - Callback for progress updates - * @returns {Promise} - Resolves with success status - */ - async initialize(progressCallback = null) { - try { - if (progressCallback) { - progressCallback(10, 'Initializing Browser TTS'); - } - - this.changeState('LOADING'); - - // Check for browser support - if (!window.speechSynthesis) { - console.warn('Browser TTS: Speech synthesis not available in this browser'); - if (progressCallback) { - progressCallback(100, 'Browser TTS not available'); - } - this.changeState('ERROR'); - return false; - } - - if (progressCallback) { - progressCallback(30, 'Browser TTS supported'); - } - - // Check for required dependencies - const localization = this.getModule('localization'); - const persistenceManager = this.getModule('persistence-manager'); - - if (!localization) { - console.error('Browser TTS: Required dependency \'localization\' not found'); - this.changeState('ERROR'); - return false; - } - - if (!persistenceManager) { - console.error('Browser TTS: Required dependency \'persistence-manager\' not found'); - this.changeState('ERROR'); - return false; - } - - if (progressCallback) { - progressCallback(40, 'Browser TTS dependencies loaded'); - } - - // Load voices - but don't fail initialization if no voices are found yet - // The browser may provide voices later - try { - await this.loadVoices(); - console.log(`Browser TTS: Loaded ${this.voices.length} voices initially`); - } catch (error) { - console.warn('Browser TTS: Error loading voices initially:', error); - // Don't fail initialization - voices may become available later - this.voices = []; - } - - if (progressCallback) { - progressCallback(60, `Browser TTS loaded ${this.voices.length} voices`); - } - - // Set speech options from preferences - try { - const rate = persistenceManager.getPreference('tts', 'speed', 1.0); - const pitch = persistenceManager.getPreference('tts', 'pitch', 1.0); - const volume = persistenceManager.getPreference('tts', 'volume', 1.0); - - this.options.rate = parseFloat(rate); - this.options.pitch = parseFloat(pitch); - this.options.volume = parseFloat(volume); - - // Log all available voices for debugging - console.log('Browser TTS: Available voices:', this.voices.map(v => `${v.name} (${v.lang})`)); - - // Set voice based on locale - const locale = localization.getLocale(); - console.log(`Browser TTS: Setting voice for locale: ${locale}`); - const preferredVoice = persistenceManager.getPreference('tts', 'browser_voice'); - await this.selectVoiceForLocale(locale, preferredVoice); - - if (progressCallback) { - progressCallback(80, 'Browser TTS voice selected'); - } - } catch (error) { - console.warn('Browser TTS: Error setting speech options:', error); - // Don't fail initialization due to voice selection issues - } - - // If voices were loaded but no voice is selected, try to set a default - if (this.voices.length > 0 && !this.voiceOptions.voice) { - console.warn('Browser TTS: No voice selected after initialization, trying fallback'); - this.voiceOptions.voice = this.voices[0]; - } - - // Always mark as available if speech synthesis is supported, regardless of voice selection - // This ensures the Browser TTS option always appears in the dropdown - this.available = true; - this.isReady = true; - - if (progressCallback) { - progressCallback(100, 'Browser TTS initialized'); - } - - this.changeState('FINISHED'); - return true; - } catch (error) { - console.error('Browser TTS: Initialization error:', error); - if (progressCallback) { - progressCallback(100, `Browser TTS initialization failed - ${error.message}`); - } - this.changeState('ERROR'); - return false; - } - } - - /** - * Handle voices changed event - */ - async onVoicesChanged() { - await this.loadVoices(); - const localization = this.getModule('localization'); - const persistenceManager = this.getModule('persistence-manager'); - let currentLocale = localization ? localization.getLocale() : 'en-us'; - let preferredVoice = persistenceManager ? persistenceManager.getPreference('tts', 'voice', '') : ''; - await this.selectVoiceForLocale(currentLocale, preferredVoice); - } - - /** - * Load available voices - * @returns {Promise} - */ - async loadVoices() { - return new Promise(resolve => { - // Helper function to filter and sort voices - const processVoices = () => { - this.voices = speechSynthesis.getVoices() || []; - - // Log all available voices for debugging - console.log('Browser TTS: Raw loaded voices:', - this.voices.map(v => `${v.name} (${v.lang})`)); - - // Ensure we have at least one voice - if (this.voices.length === 0) { - console.warn('Browser TTS: No voices available from speech synthesis'); - resolve(); - return; - } - - // Sort voices to prioritize English voices first - this.voices.sort((a, b) => { - // Put English voices first - const aIsEnglish = a.lang.toLowerCase().startsWith('en'); - const bIsEnglish = b.lang.toLowerCase().startsWith('en'); - - if (aIsEnglish && !bIsEnglish) return -1; - if (!aIsEnglish && bIsEnglish) return 1; - - // Then sort by language - return a.lang.localeCompare(b.lang); - }); - - console.log('Browser TTS: Sorted voices:', - this.voices.map(v => `${v.name} (${v.lang})`)); - - resolve(); - }; - - // Some browsers need a timeout to get voices - const timeoutId = setTimeout(() => { - if (this.voices.length === 0) { - console.log('Browser TTS: Using timeout fallback to get voices'); - processVoices(); - } - }, 1000); - - // Try to get voices immediately - this.voices = speechSynthesis.getVoices() || []; - if (this.voices.length > 0) { - clearTimeout(timeoutId); - console.log(`Browser TTS: Loaded ${this.voices.length} voices immediately`); - processVoices(); - } else { - // If no voices are available yet, set up the onvoiceschanged event - speechSynthesis.onvoiceschanged = () => { - clearTimeout(timeoutId); - console.log('Browser TTS: Voices changed event fired'); - processVoices(); - speechSynthesis.onvoiceschanged = null; - }; - } - }); - } - - /** - * Set voice based on locale - * @param {string} locale - Locale code (e.g., 'en-us', 'de', 'fr') - * @param {string} preferredVoice - Optional preferred voice name - * @returns {Promise} - */ - async selectVoiceForLocale(locale = 'en-us', preferredVoice = '') { - // Debug voice selection process - console.log(`Browser TTS: Selecting voice for locale ${locale}, preferred voice: ${preferredVoice || 'none'}`); - console.log(`Browser TTS: Available voices:`, this.voices.map(v => `${v.name} (${v.lang})`)); - - // Normalize locale for comparison - const normalizedLocale = locale.toLowerCase(); - const languageCode = normalizedLocale.split('-')[0]; // e.g., 'en' from 'en-us' - - console.log(`Browser TTS: Normalized locale: ${normalizedLocale}, language code: ${languageCode}`); - - // If we have a preferred voice, try to use it first - if (preferredVoice) { - const matchingVoice = this.voices.find(voice => - voice.name === preferredVoice || - voice.voiceURI === preferredVoice - ); - - if (matchingVoice) { - this.voiceOptions.voice = matchingVoice; - console.log(`Browser TTS: Using preferred voice: ${matchingVoice.name}`); - return; - } - } - - // Find voices exactly matching the locale (e.g., 'en-us') - const exactLocaleVoices = this.voices.filter(voice => { - const voiceLocale = voice.lang.toLowerCase(); - return voiceLocale === normalizedLocale; - }); - - console.log(`Browser TTS: Found ${exactLocaleVoices.length} exact locale matches for ${normalizedLocale}`); - - if (exactLocaleVoices.length > 0) { - // Use the first matching voice - this.voiceOptions.voice = exactLocaleVoices[0]; - console.log(`Browser TTS: Using exact locale match for ${normalizedLocale}: ${this.voiceOptions.voice.name}`); - return; - } - - // Find voices matching the language code (e.g., 'en') - const languageVoices = this.voices.filter(voice => { - const voiceLocale = voice.lang.toLowerCase(); - console.log(`Browser TTS: Comparing voice lang ${voiceLocale} with language code ${languageCode}`); - return voiceLocale.startsWith(languageCode) || - (voiceLocale.length === 2 && languageCode.startsWith(voiceLocale)); - }); - - console.log(`Browser TTS: Found ${languageVoices.length} language matches for ${languageCode}`); - - if (languageVoices.length > 0) { - // Use the first matching voice - this.voiceOptions.voice = languageVoices[0]; - console.log(`Browser TTS: Using language match for ${languageCode}: ${this.voiceOptions.voice.name}`); - return; - } - - // If current language is not English and no matching voice found, try to find English voices - if (languageCode !== 'en') { - const englishVoices = this.voices.filter(voice => - voice.lang.toLowerCase().startsWith('en') - ); - - console.log(`Browser TTS: Found ${englishVoices.length} English voices as fallback`); - - if (englishVoices.length > 0) { - this.voiceOptions.voice = englishVoices[0]; - console.log(`Browser TTS: No ${languageCode} voice found, using English voice: ${this.voiceOptions.voice.name}`); - return; - } - } - - // As a last resort, use any available voice - if (this.voices.length > 0) { - this.voiceOptions.voice = this.voices[0]; - console.log(`Browser TTS: No matching voice found, using first available voice: ${this.voiceOptions.voice.name}`); - } else { - console.log("Browser TTS: No voices available"); - } - } - - /** - * Preload speech for a text - * @param {string} text - Text to preload - * @returns {Promise} - Preloaded speech data - */ - async preloadSpeech(text) { - if (!this.available || !text || !this.voiceOptions.voice) { - return null; - } - - try { - // Process text for TTS - const processedText = this.preprocessText(text); - - console.log(`Browser TTS: Preloading speech for: "${processedText.substring(0, 50)}${processedText.length > 50 ? '...' : ''}"`); - - // Use MediaRecorder to capture audio output to WAV - const audioData = await this.synthesizeToWav(processedText); - if (!audioData) { - console.warn("Browser TTS: Failed to generate WAV audio"); - return null; - } - - // Create audio element from blob - const audio = new Audio(URL.createObjectURL(audioData.blob)); - - // Store preloaded data in the centralized TTSFactory cache - const preloadData = { - audio: audio, - blob: audioData.blob, - text: processedText - }; - - // Use the TTSFactory's cache instead of a local cache - // this.preloadCache.set(text, preloadData); - // Instead, return the preloaded data to be stored in the TTSFactory's cache - return preloadData; - } catch (error) { - console.warn("Browser TTS: Error preloading speech:", error); - return null; - } - } - - /** - * Convert speech synthesis to WAV format - * @param {string} text - Text to synthesize - * @returns {Promise} - Object with WAV blob - */ - synthesizeToWav(text) { - return new Promise((resolve, reject) => { - try { - // Create utterance - const utterance = new SpeechSynthesisUtterance(text); - - // Set voice and options - utterance.voice = this.voiceOptions.voice; - utterance.rate = this.voiceOptions.rate; - utterance.pitch = this.voiceOptions.pitch; - utterance.volume = this.voiceOptions.volume; - utterance.lang = this.voiceOptions.voice.lang; - - // Use Web Audio API to capture the speech output - const audioContext = new (window.AudioContext || window.webkitAudioContext)(); - const destination = audioContext.createMediaStreamDestination(); - const mediaRecorder = new MediaRecorder(destination.stream); - const audioChunks = []; - - // Capture the audio chunks - mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0) { - audioChunks.push(event.data); - } - }; - - // When recording completes - mediaRecorder.onstop = () => { - // Create a WAV blob from the audio chunks - const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }); - resolve({ blob: audioBlob }); - }; - - // Set up speech synthesis events - utterance.onstart = () => { - console.log("Browser TTS: Started synthesizing audio to WAV"); - mediaRecorder.start(); - }; - - utterance.onend = () => { - console.log("Browser TTS: Finished synthesizing audio to WAV"); - mediaRecorder.stop(); - }; - - utterance.onerror = (error) => { - console.error("Browser TTS: Error synthesizing audio:", error); - reject(error); - }; - - // Start the speech synthesis - speechSynthesis.speak(utterance); - - // If synthesis doesn't start within a reasonable timeout, reject the promise - const timeout = setTimeout(() => { - if (mediaRecorder.state === 'inactive') { - console.warn("Browser TTS: Synthesis to WAV timed out"); - reject(new Error("Synthesis timed out")); - } - }, 5000); - - // Clear timeout when synthesis starts - utterance.onstart = () => { - clearTimeout(timeout); - console.log("Browser TTS: Started synthesizing audio to WAV"); - mediaRecorder.start(); - }; - } catch (error) { - console.error("Browser TTS: Error setting up WAV synthesis:", error); - reject(error); - } - }); - } - - /** - * Speak text using preloaded utterance - * @param {Object} preloadData - Preloaded speech data - * @param {Function} callback - Callback for when speech completes - * @returns {boolean} - Success status - */ - speakPreloaded(preloadData, callback = null) { - if (!this.available || !preloadData || !preloadData.audio) { - if (callback) { - setTimeout(() => callback({ success: false, reason: 'no_preloaded_data' }), 0); - } - return false; - } - - try { - // Stop any current speech - this.stop(); - - const { audio, text } = preloadData; - - // Dispatch start event - this.dispatchEvent('tts:speak:start', { text }); - - // Set up event listeners - audio.onended = () => { - this.currentUtterance = null; - - // Dispatch end event - this.dispatchEvent('tts:speak:end', { text }); - - if (callback) { - callback({ success: true }); - } - }; - - audio.onerror = (error) => { - this.currentUtterance = null; - - // Dispatch error event - this.dispatchEvent('tts:speak:error', { - text, - error: error.error || 'Unknown error' - }); - - if (callback) { - callback({ success: false, reason: 'audio_error', error }); - } - }; - - // Store reference to current utterance - this.currentUtterance = audio; - - // Play the audio - audio.play(); - - return true; - } catch (error) { - console.error("Browser TTS: Error playing preloaded speech:", error); - - // Dispatch error event - this.dispatchEvent('tts:speak:error', { - text: preloadData.text, - error: error.message || 'Unknown error' - }); - - if (callback) { - setTimeout(() => callback({ success: false, reason: 'audio_error', error }), 0); - } - - return false; - } - } - - /** - * Speak text - * @param {string} text - Text to speak - * @param {Function} callback - Callback for when speech completes - * @returns {boolean} - Success status - */ - async speak(text, callback = null) { - if (!this.available || !text) { - if (callback) { - setTimeout(() => callback({ success: false, reason: 'not_available' }), 0); - } - return false; - } - - try { - // Process text for TTS - const processedText = this.preprocessText(text); - - // Use MediaRecorder to capture audio output to WAV - const audioData = await this.synthesizeToWav(processedText); - if (!audioData) { - console.warn("Browser TTS: Failed to generate WAV audio"); - if (callback) { - setTimeout(() => callback({ success: false, reason: 'synthesis_error' }), 0); - } - return false; - } - - // Create audio element from blob - const audio = new Audio(URL.createObjectURL(audioData.blob)); - - // Dispatch start event - this.dispatchEvent('tts:speak:start', { text: processedText }); - - // Set up event listeners - audio.onended = () => { - this.currentUtterance = null; - - // Dispatch end event - this.dispatchEvent('tts:speak:end', { text: processedText }); - - if (callback) { - callback({ success: true }); - } - }; - - audio.onerror = (error) => { - this.currentUtterance = null; - - // Dispatch error event - this.dispatchEvent('tts:speak:error', { - text: processedText, - error: error.message || 'Unknown error' - }); - - if (callback) { - callback({ success: false, reason: 'audio_error', error }); - } - }; - - // Store the current utterance for stopping later - this.currentUtterance = audio; - - // Play the audio - audio.play(); - - return true; - } catch (error) { - console.error("Browser TTS: Error speaking:", error); - - // Dispatch error event - this.dispatchEvent('tts:speak:error', { - text, - error: error.message || 'Unknown error' - }); - - if (callback) { - setTimeout(() => callback({ success: false, reason: 'synthesis_error', error }), 0); - } - - return false; - } - } - - /** - * Preprocess text for TTS - * @param {string} text - Text to preprocess - * @returns {string} - Processed text - */ - preprocessText(text) { - if (!text) return ''; - - // Trim whitespace - let processed = text.trim(); - - // Replace multiple spaces with a single space - processed = processed.replace(/\s+/g, ' '); - - // Add a period at the end if there's no punctuation - if (!/[.!?]$/.test(processed)) { - processed += '.'; - } - - return processed; - } - - /** - * Stop speaking - */ - stop() { - if (this.currentUtterance) { - if (this.currentUtterance.stop) { - this.currentUtterance.stop(); - } else if (this.currentUtterance.pause) { - this.currentUtterance.pause(); - } - this.currentUtterance = null; - } - } - - /** - * Check if TTS is available - * @returns {boolean} - True if TTS is available - */ - isAvailable() { - return this.available && this.voiceOptions.voice !== null; - } - - /** - * Get handler ID - * @returns {string} - Handler ID - */ - getId() { - return this.id; - } - - /** - * Get available voices - * @returns {Array} - Array of voice objects - */ - getVoices() { - // Get localization module for current locale - const localization = this.getModule('localization'); - let currentLocale = localization ? localization.getLocale() : 'en-us'; - - // Create language code variations for matching - const languageCode = currentLocale.split('-')[0]; // e.g., 'en' from 'en-us' - - // Filter voices by current locale - const filteredVoices = this.voices.filter(voice => { - const voiceLang = voice.lang.toLowerCase(); - return voiceLang.startsWith(languageCode) || - voiceLang === currentLocale || - // For handling cases like 'en' matching 'en-us' - (currentLocale.startsWith(voiceLang) && voiceLang.length === 2); - }); - - // If matching voices found, use them - if (filteredVoices.length > 0) { - return filteredVoices.map(voice => ({ - id: voice.voiceURI, - name: voice.name, - lang: voice.lang, - gender: this.inferVoiceGender(voice.name) - })); - } - - // If no matching voices found and current locale isn't English, - // try to fallback to English voices - if (languageCode !== 'en') { - const englishVoices = this.voices.filter(voice => { - const voiceLang = voice.lang.toLowerCase(); - return voiceLang.startsWith('en'); - }); - - if (englishVoices.length > 0) { - return englishVoices.map(voice => ({ - id: voice.voiceURI, - name: voice.name, - lang: voice.lang, - gender: this.inferVoiceGender(voice.name) - })); - } - } - - // As a last resort, return all voices - return this.voices.map(voice => ({ - id: voice.voiceURI, - name: voice.name, - lang: voice.lang, - gender: this.inferVoiceGender(voice.name) - })); - } - - /** - * Infer voice gender from name - * @param {string} name - Voice name - * @returns {string} - Inferred gender ('male', 'female', or 'unknown') - */ - inferVoiceGender(name) { - const lowerName = name.toLowerCase(); - - // Common terms indicating gender - const maleTerms = ['male', 'man', 'guy', 'boy', 'mr', 'sir', 'him', 'his']; - const femaleTerms = ['female', 'woman', 'lady', 'girl', 'ms', 'mrs', 'miss', 'her', 'hers']; - - // Check for explicit gender terms in the name - for (const term of maleTerms) { - if (lowerName.includes(term)) return 'male'; - } - - for (const term of femaleTerms) { - if (lowerName.includes(term)) return 'female'; - } - - // Common male/female voice names - if (/(david|james|john|paul|mark|thomas|daniel|jack|william|george|michael|robert|peter|brian|richard|steve|bruce)/i.test(lowerName)) { - return 'male'; - } - - if (/(mary|sarah|emma|susan|julia|karen|lisa|anna|laura|amy|elizabeth|jennifer|maria|emily|jessica|alice|victoria)/i.test(lowerName)) { - return 'female'; - } - - return 'unknown'; - } - - /** - * Set voice options - * @param {Object} options - Voice options - */ - setVoiceOptions(options = {}) { - if (options.voice) { - // Find the voice by ID or name - const voice = this.voices.find(v => - v.voiceURI === options.voice || - v.name === options.voice - ); - - if (voice) { - this.voiceOptions.voice = voice; - } - } - - if (typeof options.rate === 'number') { - // Clamp rate between 0.1 and 10 - this.voiceOptions.rate = Math.max(0.1, Math.min(10, options.rate)); - } - - if (typeof options.pitch === 'number') { - // Clamp pitch between 0 and 2 - this.voiceOptions.pitch = Math.max(0, Math.min(2, options.pitch)); - } - - if (typeof options.volume === 'number') { - // Clamp volume between 0 and 1 - this.voiceOptions.volume = Math.max(0, Math.min(1, options.volume)); - } - } -} diff --git a/public/js/browser-tts-module.js b/public/js/browser-tts-module.js index 2af0c36..4cc81fd 100644 --- a/public/js/browser-tts-module.js +++ b/public/js/browser-tts-module.js @@ -1,60 +1,43 @@ /** - * BrowserTTSModule for AI Interactive Fiction - * Implementation using the browser's Web Speech API + * BrowserTTSModule + * Provides TTS via Browser's Web Speech API */ import { TTSHandlerModule } from './tts-handler-module.js'; -/** - * Browser TTS Module - Uses the browser's Web Speech API for TTS - */ export class BrowserTTSModule extends TTSHandlerModule { constructor() { - super('browser', 'Browser TTS'); + super('browser-tts', 'Browser TTS'); + + // Declare proper dependencies according to architecture principles + this.dependencies = ['persistence-manager', 'localization']; // Voice options this.voiceOptions = { - voice: null, // Will be set during initialization - rate: 1.0, + voice: null, // Will be set during initialization + speed: 1.0, pitch: 1.0, volume: 1.0 }; - // State - this.available = false; + // State variables + this.voices = []; + this.voicesByLang = {}; + this.lastPreprocessedText = ''; + this.isSpeaking = false; this.currentUtterance = null; - // Ensure dependencies are correctly defined from parent class - // this.dependencies should already contain ['persistence-manager', 'localization'] - - // Bind additional methods beyond those in TTSHandlerModule - this.bindMethods([ - 'onVoicesChanged', - 'loadVoices', - 'selectVoiceForLocale', - 'synthesizeToWav', - 'speakPreloaded', - 'speak', - 'preprocessText', - 'inferVoiceGender' - ]); + // Bind additional methods + this.bindMethods(['onVoicesChanged', 'handleVoicePreferenceChanged']); } /** - * Initialize the browser TTS module + * Initialize the Browser TTS module * @returns {Promise} - Resolves with success status */ async initialize() { try { this.reportProgress(10, 'Initializing Browser TTS'); - // Check for browser support - if (!window.speechSynthesis) { - console.error('Browser TTS: Speech synthesis not available in this browser'); - return false; - } - - this.reportProgress(30, 'Browser TTS supported'); - // Initialize parent const parentInit = await super.initialize(); if (!parentInit) { @@ -62,201 +45,264 @@ export class BrowserTTSModule extends TTSHandlerModule { return false; } - // Get required dependencies + // Get dependencies using proper pattern const persistenceManager = this.getModule('persistence-manager'); if (!persistenceManager) { - console.error('Browser TTS: Required dependency persistence-manager not found'); + console.error('Browser TTS: Persistence Manager dependency not found'); return false; } const localization = this.getModule('localization'); if (!localization) { - console.error('Browser TTS: Required dependency localization not found'); + console.error('Browser TTS: Localization dependency not found'); + return false; + } + + // Check if browser supports speech synthesis + if (!window.speechSynthesis) { + console.error('Browser TTS: Speech synthesis not available in this browser'); return false; } // Load voices - const voicesLoaded = await this.loadVoices(); - if (!voicesLoaded) { - console.error('Browser TTS: Failed to load voices'); - return false; - } - - // Set speech options from preferences - this.voiceOptions.rate = persistenceManager.getPreference('tts', 'rate', 1.0); - this.voiceOptions.pitch = persistenceManager.getPreference('tts', 'pitch', 1.0); - this.voiceOptions.volume = persistenceManager.getPreference('tts', 'volume', 1.0); - const preferredVoice = persistenceManager.getPreference('tts', 'browser_voice', ''); + this.reportProgress(30, 'Loading browser voices'); + await this.loadVoices(); - // Set voice based on current locale - const currentLocale = localization.getLocale() || 'en-us'; - await this.selectVoiceForLocale(currentLocale, preferredVoice); + // Set up voice from preferences + this.reportProgress(70, 'Setting up voice preferences'); + await this.setupVoiceFromPreferences(); - // Listen for locale changes - document.addEventListener('locale:changed', async (event) => { - if (event.detail && event.detail.locale) { - await this.selectVoiceForLocale(event.detail.locale); - } - }); + // Set up event listeners + document.addEventListener('tts:browser:voicePreferenceChanged', this.handleVoicePreferenceChanged); - // Listen for voices changed events - if (window.speechSynthesis.onvoiceschanged !== undefined) { - window.speechSynthesis.onvoiceschanged = this.onVoicesChanged; - } + // Set up utterance handlers + this.setupUtteranceHandlers(); + // Mark as ready this.isReady = true; - this.available = true; - this.reportProgress(100, 'Browser TTS initialized'); + this.reportProgress(100, 'Browser TTS initialization complete'); return true; } catch (error) { console.error('Browser TTS: Initialization error:', error); - this.isReady = false; - this.available = false; return false; } } /** - * Handle voices changed event - */ - async onVoicesChanged() { - await this.loadVoices(); - - // Re-select voice based on current locale - const localization = this.getModule('localization'); - const persistenceManager = this.getModule('persistence-manager'); - - if (localization && persistenceManager) { - const currentLocale = localization.getLocale() || 'en-us'; - const preferredVoice = persistenceManager.getPreference('tts', 'browser_voice', ''); - await this.selectVoiceForLocale(currentLocale, preferredVoice); - } - } - - /** - * Load available voices from the speech synthesis API + * Load voices from browser speech synthesis API * @returns {Promise} - Resolves with success status */ async loadVoices() { - try { - this.reportProgress(40, 'Loading browser voices'); + // Helper function to process voices + const processVoices = () => { + // Get all voices from speechSynthesis + const synVoices = window.speechSynthesis.getVoices() || []; - // Try to get voices - let voices = window.speechSynthesis.getVoices(); + if (synVoices.length === 0) { + console.warn('Browser TTS: No voices available'); + return false; + } - // If voices array is empty, wait for onvoiceschanged event - if (!voices || voices.length === 0) { - try { - console.log('Browser TTS: No voices available immediately, waiting for voices to load...'); - - // Wait for voices to be loaded (with timeout) - voices = await new Promise((resolve, reject) => { - // Set a timeout in case voices never load - const timeout = setTimeout(() => { - console.warn('Browser TTS: Timeout waiting for voices'); - // Resolve with empty array instead of rejecting - resolve([]); - }, 3000); - - // Listen for voices changed event - window.speechSynthesis.onvoiceschanged = () => { - clearTimeout(timeout); - const loadedVoices = window.speechSynthesis.getVoices(); - console.log(`Browser TTS: Voices loaded, found ${loadedVoices.length} voices`); - resolve(loadedVoices); - }; - }); - } catch (voiceWaitError) { - console.error('Browser TTS: Error waiting for voices:', voiceWaitError); - // Continue with empty voices array - voices = []; + // Transform to our format + this.voices = synVoices.map((voice, index) => ({ + id: voice.voiceURI || `voice-${index}`, + name: voice.name, + language: voice.lang, + localService: voice.localService, + default: voice.default, + original: voice // Keep reference to original voice + })); + + // Group voices by language + this.voicesByLang = {}; + this.voices.forEach(voice => { + if (voice.language) { + const langCode = voice.language.split('-')[0].toLowerCase(); + if (!this.voicesByLang[langCode]) { + this.voicesByLang[langCode] = []; + } + this.voicesByLang[langCode].push(voice); } - } + }); - // Store voices - this.voices = voices || []; - - // Log available voices for debugging - console.log(`Browser TTS: Loaded ${this.voices.length} voices`); - if (this.voices.length > 0) { - console.log('Browser TTS: First few voices:', this.voices.slice(0, 3)); - } - - // If no voices available but speech synthesis is supported, still return true - // Some browsers may not expose voices but still support speech synthesis - if (this.voices.length === 0) { - console.warn('Browser TTS: No voices available, but continuing with default voice'); - // Create a default voice entry - this.voices = [{ - default: true, - lang: 'en-US', - localService: true, - name: 'Default Voice', - voiceURI: 'default' - }]; - } - - this.reportProgress(60, 'Browser voices loaded'); return true; - } catch (error) { - console.error('Browser TTS: Error loading voices:', error); + }; + + // If voices are already loaded, process them + if (window.speechSynthesis.getVoices().length > 0) { + return processVoices(); + } + + // Otherwise, wait for voiceschanged event + return new Promise(resolve => { + // Set up timeout to handle browsers that don't trigger voiceschanged + const timeoutId = setTimeout(() => { + if (window.speechSynthesis.getVoices().length > 0) { + window.speechSynthesis.removeEventListener('voiceschanged', this.onVoicesChanged); + resolve(processVoices()); + } else { + console.warn('Browser TTS: Voices not loaded after timeout'); + resolve(false); + } + }, 1000); + + this.onVoicesChanged = () => { + clearTimeout(timeoutId); + window.speechSynthesis.removeEventListener('voiceschanged', this.onVoicesChanged); + resolve(processVoices()); + }; + + window.speechSynthesis.addEventListener('voiceschanged', this.onVoicesChanged); + }); + } + + /** + * Set up voice based on preferences and locale + * @returns {Promise} - Resolves with success status + */ + async setupVoiceFromPreferences() { + const persistenceManager = this.getModule('persistence-manager'); + const localization = this.getModule('localization'); + + if (!persistenceManager || !localization || this.voices.length === 0) { return false; } + + // Get preferred voice ID from preferences + const preferredVoiceId = persistenceManager.getPreference('tts', 'browser_voice', ''); + + // Get current locale + const currentLocale = localization.getLocale(); + + // If we have a preferred voice ID, use it + if (preferredVoiceId && this.voices.some(v => v.id === preferredVoiceId)) { + this.voiceOptions.voice = preferredVoiceId; + return true; + } + + // Otherwise, select voice based on locale + if (currentLocale) { + return this.selectVoiceForLocale(currentLocale); + } + + // Fall back to default voice + return this.selectDefaultVoice(); + } + + /** + * Select a voice for the given locale + * @param {string} locale - Locale code + * @returns {boolean} - Success status + */ + selectVoiceForLocale(locale) { + if (!locale || this.voices.length === 0) { + return this.selectDefaultVoice(); + } + + // Extract language code from locale (e.g., 'en-US' -> 'en') + const langCode = locale.split('-')[0].toLowerCase(); + + // First try to find a voice that exactly matches the locale + let matchedVoice = this.voices.find(v => v.language && v.language.toLowerCase() === locale.toLowerCase()); + + // If not found, try to find a voice for the language + if (!matchedVoice && this.voicesByLang[langCode]) { + // Prefer default voices if available + matchedVoice = this.voicesByLang[langCode].find(v => v.default) || this.voicesByLang[langCode][0]; + } + + if (matchedVoice) { + this.voiceOptions.voice = matchedVoice.id; + return true; + } + + // Fall back to default voice + return this.selectDefaultVoice(); + } + + /** + * Select a default voice + * @returns {boolean} - Success status + */ + selectDefaultVoice() { + if (this.voices.length === 0) { + return false; + } + + // Find a default English voice if available + const defaultEnVoice = this.voices.find(v => v.default && v.language && v.language.startsWith('en')); + + // Otherwise use any default voice + const defaultVoice = defaultEnVoice || this.voices.find(v => v.default) || this.voices[0]; + + this.voiceOptions.voice = defaultVoice.id; + return true; + } + + /** + * Set up utterance handlers for speech events + */ + setupUtteranceHandlers() { + // Handler functions for utterance events + this.utteranceHandlers = { + start: () => { + this.isSpeaking = true; + }, + end: () => { + this.isSpeaking = false; + this.currentUtterance = null; + }, + error: (event) => { + console.error('Browser TTS: Speech error:', event); + this.isSpeaking = false; + this.currentUtterance = null; + }, + pause: () => { + this.isSpeaking = false; + }, + resume: () => { + this.isSpeaking = true; + } + }; + } + + /** + * Handle voice preference changed event + * @param {Event} event - Event object + */ + handleVoicePreferenceChanged(event) { + if (event && event.detail) { + this.setVoiceOptions(event.detail); + } } /** - * Set voice based on locale - * @param {string} locale - Locale code (e.g., 'en-us', 'de', 'fr') - * @param {string} preferredVoice - Optional preferred voice name - * @returns {Promise} - Success status + * Preprocess text for TTS + * @param {string} text - Text to preprocess + * @returns {string} - Processed text */ - async selectVoiceForLocale(locale = 'en-us', preferredVoice = '') { - // Normalize locale format - locale = locale.toLowerCase().replace('_', '-'); - const languageCode = locale.split('-')[0]; - - // First try to use the preferred voice if specified - if (preferredVoice) { - const voice = this.voices.find(v => - v.name === preferredVoice || - v.voiceURI === preferredVoice - ); - - if (voice) { - this.voiceOptions.voice = voice; - return true; - } + preprocessText(text) { + if (!text) { + return ''; } - // Try to find a voice that matches the exact locale - const exactMatch = this.voices.find(v => - v.lang.toLowerCase() === locale - ); + // Remove HTML tags + let processed = text.replace(/<[^>]*>/g, ' '); - if (exactMatch) { - this.voiceOptions.voice = exactMatch; - return true; + // Replace special characters + processed = processed.replace(/&/g, ' and '); + + // Normalize whitespace + processed = processed.replace(/\s+/g, ' ').trim(); + + // Add trailing period if missing + if (!/[.!?]$/.test(processed)) { + processed += '.'; } - // Try to find a voice that matches the language code - const languageMatch = this.voices.find(v => - v.lang.toLowerCase().startsWith(languageCode) - ); - - if (languageMatch) { - this.voiceOptions.voice = languageMatch; - return true; - } - - // Fallback to the first available voice - if (this.voices.length > 0) { - this.voiceOptions.voice = this.voices[0]; - return true; - } - - // No voices available - return false; + this.lastPreprocessedText = processed; + return processed; } /** @@ -266,210 +312,64 @@ export class BrowserTTSModule extends TTSHandlerModule { * @returns {boolean} - Success status */ speak(text, callback = null) { - if (!this.isReady || !window.speechSynthesis) { + if (!this.isReady || !text) { if (callback) { - callback({ success: false, reason: 'not_ready' }); + callback({ success: false, reason: 'not_ready_or_empty_text' }); } return false; } - // Stop any ongoing speech - this.stop(); - - const processedText = this.preprocessText(text); - - // Create utterance - const utterance = new SpeechSynthesisUtterance(processedText); - - // Set options - if (this.voiceOptions.voice) { - utterance.voice = this.voiceOptions.voice; - } - - utterance.rate = this.voiceOptions.rate; - utterance.pitch = this.voiceOptions.pitch; - utterance.volume = this.voiceOptions.volume; - - // Set up event handlers - utterance.onend = () => { - this.isSpeaking = false; - if (callback) { - callback({ success: true }); - } - }; - - utterance.onerror = (error) => { - this.isSpeaking = false; - console.error('Browser TTS: Speech error', error); - if (callback) { - callback({ success: false, reason: 'synthesis_error', error }); - } - }; - - // Store current utterance - this.currentUtterance = utterance; - this.isSpeaking = true; - - // Start speaking - window.speechSynthesis.speak(utterance); - - return true; - } - - /** - * Preload speech for a text - * @param {string} text - Text to preload - * @returns {Promise} - Preloaded speech data - */ - async preloadSpeech(text) { - if (!this.isReady || !window.speechSynthesis) { - return { success: false, reason: 'not_ready' }; - } - - // Generate WAV audio data - const wavResult = await this.synthesizeToWav(text); - - if (!wavResult.success) { - return { success: false, reason: 'synthesis_failed' }; - } - - return { - success: true, - audioData: wavResult.audioData, - text, - duration: wavResult.duration || 0 - }; - } - - /** - * Convert speech synthesis to WAV format - * @param {string} text - Text to synthesize - * @returns {Promise} - Object with audio data - */ - async synthesizeToWav(text) { - return new Promise((resolve) => { - if (!this.isReady || !window.speechSynthesis) { - resolve({ success: false, reason: 'not_ready' }); - return; - } + try { + // Stop any ongoing speech + this.stop(); - // Process text for better synthesis + // Process the text const processedText = this.preprocessText(text); - // Create audio context - const AudioContext = window.AudioContext || window.webkitAudioContext; - if (!AudioContext) { - resolve({ success: false, reason: 'no_audio_context' }); - return; - } - - const audioContext = new AudioContext(); - - // Create media stream destination - const destination = audioContext.createMediaStreamDestination(); - - // Create media recorder - const mediaRecorder = new MediaRecorder(destination.stream); - const audioChunks = []; - - // Set up event handlers - mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0) { - audioChunks.push(event.data); - } - }; - - mediaRecorder.onstop = () => { - // Create blob from chunks - const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }); - - // Convert blob to array buffer - const reader = new FileReader(); - reader.onloadend = () => { - resolve({ - success: true, - audioData: reader.result - }); - }; - - reader.onerror = () => { - resolve({ success: false, reason: 'blob_read_error' }); - }; - - reader.readAsArrayBuffer(audioBlob); - }; - - // Create utterance + // Create a new utterance const utterance = new SpeechSynthesisUtterance(processedText); - // Set options + // Set voice options if (this.voiceOptions.voice) { - utterance.voice = this.voiceOptions.voice; + const voice = this.voices.find(v => v.id === this.voiceOptions.voice); + if (voice && voice.original) { + utterance.voice = voice.original; + } } - utterance.rate = this.voiceOptions.rate; - utterance.pitch = this.voiceOptions.pitch; - utterance.volume = this.voiceOptions.volume; + utterance.rate = this.voiceOptions.speed || 1.0; + utterance.pitch = this.voiceOptions.pitch || 1.0; + utterance.volume = this.voiceOptions.volume || 1.0; - // Start recording - mediaRecorder.start(); - - // Set up completion handling + // Set up event handlers + utterance.onstart = this.utteranceHandlers.start; utterance.onend = () => { - mediaRecorder.stop(); + this.utteranceHandlers.end(); + if (callback) { + callback({ success: true }); + } }; - - utterance.onerror = (error) => { - console.error('Browser TTS: Synthesis error', error); - mediaRecorder.stop(); - resolve({ success: false, reason: 'synthesis_error' }); + utterance.onerror = (event) => { + this.utteranceHandlers.error(event); + if (callback) { + callback({ success: false, reason: 'synthesis_error', error: event }); + } }; + utterance.onpause = this.utteranceHandlers.pause; + utterance.onresume = this.utteranceHandlers.resume; // Start speaking - window.speechSynthesis.speak(utterance); + this.currentUtterance = utterance; + speechSynthesis.speak(utterance); - // Set timeout in case onend never fires - setTimeout(() => { - if (mediaRecorder.state === 'recording') { - mediaRecorder.stop(); - } - }, 30000); // 30-second timeout - }); - } - - /** - * Speak preloaded audio data - * @param {Object} preloadedData - Data from preloadSpeech - * @param {Function} callback - Callback for when speech completes - * @returns {boolean} - Success status - */ - speakPreloaded(preloadedData, callback = null) { - if (!preloadedData || !preloadedData.text) { - console.error('Browser TTS: Invalid preloaded data'); + return true; + } catch (error) { + console.error('Browser TTS: Failed to speak:', error); + if (callback) { + callback({ success: false, reason: 'speak_error', error }); + } return false; } - - // For browser TTS, we don't use the preloaded data directly - // Instead, we just speak the text again - return this.speak(preloadedData.text, callback); - } - - /** - * Preprocess text for TTS - * @param {string} text - Text to preprocess - * @returns {string} - Processed text - */ - preprocessText(text) { - // Remove HTML tags - text = text.replace(/<[^>]*>/g, ' '); - - // Replace special characters with their spoken equivalents - text = text.replace(/&/g, ' and '); - - // Normalize whitespace - text = text.replace(/\s+/g, ' ').trim(); - - return text; } /** @@ -477,94 +377,122 @@ export class BrowserTTSModule extends TTSHandlerModule { * @returns {boolean} - Success status */ stop() { - if (window.speechSynthesis) { - window.speechSynthesis.cancel(); + try { + speechSynthesis.cancel(); this.isSpeaking = false; this.currentUtterance = null; return true; + } catch (error) { + console.error('Browser TTS: Failed to stop speech:', error); + return false; + } + } + + /** + * Pause speaking + * @returns {boolean} - Success status + */ + pause() { + try { + if (this.isSpeaking) { + speechSynthesis.pause(); + return true; + } + return false; + } catch (error) { + console.error('Browser TTS: Failed to pause speech:', error); + return false; + } + } + + /** + * Resume speaking + * @returns {boolean} - Success status + */ + resume() { + try { + speechSynthesis.resume(); + return true; + } catch (error) { + console.error('Browser TTS: Failed to resume speech:', error); + return false; } - return false; } /** * Get available voices * @returns {Array} - Array of voice objects */ - async getVoices() { - if (!this.isReady) { - return []; - } - - const localization = this.getModule('localization'); - const currentLocale = localization ? localization.getLocale() : 'en-us'; - - // Normalize locale format - const normalizedLocale = currentLocale.toLowerCase().replace('_', '-'); - const languageCode = normalizedLocale.split('-')[0]; - - // Filter voices by current locale - const filteredVoices = this.voices.filter(voice => { - const voiceLang = voice.lang.toLowerCase(); - return voiceLang.startsWith(languageCode) || - voiceLang === normalizedLocale || - (normalizedLocale.startsWith(voiceLang) && voiceLang.length === 2); - }); - - // If matching voices found, use them - if (filteredVoices.length > 0) { - return filteredVoices.map(voice => ({ - id: voice.voiceURI, - name: voice.name, - lang: voice.lang, - gender: this.inferVoiceGender(voice.name) - })); - } - - // If no matching voices found, return all voices - return this.voices.map(voice => ({ - id: voice.voiceURI, - name: voice.name, - lang: voice.lang, - gender: this.inferVoiceGender(voice.name) - })); + getAvailableVoices() { + return this.voices; } /** - * Infer voice gender from name - * @param {string} name - Voice name - * @returns {string} - Inferred gender ('male', 'female', or 'unknown') + * Set voice options + * @param {Object} options - Voice options */ - inferVoiceGender(name) { - const lowerName = name.toLowerCase(); - - // Common terms indicating gender - const maleTerms = ['male', 'man', 'guy', 'boy', 'mr', 'sir']; - const femaleTerms = ['female', 'woman', 'lady', 'girl', 'ms', 'mrs', 'miss']; - - // Check for explicit gender terms in the name - for (const term of maleTerms) { - if (lowerName.includes(term)) return 'male'; + setVoiceOptions(options = {}) { + if (options.voice) { + this.voiceOptions.voice = options.voice; + + // Save voice preference + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'browser_voice', options.voice); + } } - for (const term of femaleTerms) { - if (lowerName.includes(term)) return 'female'; + if (typeof options.speed === 'number') { + this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed)); + + // Save speed preference + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'browser_speed', options.speed); + } } - return 'unknown'; + if (typeof options.pitch === 'number') { + this.voiceOptions.pitch = Math.max(0.5, Math.min(2.0, options.pitch)); + + // Save pitch preference + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'browser_pitch', options.pitch); + } + } + + if (typeof options.volume === 'number') { + this.voiceOptions.volume = Math.max(0, Math.min(1.0, options.volume)); + } + } + + /** + * Preload speech for later playback + * Not applicable for the browser TTS (always returns null) + * @param {string} text - Text to preload + * @returns {Promise} - Promise that resolves to null + */ + async preloadSpeech(text) { + // Browser TTS can't preload speech + return { success: false, reason: 'not_supported' }; + } + + /** + * Speak preloaded speech + * Not applicable for the browser TTS (always returns false) + * @param {Object} preloadData - Preloaded speech data + * @param {Function} callback - Callback for when speech completes + * @returns {boolean} - Success status (always false) + */ + speakPreloaded(preloadData, callback = null) { + if (callback) { + callback({ success: false, reason: 'not_supported' }); + } + return false; } } -// Register the module with the module registry -// Module registry MUST be accessed via window, not direct import -if (window.moduleRegistry) { - try { - // Create instance first, then register it - const browserTTSModule = new BrowserTTSModule(); - window.moduleRegistry.register(browserTTSModule); - console.log('Browser TTS Module registered successfully'); - } catch (err) { - console.error('Failed to register Browser TTS Module:', err); - } -} else { - console.error('Module registry not available when attempting to register Browser TTS Module'); -} +const browserTTSModule = new BrowserTTSModule(); + +export { browserTTSModule }; diff --git a/public/js/debug-utils-module.js b/public/js/debug-utils-module.js new file mode 100644 index 0000000..fe8b7fe --- /dev/null +++ b/public/js/debug-utils-module.js @@ -0,0 +1,187 @@ +/** + * Debug Utilities Module for AI Interactive Fiction + * Provides debugging and testing tools + */ +import { BaseModule } from './base-module.js'; + +export class DebugUtilsModule extends BaseModule { + constructor() { + super('debug-utils', 'Debug Utilities'); + + // Declare dependencies explicitly + this.dependencies = ['text-buffer', 'socket-client', 'tts-player', 'ui-controller', 'game-loop']; + } + + /** + * Initialize the debug utilities module + * @returns {boolean} - Success status + */ + async initialize() { + console.log('Debug Utilities: Initializing'); + + // Make utilities available globally for console access + window.DebugUtils = { + testTextPipeline: this.testTextPipeline.bind(this), + testSocketConnection: this.testSocketConnection.bind(this), + testTTS: this.testTTS.bind(this), + forceReconnect: this.forceReconnect.bind(this) + }; + + console.log('Debug Utilities: Debug tools are now available via window.DebugUtils'); + this.isReady = true; + return true; + } + + /** + * Test the text processing pipeline with sample text + * @param {string} text - Test text to process + * @returns {boolean} - Success status + */ + testTextPipeline(text = "This is a test sentence. Let's see if it displays correctly!") { + console.log("Debug: Testing text pipeline with:", text); + + // Get the text buffer module properly through dependency system + const textBuffer = this.getModule('text-buffer'); + if (!textBuffer) { + console.error("Debug: TextBuffer module not found"); + return false; + } + + textBuffer.addText(text); + console.log("Debug: Text added to buffer"); + return true; + } + + /** + * Test the socket connection + * @returns {boolean} - Success status + */ + testSocketConnection() { + console.log("Debug: Testing socket connection"); + + // Get the socket client module properly through dependency system + const socketClient = this.getModule('socket-client'); + if (!socketClient) { + console.error("Debug: SocketClient module not found"); + return false; + } + + 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; + } + } + + /** + * Test the TTS system + * @param {string} text - Test text to speak + * @returns {boolean} - Success status + */ + testTTS(text = "This is a test of the text to speech system.") { + console.log("Debug: Testing TTS with:", text); + + // Get the TTS module properly through dependency system + const ttsPlayer = this.getModule('tts-player'); + if (!ttsPlayer) { + console.error("Debug: TTS module not found"); + return false; + } + + 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; + } + + /** + * Force all modules to reconnect + * @returns {boolean} - Success status + */ + forceReconnect() { + console.log("Debug: Forcing module reconnection"); + + // Get all required modules properly through dependency system + const uiController = this.getModule('ui-controller'); + const socketClient = this.getModule('socket-client'); + const gameLoop = this.getModule('game-loop'); + const textBuffer = this.getModule('text-buffer'); + const ttsHandler = this.getModule('tts-player'); + + // Check if all modules are available + if (!uiController || !socketClient || !gameLoop || !textBuffer || !ttsHandler) { + console.error("Debug: One or more required modules not found"); + return false; + } + + // UI Controller + if (uiController.textBuffer === null) { + uiController.textBuffer = textBuffer; + console.log("Debug: Reconnected UI Controller to Text Buffer"); + + // Reinitialize text buffer + if (uiController.initializeTextBuffer) { + uiController.initializeTextBuffer(); + } + } + + if (uiController.ttsHandler === null) { + uiController.ttsHandler = ttsHandler; + console.log("Debug: Reconnected UI Controller to TTS Player"); + } + + // Socket Client + if (socketClient.textBuffer === null) { + socketClient.textBuffer = textBuffer; + console.log("Debug: Reconnected Socket Client to Text Buffer"); + } + + // Game Loop + if (gameLoop.uiController === null) { + gameLoop.uiController = uiController; + console.log("Debug: Reconnected Game Loop to UI Controller"); + } + + if (gameLoop.socketClient === null) { + gameLoop.socketClient = socketClient; + console.log("Debug: Reconnected Game Loop to Socket Client"); + } + + if (gameLoop.textBuffer === null) { + gameLoop.textBuffer = textBuffer; + console.log("Debug: Reconnected Game Loop to Text Buffer"); + } + + return true; + } +} + +// Register the module with the module registry +if (window.moduleRegistry) { + try { + const debugUtilsModule = new DebugUtilsModule(); + window.moduleRegistry.register(debugUtilsModule); + console.log('Debug Utilities Module registered successfully'); + } catch (err) { + console.error('Failed to register Debug Utilities Module:', err); + } +} else { + console.error('Module registry not available when attempting to register Debug Utilities Module'); +} diff --git a/public/js/debug-utils.js b/public/js/debug-utils.js deleted file mode 100644 index bc2ced6..0000000 --- a/public/js/debug-utils.js +++ /dev/null @@ -1,152 +0,0 @@ -/** - * 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/elevenlabs-tts-handler.js b/public/js/elevenlabs-tts-handler.js deleted file mode 100644 index f1db7cc..0000000 --- a/public/js/elevenlabs-tts-handler.js +++ /dev/null @@ -1,332 +0,0 @@ -/** - * ElevenLabs TTS Handler - * Provides TTS via ElevenLabs API - */ -import { ApiTTSHandlerBase } from './api-tts-handler-base.js'; -import { moduleRegistry } from './module-registry.js'; - -export class ElevenLabsTTSHandler extends ApiTTSHandlerBase { - constructor() { - super('elevenlabs', 'ElevenLabs TTS'); - - // Voice options specific to ElevenLabs - this.voiceOptions = { - voice: 'pNInz6obpgDQGcFmaJgB', // Default voice ID for ElevenLabs - model: 'eleven_multilingual_v2', // Use the multilingual model - speed: 1.0 - }; - - // Bind methods - this.bindMethods([ - 'initialize', - 'speak', - 'speakPreloaded', - 'preloadSpeech', - 'stop', - 'isAvailable', - 'getId', - 'getVoices', - 'setVoiceOptions', - 'getModule', - 'setupVoiceFromPreferences', - 'loadVoices', - 'selectVoiceForLocale', - 'selectDefaultVoice', - 'generateSpeechAudio', - 'getDefaultApiBaseUrl' - ]); - } - - /** - * Initialize the ElevenLabs TTS handler - * @param {Function} progressCallback - Callback for progress updates - * @returns {Promise} - Resolves with success status - */ - async initialize(progressCallback = null) { - try { - if (progressCallback) { - progressCallback(10, 'Initializing ElevenLabs TTS'); - } - - // Call parent initialize method - const initSuccess = await super.initialize(progressCallback); - - if (!initSuccess) { - return false; - } - - if (progressCallback) { - progressCallback(40, 'ElevenLabs TTS dependencies loaded'); - } - - // Set default voices in case API call fails - this.voices = [ - { id: 'pNInz6obpgDQGcFmaJgB', name: 'Rachel', language: 'en' }, - { id: '21m00Tcm4TlvDq8ikWAM', name: 'Adam', language: 'en' }, - { id: 'AZnzlk1XvdvUeBnXmlld', name: 'Antoni', language: 'en' }, - { id: 'EXAVITQu4vr4xnSDxMaL', name: 'Bella', language: 'en' }, - { id: 'ErXwobaYiN019PkySvjV', name: 'Daniel', language: 'en' } - ]; - - // Load voice preferences - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - // Load model preference - const model = persistenceManager.getPreference('tts', 'elevenlabs_model', 'eleven_multilingual_v2'); - if (model) { - this.voiceOptions.model = model; - } - - // Load voice preference - const voice = persistenceManager.getPreference('tts', 'elevenlabs_voice'); - if (voice) { - this.voiceOptions.voice = voice; - } - } - - if (progressCallback) { - progressCallback(60, 'ElevenLabs TTS preferences loaded'); - } - - // Only attempt to load voices from API if we have an API key - if (this.apiKey) { - try { - await this.loadVoices(); - console.log(`ElevenLabs TTS: Loaded ${this.voices.length} voices from API`); - } catch (error) { - console.warn('ElevenLabs TTS: Could not load voices from API, using defaults'); - // Don't fail initialization, we already have default voices - } - } else { - console.log('ElevenLabs TTS: No API key provided, using default voices'); - // Mark as available but not fully functional - this.available = true; - } - - if (progressCallback) { - progressCallback(80, `ElevenLabs TTS loaded ${this.voices.length} voices`); - } - - // Set voice based on locale - const localization = this.getModule('localization'); - if (localization) { - const locale = localization.getLocale(); - console.log(`ElevenLabs TTS: Setting voice for locale: ${locale}`); - this.selectVoiceForLocale(locale); - } else { - this.selectDefaultVoice(); - } - - // Mark as ready even if we're using default voices - this.isReady = true; - - if (progressCallback) { - progressCallback(100, 'ElevenLabs TTS initialized'); - } - - return true; - } catch (error) { - console.error('ElevenLabs TTS: Initialization error:', error); - if (progressCallback) { - progressCallback(100, `ElevenLabs TTS initialization failed - ${error.message}`); - } - return false; - } - } - - /** - * Get the default API base URL for ElevenLabs - * @returns {string} - Default API base URL - */ - getDefaultApiBaseUrl() { - return 'https://api.elevenlabs.io/v1'; - } - - /** - * Load available voices from ElevenLabs API - * @returns {Promise} - Resolves with success status - */ - async loadVoices() { - if (!this.apiKey) { - console.log('ElevenLabs TTS: No API key provided, skipping voice loading'); - // Return true to indicate initialization was successful, even without voices - // This allows the handler to appear in the dropdown for configuration - return true; - } - - try { - const response = await fetch(`${this.apiBaseUrl}/voices`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'xi-api-key': this.apiKey - } - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - - if (data && data.voices && Array.isArray(data.voices)) { - this.voices = data.voices.map(voice => ({ - id: voice.voice_id, - name: voice.name, - language: voice.labels?.language || 'unknown' - })); - return true; - } - - return false; - } catch (error) { - console.error('ElevenLabs TTS: Error loading voices:', error); - return true; // Still return true to allow the handler to be configured - } - } - - /** - * Select a voice for the given locale - * @param {string} locale - Locale code - * @returns {boolean} - Success status - */ - selectVoiceForLocale(locale) { - if (!this.voices || this.voices.length === 0) { - return this.selectDefaultVoice(); - } - - // Extract language code from locale (e.g., 'en-US' -> 'en') - const langCode = locale.split('-')[0].toLowerCase(); - - // Find a voice that matches the language code - const matchingVoice = this.voices.find(voice => { - if (voice.language && voice.language !== 'unknown') { - return voice.language.toLowerCase() === langCode; - } - return false; - }); - - if (matchingVoice) { - this.voiceOptions.voice = matchingVoice.id; - return true; - } - - // If no match, use default - return this.selectDefaultVoice(); - } - - /** - * Select a default voice - * @returns {boolean} - Success status - */ - selectDefaultVoice() { - // If we have voices, use the first one - if (this.voices && this.voices.length > 0) { - this.voiceOptions.voice = this.voices[0].id; - return true; - } - - // Use hardcoded default voice ID - this.voiceOptions.voice = 'pNInz6obpgDQGcFmaJgB'; - return true; - } - - /** - * Generate speech audio data using ElevenLabs API - * @param {string} text - Text to generate speech for - * @returns {Promise} - Audio data (Blob) - */ - async generateSpeechAudio(text) { - // Don't attempt to call the API if no API key is set or text is empty - if (!text || !this.apiKey || this.apiKey.trim() === '') { - console.log('ElevenLabs TTS: No API key provided or empty text, skipping API call'); - return null; - } - - try { - // Create request payload - const payload = { - text: text, - model_id: this.voiceOptions.model || 'eleven_multilingual_v2', - voice_settings: { - stability: 0.5, - similarity_boost: 0.75, - style: 0.0, - use_speaker_boost: true, - speed: this.voiceOptions.speed || 1.0 - } - }; - - // Make API request - const response = await fetch(`${this.apiBaseUrl}/text-to-speech/${this.voiceOptions.voice}?optimize_streaming_latency=0`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'xi-api-key': this.apiKey, - 'Accept': 'audio/wav' - }, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`); - } - - // Get audio blob from response - const audioBlob = await response.blob(); - - // Ensure it's treated as WAV - return new Blob([audioBlob], { type: 'audio/wav' }); - } catch (error) { - console.error('ElevenLabs TTS: Error generating speech:', error); - return null; - } - } - - /** - * Get available voices - * @returns {Promise} - Resolves with array of voice objects - */ - async getVoices() { - if (!this.available) { - return []; - } - - // If voices are already loaded, return them - if (this.voices && this.voices.length > 0) { - return this.voices; - } - - // Otherwise try to load voices - try { - await this.loadVoices(); - return this.voices || []; - } catch (error) { - console.error('ElevenLabs TTS: Error getting voices:', error); - return []; - } - } - - /** - * Set voice options - * @param {Object} options - Voice options - */ - setVoiceOptions(options = {}) { - // Call parent method for common options - super.setVoiceOptions(options); - - // Handle ElevenLabs-specific options - if (options.model) { - this.voiceOptions.model = options.model; - - // Save the model preference - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - persistenceManager.updatePreference('tts', 'elevenlabs_model', options.model); - } - } - } -} - -// Create the singleton instance -const ElevenLabsTTS = new ElevenLabsTTSHandler(); diff --git a/public/js/elevenlabs-tts-module.js b/public/js/elevenlabs-tts-module.js index 58e2dee..ab7b92c 100644 --- a/public/js/elevenlabs-tts-module.js +++ b/public/js/elevenlabs-tts-module.js @@ -6,7 +6,7 @@ import { ApiTTSModuleBase } from './api-tts-module-base.js'; export class ElevenLabsTTSModule extends ApiTTSModuleBase { constructor() { - super('elevenlabs', 'ElevenLabs TTS'); + super('elevenlabs-tts', 'ElevenLabs TTS'); // Voice options specific to ElevenLabs this.voiceOptions = { @@ -112,35 +112,35 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase { const response = await fetch(`${this.apiBaseUrl}/voices`, { method: 'GET', headers: { - 'xi-api-key': apiKey, - 'Content-Type': 'application/json' + 'Accept': 'application/json', + 'xi-api-key': apiKey } }); if (!response.ok) { - console.error(`ElevenLabs TTS: API error: ${response.status} ${response.statusText}`); - return true; // Use defaults, but don't fail initialization + console.error(`ElevenLabs TTS: API error ${response.status} ${response.statusText}`); + return true; // Continue with default voices } const data = await response.json(); - if (data && data.voices && Array.isArray(data.voices)) { - // Transform API response to our internal format + // Map API voices to our format this.voices = data.voices.map(voice => ({ id: voice.voice_id, name: voice.name, - language: 'en', // ElevenLabs doesn't provide language info - preview: voice.preview_url + language: voice.language || 'en', + gender: 'unknown', + preview_url: voice.preview_url })); return true; } + + return true; // Continue with default voices } catch (error) { console.error('ElevenLabs TTS: Error loading voices:', error); + return true; // Continue with default voices } - - // If API call failed, we still return true since we have default voices - return true; } /** @@ -149,12 +149,18 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase { * @returns {boolean} - Success status */ selectVoiceForLocale(locale) { - if (!this.voices || this.voices.length === 0) { - return this.selectDefaultVoice(); + // Extract language code from locale (e.g., 'en-US' -> 'en') + const langCode = locale.split('-')[0].toLowerCase(); + + // For English locales, select 'Rachel' if available + if (langCode === 'en') { + const defaultVoice = this.voices.find(v => v.id === 'pNInz6obpgDQGcFmaJgB'); + if (defaultVoice) { + this.voiceOptions.voice = defaultVoice.id; + return true; + } } - // ElevenLabs doesn't provide language info for voices - // Simply use the first voice as default return this.selectDefaultVoice(); } @@ -254,17 +260,6 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase { } } -// Register the module with the module registry -// Module registry MUST be accessed via window, not direct import -if (window.moduleRegistry) { - try { - // Create instance first, then register it - const elevenLabsTTSModule = new ElevenLabsTTSModule(); - window.moduleRegistry.register(elevenLabsTTSModule); - console.log('ElevenLabs TTS Module registered successfully'); - } catch (err) { - console.error('Failed to register ElevenLabs TTS Module:', err); - } -} else { - console.error('Module registry not available when attempting to register ElevenLabs TTS Module'); -} +const elevenLabsTTSModule = new ElevenLabsTTSModule(); + +export { elevenLabsTTSModule }; diff --git a/public/js/game-loop.js b/public/js/game-loop-module.js similarity index 82% rename from public/js/game-loop.js rename to public/js/game-loop-module.js index 515c902..9387036 100644 --- a/public/js/game-loop.js +++ b/public/js/game-loop-module.js @@ -3,7 +3,6 @@ * 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() { @@ -62,24 +61,24 @@ class GameLoopModule extends BaseModule { setupSocketEventListeners() { // Get the socket client module using parent's getModule method - this.socketClient = this.getModule('socket-client'); + const socketClient = this.getModule('socket-client'); - if (!this.socketClient) { + if (!socketClient) { console.error("Socket client module not found"); return; } // Connect UI controller to socket client for command handling - this.uiController = this.getModule('ui-controller'); + const uiController = this.getModule('ui-controller'); - if (this.uiController) { - this.uiController.socketClient = this.socketClient; + if (uiController) { + uiController.socketClient = socketClient; } else { console.warn("GameLoop: UI Controller not ready for Socket Client assignment."); } // Listen for socket connection event - this.socketClient.on('connect', () => { + socketClient.on('connect', () => { console.log("GameLoop: Socket connected event received."); // Request a new game start when we connect @@ -93,19 +92,19 @@ class GameLoopModule extends BaseModule { }); // Listen for game state updates - this.socketClient.on('gameStateUpdate', (data) => { + socketClient.on('gameStateUpdate', (data) => { console.log("GameLoop: Game state update received", data); this.updateGameState(data); }); // Listen for narrative responses - this.socketClient.on('narrativeResponse', (data) => { + 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) => { + socketClient.on('gameIntroduction', (data) => { console.log("GameLoop: Received gameIntroduction"); this.gameState.started = true; this.updateUIState(); @@ -113,7 +112,7 @@ class GameLoopModule extends BaseModule { }); // Connect to the socket server - this.socketClient.connect().then(success => { + socketClient.connect().then(success => { if (success) { console.log("GameLoop: Socket connection established successfully."); } else { @@ -146,19 +145,21 @@ class GameLoopModule extends BaseModule { * Update UI with current game state */ updateUIState() { - if (!this.uiController) return; + const uiController = this.getModule('ui-controller'); + if (!uiController) return; // Update UI components based on game state - this.uiController.updateButtonStates(this.gameState); + uiController.updateButtonStates(this.gameState); } /** * Request to start a new game */ requestStartGame() { - if (!this.socketClient) return; + const socketClient = this.getModule('socket-client'); + if (!socketClient) return; - this.socketClient.requestStartGame(); + socketClient.requestStartGame(); this.gameState.started = true; } @@ -166,18 +167,20 @@ class GameLoopModule extends BaseModule { * Request to save the current game */ requestSaveGame() { - if (!this.socketClient) return; + const socketClient = this.getModule('socket-client'); + if (!socketClient) return; - this.socketClient.requestSaveGame(); + socketClient.requestSaveGame(); } /** * Request to load a saved game */ requestLoadGame() { - if (!this.socketClient) return; + const socketClient = this.getModule('socket-client'); + if (!socketClient) return; - this.socketClient.requestLoadGame(); + socketClient.requestLoadGame(); } /** @@ -201,11 +204,5 @@ class GameLoopModule extends BaseModule { // 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/kokoro-handler.js b/public/js/kokoro-handler.js deleted file mode 100644 index bfa03ca..0000000 --- a/public/js/kokoro-handler.js +++ /dev/null @@ -1,789 +0,0 @@ -/** - * Kokoro TTS Handler - * Handles text-to-speech using the Kokoro library - */ - -import { TTSHandler } from './tts-handler.js'; -import { moduleRegistry } from './module-registry.js'; - -export class KokoroHandler extends TTSHandler { - /** - * Constructor - * @param {Object} options - Options for the handler - */ - constructor(options = {}) { - super(options); - - // Set default options - this.options = { - rate: 1.0, - volume: 1.0, - ...options - }; - - // Initialize properties - this.id = 'kokoro'; - this.name = 'Kokoro TTS Handler'; - this.available = false; - this.loading = false; - this.iframe = null; - this.currentAudio = null; - this.currentVoice = null; - this.pendingGenerations = new Map(); - this.generationCounter = 0; - - // Default voices (will be replaced by dynamically fetched voices) - this.voices = []; - - // Dependencies - this.dependencies = ['localization', 'persistence-manager']; - - // Bind methods - this.initialize = this.initialize.bind(this); - this.speak = this.speak.bind(this); - this.stop = this.stop.bind(this); - this.getVoices = this.getVoices.bind(this); - this.setVoice = this.setVoice.bind(this); - this.generateSpeech = this.generateSpeech.bind(this); - this.preprocessText = this.preprocessText.bind(this); - this.speakPreloaded = this.speakPreloaded.bind(this); - this.preloadSpeech = this.preloadSpeech.bind(this); - this.pause = this.pause.bind(this); - this.resume = this.resume.bind(this); - this.setOptions = this.setOptions.bind(this); - this.setupVoiceFromPreferences = this.setupVoiceFromPreferences.bind(this); - this.getId = this.getId.bind(this); - this.handleIframeMessage = this.handleIframeMessage.bind(this); - } - - /** - * Get the ID of the handler - * @returns {string} - Handler ID - */ - getId() { - return 'kokoro'; - } - - /** - * Get a module from the registry - * @param {string} id - Module ID - * @returns {Object} - Module instance - */ - getModule(id) { - return moduleRegistry.getModule(id); - } - - /** - * Initialize the handler - * @param {Function} progressCallback - Callback for progress updates - * @returns {Promise} - Resolves with success status - */ - async initialize(progressCallback) { - try { - console.log('Kokoro TTS: Initializing...'); - - // Check if already initialized - if (this.available && this.isReady) { - console.log('Kokoro TTS: Already initialized and ready'); - return true; - } - - // Ensure we have at least default voices ready - if (!this.voices || this.voices.length === 0) { - console.log('Kokoro TTS: No voices set, initializing with defaults'); - this.voices = this.getDefaultVoices(); - } - - // Set loading flag - this.loading = true; - this.isReady = false; // Explicitly set to false during initialization - - // Create iframe if not already created - if (!this.iframe) { - console.log('Kokoro TTS: Creating iframe'); - // Create iframe - this.iframe = document.createElement('iframe'); - this.iframe.style.display = 'none'; - this.iframe.src = '/kokoro-loader.html'; - document.body.appendChild(this.iframe); - - // Add message listener - IMPORTANT: Use an arrow function to preserve 'this' - window.addEventListener('message', (event) => this.handleIframeMessage(event)); - } - - // Set up event handler for configuration changes - document.addEventListener('tts:configure', (event) => { - if (event.detail) { - if (typeof event.detail.rate === 'number') { - this.options.rate = event.detail.rate; - console.log(`Kokoro TTS: Rate updated to ${this.options.rate}`); - } - if (typeof event.detail.volume === 'number') { - this.options.volume = event.detail.volume; - console.log(`Kokoro TTS: Volume updated to ${this.options.volume}`); - } - } - }); - - // Wait for Kokoro to load - return new Promise((resolve) => { - // Set a timeout to prevent hanging indefinitely - const timeout = setTimeout(() => { - console.error('Kokoro TTS: Initialization timed out'); - this.loading = false; - this.isReady = false; - this.available = false; - resolve(false); - }, 30000); // 30 second timeout - - // Handle progress updates - const handleProgress = (progress, message) => { - console.log(`Kokoro TTS: Progress ${progress * 100}% - ${message}`); - if (progressCallback) { - progressCallback(progress, message); - } - }; - - // Handle message events - const messageHandler = (event) => { - if (event.source !== this.iframe.contentWindow) { - return; - } - - const data = event.data; - - if (data.type === 'kokoro-progress') { - handleProgress(data.progress, data.message); - } else if (data.type === 'kokoro-ready') { - console.log('Kokoro TTS: Received ready message from iframe', data); - // Remove the message listener - window.removeEventListener('message', messageHandler); - - // Clear the timeout - clearTimeout(timeout); - - // Set availability based on success - this.available = data.success; - this.loading = false; - this.isReady = data.success; // Set isReady flag based on success - - // Store voices if provided - if (data.success && data.voices && Array.isArray(data.voices)) { - console.log(`Kokoro TTS: Received ${data.voices.length} voices from Kokoro iframe during initialization`); - this.voices = data.voices; - } else { - console.warn('Kokoro TTS: No voices received during initialization or invalid voices data'); - if (data.success) { - // Even though we already set the default voices, check and update if needed - if (!this.voices || this.voices.length === 0) { - this.voices = this.getDefaultVoices(); - console.log('Kokoro TTS: Using default voices as fallback'); - } - } - } - - // Set up voice from preferences - if (data.success) { - this.setupVoiceFromPreferences().then(() => { - console.log('Kokoro TTS: Voice set up from preferences during initialization'); - }).catch(error => { - console.error('Kokoro TTS: Error setting up voice from preferences during initialization:', - error ? (error.message || error) : 'Unknown error'); - }); - } - - // Resolve with success status - resolve(data.success); - } - }; - - // Add the message handler - window.addEventListener('message', messageHandler); - - // Initial progress update - handleProgress(0.1, 'Starting Kokoro initialization'); - }); - } catch (error) { - console.error('Kokoro TTS: Error during initialization:', error); - this.loading = false; - this.isReady = false; - this.available = false; - return false; - } - } - - /** - * Handle messages from the iframe - * @param {MessageEvent} event - Message event - */ - handleIframeMessage(event) { - // Only process messages from our iframe - if (!this.iframe || event.source !== this.iframe.contentWindow) { - return; - } - - const data = event.data; - console.log('Kokoro TTS: Received message from iframe:', data.type); - - switch (data.type) { - case 'kokoro-log': - console.log(`Kokoro Loader: ${data.message}`); - break; - - case 'kokoro-ready': - console.log('Kokoro TTS: Received ready message from iframe. Success:', data.success, 'Voices:', data.voices ? data.voices.length : 0); - - // Store availability - this.loading = false; - this.available = data.success; - this.isReady = data.success; // Important to set this for the base handler - - // Store voices - if (data.success && data.voices && Array.isArray(data.voices)) { - console.log(`Kokoro TTS: Storing ${data.voices.length} voices from iframe`); - this.voices = data.voices; - } else if (data.success) { - // If success but no voices, use defaults - console.warn('Kokoro TTS: No voices received from iframe, using defaults'); - this.voices = this.getDefaultVoices(); - } - - // Set up voice from preferences if ready - if (this.available) { - this.setupVoiceFromPreferences().then(() => { - console.log('Kokoro TTS: Voice set up from preferences'); - }); - } - - // Dispatch ready event - this.dispatchEvent('tts:ready', { success: data.success }); - break; - - case 'kokoro-generated': - // Handle generated speech - if (data.id && this.pendingGenerations.has(data.id)) { - const { resolve, reject } = this.pendingGenerations.get(data.id); - this.pendingGenerations.delete(data.id); - - if (data.success && data.result) { - // Create an audio element from the result - try { - // Create a blob from the buffer - const blob = new Blob([data.result.buffer], { type: 'audio/wav' }); - - // Create audio element - const audio = new Audio(URL.createObjectURL(blob)); - - // Create a play function - const play = () => { - audio.play().catch(error => { - console.error('Error playing Kokoro audio:', error); - }); - }; - - resolve({ audio, play, blob }); - } catch (error) { - console.error('Error processing Kokoro audio:', error); - reject(error); - } - } else { - console.error('Kokoro TTS: Invalid speech generation result'); - reject(new Error(data.error || 'Unknown error generating speech')); - } - } - break; - - case 'kokoro-progress': - // Progress updates are handled during initialization - break; - } - } - - /** - * Set up the voice from preferences - * @returns {Promise} - */ - async setupVoiceFromPreferences() { - try { - console.log('Kokoro TTS: Setting up voice from preferences, available voices:', this.voices ? this.voices.length : 0); - - // If no voices are available yet, use default voice - if (!this.voices || this.voices.length === 0) { - console.warn('Kokoro TTS: No voices available yet, using default voice'); - return; - } - - // Get persistence manager - const persistenceManager = this.getModule('persistence-manager'); - if (!persistenceManager) { - console.warn('Kokoro TTS: Persistence manager not available'); - this.currentVoice = this.voices[0]; // Default to first voice - return; - } - - // Get localization - const localization = this.getModule('localization'); - if (!localization) { - console.warn('Kokoro TTS: Localization not available'); - this.currentVoice = this.voices[0]; // Default to first voice - return; - } - - // Get current locale - let currentLocale = 'en-us'; // Default locale - if (localization && typeof localization.getLocale === 'function') { - currentLocale = localization.getLocale(); - console.log('Kokoro TTS: Current locale from localization:', currentLocale); - } else { - console.warn('Kokoro TTS: getLocale method not available, using default locale'); - } - - // Get voice preference - const voiceId = persistenceManager.getPreference('tts-voice-kokoro'); - console.log('Kokoro TTS: Preferred voice ID:', voiceId); - - // Find voice - if (voiceId) { - const voice = this.voices.find(v => v.id === voiceId); - if (voice) { - console.log('Kokoro TTS: Found preferred voice:', voice.id, voice.name); - this.currentVoice = voice; - return; - } else { - console.warn('Kokoro TTS: Preferred voice not found:', voiceId); - } - } - - // Find voice for current locale - if (currentLocale) { - // Standardize locale format (compare lowercase and handle hyphens/underscores) - const normalizedLocale = currentLocale.toLowerCase().replace('_', '-'); - const localePrefix = normalizedLocale.split('-')[0]; // Get language prefix (en, de, etc.) - - // First try exact locale match - let localeVoice = this.voices.find(v => v.lang && v.lang.toLowerCase().replace('_', '-') === normalizedLocale); - - // If no exact match, try prefix match (en-US with en-GB for example) - if (!localeVoice) { - localeVoice = this.voices.find(v => { - if (!v.lang) return false; - const voiceLocale = v.lang.toLowerCase().replace('_', '-'); - return voiceLocale.startsWith(localePrefix + '-'); - }); - } - - if (localeVoice) { - console.log('Kokoro TTS: Found locale voice:', localeVoice.id, localeVoice.name, 'for locale:', normalizedLocale); - this.currentVoice = localeVoice; - return; - } else { - console.warn('Kokoro TTS: No voice found for locale:', normalizedLocale); - } - } - - // Default to first voice if available - if (this.voices.length > 0) { - console.log('Kokoro TTS: Using first available voice:', this.voices[0].id, this.voices[0].name); - this.currentVoice = this.voices[0]; - } else { - console.warn('Kokoro TTS: No voices available after all checks'); - } - } catch (error) { - // Log detailed error information - console.error('Kokoro TTS: Error setting up voice from preferences:', error ? error.message || error : 'Unknown error'); - - // Default to first voice if available - if (this.voices && this.voices.length > 0) { - console.log('Kokoro TTS: Falling back to first voice after error'); - this.currentVoice = this.voices[0]; - } else { - console.warn('Kokoro TTS: No voices available to fall back to after error'); - } - } - } - - /** - * Set voice for TTS - * @param {Object} voice - Voice to set - * @returns {boolean} - Success status - */ - setVoice(voice) { - if (!voice || !voice.id) { - return false; - } - - // Find voice - const foundVoice = this.voices.find(v => v.id === voice.id); - if (!foundVoice) { - return false; - } - - // Set voice - this.currentVoice = foundVoice; - - // Save preference - try { - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - persistenceManager.updatePreference('tts-voice-kokoro', foundVoice.id); - } - } catch (error) { - console.error('Kokoro TTS: Error saving voice preference:', error); - } - - return true; - } - - /** - * Set options for TTS - * @param {Object} options - Options to set - * @returns {boolean} - Success status - */ - setOptions(options) { - if (!options) { - return false; - } - - // Update options - this.options = { - ...this.options, - ...options - }; - - return true; - } - - /** - * Get available voices - * @returns {Array} - Array of voice objects - */ - getVoices() { - if (!this.voices || this.voices.length === 0) { - return this.getDefaultVoices(); - } - return this.voices; - } - - /** - * Preprocess text for TTS - * @param {string} text - Text to preprocess - * @returns {string} - Preprocessed text - */ - preprocessText(text) { - if (!text) { - return ''; - } - - // Remove HTML tags - let processed = text.replace(/<[^>]*>/g, ''); - - // Replace special characters - processed = processed.replace(/ /g, ' '); - processed = processed.replace(/&/g, '&'); - processed = processed.replace(/</g, '<'); - processed = processed.replace(/>/g, '>'); - processed = processed.replace(/"/g, '"'); - processed = processed.replace(/'/g, "'"); - - return processed; - } - - /** - * Preload speech for later playback - * @param {string} text - Text to preload - * @returns {Promise} - Resolves with preloaded audio data - */ - async preloadSpeech(text) { - if (!this.available) { - console.warn('Kokoro TTS: Not available'); - return null; - } - - try { - // No longer check the local cache as we're using TTSFactory's centralized cache - // Generate speech directly - const result = await this.generateSpeech(text); - - // Return result for centralized caching in TTSFactory - return result; - } catch (error) { - console.error('Kokoro TTS: Error preloading speech:', error); - return null; - } - } - - /** - * Speak text using preloaded audio - * @param {Object} preloadData - Preloaded audio data - * @param {Function} callback - Callback for when speech completes - * @returns {boolean} - Success status - */ - speakPreloaded(preloadData, callback = null) { - if (!this.available) { - console.warn('Kokoro TTS: Not available'); - return false; - } - - try { - // Stop any current speech - this.stop(); - - // Create audio element if not already created - const audio = preloadData.audio; - - // Set up event handlers - audio.onended = () => { - this.currentAudio = null; - if (callback) callback(); - }; - - audio.onerror = (error) => { - console.error('Kokoro TTS: Audio playback error:', error); - this.currentAudio = null; - if (callback) callback(error); - }; - - // Set volume - audio.volume = this.options.volume; - - // Store current audio - this.currentAudio = audio; - - // Play audio - if (preloadData.play) { - preloadData.play(); - } else { - audio.play().catch(error => { - console.error('Kokoro TTS: Error playing audio:', error); - this.currentAudio = null; - if (callback) callback(error); - }); - } - - return true; - } catch (error) { - console.error('Kokoro TTS: Error speaking preloaded audio:', error); - return false; - } - } - - /** - * Speak text - * @param {string} text - Text to speak - * @param {Object} options - Speech options - * @returns {Promise} - Resolves with success status - */ - async speak(text, options = {}) { - if (!this.available) { - console.warn('Kokoro TTS: Not available'); - return false; - } - - try { - // Stop any current speech - this.stop(); - - console.log('Kokoro TTS: Generating speech for:', text); - - // Generate speech - const result = await this.generateSpeech(text); - - if (!result || !result.audio) { - console.error('Kokoro TTS: Invalid speech generation result'); - return false; - } - - // Set up event handlers - result.audio.onended = () => { - console.log('Kokoro TTS: Audio playback ended'); - this.currentAudio = null; - // Dispatch event for completion - window.dispatchEvent(new CustomEvent('tts:speak-completed')); - }; - - result.audio.onerror = (error) => { - console.error('Kokoro TTS: Audio playback error:', error); - this.currentAudio = null; - // Dispatch event for error - window.dispatchEvent(new CustomEvent('tts:speak-error', { - detail: { error: error } - })); - }; - - // Set volume - result.audio.volume = this.options.volume; - - // Store current audio - this.currentAudio = result.audio; - - console.log('Kokoro TTS: Attempting to play audio'); - - // Play audio with better error handling - try { - if (result.play && typeof result.play === 'function') { - await result.play(); - } else { - await result.audio.play(); - } - console.log('Kokoro TTS: Audio playback started successfully'); - return true; - } catch (playError) { - console.error('Error playing Kokoro audio:', playError); - this.currentAudio = null; - return false; - } - } catch (error) { - console.error('Kokoro TTS: Error speaking:', error); - return false; - } - } - - /** - * Generate speech using the iframe - * @param {string} text - Text to generate speech for - * @returns {Promise} - Resolves with audio data - */ - async generateSpeech(text) { - if (!this.iframe || !this.iframe.contentWindow) { - throw new Error('Kokoro iframe not initialized'); - } - - // Preprocess text - const processedText = this.preprocessText(text); - - // Ensure we have a valid voice - let voiceId = 'af_heart'; // Default fallback - if (this.currentVoice && this.currentVoice.id) { - voiceId = this.currentVoice.id; - } else if (this.voices && this.voices.length > 0) { - // Default to first voice if none selected - this.currentVoice = this.voices[0]; - voiceId = this.currentVoice.id; - console.log(`Kokoro TTS: No voice set, defaulting to ${voiceId}`); - } - - console.log(`Kokoro TTS: Generating speech with voice ${voiceId}`); - - return new Promise((resolve, reject) => { - // Generate unique ID for this request - const id = `gen-${++this.generationCounter}`; - - // Store the pending generation - this.pendingGenerations.set(id, { resolve, reject }); - - // Send the generation request to the iframe - this.iframe.contentWindow.postMessage({ - type: 'kokoro-generate', - id: id, - text: processedText, - voice: voiceId, - speed: this.options.rate - }, '*'); - }); - } - - /** - * Stop current speech - * @returns {boolean} - Success status - */ - stop() { - if (this.currentAudio) { - try { - this.currentAudio.pause(); - this.currentAudio.currentTime = 0; - this.currentAudio = null; - return true; - } catch (error) { - console.error('Kokoro TTS: Error stopping speech:', error); - return false; - } - } - return true; - } - - /** - * Pause current speech - * @returns {boolean} - Success status - */ - pause() { - if (this.currentAudio) { - try { - this.currentAudio.pause(); - return true; - } catch (error) { - console.error('Kokoro TTS: Error pausing speech:', error); - return false; - } - } - return true; - } - - /** - * Resume current speech - * @returns {boolean} - Success status - */ - resume() { - if (this.currentAudio) { - try { - this.currentAudio.play(); - return true; - } catch (error) { - console.error('Kokoro TTS: Error resuming speech:', error); - return false; - } - } - return false; - } - - /** - * Get default voices for current locale - * @returns {Array} Default voices - */ - getDefaultVoices() { - // Check if localization module is available - const localization = this.getModule('localization'); - let locale = 'en-us'; // Default fallback - - if (localization) { - locale = localization.getLocale(); - console.log(`Kokoro TTS: Getting default voices for locale: ${locale}`); - } else { - console.log('Kokoro TTS: Localization module not available, using default locale: en-us'); - } - - // Use the actual voices defined in the Kokoro loader - return [ - // American Female voices - { id: 'af_heart', name: 'Heart', lang: 'en-US', gender: 'female' }, - { id: 'af_daisy', name: 'Daisy', lang: 'en-US', gender: 'female' }, - { id: 'af_soft', name: 'Soft', lang: 'en-US', gender: 'female' }, - { id: 'af_glados', name: 'GLaDOS', lang: 'en-US', gender: 'female' }, - { id: 'af_southern_belle', name: 'Southern Belle', lang: 'en-US', gender: 'female' }, - { id: 'af_dramatic', name: 'Dramatic', lang: 'en-US', gender: 'female' }, - { id: 'af_valley_girl', name: 'Valley Girl', lang: 'en-US', gender: 'female' }, - { id: 'af_british', name: 'British', lang: 'en-US', gender: 'female' }, - { id: 'af_russian', name: 'Russian', lang: 'en-US', gender: 'female' }, - { id: 'af_german', name: 'German', lang: 'en-US', gender: 'female' }, - { id: 'af_cheeky_cute', name: 'Cheeky Cute', lang: 'en-US', gender: 'female' }, - - // American Male voices - { id: 'am_bruce', name: 'Bruce', lang: 'en-US', gender: 'male' }, - { id: 'am_announcer', name: 'Announcer', lang: 'en-US', gender: 'male' }, - { id: 'am_radio_host', name: 'Radio Host', lang: 'en-US', gender: 'male' }, - - // British Female voices - { id: 'bf_charlotte', name: 'Charlotte', lang: 'en-GB', gender: 'female' }, - { id: 'bf_elizabeth', name: 'Elizabeth', lang: 'en-GB', gender: 'female' }, - { id: 'bf_lily', name: 'Lily', lang: 'en-GB', gender: 'female' }, - { id: 'bf_olivia', name: 'Olivia', lang: 'en-GB', gender: 'female' }, - { id: 'bf_victoria', name: 'Victoria', lang: 'en-GB', gender: 'female' }, - - // British Male voices - { id: 'bm_william', name: 'William', lang: 'en-GB', gender: 'male' }, - { id: 'bm_arthur', name: 'Arthur', lang: 'en-GB', gender: 'male' }, - { id: 'bm_george', name: 'George', lang: 'en-GB', gender: 'male' }, - { id: 'bm_harry', name: 'Harry', lang: 'en-GB', gender: 'male' }, - { id: 'bm_jack', name: 'Jack', lang: 'en-GB', gender: 'male' } - ]; - } -} \ No newline at end of file diff --git a/public/js/kokoro-tts-module.js b/public/js/kokoro-tts-module.js index 487bb77..a824e7e 100644 --- a/public/js/kokoro-tts-module.js +++ b/public/js/kokoro-tts-module.js @@ -6,7 +6,10 @@ import { TTSHandlerModule } from './tts-handler-module.js'; export class KokoroTTSModule extends TTSHandlerModule { constructor() { - super('kokoro', 'Kokoro TTS'); + super('kokoro-tts', 'Kokoro TTS'); + + // Declare proper dependencies according to architecture principles + this.dependencies = ['persistence-manager', 'localization']; // State this.iframe = null; @@ -641,17 +644,6 @@ export class KokoroTTSModule extends TTSHandlerModule { } } -// Register the module with the module registry -// Module registry MUST be accessed via window, not direct import -if (window.moduleRegistry) { - try { - // Create instance first, then register it - const kokoroTTSModule = new KokoroTTSModule(); - window.moduleRegistry.register(kokoroTTSModule); - console.log('Kokoro TTS Module registered successfully'); - } catch (err) { - console.error('Failed to register Kokoro TTS Module:', err); - } -} else { - console.error('Module registry not available when attempting to register Kokoro TTS Module'); -} +const kokoroTTSModule = new KokoroTTSModule(); + +export { kokoroTTSModule }; \ No newline at end of file diff --git a/public/js/layout-renderer.js b/public/js/layout-renderer-module.js similarity index 50% rename from public/js/layout-renderer.js rename to public/js/layout-renderer-module.js index e9cb769..395106c 100644 --- a/public/js/layout-renderer.js +++ b/public/js/layout-renderer-module.js @@ -3,18 +3,13 @@ * Renders calculated paragraph layouts into the DOM with proper animations */ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; class LayoutRendererModule extends BaseModule { constructor() { super('layout-renderer', 'Layout Renderer'); // Module dependencies - this.dependencies = ['animation-queue']; - - // Module references - this.animationQueue = null; - this.ttsPlayer = null; + this.dependencies = ['animation-queue', 'tts-player']; // Configuration this.updateConfig({ @@ -40,22 +35,13 @@ class LayoutRendererModule extends BaseModule { try { this.reportProgress(10, "Initializing Layout Renderer"); - // Get animation queue from module registry - this.animationQueue = this.getModule('animation-queue'); - if (!this.animationQueue) { + // Check for animation queue dependency + const animationQueue = this.getModule('animation-queue'); + if (!animationQueue) { console.warn("Layout Renderer: Animation Queue module not found in registry"); + return false; } - // We'll try to get the TTS module, but it's not a hard dependency - // We'll check for it again at runtime when needed - setTimeout(() => { - // Try to get TTS module after a delay to allow it to initialize - this.ttsPlayer = this.getModule('tts-player'); - if (!this.ttsPlayer) { - console.log("Layout Renderer: TTS Player module not found yet, will try again when needed"); - } - }, 500); - this.reportProgress(100, "Layout Renderer ready"); return true; } catch (error) { @@ -85,6 +71,8 @@ class LayoutRendererModule extends BaseModule { * @returns {HTMLElement} - The created paragraph element */ renderParagraph(layout, options = {}) { + const animationQueue = this.getModule('animation-queue'); + const { container = document.getElementById('paragraphs'), id = `p-${Date.now()}`, @@ -113,94 +101,64 @@ class LayoutRendererModule extends BaseModule { // Calculate paragraph height based on number of lines const numLines = layout.breaks.length - 1; - paragraphElement.style.height = `${lineHeight * numLines}px`; + const paragraphHeight = numLines * lineHeight; + paragraphElement.style.height = `${paragraphHeight}em`; - // Apply custom styles - Object.assign(paragraphElement.style, style); + // Apply custom style properties + for (const prop in style) { + paragraphElement.style[prop] = style[prop]; + } - // Create a fragment to build the paragraph - const fragment = document.createDocumentFragment(); - - // Track total delay for animations + // Populate with words + const wordElements = []; + let lineIndex = 0; let totalDelay = 0; - let wordElements = []; - // Process each line in the layout - for (let i = 1; i < layout.breaks.length; i++) { - // Track the current x position within the line - let xPosition = 0; + // Calculate each word's position based on layout data + for (let i = 0; i < layout.nodes.length; i++) { + const wordNode = layout.nodes[i]; - // Process nodes in this line - for (let j = layout.breaks[i-1].position; j < layout.breaks[i].position; j++) { - const node = layout.nodes[j]; + // Get the current line index from breaks array + while (lineIndex < layout.breaks.length - 1 && i >= layout.breaks[lineIndex + 1]) { + lineIndex++; + } + + // Create the word element + const wordElement = this.renderWord(wordNode.text, animateWords); + wordElements.push(wordElement); + + // Position the word absolutely within paragraph + if (wordNode.x !== undefined && wordNode.y !== undefined) { + // Use calculated position + wordElement.style.position = 'absolute'; + wordElement.style.left = `${wordNode.x}px`; + wordElement.style.top = `${lineIndex * lineHeight}em`; + } else { + // Fallback for missing positioning data + wordElement.style.position = 'relative'; + wordElement.style.marginRight = '0.25em'; + } + + // Add to paragraph + paragraphElement.appendChild(wordElement); + + // Handle whitespace after the word + if (wordNode.spaceAfter) { + const spaceElement = document.createElement('span'); + spaceElement.className = 'space'; + spaceElement.innerHTML = ' '; - // Handle different node types - switch (node.type) { - case 'box': - // This is a word - if (node.value && node.value.trim() !== '') { - const wordElement = this.renderWord(node.value, animateWords); - - // Position the word within the line - wordElement.style.position = 'absolute'; - wordElement.style.left = `${xPosition * 100 / containerWidth}%`; - wordElement.style.top = `${(i - 1) * lineHeight}px`; - - // Update x position for next word - xPosition += node.width; - - paragraphElement.appendChild(wordElement); - wordElements.push(wordElement); - } - break; - case 'glue': - // This is a space - calculate its width based on the ratio - const ratio = layout.breaks[i].ratio; - let spaceWidth = node.width; - - if (ratio > 0) { - // Stretch space - spaceWidth += ratio * node.stretch; - } else if (ratio < 0) { - // Shrink space - spaceWidth += ratio * node.shrink; - } - - xPosition += spaceWidth; - break; - case 'penalty': - // This is a hyphen or line break opportunity - if (node.flagged && node.penalty < Infinity && j === layout.breaks[i].position) { - const hyphenElement = document.createElement('span'); - hyphenElement.className = 'hyphen-marker'; - hyphenElement.textContent = '-'; - hyphenElement.style.position = 'absolute'; - hyphenElement.style.left = `${xPosition * 100 / containerWidth}%`; - hyphenElement.style.top = `${(i - 1) * lineHeight}px`; - - paragraphElement.appendChild(hyphenElement); - wordElements.push(hyphenElement); - } - break; - case 'tag': - // This is a preserved tag - if (typeof node.value === 'string') { - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = node.value; - while (tempDiv.firstChild) { - const tagElement = tempDiv.firstChild; - tagElement.style.position = 'absolute'; - tagElement.style.left = `${xPosition * 100 / containerWidth}%`; - tagElement.style.top = `${(i - 1) * lineHeight}px`; - - paragraphElement.appendChild(tagElement); - - // Estimate width for positioning next element - xPosition += 20; // Approximate width of tag - } - } - break; + if (wordNode.x !== undefined) { + // Position space after word + spaceElement.style.position = 'absolute'; + const wordWidth = wordElement.offsetWidth || wordNode.width || wordNode.text.length * 8; + spaceElement.style.left = `${wordNode.x + wordWidth}px`; + spaceElement.style.top = `${lineIndex * lineHeight}em`; + } else { + spaceElement.style.position = 'relative'; } + + paragraphElement.appendChild(spaceElement); } } @@ -208,7 +166,7 @@ class LayoutRendererModule extends BaseModule { container.appendChild(paragraphElement); // Schedule animations for words if enabled - if (animateWords && this.animationQueue) { + if (animateWords && animationQueue) { // Schedule animations for each word with a faster timing const baseDelay = 0; // Starting delay const wordDelay = 20; // Delay between words in ms (reduced from 40) @@ -221,24 +179,27 @@ class LayoutRendererModule extends BaseModule { }); // Schedule TTS if enabled - start it earlier in the animation sequence - if (tts && this.ttsPlayer) { - // Get the full text for TTS - const fullText = layout.originalText || layout.processedText || paragraphElement.textContent; - - // Schedule TTS with the animation queue - start after just a few words appear - this.animationQueue.schedule(() => { - this.ttsPlayer.speak(fullText, (result) => { - if (!result || !result.success) { - console.warn('TTS playback issue:', result ? result.reason : 'unknown'); - } - }); - }, Math.min(100, wordDelay * 3)); // Start TTS earlier + if (tts) { + const ttsPlayer = this.getModule('tts-player'); + if (ttsPlayer) { + // Get the full text for TTS + const fullText = layout.originalText || layout.processedText || paragraphElement.textContent; + + // Schedule TTS with the animation queue - start after just a few words appear + animationQueue.schedule(() => { + ttsPlayer.speak(fullText, (result) => { + if (!result || !result.success) { + console.warn('TTS playback issue:', result ? result.reason : 'unknown'); + } + }); + }, Math.min(100, wordDelay * 3)); // Start TTS earlier + } } // Schedule completion callback if (onComplete && typeof onComplete === 'function') { const completionDelay = totalDelay + 200; // Reduced completion delay - this.animationQueue.schedule(onComplete, completionDelay); + animationQueue.schedule(onComplete, completionDelay); } } else if (onComplete && typeof onComplete === 'function') { // If not animating, call onComplete immediately @@ -286,11 +247,12 @@ class LayoutRendererModule extends BaseModule { * @param {number} speed - Animation speed factor */ scheduleWordAnimation(wordElement, delay, speed) { - if (!this.animationQueue) return; + const animationQueue = this.getModule('animation-queue'); + if (!animationQueue) return; const actualDelay = delay * speed; - this.animationQueue.schedule(() => { + animationQueue.schedule(() => { wordElement.style.opacity = '1'; wordElement.style.transform = 'translateY(0)'; wordElement.style.transition = `opacity 0.2s ease-out, transform 0.3s ease-out`; @@ -301,11 +263,5 @@ class LayoutRendererModule extends BaseModule { // Create the singleton instance const LayoutRenderer = new LayoutRendererModule(); -// Register with the module registry -moduleRegistry.register(LayoutRenderer); - // Export the module export { LayoutRenderer }; - -// Keep a reference in window for loader system -window.LayoutRenderer = LayoutRenderer; diff --git a/public/js/loader.js b/public/js/loader.js index ec2bf36..e8e63a1 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -17,6 +17,7 @@ console.log('Module registry initialized and assigned to window.moduleRegistry') const ModuleState = { PENDING: 'PENDING', LOADING: 'LOADING', + FETCHING: 'FETCHING', // Added new state for fetching resources WAITING: 'WAITING', INITIALIZING: 'INITIALIZING', FINISHED: 'FINISHED', @@ -37,6 +38,7 @@ const ModuleLoader = (function() { let moduleWeights = {}; let createdModules = new Set(); // Track which modules we've created UI elements for let gameLoopModule = null; // Add variable to hold game loop instance + let moduleTimings = {}; // Track timing data for modules /** * Initialize the loader @@ -80,14 +82,15 @@ const ModuleLoader = (function() { * Setup event listeners for module communication */ function setupEventListeners() { - // Listen for module progress events - document.addEventListener('module:progress', handleModuleProgress); // Listen for module state change events document.addEventListener('module:stateChange', handleModuleStateChange); // Listen for module status message events document.addEventListener('module:message', handleModuleMessage); + + // Listen for module progress events + document.addEventListener('module:progress', handleModuleProgress); } /** @@ -103,35 +106,35 @@ const ModuleLoader = (function() { ]; // Define modules with their weights - const modulesToLoad = [ + let modulesToLoad = [ // Core functionality modules - { id: 'persistence-manager', script: '/js/persistence-manager.js', weight: 40 }, - { id: 'localization', script: '/js/localization.js', weight: 40 }, - { id: 'text-processor', script: '/js/text-processor.js', weight: 40 }, - { id: 'paragraph-layout', script: '/js/paragraph-layout.js', weight: 40 }, - { id: 'layout-renderer', script: '/js/layout-renderer.js', weight: 45 }, // Add Layout Renderer module - { id: 'animation-queue', script: '/js/animation-queue.js', weight: 50 }, + { id: 'persistence-manager', script: '/js/persistence-manager-module.js', weight: 12 }, + { id: 'localization', script: '/js/localization-module.js', weight: 12 }, + { id: 'text-processor', script: '/js/text-processor-module.js', weight: 15 }, + { id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 }, + { id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module + { id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 }, // Audio and TTS modules - { id: 'audio-manager', script: '/js/audio-manager.js', weight: 60 }, - { id: 'kokoro', script: '/js/kokoro-tts-module.js', weight: 65 }, - { id: 'browser', script: '/js/browser-tts-module.js', weight: 65 }, - { id: 'elevenlabs', script: '/js/elevenlabs-tts-module.js', weight: 65 }, - { id: 'openai', script: '/js/openai-tts-module.js', weight: 65 }, - { id: 'tts-factory', script: '/js/tts-factory.js', weight: 70 }, // TTSFactory must be loaded before TTSPlayer - { id: 'tts', script: '/js/tts-player.js', weight: 75 }, + { id: 'audio-manager', script: '/js/audio-manager-module.js', weight: 12 }, + { id: 'kokoro', script: '/js/kokoro-tts-module.js', weight: 50 }, + { id: 'browser', script: '/js/browser-tts-module.js', weight: 12 }, + { id: 'elevenlabs', script: '/js/elevenlabs-tts-module.js', weight: 12 }, + { id: 'openai', script: '/js/openai-tts-module.js', weight: 12 }, + { id: 'tts-factory', script: '/js/tts-factory-module.js', weight: 13 }, // TTSFactory must be loaded before TTSPlayer + { id: 'tts-player', script: '/js/tts-player-module.js', weight: 13 }, // UI and interaction modules - { id: 'text-buffer', script: '/js/text-buffer.js', weight: 50 }, - { id: 'ui-effects', script: '/js/ui-effects.js', weight: 50 }, // Add UI Effects module - { id: 'ui-input-handler', script: '/js/ui-input-handler.js', weight: 50 }, // Add UI Input Handler module - { id: 'ui-display-handler', script: '/js/ui-display-handler.js', weight: 60 }, // Add UI Display Handler module - { id: 'ui-controller', script: '/js/ui-controller.js', weight: 100 }, - { id: 'options-ui', script: '/js/options-ui.js', weight: 40 }, - { id: 'socket-client', script: '/js/socket-client.js', weight: 60 }, + { id: 'text-buffer', script: '/js/text-buffer-module.js', weight: 12 }, + { id: 'ui-effects', script: '/js/ui-effects-module.js', weight: 12 }, // Add UI Effects module + { id: 'ui-input-handler', script: '/js/ui-input-handler-module.js', weight: 27 }, // Add UI Input Handler module + { id: 'ui-display-handler', script: '/js/ui-display-handler-module.js', weight: 27 }, // Add UI Display Handler module + { id: 'ui-controller', script: '/js/ui-controller-module.js', weight: 27 }, + { id: 'options-ui', script: '/js/options-ui-module.js', weight: 13 }, + { id: 'socket-client', script: '/js/socket-client-module.js', weight: 17 }, // Main game module - should be last to load - { id: 'game-loop', script: '/js/game-loop.js', weight: 25 } + { id: 'game-loop', script: '/js/game-loop-module.js', weight: 27 } ]; // Store module weights for progress calculation @@ -141,7 +144,7 @@ const ModuleLoader = (function() { // Create a module list entry for each module modulesToLoad.forEach(module => { - createModuleListItem(module.id, getModuleNameFromId(module.id)); + createModuleItem(module.id, getModuleNameFromId(module.id)); }); // Load dependencies first @@ -150,7 +153,271 @@ const ModuleLoader = (function() { // Load each module script const loadPromises = modulesToLoad.map(module => loadScript(module.script)); - return Promise.all(loadPromises); + const loadResult = await Promise.all(loadPromises); + + // Wait briefly for modules to register + await new Promise(resolve => setTimeout(resolve, 100)); + + // Analyze dependencies and detect circular references + analyzeModuleDependencies(); + + return loadResult; + } + + /** + * Analyze module dependencies to detect circular references and print detailed diagnostics + */ + function analyzeModuleDependencies() { + const registry = window.moduleRegistry; + if (!registry || !registry.modules) { + console.error("Module Registry not available for dependency analysis"); + return; + } + + // Build dependency graph + const graph = {}; + + // Initialize the graph with all modules + Object.keys(registry.modules).forEach(moduleId => { + graph[moduleId] = []; + }); + + // Add dependencies to graph + Object.entries(registry.modules).forEach(([moduleId, module]) => { + if (module.dependencies && Array.isArray(module.dependencies)) { + module.dependencies.forEach(depId => { + // Check if dependency exists + if (!registry.modules[depId]) { + console.warn(`Module ${moduleId} depends on missing module ${depId}`); + } else { + graph[moduleId].push(depId); + } + }); + } + }); + + // Detect circular dependencies using DFS + const detectCycles = () => { + const visited = {}; + const recStack = {}; + const cycles = []; + const pathStack = []; + + const dfs = (node, path = []) => { + // Node is already in recursion stack - we found a cycle + if (recStack[node]) { + const cycleStart = path.indexOf(node); + if (cycleStart !== -1) { + const cycle = path.slice(cycleStart).concat(node); + cycles.push(cycle); + return true; + } + } + + // If already visited and not in recursion, no cycle through this node + if (visited[node]) { + return false; + } + + // Mark node as visited and add to recursion stack + visited[node] = true; + recStack[node] = true; + pathStack.push(node); + + // Visit all neighbors + const hasCycle = graph[node].some(neighbor => { + return dfs(neighbor, [...pathStack]); + }); + + // Remove from recursion stack and path + recStack[node] = false; + pathStack.pop(); + + return hasCycle; + }; + + // Start DFS from each node + Object.keys(graph).forEach(node => { + if (!visited[node]) { + dfs(node); + } + }); + + return cycles; + }; + + // Find all circular dependencies + const cycles = detectCycles(); + + // Display detailed information about circular dependencies + if (cycles.length > 0) { + console.group("%cCircular Dependencies Detected", "color: red; font-weight: bold"); + cycles.forEach((cycle, index) => { + console.log(`%cCircular Dependency Chain ${index + 1}:`, "font-weight: bold"); + + // Print the cycle with dependency details + cycle.forEach((moduleId, i) => { + const module = registry.modules[moduleId] || { name: 'Unknown' }; + const nextModuleId = cycle[(i + 1) % cycle.length]; + const nextModule = registry.modules[nextModuleId] || { name: 'Unknown' }; + console.log( + `%c${moduleId}%c (${module.name}) depends on %c${nextModuleId}%c (${nextModule.name})`, + "color: blue; font-weight: bold", + "color: black", + "color: blue; font-weight: bold", + "color: black" + ); + + // Print the actual dependencies declared by this module + if (module.dependencies && Array.isArray(module.dependencies)) { + console.log(` Dependencies declared by ${moduleId}:`, module.dependencies); + } + }); + + // Suggest potential solutions + console.log('%cPossible solutions:', 'font-weight: bold'); + console.log('1. Remove one of the dependencies from the chain'); + console.log('2. Use dynamic dependency resolution instead of static dependencies'); + console.log('3. Create an interface module that both modules depend on'); + console.log('4. Refactor module responsibilities to eliminate circular needs'); + }); + console.groupEnd(); + } else { + console.log("%cNo circular dependencies detected", "color: green; font-weight: bold"); + } + + // Calculate an optimized loading order using topological sort + const calculateOptimalLoadOrder = () => { + const result = []; + const visited = {}; + const temp = {}; // Temporary marks for detecting cycles + + const visit = (node) => { + // Node is already in result, skip + if (visited[node]) return; + + // If temp is true, we have a cycle + if (temp[node]) { + console.warn(`Skipping cycle involving ${node} during topological sort`); + return; + } + + // Mark node as being processed + temp[node] = true; + + // Process all dependencies first + if (graph[node]) { + graph[node].forEach(dep => visit(dep)); + } + + // Mark as visited and add to result + temp[node] = false; + visited[node] = true; + result.push(node); + }; + + // Visit all nodes + Object.keys(graph).forEach(node => { + if (!visited[node]) { + visit(node); + } + }); + + return result.reverse(); // Reverse to get correct order + }; + + const optimalOrder = calculateOptimalLoadOrder(); + + // Print the optimal loading order + console.group("%cOptimal Module Loading Order", "color: green; font-weight: bold"); + optimalOrder.forEach((moduleId, index) => { + const module = registry.modules[moduleId] || { name: 'Unknown' }; + console.log(`${index + 1}. %c${moduleId}%c (${module.name})`, + "font-weight: bold", "font-weight: normal"); + }); + console.groupEnd(); + + // Compare with actual loading order and suggest improvements + console.log("%cRecommended changes to module loading order in loader.js:", "font-weight: bold"); + if (optimalOrder.length > 0) { + console.log("const modulesToLoad = ["); + optimalOrder.forEach((moduleId, index) => { + // Generate a reasonable path based on the moduleId + const scriptPath = `/js/${moduleId}.js`; + console.log(` { id: '${moduleId}', script: '${scriptPath}', weight: ${index * 5 + 5} },`); + }); + console.log("];"); + } + } + + /** + * Sort modules by their dependencies to create an optimal loading order + * This function can be used before initialization to ensure modules are loaded in the correct order + * @param {Array} modules - Array of module objects with id and dependencies + * @returns {Array} - Sorted array of modules + */ + function sortModulesByDependencies(modules) { + // Build a dependency graph + const graph = {}; + + // Initialize the graph with all modules + modules.forEach(module => { + graph[module.id] = { module, dependencies: [] }; + }); + + // Add dependencies to the graph + // We need to do this in a second pass because some modules might reference others that come later in the array + modules.forEach(module => { + if (module.dependencies && Array.isArray(module.dependencies)) { + module.dependencies.forEach(depId => { + if (graph[depId]) { + graph[module.id].dependencies.push(depId); + } else { + console.warn(`Module ${module.id} depends on unknown module ${depId}`); + } + }); + } + }); + + // Perform a topological sort + const result = []; + const visited = {}; + const temp = {}; // For cycle detection + + function visit(nodeId) { + // Node is already in result, skip + if (visited[nodeId]) return; + + // If temp is true, we have a cycle + if (temp[nodeId]) { + console.warn(`Circular dependency detected involving ${nodeId}. Skipping.`); + return; + } + + // Mark node as being processed + temp[nodeId] = true; + + // Process all dependencies first + if (graph[nodeId] && graph[nodeId].dependencies) { + graph[nodeId].dependencies.forEach(depId => { + visit(depId); + }); + } + + // Mark as visited and add to result + temp[nodeId] = false; + visited[nodeId] = true; + result.push(graph[nodeId].module); + } + + // Visit all nodes + Object.keys(graph).forEach(nodeId => { + if (!visited[nodeId]) { + visit(nodeId); + } + }); + + return result.reverse(); // Reverse for correct dependency order } /** @@ -159,12 +426,78 @@ const ModuleLoader = (function() { * @returns {Promise} - Resolves when script is loaded */ function loadScript(src) { + // Extract module ID from src path + const moduleId = src.split('/').pop().replace('.js', ''); + + // Update state to LOADING if this is a module + if (moduleId && moduleWeights[moduleId]) { + // Ensure module item exists in UI + const moduleItem = document.getElementById(`module-${moduleId}`); + if (!moduleItem) { + createModuleItem(moduleId, getModuleNameFromId(moduleId)); + } + + // Set initial progress to 0% + handleModuleProgress({ + detail: { moduleId, progress: 0 } + }); + + // Set state to loading + updateModuleState(moduleId, ModuleState.LOADING); + + // Record start time for this module (for timing data) + if (!moduleTimings[moduleId]) { + moduleTimings[moduleId] = {}; + } + moduleTimings[moduleId].startTime = performance.now(); + } + return new Promise((resolve, reject) => { const script = document.createElement('script'); script.type = 'module'; script.src = src; - script.onload = () => resolve(); - script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); + + // Monitor loading progress using a fake progress indicator (0-10%) + if (moduleId && moduleWeights[moduleId]) { + let loadProgress = 0; + const progressInterval = setInterval(() => { + loadProgress = Math.min(loadProgress + 1, 9); // Max 9% until actual load completes + handleModuleProgress({ + detail: { moduleId, progress: loadProgress } + }); + }, 100); + + script.onload = () => { + clearInterval(progressInterval); + // Final progress at 10% when script is loaded + handleModuleProgress({ + detail: { moduleId, progress: 10 } + }); + + // Record script load complete time + if (moduleTimings[moduleId]) { + moduleTimings[moduleId].scriptLoadTime = performance.now(); + } + + resolve(); + }; + + script.onerror = (error) => { + clearInterval(progressInterval); + updateModuleState(moduleId, ModuleState.ERROR); + + // Record error time + if (moduleTimings[moduleId]) { + moduleTimings[moduleId].errorTime = performance.now(); + } + + reject(new Error(`Failed to load script: ${src}`)); + }; + } else { + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); + } + document.head.appendChild(script); }); } @@ -292,27 +625,50 @@ const ModuleLoader = (function() { } /** - * Create a module list item in the UI + * Create a module list item * @param {string} id - Module ID - * @param {string} name - Module display name + * @param {string} name - Module name + * @returns {HTMLLIElement} List item element */ - function createModuleListItem(id, name) { - if (!modulesList) return; - - // Check if we've already created this module item - if (createdModules.has(id)) return; - - // Mark this module as created + function createModuleItem(id, name) { + if (!modulesList || createdModules.has(id)) return null; createdModules.add(id); - const moduleItem = document.createElement('li'); - moduleItem.className = 'module-item'; - moduleItem.id = `module-${id}`; - moduleItem.innerHTML = ` - ${name} - Pending - `; - modulesList.appendChild(moduleItem); + // Create elements dynamically + const li = document.createElement('li'); + li.id = `module-${id}`; + li.className = 'module-item'; + + // Set initial progress to 0 using CSS variable + li.style.setProperty('--progress-width', '0%'); + + // Create module name element + const moduleName = document.createElement('div'); + moduleName.className = 'module-name'; + moduleName.textContent = name; + + // Create module status element + const moduleStatus = document.createElement('div'); + moduleStatus.className = 'module-status status-pending'; + moduleStatus.textContent = 'Pending'; + + // Create module status details element + const moduleDetails = document.createElement('div'); + moduleDetails.className = 'module-status-detail'; + moduleDetails.textContent = ''; + + // Append all elements to the list item + li.appendChild(moduleName); + li.appendChild(moduleStatus); + li.appendChild(moduleDetails); + + // Force a reflow to ensure animation works + void li.offsetWidth; + + // Add to modules list + modulesList.appendChild(li); + + return li; } /** @@ -320,7 +676,17 @@ const ModuleLoader = (function() { */ function handleModuleProgress(event) { const { moduleId, progress } = event.detail; - updateModuleProgress(moduleId, progress); + + // Get the module element + const moduleItem = document.querySelector(`#module-${moduleId}`); + if (moduleItem) { + // Update module item's before pseudo-element width using CSS variable + moduleItem.style.setProperty('--progress-width', `${progress}%`); + + // Also set a data attribute for browsers that don't support CSS variables + moduleItem.setAttribute('data-progress', progress); + } + updateOverallProgress(); } @@ -329,9 +695,37 @@ const ModuleLoader = (function() { */ function handleModuleStateChange(event) { const { moduleId, state } = event.detail; + // Update UI with the new state updateModuleState(moduleId, state); + + // If module is finished, update overall completion + if (state === ModuleState.FINISHED) { + // This triggers only when ALL modules are complete, so modules would be removed too quickly + // if (areAllModulesComplete()) { + // hideLoadingOverlay(); + // } + const moduleItem = document.getElementById(`module-${moduleId}`); + if (moduleItem) { + // Ensure module-finished class is added with a small delay to avoid race conditions + setTimeout(() => { + moduleItem.classList.add('module-finished'); + }, 100); + } + } + updateOverallProgress(); + // Record timing data + if (moduleTimings[moduleId]) { + moduleTimings[moduleId][state] = performance.now(); + + // If the module is finished or has an error, calculate total time + if (state === ModuleState.FINISHED || state === ModuleState.ERROR) { + const startTime = moduleTimings[moduleId].startTime || 0; + moduleTimings[moduleId].totalTime = performance.now() - startTime; + } + } + // Check if all modules are finished after each state change checkAllFinished(); } @@ -349,33 +743,45 @@ const ModuleLoader = (function() { */ function checkAllFinished() { const modules = moduleRegistry.getAllModules(); - const allFinished = Object.values(modules).every(module => { + + // Add detailed logging of all module states + console.log('Module states:', Object.entries(modules).map(([id, module]) => { + return `${id}: ${module.getState()}`; + })); + + // First determine which modules are pending + const pendingModules = Object.values(modules).filter(module => { const state = module.getState(); - return state === ModuleState.FINISHED || state === ModuleState.ERROR; + return state !== ModuleState.FINISHED && state !== ModuleState.ERROR; }); + // Log pending modules (if any) + if (pendingModules.length > 0) { + console.log('Modules still pending:', pendingModules.map(m => `${m.id} (${m.getState()})`)); + } else { + console.log('No modules pending - all modules are in FINISHED or ERROR state'); + } + + // Determine if all modules are finished based on pendingModules + const allFinished = pendingModules.length === 0; + if (allFinished && !isLoadingComplete) { console.log('All modules finished loading. Proceeding to finalization...'); finalizeLoading(); - } else if (!allFinished) { - // Log which modules are not finished yet - const pendingModules = Object.values(modules).filter(module => { - const state = module.getState(); - return state !== ModuleState.FINISHED && state !== ModuleState.ERROR; - }); - - if (pendingModules.length > 0) { - console.log('Modules still pending:', pendingModules.map(m => `${m.id} (${m.getState()})`)) - } + } else if (allFinished && isLoadingComplete) { + console.log('All modules are finished but isLoadingComplete is already true'); } } - + /** * Finalize the loading process */ function finalizeLoading() { console.log('Loading completed. Finalizing...'); try { + // Display timing data + displayModuleTimings(); + completeFinalization(); } catch (error) { console.error('Error during finalization:', error); @@ -409,6 +815,36 @@ const ModuleLoader = (function() { } } + /** + * Display module timing data to help with weight optimization + */ + function displayModuleTimings() { + console.group('Module Loading Performance Data'); + console.log('This data can be used to optimize module weights:'); + + // Format timing data as tuples [moduleId, totalTime, weight] + const timingData = Object.entries(moduleTimings) + .filter(([moduleId, timing]) => timing.totalTime !== undefined) + .map(([moduleId, timing]) => { + return [moduleId, Math.round(timing.totalTime), moduleWeights[moduleId] || 1]; + }) + .sort((a, b) => b[1] - a[1]); // Sort by total time (descending) + + // Create a table for easy reading + console.table(timingData.map(([moduleId, time, weight]) => { + return { + moduleId, + 'totalTime (ms)': time, + 'current weight': weight, + 'suggested weight': Math.max(1, Math.round(time / 50)) // Simple heuristic based on time + }; + })); + + console.log('Raw timing data:'); + console.table(moduleTimings); + console.groupEnd(); + } + /** * Hide the loading overlay with a fade out animation * Then completely remove it from the DOM @@ -445,16 +881,6 @@ const ModuleLoader = (function() { } }); - // Fallback in case the transition event doesn't fire - setTimeout(() => { - if (loadingOverlay && loadingOverlay.parentNode) { - console.log('Module Loader: Removing overlay from DOM (fallback)'); - loadingOverlay.parentNode.removeChild(loadingOverlay); - loadingOverlay = null; - } - // Execute callback in fallback as well - if (callback) callback(); - }, 1000); // Wait longer than the transition duration } /** @@ -477,11 +903,12 @@ const ModuleLoader = (function() { 'status-waiting', 'status-initializing', 'status-finished', - 'status-error' + 'status-error', + 'status-fetching' ); - // Add appropriate class and text - let statusText = ''; + // Set the new status + let statusText = 'Unknown'; switch (state) { case ModuleState.PENDING: statusElement.classList.add('status-pending'); @@ -491,6 +918,10 @@ const ModuleLoader = (function() { statusElement.classList.add('status-loading'); statusText = 'Loading'; break; + case ModuleState.FETCHING: + statusElement.classList.add('status-fetching'); + statusText = 'Fetching'; + break; case ModuleState.WAITING: statusElement.classList.add('status-waiting'); statusText = 'Waiting'; @@ -508,25 +939,9 @@ const ModuleLoader = (function() { statusText = 'Error'; break; } - statusElement.textContent = statusText; } - /** - * Update the progress of a module - * @param {string} id - Module ID - * @param {number} progress - Progress percentage (0-100) - */ - function updateModuleProgress(id, progress) { - // Module states are now managed by the module itself - - // Update any additional UI elements for module progress if needed - const moduleItem = document.getElementById(`module-${id}`); - if (moduleItem) { - // Update progress display if needed - } - } - /** * Update the status text of a module in the UI * @param {string} id - Module ID diff --git a/public/js/localization.js b/public/js/localization-module.js similarity index 90% rename from public/js/localization.js rename to public/js/localization-module.js index 6e572ba..3035f24 100644 --- a/public/js/localization.js +++ b/public/js/localization-module.js @@ -3,7 +3,6 @@ * Handles translations and locale settings */ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; class LocalizationModule extends BaseModule { /** @@ -106,16 +105,16 @@ class LocalizationModule extends BaseModule { const translations = await response.json(); this.translations[normalizedLocale] = translations; } else { - // Don't try to load language part without region (e.g., "en") - we only support full locales - // Fallback to en-us if the requested locale isn't found - if (normalizedLocale !== 'en-us') { - console.warn(`Translations for ${normalizedLocale} not found, falling back to en-us`); - await this.loadTranslations('en-us'); - this.translations[normalizedLocale] = this.translations['en-us']; - } else { - // If en-us is not found, create an empty translation set - console.warn('English translations not found, using empty set'); - this.translations[normalizedLocale] = {}; + // If exact locale not found, try to load just the language part + const langPart = normalizedLocale.split('-')[0]; + if (langPart !== normalizedLocale) { + const langResponse = await fetch(`/locales/${langPart}.json`); + if (langResponse.ok) { + const translations = await langResponse.json(); + this.translations[normalizedLocale] = translations; + } else { + console.warn(`No translations found for ${locale} or ${langPart}`); + } } } } catch (error) { @@ -251,8 +250,5 @@ class LocalizationModule extends BaseModule { // Create the singleton instance const Localization = new LocalizationModule(); -// Register with the module registry -moduleRegistry.register(Localization); - // Export the module export { Localization }; diff --git a/public/js/openai-tts-handler.js b/public/js/openai-tts-handler.js deleted file mode 100644 index 820fdd6..0000000 --- a/public/js/openai-tts-handler.js +++ /dev/null @@ -1,240 +0,0 @@ -/** - * OpenAI TTS Handler - * Provides TTS via OpenAI API - */ -import { ApiTTSHandlerBase } from './api-tts-handler-base.js'; - -export class OpenAITTSHandler extends ApiTTSHandlerBase { - constructor() { - super('openai', 'OpenAI TTS'); - - // Voice options specific to OpenAI - this.voiceOptions = { - voice: 'alloy', // Default voice for OpenAI - model: 'tts-1', // Standard model - speed: 1.0, - response_format: 'mp3' // OpenAI supports mp3, opus, aac, and flac (not wav) - }; - - // Predefined voices - this.voices = [ - { id: 'alloy', name: 'Alloy', language: 'en' }, - { id: 'echo', name: 'Echo', language: 'en' }, - { id: 'fable', name: 'Fable', language: 'en' }, - { id: 'onyx', name: 'Onyx', language: 'en' }, - { id: 'nova', name: 'Nova', language: 'en' }, - { id: 'shimmer', name: 'Shimmer', language: 'en' } - ]; - - // Bind methods - this.bindMethods([ - 'initialize', - 'speak', - 'speakPreloaded', - 'preloadSpeech', - 'stop', - 'isAvailable', - 'getId', - 'getVoices', - 'setVoiceOptions', - 'getModule', - 'setupVoiceFromPreferences', - 'loadVoices', - 'selectVoiceForLocale', - 'selectDefaultVoice', - 'generateSpeechAudio', - 'getDefaultApiBaseUrl' - ]); - } - - /** - * Initialize the OpenAI TTS handler - * @param {Function} progressCallback - Callback for progress updates - * @returns {Promise} - Resolves with success status - */ - async initialize(progressCallback = null) { - try { - // Call parent initialize method - const initSuccess = await super.initialize(progressCallback); - - if (!initSuccess) { - return false; - } - - // Load voice preferences - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - // Load model preference - const model = persistenceManager.getPreference('tts', 'openai_model', 'tts-1'); - if (model) { - this.voiceOptions.model = model; - } - - // Load format preference - const format = persistenceManager.getPreference('tts', 'openai_format', 'mp3'); - if (format) { - this.voiceOptions.response_format = format; - } - } - - // OpenAI TTS should be considered available if the API key is set - // This will be checked by the parent class already - - return true; - } catch (error) { - console.error('OpenAI TTS: Initialization error:', error); - if (progressCallback) { - progressCallback(100, `OpenAI TTS initialization failed - ${error.message}`); - } - return false; - } - } - - /** - * Get the default API base URL for OpenAI - * @returns {string} - Default API base URL - */ - getDefaultApiBaseUrl() { - return 'https://api.openai.com/v1'; - } - - /** - * Load available voices from OpenAI API - * @returns {Promise} - Resolves with success status - */ - async loadVoices() { - // OpenAI has a fixed set of voices, no need to fetch them - return true; - } - - /** - * Select a voice for the given locale - * @param {string} locale - Locale code - * @returns {boolean} - Success status - */ - selectVoiceForLocale(locale) { - // Extract language code from locale (e.g., 'en-US' -> 'en') - const langCode = locale.split('-')[0].toLowerCase(); - - // All OpenAI voices are English-based, so if the locale is English, we might want to pick a specific voice - // Otherwise, just use the default voice - if (langCode === 'en') { - this.voiceOptions.voice = 'nova'; // A bit more natural-sounding for general use - return true; - } - - // For non-English locales, still use a default voice (OpenAI voices can handle multiple languages) - return this.selectDefaultVoice(); - } - - /** - * Select a default voice - * @returns {boolean} - Success status - */ - selectDefaultVoice() { - this.voiceOptions.voice = 'alloy'; - return true; - } - - /** - * Generate speech audio data using OpenAI API - * @param {string} text - Text to generate speech for - * @returns {Promise} - Audio data (Blob) - */ - async generateSpeechAudio(text) { - if (!text || !this.apiKey) { - return null; - } - - try { - // Log the actual values being used - don't truncate or mask for debugging - console.log('OpenAI TTS: Generating speech with:'); - console.log('- API Key:', this.apiKey); - console.log('- API URL:', this.apiBaseUrl); - - // Create request payload - const payload = { - model: this.voiceOptions.model || 'tts-1', - input: text, - voice: this.voiceOptions.voice || 'alloy', - response_format: this.voiceOptions.response_format || 'mp3', - speed: this.voiceOptions.speed || 1.0 - }; - - // Make API request - const response = await fetch(`${this.apiBaseUrl}/audio/speech`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}` - }, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`API error: ${response.status} ${response.statusText} - ${errorText}`); - } - - // Get audio blob from response - const audioBlob = await response.blob(); - - // Note: OpenAI doesn't support WAV format directly, so we're using the format specified in voiceOptions - // The audio element should still be able to play mp3/opus/aac properly - return new Blob([audioBlob], { type: `audio/${this.voiceOptions.response_format}` }); - } catch (error) { - console.error('OpenAI TTS: Error generating speech:', error); - return null; - } - } - - /** - * Get available voices - * @returns {Promise} - Resolves with array of voice objects - */ - async getVoices() { - if (!this.available) { - return []; - } - - // OpenAI has a fixed set of voices - return this.voices; - } - - /** - * Set voice options - * @param {Object} options - Voice options - */ - setVoiceOptions(options = {}) { - // Call parent method for common options - super.setVoiceOptions(options); - - // Handle OpenAI-specific options - if (options.model) { - this.voiceOptions.model = options.model; - - // Save the model preference - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - persistenceManager.updatePreference('tts', 'openai_model', options.model); - } - } - - if (options.response_format) { - // Ensure valid format: mp3, opus, aac, or flac - const validFormats = ['mp3', 'opus', 'aac', 'flac']; - if (validFormats.includes(options.response_format)) { - this.voiceOptions.response_format = options.response_format; - - // Save the format preference - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - persistenceManager.updatePreference('tts', 'openai_format', options.response_format); - } - } - } - } -} - -// Create the singleton instance -const OpenAITTS = new OpenAITTSHandler(); diff --git a/public/js/openai-tts-module.js b/public/js/openai-tts-module.js index df772ca..35e98f5 100644 --- a/public/js/openai-tts-module.js +++ b/public/js/openai-tts-module.js @@ -6,7 +6,7 @@ import { ApiTTSModuleBase } from './api-tts-module-base.js'; export class OpenAITTSModule extends ApiTTSModuleBase { constructor() { - super('openai', 'OpenAI TTS'); + super('openai-tts', 'OpenAI TTS'); // Voice options specific to OpenAI this.voiceOptions = { @@ -115,10 +115,7 @@ export class OpenAITTSModule extends ApiTTSModuleBase { const langCode = locale.split('-')[0].toLowerCase(); // All OpenAI voices are English-based - // For English locales, we could customize the voice selection - // For non-English locales, we'll just use the default - - // In this simple implementation, we'll just use the default voice + // Return default voice return this.selectDefaultVoice(); } @@ -127,21 +124,26 @@ export class OpenAITTSModule extends ApiTTSModuleBase { * @returns {boolean} - Success status */ selectDefaultVoice() { - this.voiceOptions.voice = 'alloy'; + this.voiceOptions.voice = 'alloy'; // Default voice return true; } + /** + * Get available voices + * @returns {Array} - Array of voice objects + */ + getAvailableVoices() { + return this.voices; + } + /** * Generate speech audio data using OpenAI API * @param {string} text - Text to generate speech for * @returns {Promise} - Audio data object */ async generateSpeechAudio(text) { - if (!text || !this.apiKey) { - return { - success: false, - reason: 'missing_api_key_or_text' - }; + if (!this.isReady || !this.apiKey) { + return { success: false, reason: 'not_ready' }; } try { @@ -239,17 +241,6 @@ export class OpenAITTSModule extends ApiTTSModuleBase { } } -// Register the module with the module registry -// Module registry MUST be accessed via window, not direct import -if (window.moduleRegistry) { - try { - // Create instance first, then register it - const openAITTSModule = new OpenAITTSModule(); - window.moduleRegistry.register(openAITTSModule); - console.log('OpenAI TTS Module registered successfully'); - } catch (err) { - console.error('Failed to register OpenAI TTS Module:', err); - } -} else { - console.error('Module registry not available when attempting to register OpenAI TTS Module'); -} +const openAITTSModule = new OpenAITTSModule(); + +export { openAITTSModule }; \ No newline at end of file diff --git a/public/js/options-ui-module.js b/public/js/options-ui-module.js new file mode 100644 index 0000000..fed4470 --- /dev/null +++ b/public/js/options-ui-module.js @@ -0,0 +1,974 @@ +/** + * Options UI Module + * Provides the options UI for the game + */ +import { BaseModule } from './base-module.js'; +import { createUIElement, populateDropdown, registerHandler, createPreferenceBinding } from './ui-helper.js'; + +class OptionsUIModule extends BaseModule { + /** + * Create a new options UI module + */ + constructor() { + super('options-ui', 'Options UI'); + + // Set up dependencies + this.dependencies = [ + 'persistence-manager', + 'localization', + 'tts-factory', + 'audio-manager' + ]; + + // Modal element + this.modal = null; + + // UI elements + this.elements = {}; + + // Settings that require reload + this.reloadRequired = false; + + // Bind methods + this.bindMethods([ + 'show', + 'hide', + 'createModal', + 'populateTtsSystems', + 'populateVoices', + 'populateLanguages', + 'loadPreferences', + 'applySettings', + 'handleTtsSystemChanged', + 'showReloadNotice', + 'toggle', + 'setupEventListeners', + 'saveCurrentSettings', + 'setupApiUrlFields', + 'setupInitialState', + 'dispatchApiChangeEvent', + 'getPreference', + 'updatePreference' + ]); + } + + /** + * Dispatches an API change event + * @param {string} eventType - Event type (e.g. 'api:key:change') + * @param {string} provider - Provider name (e.g. 'elevenlabs') + * @param {string} valueType - Value type (e.g. 'key', 'url') + * @param {string} value - Value to dispatch + */ + dispatchApiChangeEvent(eventType, provider, valueType, value) { + document.dispatchEvent(new CustomEvent(eventType, { + detail: { provider, [valueType]: value } + })); + } + + /** + * Gets a preference from the persistence manager + * @param {string} category - Preference category + * @param {string} key - Preference key + * @param {*} defaultValue - Default value if preference doesn't exist + * @returns {*} - Preference value + */ + getPreference(category, key, defaultValue) { + const persistenceManager = this.getModule('persistence-manager'); + return persistenceManager.getPreference(category, key, defaultValue); + } + + /** + * Updates a preference in the persistence manager + * @param {string} category - Preference category + * @param {string} key - Preference key + * @param {*} value - Value to set + */ + updatePreference(category, key, value) { + const persistenceManager = this.getModule('persistence-manager'); + persistenceManager.updatePreference(category, key, value); + } + + /** + * Initialize the Options UI module + * @returns {Promise} - Promise resolves with initialization success + */ + async initialize() { + console.log('Options UI: Initializing'); + + // Create DOM elements + this.createModal(); + + // Set up event listeners + this.setupEventListeners(); + + // Set up API URL fields + this.setupApiUrlFields(); + + // Set up initial state + await this.setupInitialState(); + + // Set up immediate save listeners + this.setupImmediateSaveListeners(); + + this.reportProgress(100, 'Options UI initialized'); + return true; + } + + /** + * Create the options modal + */ + createModal() { + if (this.modal) return; + + const body = document.body; + + // Create modal container + this.modal = createUIElement('div', { className: 'options-modal', id: 'options-modal' }, null, body); + + // Create modal content + const modalContent = createUIElement('div', { className: 'options-content' }, null, this.modal); + + // Create header + const header = createUIElement('div', { className: 'options-header' }, null, modalContent); + createUIElement('h2', {}, 'Options', header); + this.elements.closeButton = createUIElement('button', { className: 'options-close', 'aria-label': 'Close' }, '×', header); + + // Create settings container + const settings = createUIElement('div', { className: 'options-settings' }, null, modalContent); + + // TTS Settings + const ttsSection = createUIElement('div', { className: 'options-section' }, null, settings); + createUIElement('h3', {}, 'Text-to-Speech', ttsSection); + + // TTS Toggle + const ttsSpeechToggleContainer = createUIElement('div', { className: 'options-row' }, null, ttsSection); + createUIElement('label', {}, 'Enable Text-to-Speech:', ttsSpeechToggleContainer); + this.elements.ttsEnabled = createUIElement('input', { type: 'checkbox', id: 'tts-enabled' }, null, ttsSpeechToggleContainer); + + // TTS System + const ttsSystemContainer = createUIElement('div', { className: 'options-row' }, null, ttsSection); + createUIElement('label', {}, 'TTS System:', ttsSystemContainer); + this.elements.ttsSystem = createUIElement('select', { id: 'tts-system' }, null, ttsSystemContainer); + + // TTS Voice + const ttsVoiceContainer = createUIElement('div', { className: 'options-row' }, null, ttsSection); + createUIElement('label', {}, 'Voice:', ttsVoiceContainer); + this.elements.ttsVoice = createUIElement('select', { id: 'tts-voice' }, null, ttsVoiceContainer); + + // TTS Speed + const speedContainer = createUIElement('div', { className: 'options-row' }, null, ttsSection); + createUIElement('label', {}, 'TTS Speed:', speedContainer); + this.elements.ttsSpeed = createUIElement('input', { + type: 'range', + id: 'tts-speed', + min: '0', + max: '100' + }, null, speedContainer); + + // Create API settings for each provider + const apiSettings = this.createApiSettings(ttsSection); + + // Audio Settings Section + const audioSection = createUIElement('div', { className: 'options-section' }, null, settings); + createUIElement('h3', {}, 'Audio', audioSection); + + // Master Volume + const masterVolumeContainer = createUIElement('div', { className: 'options-row' }, null, audioSection); + createUIElement('label', {}, 'Master Volume:', masterVolumeContainer); + this.elements.masterVolume = createUIElement('input', { + type: 'range', + id: 'master-volume', + min: '0', + max: '100' + }, null, masterVolumeContainer); + + // Music Volume + const musicVolumeContainer = createUIElement('div', { className: 'options-row' }, null, audioSection); + createUIElement('label', {}, 'Music Volume:', musicVolumeContainer); + this.elements.musicVolume = createUIElement('input', { + type: 'range', + id: 'music-volume', + min: '0', + max: '100' + }, null, musicVolumeContainer); + + // SFX Volume + const sfxVolumeContainer = createUIElement('div', { className: 'options-row' }, null, audioSection); + createUIElement('label', {}, 'Sound Effects Volume:', sfxVolumeContainer); + this.elements.sfxVolume = createUIElement('input', { + type: 'range', + id: 'sfx-volume', + min: '0', + max: '100' + }, null, sfxVolumeContainer); + + // Ambience Volume + const ambienceVolumeContainer = createUIElement('div', { className: 'options-row' }, null, audioSection); + createUIElement('label', {}, 'Ambience Volume:', ambienceVolumeContainer); + this.elements.ambienceVolume = createUIElement('input', { + type: 'range', + id: 'ambience-volume', + min: '0', + max: '100' + }, null, ambienceVolumeContainer); + + // Language Section + const languageSection = createUIElement('div', { className: 'options-section' }, null, settings); + createUIElement('h3', {}, 'Language Settings', languageSection); + + // Language selection + const languageContainer = createUIElement('div', { className: 'options-row' }, null, languageSection); + createUIElement('label', {}, 'Language:', languageContainer); + this.elements.language = createUIElement('select', { id: 'app-language' }, null, languageContainer); + + // Initialize with display: none + this.modal.style.display = 'none'; + + // Add event handlers + this.elements.closeButton.addEventListener('click', () => { + this.saveCurrentSettings(); + this.hide(); + }); + } + + /** + * Create API settings for TTS providers + * @param {HTMLElement} parentSection - Parent section for API settings + * @returns {Object} - Object with API settings elements + */ + createApiSettings(parentSection) { + // ElevenLabs settings + // API Key + const elevenLabsApiKeyContainer = createUIElement('div', { + className: 'options-row elevenlabs-setting', + 'data-provider': 'elevenlabs' + }, null, parentSection); + + createUIElement('label', {}, 'ElevenLabs API Key:', elevenLabsApiKeyContainer); + this.elements.elevenLabsApiKey = createUIElement('input', { + type: 'password', + placeholder: 'Enter your ElevenLabs API key' + }, null, elevenLabsApiKeyContainer); + + // API URL + const elevenLabsApiUrlContainer = createUIElement('div', { + className: 'options-row elevenlabs-setting', + 'data-provider': 'elevenlabs' + }, null, parentSection); + + createUIElement('label', {}, 'ElevenLabs API URL:', elevenLabsApiUrlContainer); + this.elements.elevenLabsApiUrl = createUIElement('input', { + type: 'text', + placeholder: 'https://api.elevenlabs.io/v1' + }, null, elevenLabsApiUrlContainer); + + // OpenAI settings + // API Key + const openaiApiKeyContainer = createUIElement('div', { + className: 'options-row openai-setting', + 'data-provider': 'openai' + }, null, parentSection); + + createUIElement('label', {}, 'OpenAI API Key:', openaiApiKeyContainer); + this.elements.openaiApiKey = createUIElement('input', { + type: 'password', + placeholder: 'Enter your OpenAI API key' + }, null, openaiApiKeyContainer); + + // API URL + const openaiApiUrlContainer = createUIElement('div', { + className: 'options-row openai-setting', + 'data-provider': 'openai' + }, null, parentSection); + + createUIElement('label', {}, 'OpenAI API URL:', openaiApiUrlContainer); + this.elements.openaiApiUrl = createUIElement('input', { + type: 'text', + placeholder: 'https://api.openai.com/v1' + }, null, openaiApiUrlContainer); + + // Initially hide API settings + const apiSettings = document.querySelectorAll('.elevenlabs-setting, .openai-setting'); + apiSettings.forEach(setting => { + setting.style.display = 'none'; + }); + + return { elevenLabsApiKeyContainer, elevenLabsApiUrlContainer, openaiApiKeyContainer, openaiApiUrlContainer }; + } + + /** + * Set up event listeners for UI elements + */ + setupEventListeners() { + // TTS System change event + if (this.elements.ttsSystem) { + this.elements.ttsSystem.addEventListener('change', this.handleTtsSystemChanged); + } + + // TTS Enable toggle event + if (this.elements.ttsEnabled) { + this.elements.ttsEnabled.addEventListener('change', (event) => { + const enabled = event.target.checked; + console.log('Options UI: TTS enabled changed to', enabled); + + // Save setting + this.updatePreference('tts', 'enabled', enabled); + + // Update TTS Factory + const ttsFactory = this.getModule('tts-factory'); + if (ttsFactory) { + ttsFactory.configure({ enabled }); + } + }); + } + + // Voice change event + if (this.elements.ttsVoice) { + this.elements.ttsVoice.addEventListener('change', (event) => { + const voice = event.target.value; + console.log('Options UI: TTS voice changed to', voice); + + // Save setting + this.updatePreference('tts', 'voice', voice); + + // Update TTS Factory + const ttsFactory = this.getModule('tts-factory'); + if (ttsFactory) { + ttsFactory.configure({ voice }); + } + }); + } + + // TTS Speed change event + if (this.elements.ttsSpeed) { + this.elements.ttsSpeed.addEventListener('input', (event) => { + const speed = parseInt(event.target.value) / 100; + console.log('Options UI: TTS speed changed to', speed); + + // Save setting + this.updatePreference('tts', 'speed', speed); + + // Update TTS Factory + const ttsFactory = this.getModule('tts-factory'); + if (ttsFactory) { + ttsFactory.configure({ speed }); + } + }); + } + + // Language change event + if (this.elements.language) { + this.elements.language.addEventListener('change', (event) => { + const locale = event.target.value; + console.log('Options UI: Language changed to', locale); + + // Save settings + this.updatePreference('app', 'locale', locale); + this.updatePreference('tts', 'language', locale); + + // Update Localization module + const localization = this.getModule('localization'); + if (localization) { + localization.setLocale(locale); + } + + // Show reload notice + this.showReloadNotice(); + }); + } + + // Audio Settings + // Master Volume + if (this.elements.masterVolume) { + this.elements.masterVolume.addEventListener('input', (event) => { + const volume = parseInt(event.target.value) / 100; + console.log('Options UI: Master volume changed to', volume); + + // Save setting + this.updatePreference('audio', 'masterVolume', volume); + + // Update Audio Manager + const audioManager = this.getModule('audio-manager'); + if (audioManager) { + audioManager.setMasterVolume(volume); + } + }); + } + + // Music Volume + if (this.elements.musicVolume) { + this.elements.musicVolume.addEventListener('input', (event) => { + const volume = parseInt(event.target.value) / 100; + console.log('Options UI: Music volume changed to', volume); + + // Save setting + this.updatePreference('audio', 'musicVolume', volume); + + // Update Audio Manager + const audioManager = this.getModule('audio-manager'); + if (audioManager) { + audioManager.setMusicVolume(volume); + } + }); + } + + // SFX Volume + if (this.elements.sfxVolume) { + this.elements.sfxVolume.addEventListener('input', (event) => { + const volume = parseInt(event.target.value) / 100; + console.log('Options UI: SFX volume changed to', volume); + + // Save setting + this.updatePreference('audio', 'sfxVolume', volume); + + // Update Audio Manager + const audioManager = this.getModule('audio-manager'); + if (audioManager) { + audioManager.setSfxVolume(volume); + } + }); + } + + // Ambience Volume + if (this.elements.ambienceVolume) { + this.elements.ambienceVolume.addEventListener('input', (event) => { + const volume = parseInt(event.target.value) / 100; + console.log('Options UI: Ambience volume changed to', volume); + + // Save setting + this.updatePreference('audio', 'ambienceVolume', volume); + + // Update Audio Manager + const audioManager = this.getModule('audio-manager'); + if (audioManager) { + audioManager.setAmbienceVolume(volume); + } + }); + } + } + + /** + * Handle TTS system change + * @param {Event} event - Change event + */ + async handleTtsSystemChanged(event) { + const selectedSystem = event.target.value; + console.log('Options UI: TTS system changed to', selectedSystem); + + // Update API settings visibility + this.updateApiSettingsVisibility(selectedSystem); + + // Save setting + this.updatePreference('tts', 'preferred_handler', selectedSystem); + + // Notify TTSFactory of handler change + const ttsFactory = this.getModule('tts-factory'); + if (ttsFactory) { + await ttsFactory.setActiveHandler(selectedSystem); + + // Now that the handler has changed, update voices for the selected system + await this.populateVoices(); + } + } + + /** + * Update API settings visibility based on selected TTS system + * @param {string} selectedSystem - Selected TTS system + */ + updateApiSettingsVisibility(selectedSystem) { + const elevenLabsSettings = document.querySelectorAll('.elevenlabs-setting'); + const openaiSettings = document.querySelectorAll('.openai-setting'); + + elevenLabsSettings.forEach(setting => { + setting.style.display = selectedSystem === 'elevenlabs' ? 'flex' : 'none'; + }); + + openaiSettings.forEach(setting => { + setting.style.display = selectedSystem === 'openai' ? 'flex' : 'none'; + }); + } + + /** + * Show the options UI + */ + show() { + if (this.modal) { + this.modal.style.display = 'flex'; + document.body.classList.add('modal-open'); + } + } + + /** + * Hide the options UI + */ + hide() { + if (this.modal) { + this.modal.style.display = 'none'; + document.body.classList.remove('modal-open'); + } + } + + /** + * Toggle the options UI visibility + */ + toggle() { + if (this.modal) { + if (this.modal.style.display === 'flex') { + this.hide(); + } else { + this.show(); + } + } + } + + /** + * Populate the TTS systems dropdown + */ + async populateTtsSystems() { + const ttsFactory = this.getModule('tts-factory'); + if (!ttsFactory || !this.elements.ttsSystem) return; + + // Get available TTS systems + const handlers = ttsFactory.getAvailableHandlers(); + console.log('Options UI: Available TTS handlers:', handlers); + + // Format for display + const systems = handlers.map(handler => ({ + id: handler.id, + name: this.getTtsSystemName(handler.id) + })); + + // Populate dropdown + populateDropdown( + this.elements.ttsSystem, + systems, + 'id', + 'name', + this.getPreference('tts', 'preferred_handler', 'none') + ); + + // Update API settings visibility + this.updateApiSettingsVisibility(this.elements.ttsSystem.value); + } + + /** + * Get a friendly name for a TTS system + * @param {string} id - TTS system ID + * @returns {string} - Friendly name + */ + getTtsSystemName(id) { + const names = { + 'none': 'None', + 'browser': 'Browser', + 'kokoro': 'Kokoro', + 'elevenlabs': 'ElevenLabs', + 'openai': 'OpenAI' + }; + + return names[id] || id; + } + + /** + * Populate the voices dropdown + */ + async populateVoices() { + const ttsFactory = this.getModule('tts-factory'); + if (!ttsFactory || !this.elements.ttsVoice) return; + + // Get voices for current TTS system + const voices = await ttsFactory.getVoices() || []; + console.log('Options UI: TTS voices:', voices); + + // Populate dropdown + populateDropdown( + this.elements.ttsVoice, + voices, + 'id', + 'name', + this.getPreference('tts', 'voice', '') + ); + } + + /** + * Populate the languages dropdown + */ + async populateLanguages() { + const localization = this.getModule('localization'); + if (!localization || !this.elements.language) return; + + // Get available languages + const languages = localization.getAvailableLocales() || []; + console.log('Options UI: Available languages:', languages); + + // Format languages with their names + const languageOptions = languages.map(code => ({ + code, + name: localization.getLanguageName(code) + })); + + // Populate dropdown + populateDropdown( + this.elements.language, + languageOptions, + 'code', + 'name', + this.getPreference('app', 'locale', 'en-us') + ); + } + + /** + * Load user preferences from the persistence manager + */ + loadPreferences() { + const persistenceManager = this.getModule('persistence-manager'); + if (!persistenceManager) return; + + console.log('Options UI: Loading preferences'); + + // TTS Settings + // TTS Enable + if (this.elements.ttsEnabled) { + this.elements.ttsEnabled.checked = this.getPreference('tts', 'enabled', true); + } + + // TTS System + if (this.elements.ttsSystem) { + const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none'); + if (this.elements.ttsSystem.querySelector(`option[value="${preferredHandler}"]`)) { + this.elements.ttsSystem.value = preferredHandler; + } + } + + // TTS Speed + if (this.elements.ttsSpeed) { + const speed = this.getPreference('tts', 'speed', 1); + this.elements.ttsSpeed.value = Math.round(speed * 100); + } + + // Audio Settings + // Master Volume + if (this.elements.masterVolume) { + const masterVolume = this.getPreference('audio', 'masterVolume', 1); + this.elements.masterVolume.value = Math.round(masterVolume * 100); + } + + // Music Volume + if (this.elements.musicVolume) { + const musicVolume = this.getPreference('audio', 'musicVolume', 1); + this.elements.musicVolume.value = Math.round(musicVolume * 100); + } + + // SFX Volume + if (this.elements.sfxVolume) { + const sfxVolume = this.getPreference('audio', 'sfxVolume', 1); + this.elements.sfxVolume.value = Math.round(sfxVolume * 100); + } + + // Ambience Volume + if (this.elements.ambienceVolume) { + const ambienceVolume = this.getPreference('audio', 'ambienceVolume', 1); + this.elements.ambienceVolume.value = Math.round(ambienceVolume * 100); + } + + // Language + if (this.elements.language) { + const locale = this.getPreference('app', 'locale', 'en'); + if (this.elements.language.querySelector(`option[value="${locale}"]`)) { + this.elements.language.value = locale; + } + } + + // Update API settings visibility + if (this.elements.ttsSystem) { + this.updateApiSettingsVisibility(this.elements.ttsSystem.value); + } + } + + /** + * Set up two-way binding for TTS Enabled + * @param {HTMLElement} element - UI element + * @param {Object} persistenceManager - Persistence Manager module + * @param {string} category - Preference category + * @param {string} key - Preference key + * @param {*} defaultValue - Default value if preference doesn't exist + * @param {Function} [transform] - Optional transform function + */ + setupTtsEnabledBinding(element, persistenceManager, category, key, defaultValue, transform) { + createPreferenceBinding( + element, + persistenceManager, + category, + key, + defaultValue, + transform + ); + } + + /** + * Set up two-way binding for TTS Voice + * @param {HTMLElement} element - UI element + * @param {Object} persistenceManager - Persistence Manager module + * @param {string} category - Preference category + * @param {string} key - Preference key + * @param {*} defaultValue - Default value if preference doesn't exist + * @param {Function} [transform] - Optional transform function + */ + setupTtsVoiceBinding(element, persistenceManager, category, key, defaultValue, transform) { + createPreferenceBinding( + element, + persistenceManager, + category, + key, + defaultValue, + transform + ); + } + + /** + * Set up two-way binding for App Language + * @param {HTMLElement} element - UI element + * @param {Object} persistenceManager - Persistence Manager module + * @param {string} category - Preference category + * @param {string} key - Preference key + * @param {*} defaultValue - Default value if preference doesn't exist + * @param {Function} [transform] - Optional transform function + */ + setupLanguageBinding(element, persistenceManager, category, key, defaultValue, transform) { + createPreferenceBinding( + element, + persistenceManager, + category, + key, + defaultValue, + transform + ); + } + + /** + * Set up two-way binding for API settings + * @param {Object} persistenceManager - Persistence Manager module + */ + setupApiPreferenceBindings(persistenceManager) { + // ElevenLabs API Key + createPreferenceBinding( + this.elements.elevenLabsApiKey, + persistenceManager, + 'tts', + 'elevenlabs_api_key', + null, + (value) => { + this.dispatchApiChangeEvent('api:key:change', 'elevenlabs', 'key', value); + return value; + } + ); + + // ElevenLabs API URL + createPreferenceBinding( + this.elements.elevenLabsApiUrl, + persistenceManager, + 'tts', + 'elevenlabs_api_url', + null, + (value) => { + this.dispatchApiChangeEvent('api:url:change', 'elevenlabs', 'url', value); + return value; + } + ); + + // OpenAI API Key + createPreferenceBinding( + this.elements.openaiApiKey, + persistenceManager, + 'tts', + 'openai_api_key', + null, + (value) => { + this.dispatchApiChangeEvent('api:key:change', 'openai', 'key', value); + return value; + } + ); + + // OpenAI API URL + createPreferenceBinding( + this.elements.openaiApiUrl, + persistenceManager, + 'tts', + 'openai_api_url', + null, + (value) => { + this.dispatchApiChangeEvent('api:url:change', 'openai', 'url', value); + return value; + } + ); + } + + /** + * Save current settings + */ + saveCurrentSettings() { + // With two-way binding, settings are saved automatically as they change + console.log('Options UI: Settings saved'); + } + + /** + * Apply settings + */ + applySettings() { + const ttsFactory = this.getModule('tts-factory'); + if (ttsFactory) { + // Apply TTS settings + const enabled = this.getPreference('tts', 'enabled', false); + const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none'); + + ttsFactory.configure({ enabled }); + ttsFactory.setActiveHandler(preferredHandler); + } + } + + /** + * Show a reload notice + * @param {string} message - Message to show + */ + showReloadNotice(message) { + console.log('Options UI: Reload required -', message); + this.reloadRequired = true; + } + + /** + * Set up listeners for settings that should save immediately + */ + setupImmediateSaveListeners() { + // Settings are saved immediately with two-way binding + } + + /** + * Update UI text based on current language + */ + updateUIText() { + // Update UI text based on current language + const localization = this.getModule('localization'); + if (!localization) return; + + // Update modal title + const modalTitle = this.modal.querySelector('h2'); + if (modalTitle) { + modalTitle.textContent = localization.translate('options.title', 'Options'); + } + + // Update section titles + const ttsSectionTitle = this.modal.querySelector('.options-section h3:first-child'); + if (ttsSectionTitle) { + ttsSectionTitle.textContent = localization.translate('options.tts.title', 'Text-to-Speech'); + } + + const langSectionTitle = this.modal.querySelector('.options-section:nth-child(2) h3'); + if (langSectionTitle) { + langSectionTitle.textContent = localization.translate('options.language.title', 'Language Settings'); + } + } + + /** + * Set up API URL fields with default values + */ + setupApiUrlFields() { + // Set up ElevenLabs API URL + if (this.elements.elevenLabsApiUrl) { + const savedUrl = this.getPreference('tts', 'elevenlabs_api_url'); + const defaultUrl = 'https://api.elevenlabs.io/v1'; + + // If no saved URL, set the default + if (!savedUrl) { + console.log('Options UI: Setting default ElevenLabs API URL:', defaultUrl); + this.updatePreference('tts', 'elevenlabs_api_url', defaultUrl); + } + } + + // Set up OpenAI API URL + if (this.elements.openaiApiUrl) { + const savedUrl = this.getPreference('tts', 'openai_api_url'); + const defaultUrl = 'https://api.openai.com/v1'; + + // If no saved URL, set the default + if (!savedUrl) { + console.log('Options UI: Setting default OpenAI API URL:', defaultUrl); + this.updatePreference('tts', 'openai_api_url', defaultUrl); + } + } + + // Make sure API keys are initialized if not already set + if (!this.getPreference('tts', 'elevenlabs_api_key')) { + this.updatePreference('tts', 'elevenlabs_api_key', ''); + } + + if (!this.getPreference('tts', 'openai_api_key')) { + this.updatePreference('tts', 'openai_api_key', ''); + } + } + + /** + * Set up the initial state of the Options UI + * @returns {Promise} - Promise resolves when setup is complete + */ + async setupInitialState() { + try { + console.log('Options UI: Setting up initial state'); + + // Add event listener for toggling options UI + document.addEventListener('ui:options:toggle', () => this.toggle()); + + // Set up key bindings + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.modal && this.modal.style.display === 'flex') { + this.saveCurrentSettings(); + this.hide(); + } + }); + + // Populate TTS systems + await this.populateTtsSystems(); + + // Populate languages + await this.populateLanguages(); + + // Populate voices based on current TTS system + await this.populateVoices(); + + // Load current preferences + this.loadPreferences(); + + // Register for TTS events to update voices when they change + document.addEventListener('tts:voices:updated', () => { + console.log('Options UI: Received tts:voices:updated event, updating voice dropdown'); + this.populateVoices(); + }); + + // Set up language change listener + document.addEventListener('locale:changed', async () => { + this.updateUIText(); + await this.populateLanguages(); + }); + + // Register event listeners for TTS availability and voiceId changes + document.addEventListener('tts:engine:change', async (event) => { + console.log('Options UI: Received TTS engine change event:', event.detail); + await this.populateVoices(); + this.updateApiSettingsVisibility(this.elements.ttsSystem.value); + }); + + console.log('Options UI: Initial state setup complete'); + return true; + } catch (error) { + console.error('Options UI: Error setting up initial state', error); + return false; + } + } +} + +// Create the singleton instance +const OptionsUI = new OptionsUIModule(); + +// Register with the module registry +moduleRegistry.register(OptionsUI); + +// Export the module +export { OptionsUI }; diff --git a/public/js/options-ui.js b/public/js/options-ui.js.bkp similarity index 59% rename from public/js/options-ui.js rename to public/js/options-ui.js.bkp index 084bef8..8c62afb 100644 --- a/public/js/options-ui.js +++ b/public/js/options-ui.js.bkp @@ -12,11 +12,19 @@ class OptionsUIModule extends BaseModule { constructor() { super('options-ui', 'Options UI'); + // Set up dependencies + this.dependencies = [ + 'persistence-manager', + 'localization', + 'tts-factory', + 'audio-manager' + ]; + // Modal element this.modal = null; // UI elements - this.elements = null; + this.elements = {}; // Settings that require reload this.reloadRequired = false; @@ -36,109 +44,143 @@ class OptionsUIModule extends BaseModule { 'toggle', 'setupEventListeners', 'saveCurrentSettings', - 'setupApiUrlFields' + 'setupApiUrlFields', + 'setupInitialState', + // Helper methods + 'createUIElement', + 'populateDropdown', + 'registerHandler', + 'dispatchApiChangeEvent', + 'getPreference', + 'updatePreference' ]); } /** - * Initialize the options UI - * @returns {Promise} - Resolves with success status + * Creates a UI element and optionally appends it to a parent + * @param {string} type - Element type ('div', 'button', etc.) + * @param {string} className - CSS class name + * @param {string|Object} textOrProps - Text content or properties object + * @param {HTMLElement} parent - Parent element to append to + * @returns {HTMLElement} - The created element */ - async initialize() { - try { - console.log('Initializing Options UI Module'); - - // Set up dependencies - this.dependencies = [ - 'persistence-manager', - 'localization', - 'tts-factory', - 'audio-manager' - ]; - - // Create the options modal - this.createModal(); - - // Set up event listeners - this.setupEventListeners(); - - // Add event listener for showing options UI - document.addEventListener('ui:showOptions', () => this.show()); - - // Add event listener for toggling options UI - document.addEventListener('ui:options:toggle', () => this.toggle()); - - // Wait for dependencies and populate UI with delay to ensure TTS handlers are registered - this.waitForDependencies().then(() => { - console.log('Options UI: Dependencies loaded, initializing UI with delay'); - - // Add a delay to ensure all TTS handlers are registered and initialized - setTimeout(() => { - // Populate TTS systems - this.populateTtsSystems(); - - // Populate languages - this.populateLanguages(); - - // Load current preferences - this.loadPreferences(); - - // Apply settings - this.applySettings(); - - // Setup API URLs with default values if needed - this.setupApiUrlFields(); - - console.log('Options UI: Initialization complete'); - }, 1000); // 1 second delay - }); - - // Register for TTS events to update voices when they change - document.addEventListener('tts:voices:updated', () => { - console.log('Options UI: Received tts:voices:updated event, updating voice dropdown'); - this.populateVoices(); - }); - - // Set up key bindings - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && this.modal.style.display === 'flex') { - this.saveCurrentSettings(); - this.hide(); - } - }); - - return true; - } catch (error) { - console.error("Options UI: Error initializing", error); - return false; + createUIElement(type, className, textOrProps, parent) { + const element = document.createElement(type); + if (className) element.className = className; + + if (typeof textOrProps === 'string') { + element.textContent = textOrProps; + } else if (textOrProps) { + Object.assign(element, textOrProps); + } + + if (parent) parent.appendChild(element); + return element; + } + + /** + * Populates a select dropdown with options + * @param {HTMLSelectElement} selectElement - The select element to populate + * @param {Array} items - Array of items to add as options + * @param {string|Function} valueKey - Property name or function to extract value + * @param {string|Function} textKey - Property name or function to extract text + * @param {string} selectedValue - Value to select + */ + populateDropdown(selectElement, items, valueKey, textKey, selectedValue) { + if (!selectElement) return; + + // Clear existing options + selectElement.innerHTML = ''; + + // Add options + items.forEach(item => { + const option = document.createElement('option'); + option.value = typeof valueKey === 'function' ? valueKey(item) : item[valueKey]; + option.textContent = typeof textKey === 'function' ? textKey(item) : item[textKey]; + selectElement.appendChild(option); + }); + + // Set selected value if provided and exists + if (selectedValue && selectElement.querySelector(`option[value="${selectedValue}"]`)) { + selectElement.value = selectedValue; } } /** - * Wait for dependencies to be available - * @returns {Promise} - Resolves when dependencies are available + * Registers an event handler on an element + * @param {string} elementName - Element name in this.elements + * @param {string} eventType - Event type to listen for + * @param {Function} handler - Event handler function */ - waitForDependencies() { - return new Promise((resolve) => { - const checkDependencies = () => { - const persistenceManager = this.getModule('persistence-manager'); - const localization = this.getModule('localization'); - const ttsFactory = this.getModule('tts-factory'); - const audioManager = this.getModule('audio-manager'); - - if (persistenceManager && localization && ttsFactory && audioManager) { - this.persistenceManager = persistenceManager; - this.localization = localization; - this.ttsFactory = ttsFactory; - this.audioManager = audioManager; - resolve(); - } else { - setTimeout(checkDependencies, 100); - } - }; - - checkDependencies(); - }); + registerHandler(elementName, eventType, handler) { + if (this.elements && this.elements[elementName]) { + this.elements[elementName].addEventListener(eventType, handler); + } + } + + /** + * Dispatches an API change event + * @param {string} eventType - Event type (e.g. 'tts:api:keyChanged') + * @param {string} provider - Provider name (e.g. 'elevenlabs') + * @param {string} valueType - Value type (e.g. 'key', 'url') + * @param {string} value - The value + */ + dispatchApiChangeEvent(eventType, provider, valueType, value) { + const detail = { provider }; + detail[valueType] = value; + document.dispatchEvent(new CustomEvent(eventType, { detail })); + } + + /** + * Gets a preference from persistence manager + * @param {string} category - Preference category + * @param {string} key - Preference key + * @param {*} defaultValue - Default value if not found + * @returns {*} - Preference value + */ + getPreference(category, key, defaultValue = null) { + const persistenceManager = this.getModule('persistence-manager'); + return persistenceManager.getPreference(category, key) || defaultValue; + } + + /** + * Updates a preference in persistence manager + * @param {string} category - Preference category + * @param {string} key - Preference key + * @param {*} value - Value to set + */ + updatePreference(category, key, value) { + const persistenceManager = this.getModule('persistence-manager'); + persistenceManager.updatePreference(category, key, value); + } + + /** + * Initialize the Options UI module + * @returns {Promise} - Promise resolves with initialization success + */ + async initialize() { + console.log('Options UI: Initializing'); + + // Create DOM elements + this.createModal(); + + // Set up event listeners + this.setupEventListeners(); + + // Initialize module + this.reportProgress(50, 'Initializing UI'); + + // Set up initial state + await this.setupInitialState(); + + // Set up API URL fields with correct defaults + this.setupApiUrlFields(); + + // Set up immediate save listeners for all input controls + this.setupImmediateSaveListeners(); + + this.reportProgress(100, 'Options UI initialized'); + return true; } /** @@ -609,22 +651,22 @@ class OptionsUIModule extends BaseModule { // Store references to elements this.elements = { - ttsSystem, - ttsVoice, - language, - textSpeed, - masterVolume, - speechVolume, - musicVolume, - effectsVolume, - reloadNotice, + ttsSystem: ttsSystem, + ttsVoice: ttsVoice, + language: language, + textSpeed: textSpeed, + masterVolume: masterVolume, + speechVolume: speechVolume, + musicVolume: musicVolume, + effectsVolume: effectsVolume, + reloadNotice: reloadNotice, speechRate: speedSlider, - ttsSpeechToggle, - apiSettingsContainer, - elevenLabsApiKey, - elevenLabsApiUrl, - openaiApiKey, - openaiApiUrl + ttsSpeechToggle: ttsSpeechToggle, + apiSettingsContainer: apiSettingsContainer, + elevenLabsApiKey: elevenLabsApiKey, + elevenLabsApiUrl: elevenLabsApiUrl, + openaiApiKey: openaiApiKey, + openaiApiUrl: openaiApiUrl }; } @@ -670,90 +712,59 @@ class OptionsUIModule extends BaseModule { * Populate TTS systems dropdown */ populateTtsSystems() { - if (!this.elements || !this.elements.ttsSystem) return; + if (!this.elements.ttsSystem) return; const ttsFactory = this.getModule('tts-factory'); - if (!ttsFactory) return; - - // Debug TTS handlers to see what's happening - console.log('Options UI: Debugging TTS handlers before populating dropdown'); - ttsFactory.debugTTSHandlers(); - - // Clear existing options - this.elements.ttsSystem.innerHTML = ''; - - // Add 'None' option - const noneOption = document.createElement('option'); - noneOption.value = 'none'; - noneOption.textContent = 'None'; - this.elements.ttsSystem.appendChild(noneOption); // Get available TTS handlers const handlers = ttsFactory.getAvailableHandlers(); - console.log('Options UI: Available TTS handlers:', handlers.map(h => h.id).join(', ')); - // Add options for each handler - for (const handler of handlers) { - const option = document.createElement('option'); - option.value = handler.id; - option.textContent = this.getTtsSystemName(handler.id); - this.elements.ttsSystem.appendChild(option); - } + // Format for dropdown + const items = handlers.map(handler => ({ + id: handler.id, + name: this.getTtsSystemName(handler.id) + })); - // Set the current active handler - const activeHandler = ttsFactory.getActiveHandler(); - console.log('Options UI: Active TTS handler:', activeHandler ? (activeHandler.getId ? activeHandler.getId() : activeHandler.id) : 'none'); + // Get current handler + const currentHandler = ttsFactory.getActiveHandler(); + const currentHandlerId = currentHandler ? currentHandler.id : 'none'; - if (activeHandler) { - if (typeof activeHandler.getId === 'function') { - // Use getId() if available - this.elements.ttsSystem.value = activeHandler.getId(); - } else if (activeHandler.id) { - // Otherwise try to use the id property - this.elements.ttsSystem.value = activeHandler.id; - } else { - // If no id is available, default to 'none' - this.elements.ttsSystem.value = 'none'; - console.warn('Options UI: Active TTS handler has no ID'); - } - } else { - this.elements.ttsSystem.value = 'none'; - } + // Populate dropdown + this.populateDropdown( + this.elements.ttsSystem, + items, + 'id', + 'name', + currentHandlerId + ); - // Show/hide API settings based on selected TTS system + // Update API settings visibility this.updateApiSettingsVisibility(); - - // Add change event to show/hide API settings - this.elements.ttsSystem.addEventListener('change', () => { - this.updateApiSettingsVisibility(); - }); } /** * Update visibility of API settings based on selected TTS system */ updateApiSettingsVisibility() { - if (!this.elements || !this.elements.apiSettingsContainer) return; + if (!this.elements.ttsSystem || !this.elements.apiSettingsContainer) return; - const selectedProvider = this.elements.ttsSystem.value; + const selectedSystem = this.elements.ttsSystem.value; + const isApiSystem = selectedSystem === 'elevenlabs' || selectedSystem === 'openai'; - // Show/hide API settings container based on whether an API provider is selected - if (selectedProvider === 'elevenlabs' || selectedProvider === 'openai') { - this.elements.apiSettingsContainer.style.display = 'block'; - - // Show/hide provider-specific settings - const elevenLabsSettings = document.querySelectorAll('.elevenlabs-setting'); - const openaiSettings = document.querySelectorAll('.openai-setting'); - - elevenLabsSettings.forEach(element => { - element.style.display = selectedProvider === 'elevenlabs' ? 'flex' : 'none'; - }); - - openaiSettings.forEach(element => { - element.style.display = selectedProvider === 'openai' ? 'flex' : 'none'; - }); - } else { - this.elements.apiSettingsContainer.style.display = 'none'; + // Show or hide API settings + this.elements.apiSettingsContainer.style.display = isApiSystem ? 'block' : 'none'; + + // Show/hide specific provider settings + if (this.elements.elevenLabsApiKey && this.elements.elevenLabsApiUrl) { + const isElevenLabs = selectedSystem === 'elevenlabs'; + this.elements.elevenLabsApiKey.parentNode.style.display = isElevenLabs ? 'block' : 'none'; + this.elements.elevenLabsApiUrl.parentNode.style.display = isElevenLabs ? 'block' : 'none'; + } + + if (this.elements.openaiApiKey && this.elements.openaiApiUrl) { + const isOpenAI = selectedSystem === 'openai'; + this.elements.openaiApiKey.parentNode.style.display = isOpenAI ? 'block' : 'none'; + this.elements.openaiApiUrl.parentNode.style.display = isOpenAI ? 'block' : 'none'; } } @@ -764,10 +775,16 @@ class OptionsUIModule extends BaseModule { */ getTtsSystemName(id) { switch (id) { - case 'browser': return 'Browser TTS'; - case 'api': return 'API TTS'; - case 'kokoro': return 'Kokoro TTS'; - default: return id; + case 'browser': + return 'Web Browser'; + case 'elevenlabs': + return 'ElevenLabs'; + case 'openai': + return 'OpenAI'; + case 'kokoro': + return 'Kokoro (Local) '; + default: + return id.charAt(0).toUpperCase() + id.slice(1); } } @@ -775,56 +792,47 @@ class OptionsUIModule extends BaseModule { * Populate voices dropdown for the current TTS system */ populateVoices() { - if (!this.elements || !this.elements.ttsVoice) { - console.log('Options UI: Cannot populate voices - elements not initialized'); - return; - } + if (!this.elements.ttsVoice) return; const ttsFactory = this.getModule('tts-factory'); const localization = this.getModule('localization'); - if (!ttsFactory || !localization) { - console.log('Options UI: Cannot populate voices - required modules not available'); - return; - } - // Clear existing options - this.elements.ttsVoice.innerHTML = ''; + // Get current handler and voices + const currentHandler = ttsFactory.getActiveHandler(); + const currentLocale = localization.getLocale() || 'en-US'; - // Get current locale - const currentLocale = localization.getLocale(); - console.log(`Options UI: Current locale from localization module: ${currentLocale}`); - - // Get active TTS handler - const activeHandler = ttsFactory.getActiveHandler(); - const handlerId = activeHandler ? activeHandler.getId() : 'none'; - - console.log(`Options UI: Populating voices for locale: ${currentLocale}, handler: ${handlerId}`); - - // Get voices from active handler - const voices = ttsFactory.getVoices(); - console.log(`Options UI: Got ${voices ? voices.length : 0} voices from TTS factory`); - - // Add available voices to dropdown - if (voices && voices.length > 0) { - // Add options for each voice - voices.forEach(voice => { + // If we have a handler with voices, populate the dropdown + if (currentHandler && currentHandler.getVoices) { + const voices = currentHandler.getVoices(currentLocale); + + if (voices && voices.length > 0) { + // Format for dropdown + const items = voices.map(voice => ({ + id: voice.id || voice.name, + name: voice.name + })); + + // Get current voice + const currentVoice = this.getPreference('tts', 'voice'); + + // Populate dropdown + this.populateDropdown( + this.elements.ttsVoice, + items, + 'id', + 'name', + currentVoice + ); + } else { + // No voices available, add a placeholder + this.elements.ttsVoice.innerHTML = ''; const option = document.createElement('option'); - option.value = voice.id || voice.name; - option.textContent = voice.name; - if (voice.lang) { - option.textContent += ` (${voice.lang})`; - } + option.value = ''; + option.textContent = `No voices available for ${currentLocale}`; + option.disabled = true; this.elements.ttsVoice.appendChild(option); - }); - console.log(`Options UI: Added ${voices.length} voice options to the dropdown`); - } else { - // No voices available - const option = document.createElement('option'); - option.value = ''; - option.textContent = `No voices available for ${currentLocale}`; - option.disabled = true; - this.elements.ttsVoice.appendChild(option); - console.log(`Options UI: No voices available for ${currentLocale}, added placeholder option`); + console.log(`Options UI: No voices available for ${currentLocale}, added placeholder option`); + } } } @@ -832,132 +840,74 @@ class OptionsUIModule extends BaseModule { * Populate languages dropdown */ populateLanguages() { - if (!this.elements || !this.elements.language) return; + if (!this.elements.language) return; const localization = this.getModule('localization'); - if (!localization) return; - // Clear existing options - this.elements.language.innerHTML = ''; - - // Get available locales from the localization module + // Get available locales const availableLocales = localization.getAvailableLocales(); - // Add options for each language - availableLocales.forEach(localeCode => { - const option = document.createElement('option'); - option.value = localeCode; - option.textContent = localization.getLanguageName(localeCode); - this.elements.language.appendChild(option); - }); + // Format for dropdown + const items = availableLocales.map(localeCode => ({ + id: localeCode, + name: localization.getLanguageName(localeCode) + })); - // Set current locale as selected + // Get current locale const currentLocale = localization.getLocale(); - if (currentLocale && this.elements.language.querySelector(`option[value="${currentLocale}"]`)) { - this.elements.language.value = currentLocale; - } + + // Populate dropdown + this.populateDropdown( + this.elements.language, + items, + 'id', + 'name', + currentLocale + ); } /** * Load current preferences into the UI */ loadPreferences() { - if (!this.persistenceManager || !this.elements) return; + console.log('Options UI: Loading preferences'); - this.waitForDependencies().then(() => { - const prefs = this.persistenceManager.getAllPreferences(); - - // TTS System - if (this.elements.ttsSystem) { - const provider = prefs.tts.provider; - if (provider) { - // Check if the option exists - const option = Array.from(this.elements.ttsSystem.options).find(opt => opt.value === provider); - if (option) { - this.elements.ttsSystem.value = provider; - } - } + // Get current preferences + const ttsSpeechEnabled = this.getPreference('tts', 'enabled', false); + const ttsSpeed = this.getPreference('tts', 'speed', 0.5); + const currentLocale = this.getPreference('app', 'locale', 'en-US'); + + // Set TTS speech toggle + if (this.elements.ttsSpeechToggle) { + this.elements.ttsSpeechToggle.checked = ttsSpeechEnabled; + } + + // Set speech rate + if (this.elements.speechRate) { + // Convert from 0-1 to 0-100 for slider + this.elements.speechRate.value = Math.round(ttsSpeed * 100); + } + + // Set language + if (this.elements.language) { + // Check if the locale is available in the dropdown + if (this.elements.language.querySelector(`option[value="${currentLocale}"]`)) { + this.elements.language.value = currentLocale; } - - // TTS Voice - if (this.elements.ttsVoice) { - const voice = prefs.tts.voice; - if (voice) { - // Check if the option exists - const option = Array.from(this.elements.ttsVoice.options).find(opt => opt.value === voice); - if (option) { - this.elements.ttsVoice.value = voice; - } - } - } - - // Language - if (this.elements.language) { - const locale = prefs.app.locale; - if (locale) { - // Check if the option exists - const option = Array.from(this.elements.language.options).find(opt => opt.value === locale); - if (option) { - this.elements.language.value = locale; - } - } - } - - // Text Speed - if (this.elements.textSpeed) { - this.elements.textSpeed.value = prefs.animation.speed; - } - - // Master Volume - if (this.elements.masterVolume) { - this.elements.masterVolume.value = Math.round(prefs.audio.masterVolume * 100); - } - - // Speech Volume - if (this.elements.speechVolume) { - this.elements.speechVolume.value = Math.round(prefs.tts.volume * 100); - } - - // Music Volume - if (this.elements.musicVolume) { - this.elements.musicVolume.value = Math.round(prefs.audio.musicVolume * 100); - } - - // Effects Volume - if (this.elements.effectsVolume) { - this.elements.effectsVolume.value = Math.round(prefs.audio.sfxVolume * 100); - } - - // Speech Rate - if (this.elements.speechRate) { - this.elements.speechRate.value = Math.round(prefs.tts.speed * 100); - } - - // TTS Speech Toggle - if (this.elements.ttsSpeechToggle) { - this.elements.ttsSpeechToggle.checked = prefs.tts.enabled; - } - - // ElevenLabs API Key - if (this.elements.elevenLabsApiKey) { - this.elements.elevenLabsApiKey.value = prefs.tts.elevenlabs_api_key; - } - - // ElevenLabs API Base URL - if (this.elements.elevenLabsApiUrl) { - this.elements.elevenLabsApiUrl.value = prefs.tts.elevenlabs_api_url; - } - - // OpenAI API Key - if (this.elements.openaiApiKey) { - this.elements.openaiApiKey.value = prefs.tts.openai_api_key; - } - - // OpenAI API Base URL - if (this.elements.openaiApiUrl) { - this.elements.openaiApiUrl.value = prefs.tts.openai_api_url; - } - }); + } + + // Set API keys + if (this.elements.elevenLabsApiKey) { + const elevenLabsApiKey = this.getPreference('tts', 'elevenlabs_api_key', ''); + this.elements.elevenLabsApiKey.value = elevenLabsApiKey; + } + + if (this.elements.openaiApiKey) { + const openaiApiKey = this.getPreference('tts', 'openai_api_key', ''); + this.elements.openaiApiKey.value = openaiApiKey; + } + + // Set API URLs - these are handled in setupApiUrlFields } /** @@ -966,51 +916,56 @@ class OptionsUIModule extends BaseModule { applySettings() { if (!this.persistenceManager) return; - this.waitForDependencies().then(() => { - const prefs = this.persistenceManager.getAllPreferences(); + const prefs = this.persistenceManager.getAllPreferences(); + + // Apply TTS settings + const ttsFactory = this.getModule('tts-factory'); + if (ttsFactory) { + // Set active handler + const provider = this.elements.ttsSystem.value; + ttsFactory.setActiveHandler(provider); - // Apply TTS settings - const ttsFactory = this.getModule('tts-factory'); - if (ttsFactory) { - // Set active handler - const provider = this.elements.ttsSystem.value; - ttsFactory.setActiveHandler(provider); - - // Update TTS state - const enabled = provider !== 'none'; - document.dispatchEvent(new CustomEvent('tts:stateChange', { - detail: { enabled: enabled } - })); - - // Update persistence - this.persistenceManager.updatePreference('tts', 'provider', provider); - this.persistenceManager.updatePreference('tts', 'enabled', enabled); - } + // Update TTS state + const enabled = provider !== 'none'; + document.dispatchEvent(new CustomEvent('tts:stateChange', { + detail: { enabled: enabled } + })); - // Apply language settings - const localization = this.getModule('localization'); - if (localization && this.elements.language) { - const currentLocale = localization.getLocale(); - // Update the UI to match the current locale - if (currentLocale && this.elements.language.value !== currentLocale) { - this.elements.language.value = currentLocale; - } + // Update persistence + this.persistenceManager.updatePreference('tts', 'provider', provider); + this.persistenceManager.updatePreference('tts', 'enabled', enabled); + } + + // Apply language settings + const localization = this.getModule('localization'); + if (localization && this.elements.language) { + const currentLocale = localization.getLocale(); + // Update the UI to match the current locale + if (currentLocale && this.elements.language.value !== currentLocale) { + this.elements.language.value = currentLocale; } - - // Apply audio settings - const audioManager = this.getModule('audio-manager'); - if (audioManager) { - audioManager.setMasterVolume(prefs.audio.masterVolume); - audioManager.setMusicVolume(prefs.audio.musicVolume); - audioManager.setSfxVolume(prefs.audio.sfxVolume); - } - }); + } + + // Apply audio settings + const audioManager = this.getModule('audio-manager'); + if (audioManager) { + audioManager.setMasterVolume(prefs.audio.masterVolume); + audioManager.setMusicVolume(prefs.audio.musicVolume); + audioManager.setSfxVolume(prefs.audio.sfxVolume); + } } /** * Handle TTS system changed event */ handleTtsSystemChanged() { + const ttsFactory = this.getModule('tts-factory'); + if (!ttsFactory) return; + + // Repopulate the systems dropdown to reflect the current state + this.populateTtsSystems(); + + // Populate voices for the new system this.populateVoices(); } @@ -1088,102 +1043,160 @@ class OptionsUIModule extends BaseModule { } setupEventListeners() { - // Listen for language change events - document.addEventListener('localization:languageChanged', () => { - this.populateLanguages(); - this.populateVoices(); - }); - - // Listen for TTS state changes - document.addEventListener('tts:stateChange', (event) => { - if (this.elements && this.elements.ttsSpeechToggle) { - this.elements.ttsSpeechToggle.checked = event.detail.enabled; - - // Update persistence manager - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - persistenceManager.updatePreference('tts', 'enabled', event.detail.enabled); + try { + // Listen for language change events + document.addEventListener('localization:languageChanged', () => { + try { + this.populateLanguages(); + this.populateVoices(); + } catch (error) { + console.error('Options UI: Error handling language change event', error); } - } - }); - - // Listen for TTS handler changes - document.addEventListener('tts:handlerChanged', (event) => { - if (this.elements && this.elements.ttsSystem) { - // Update the dropdown to match the active handler - const handlerId = event.detail.handlerId; - if (handlerId && handlerId !== 'none') { - this.elements.ttsSystem.value = handlerId; - - // Update persistence manager - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - persistenceManager.updatePreference('tts', 'provider', handlerId); + }); + + // Listen for TTS state changes + document.addEventListener('tts:stateChange', (event) => { + try { + if (this.elements && this.elements.ttsSpeechToggle) { + this.elements.ttsSpeechToggle.checked = event.detail.enabled; + + // Save preference immediately + if (this.persistenceManager) { + this.persistenceManager.updatePreference('tts', 'enabled', event.detail.enabled); + } } + } catch (error) { + console.error('Options UI: Error handling TTS state change event', error); } - - // Refresh voices when handler changes - this.populateVoices(); - } - }); - - // Listen for TTS availability events - document.addEventListener('tts:availability', (event) => { - if (!this.elements) return; + }); - const available = event.detail?.available || false; - - // DON'T hide the TTS section completely, as this prevents configuring API keys - // Instead, just mark it visually (we'll keep controls accessible) - if (this.elements.ttsSection) { - // Set a visual indicator that TTS is not working, but keep it visible - this.elements.ttsSection.classList.toggle('tts-unavailable', !available); - // Add status message if not available - if (!available && !this.elements.ttsUnavailableMessage) { - const statusDiv = document.createElement('div'); - statusDiv.className = 'tts-status-message'; - statusDiv.innerHTML = 'TTS Unavailable: Check logs for details. You can still configure API keys below.'; - statusDiv.style.color = '#ca3c3c'; - statusDiv.style.padding = '5px 0'; - statusDiv.style.marginBottom = '10px'; - this.elements.ttsUnavailableMessage = statusDiv; - // Insert at the top of the TTS section - this.elements.ttsSection.insertBefore(statusDiv, this.elements.ttsSection.firstChild); - } else if (available && this.elements.ttsUnavailableMessage) { - // Remove the message if TTS becomes available - this.elements.ttsUnavailableMessage.remove(); - this.elements.ttsUnavailableMessage = null; + // Listen for TTS handler changes + document.addEventListener('tts:handler:changed', (event) => { + try { + if (event.detail && event.detail.handler) { + console.log(`Options UI: TTS handler changed to ${event.detail.handler}`); + this.handleTtsSystemChanged(); + + // Save preference immediately + if (this.persistenceManager) { + this.persistenceManager.updatePreference('tts', 'preferred_handler', event.detail.handler); + } + } + } catch (error) { + console.error('Options UI: Error handling TTS handler change event', error); } + }); + + // Listen for TTS availability changes + document.addEventListener('tts:availability', (event) => { + try { + if (event.detail && typeof event.detail.available === 'boolean' && this.elements) { + const available = event.detail.available; + console.log(`Options UI: TTS availability changed to ${available}`); + + // Update UI to reflect TTS availability + if (this.elements.ttsSection) { + this.elements.ttsSection.classList.toggle('tts-unavailable', !available); + // Add status message if not available + if (!available && !this.elements.ttsUnavailableMessage) { + const statusDiv = document.createElement('div'); + statusDiv.className = 'tts-status-message'; + statusDiv.innerHTML = 'TTS Unavailable: Check logs for details. You can still configure API keys below.'; + statusDiv.style.color = '#ca3c3c'; + statusDiv.style.padding = '5px 0'; + statusDiv.style.marginBottom = '10px'; + this.elements.ttsUnavailableMessage = statusDiv; + // Insert at the top of the TTS section + this.elements.ttsSection.insertBefore(statusDiv, this.elements.ttsSection.firstChild); + } else if (available && this.elements.ttsUnavailableMessage) { + // Remove the message if TTS becomes available + this.elements.ttsUnavailableMessage.remove(); + this.elements.ttsUnavailableMessage = null; + } + } + } + + // Update the TTS system dropdown + this.populateTtsSystems(); + } catch (error) { + console.error('Options UI: Error handling TTS availability event', error); + } + }); + + // Listen for Kokoro voice updates + document.addEventListener('kokoro:voices-updated', () => { + try { + // Repopulate the voices dropdown when Kokoro voices become available + this.populateVoices(); + } catch (error) { + console.error('Options UI: Error handling Kokoro voices update event', error); + } + }); + + // Browser window resize event + window.addEventListener('resize', () => { + try { + // Update modal positioning + if (this.modal && this.modal.style.display === 'block') { + this.positionModal(); + } + } catch (error) { + console.error('Options UI: Error handling window resize event', error); + } + }); + + console.log('Options UI: Event listeners set up successfully'); + } catch (error) { + console.error('Options UI: Error setting up event listeners', error); + } + } + + /** + * Set up immediate save listeners for all input elements + */ + setupImmediateSaveListeners() { + try { + if (!this.elements || !this.persistenceManager) { + console.warn('Options UI: Cannot set up immediate save listeners - elements or persistence manager missing'); + return; } - // Update the TTS system dropdown - this.populateTtsSystems(); - }); - - // Listen for Kokoro voice updates - document.addEventListener('kokoro:voices-updated', () => { - // Repopulate the voices dropdown when Kokoro voices become available - this.populateVoices(); - }); - - // Browser window resize event - window.addEventListener('resize', () => { - // Update modal positioning - if (this.modal && this.modal.style.display === 'block') { - this.positionModal(); - } - }); + // Ensure we have the required elements before setting up listeners + const elementsToSetup = [ + 'ttsSystem', 'ttsVoice', 'language', + 'ttsSpeechToggle', 'speechRate', + 'elevenLabsApiKey', 'elevenLabsApiUrl', + 'openaiApiKey', 'openaiApiUrl' + ]; + + // Add change listeners for immediate save on each input + elementsToSetup.forEach(elementName => { + this.registerHandler(elementName, 'change', () => { + console.log(`Options UI: Change detected on ${elementName}, saving settings`); + this.saveCurrentSettings(); + }); + }); + + // For range inputs, also add input event to update during dragging + this.registerHandler('speechRate', 'input', () => { + // Update TTS speech rate immediately on slider movement + if (this.elements.speechRate) { + const ttsFactory = this.getModule('tts-factory'); + const speechRate = parseFloat(this.elements.speechRate.value); + ttsFactory.setSpeed(speechRate); + } + }); + } catch (error) { + console.error('Options UI: Error setting up immediate save listeners', error); + } } setupApiUrlFields() { if (!this.elements) return; - const persistenceManager = this.getModule('persistence-manager'); - if (!persistenceManager) return; - // Set up ElevenLabs API URL if (this.elements.elevenLabsApiUrl) { - const savedUrl = persistenceManager.getPreference('tts', 'elevenlabs_api_url'); + const savedUrl = this.getPreference('tts', 'elevenlabs_api_url'); const defaultUrl = 'https://api.elevenlabs.io/v1'; // Always set the input value to the saved or default URL @@ -1192,13 +1205,13 @@ class OptionsUIModule extends BaseModule { // Save default to persistence if not already set if (!savedUrl) { console.log('Options UI: Setting default ElevenLabs API URL:', defaultUrl); - persistenceManager.updatePreference('tts', 'elevenlabs_api_url', defaultUrl); + this.updatePreference('tts', 'elevenlabs_api_url', defaultUrl); } } // Set up OpenAI API URL if (this.elements.openaiApiUrl) { - const savedUrl = persistenceManager.getPreference('tts', 'openai_api_url'); + const savedUrl = this.getPreference('tts', 'openai_api_url'); const defaultUrl = 'https://api.openai.com/v1'; // Always set the input value to the saved or default URL @@ -1207,17 +1220,76 @@ class OptionsUIModule extends BaseModule { // Save default to persistence only if not already set if (!savedUrl) { console.log('Options UI: Setting default OpenAI API URL:', defaultUrl); - persistenceManager.updatePreference('tts', 'openai_api_url', defaultUrl); + this.updatePreference('tts', 'openai_api_url', defaultUrl); } } // Make sure API keys are initialized if not already set - if (!persistenceManager.getPreference('tts', 'elevenlabs_api_key')) { - persistenceManager.updatePreference('tts', 'elevenlabs_api_key', ''); + if (!this.getPreference('tts', 'elevenlabs_api_key')) { + this.updatePreference('tts', 'elevenlabs_api_key', ''); } - if (!persistenceManager.getPreference('tts', 'openai_api_key')) { - persistenceManager.updatePreference('tts', 'openai_api_key', ''); + if (!this.getPreference('tts', 'openai_api_key')) { + this.updatePreference('tts', 'openai_api_key', ''); + } + } + + /** + * Set up the initial state of the Options UI + * @returns {Promise} - Promise resolves when setup is complete + */ + async setupInitialState() { + try { + console.log('Options UI: Setting up initial state'); + + // Add event listener for toggling options UI + document.addEventListener('ui:options:toggle', () => this.toggle()); + + // Set up key bindings + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.modal && this.modal.style.display === 'flex') { + this.saveCurrentSettings(); + this.hide(); + } + }); + + // Populate TTS systems + await this.populateTtsSystems(); + + // Populate languages + await this.populateLanguages(); + + // Populate voices based on current TTS system + await this.populateVoices(); + + // Load current preferences + this.loadPreferences(); + + // Register for TTS events to update voices when they change + document.addEventListener('tts:voices:updated', () => { + console.log('Options UI: Received tts:voices:updated event, updating voice dropdown'); + this.populateVoices(); + }); + + // Set up language change listener + document.addEventListener('locale:changed', async () => { + this.updateUIText(); + await this.populateLanguages(); + }); + + // Register event listeners for TTS availability and voiceId changes + document.addEventListener('tts:engine:change', async (event) => { + console.log('Options UI: Received TTS engine change event:', event.detail); + await this.populateVoices(); + await this.populateLanguages(); + this.updateApiSettingsVisibility(); + }); + + console.log('Options UI: Initial state setup complete'); + return true; + } catch (error) { + console.error('Options UI: Error setting up initial state', error); + return false; } } } diff --git a/public/js/paragraph-layout.js b/public/js/paragraph-layout-module.js similarity index 81% rename from public/js/paragraph-layout.js rename to public/js/paragraph-layout-module.js index cc84da6..b7238a8 100644 --- a/public/js/paragraph-layout.js +++ b/public/js/paragraph-layout-module.js @@ -4,7 +4,6 @@ * and connects it to the text rendering pipeline. */ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; class ParagraphLayoutModule extends BaseModule { constructor() { @@ -44,9 +43,9 @@ class ParagraphLayoutModule extends BaseModule { this.reportProgress(20, "Initializing paragraph layout"); // Get text processor using parent's getModule method - this.textProcessor = this.getModule('text-processor'); + const textProcessor = this.getModule('text-processor'); - if (!this.textProcessor) { + if (!textProcessor) { console.warn("Paragraph Layout: Text Processor not found, will use fallback processing"); } @@ -119,12 +118,10 @@ class ParagraphLayoutModule extends BaseModule { } }); - // Use parent's addEventListener for automatic cleanup - this.addEventListener(document, 'ui:typography:hyphenation', (event) => { + // Listen for config changes + this.addEventListener(document, 'ui:hyphenation:toggle', (event) => { if (event.detail && typeof event.detail.enabled === 'boolean') { - // Use parent's updateConfig method this.updateConfig({ hyphenationEnabled: event.detail.enabled }); - console.log(`Paragraph Layout: Hyphenation ${this.config.hyphenationEnabled ? 'enabled' : 'disabled'}`); } }); } @@ -135,23 +132,20 @@ class ParagraphLayoutModule extends BaseModule { * @param {string} fontFamily - Font family */ updateFont(fontSize, fontFamily) { - if (!this.textMeasureCtx) return; + if (!this.textMeasureCtx) { + console.warn("Text measurement context not initialized"); + return; + } - // Store the font settings - this.config.defaultFontSize = fontSize; - this.config.defaultFontFamily = fontFamily; + // Update config if values are provided + if (fontSize) this.updateConfig({ defaultFontSize: fontSize }); + if (fontFamily) this.updateConfig({ defaultFontFamily: fontFamily }); - // Set the font on the canvas context - const fontString = `${fontSize} ${fontFamily}`; - this.textMeasureCtx.font = fontString; + // Set font on measurement context + this.textMeasureCtx.font = `${fontSize} ${fontFamily}`; if (this.config.debugMode) { - console.log(`Paragraph Layout: Font updated to ${fontString}`); - - // Test measurement - const testText = "The quick brown fox jumps over the lazy dog"; - const width = this.measureText(testText); - console.log(`Paragraph Layout: Test text width: ${width}px`); + console.log(`Font updated: ${fontSize} ${fontFamily}`); } } @@ -161,13 +155,11 @@ class ParagraphLayoutModule extends BaseModule { * @returns {number} - Text width in pixels */ measureText(text) { - if (!this.textMeasureCtx) { - this.initializeTextMeasurement(); - } - + if (!this.textMeasureCtx) return 0; if (!text) return 0; - return this.textMeasureCtx.measureText(text).width; + const metrics = this.textMeasureCtx.measureText(text); + return metrics.width; } /** @@ -178,28 +170,25 @@ class ParagraphLayoutModule extends BaseModule { processTextForLayout(text) { if (!text) return ''; - // Remove extra whitespace - text = text.trim().replace(/\s+/g, ' '); + let processedText = text; + const textProcessor = this.getModule('text-processor'); - try { - // Apply text processor transformations if available - if (this.textProcessor) { - // Apply smartypants for typography improvements - if (this.textProcessor.applySmartypants) { - text = this.textProcessor.applySmartypants(text); - } - - // Apply hyphenation if enabled - if (this.config.hyphenationEnabled && this.textProcessor.hyphenateText) { - text = this.textProcessor.hyphenateText(text); - } + // Apply text processing if available + if (textProcessor) { + // Apply smartypants (typographic punctuation) if available + if (typeof textProcessor.applySmartypants === 'function') { + processedText = textProcessor.applySmartypants(processedText); } - return text; - } catch (error) { - console.error("Error processing text for layout:", error); - return text; + // Apply hyphenation if enabled and available + if (this.config.hyphenationEnabled && typeof textProcessor.hyphenateText === 'function') { + processedText = textProcessor.hyphenateText(processedText); + } + } else if (this.config.debugMode) { + console.log("Text processor not available, skipping text processing"); } + + return processedText; } /** @@ -302,7 +291,9 @@ class ParagraphLayoutModule extends BaseModule { const ParagraphLayout = new ParagraphLayoutModule(); // Register with the module registry -moduleRegistry.register(ParagraphLayout); +if (window.moduleRegistry) { + window.moduleRegistry.register(ParagraphLayout); +} // Export the module export { ParagraphLayout }; diff --git a/public/js/persistence-manager.js b/public/js/persistence-manager-module.js similarity index 81% rename from public/js/persistence-manager.js rename to public/js/persistence-manager-module.js index 414884d..dd6ee43 100644 --- a/public/js/persistence-manager.js +++ b/public/js/persistence-manager-module.js @@ -3,7 +3,6 @@ * Handles saving and loading game state and user preferences */ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; class PersistenceManagerModule extends BaseModule { /** @@ -147,6 +146,8 @@ class PersistenceManagerModule extends BaseModule { * @returns {boolean} - Success status */ savePreferences() { + if (!this.preferences) return false; + try { localStorage.setItem(this.keys.preferences, JSON.stringify(this.preferences)); @@ -174,20 +175,17 @@ class PersistenceManagerModule extends BaseModule { // Parse stored preferences const storedPrefs = JSON.parse(prefsJson); - // Merge with default preferences to ensure all keys exist + // Merge with defaults to ensure all keys exist this.preferences = this.mergeWithDefaults(storedPrefs, this.defaultPreferences); } else { - // Use default preferences if none found - this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences)); - + // Use defaults if no stored preferences found + this.preferences = {...this.defaultPreferences}; } return this.preferences; } catch (error) { console.error("Error loading preferences:", error); - - // Fall back to default preferences - this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences)); + this.preferences = {...this.defaultPreferences}; return this.preferences; } } @@ -199,38 +197,31 @@ class PersistenceManagerModule extends BaseModule { * @returns {Object} - Merged preferences */ mergeWithDefaults(stored, defaults) { - const result = {}; + // Base case: if stored is not an object or is null, return defaults + if (typeof stored !== 'object' || stored === null) { + return defaults; + } - // For each category in defaults - for (const category in defaults) { - result[category] = {}; - - // Copy all settings from defaults for this category - for (const setting in defaults[category]) { - // Use stored value if it exists, otherwise use default - result[category][setting] = (stored[category] && stored[category][setting] !== undefined) - ? stored[category][setting] - : defaults[category][setting]; - } - - // Copy any additional settings from stored that aren't in defaults - if (stored[category]) { - for (const setting in stored[category]) { - if (result[category][setting] === undefined) { - result[category][setting] = stored[category][setting]; - } + // Create a new object to avoid modifying the input objects + const merged = {}; + + // Add all keys from defaults, overriding with stored values where they exist + for (const key in defaults) { + if (Object.prototype.hasOwnProperty.call(defaults, key)) { + // If the default value is an object and not null, recurse + if (typeof defaults[key] === 'object' && defaults[key] !== null) { + merged[key] = this.mergeWithDefaults( + Object.prototype.hasOwnProperty.call(stored, key) ? stored[key] : {}, + defaults[key] + ); + } else { + // Otherwise, use stored value if it exists, otherwise use default + merged[key] = Object.prototype.hasOwnProperty.call(stored, key) ? stored[key] : defaults[key]; } } } - // Copy any additional categories from stored that aren't in defaults - for (const category in stored) { - if (result[category] === undefined) { - result[category] = stored[category]; - } - } - - return result; + return merged; } /** @@ -241,26 +232,22 @@ class PersistenceManagerModule extends BaseModule { * @returns {*} - Preference value */ getPreference(category, setting, defaultValue = null) { + if (!category || !setting) return defaultValue; + + // Ensure preferences are loaded if (!this.preferences) { this.loadPreferences(); } - if (this.preferences[category] && this.preferences[category][setting] !== undefined) { - return this.preferences[category][setting]; - } + // Check if category exists + if (!this.preferences[category]) return defaultValue; - // If default value provided, use it - if (defaultValue !== null) { + // Check if setting exists in category + if (!Object.prototype.hasOwnProperty.call(this.preferences[category], setting)) { return defaultValue; } - // Otherwise check default preferences - if (this.defaultPreferences[category] && this.defaultPreferences[category][setting] !== undefined) { - return this.defaultPreferences[category][setting]; - } - - // If all else fails, return null - return null; + return this.preferences[category][setting]; } /** @@ -271,6 +258,9 @@ class PersistenceManagerModule extends BaseModule { * @returns {boolean} - Success status */ updatePreference(category, setting, value) { + if (!category || !setting) return false; + + // Ensure preferences are loaded if (!this.preferences) { this.loadPreferences(); } @@ -281,23 +271,20 @@ class PersistenceManagerModule extends BaseModule { } // Update preference - const oldValue = this.preferences[category][setting]; this.preferences[category][setting] = value; // Save preferences - this.savePreferences(); + const success = this.savePreferences(); - // Dispatch event if value changed - if (oldValue !== value) { - this.dispatchEvent('preference-changed', { - category, - setting, - value, - oldValue - }); - } + // Dispatch event + this.dispatchEvent('preference-updated', { + category, + setting, + value, + timestamp: new Date().toISOString() + }); - return true; + return success; } /** @@ -306,18 +293,18 @@ class PersistenceManagerModule extends BaseModule { */ resetPreferences() { try { - // Clone default preferences + // Create a deep clone of default preferences this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences)); // Save preferences - this.savePreferences(); + const success = this.savePreferences(); // Dispatch event this.dispatchEvent('preferences-reset', { timestamp: new Date().toISOString() }); - return true; + return success; } catch (error) { console.error("Error resetting preferences:", error); return false; @@ -346,6 +333,14 @@ class PersistenceManagerModule extends BaseModule { if (slotsJson) { this.saveSlots = JSON.parse(slotsJson); + + // Validate each save slot + for (const id in this.saveSlots) { + const slot = this.saveSlots[id]; + if (!slot.id || !slot.name || !slot.timestamp || !slot.state) { + delete this.saveSlots[id]; + } + } } else { this.saveSlots = {}; } @@ -496,8 +491,5 @@ class PersistenceManagerModule extends BaseModule { // Create the singleton instance const PersistenceManager = new PersistenceManagerModule(); -// Register with the module registry -moduleRegistry.register(PersistenceManager); - // Export the module export { PersistenceManager }; diff --git a/public/js/socket-client.js b/public/js/socket-client-module.js similarity index 97% rename from public/js/socket-client.js rename to public/js/socket-client-module.js index 6a4a266..6bc7beb 100644 --- a/public/js/socket-client.js +++ b/public/js/socket-client-module.js @@ -3,7 +3,6 @@ * Handles WebSocket communication for receiving text fragments and game state */ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; class SocketClientModule extends BaseModule { constructor() { @@ -190,8 +189,8 @@ class SocketClientModule extends BaseModule { this.textBuffer.addText(text); } else { console.error('Socket Client: Text buffer not available'); - // Attempt to get text buffer again - this.textBuffer = moduleRegistry.getModule('text-buffer'); + // Attempt to get text buffer again using parent's getModule method + this.textBuffer = this.getModule('text-buffer'); if (this.textBuffer) { this.textBuffer.addText(text); } else { @@ -400,11 +399,5 @@ class SocketClientModule extends BaseModule { // Create the singleton instance const SocketClient = new SocketClientModule(); -// Register with the module registry -moduleRegistry.register(SocketClient); - // Export the module export { SocketClient }; - -// Keep a reference in window for loader system -window.SocketClient = SocketClient; diff --git a/public/js/text-buffer.js b/public/js/text-buffer-module.js similarity index 83% rename from public/js/text-buffer.js rename to public/js/text-buffer-module.js index 9cfb334..1a03db6 100644 --- a/public/js/text-buffer.js +++ b/public/js/text-buffer-module.js @@ -3,7 +3,6 @@ * Manages text processing and sentence detection for the UI */ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; class TextBufferModule extends BaseModule { constructor() { @@ -113,60 +112,42 @@ class TextBufferModule extends BaseModule { this.buffer += text; // If we have a trailing newline as a complete sentence, add a period - if (this.buffer.endsWith('\n') && !this.buffer.endsWith('.\n')) { - const lastChar = this.buffer.charAt(this.buffer.length - 2); - if (lastChar !== '.' && lastChar !== '!' && lastChar !== '?') { - this.buffer = this.buffer.slice(0, -1) + '.\n'; - } - } + this.buffer = this.buffer.replace(/\n$/g, '.\n'); - // Process any complete sentences + // Process sentences this.processSentences(); } - + /** * Process complete sentences in the buffer */ processSentences() { - // Prevent concurrent processing - if (this.processingLock) return; + // If already processing, don't start another processing cycle + if (this.processingLock) { + return; + } + this.processingLock = true; - try { - // Check for sentence endings (including newlines as sentence endings) - const sentenceEndings = [/[.!?]\s+/g, /[.!?]$/m, /\n/g]; + // If the buffer is empty, release the lock and check queue + if (this.buffer.length === 0) { + this.processingLock = false; - let foundSentence = false; - - for (const pattern of sentenceEndings) { - if (this.buffer.match(pattern)) { - foundSentence = true; - break; - } - } - - if (!foundSentence) { - // No complete sentences yet - this.processingLock = false; + // If no more text to process, end processing + if (this.processingQueue.length === 0) { this.isProcessingActive = false; - - // Use parent's dispatchEvent method - super.dispatchEvent('buffer:waiting', { - remainingText: this.buffer, - queueLength: this.processingQueue.length - }); return; } - // Process the next complete sentence - this.processNextSentence(); - } catch (error) { - console.error("Error processing sentences:", error); - this.processingLock = false; - this.isProcessingActive = false; + // Process the next text fragment + this.processNextFromQueue(); + return; } + + // Process the next sentence + this.processNextSentence(); } - + /** * Process the next sentence in the buffer */ @@ -197,7 +178,7 @@ class TextBufferModule extends BaseModule { // Remove the processed sentence from buffer this.buffer = this.buffer.substring(endIndex); - console.log(`TextBuffer: Processing sentence: "${sentence.trim()}"`); + console.log(`TextBuffer: Processing sentence: "${sentence.trim()}"`); // Use parent's dispatchEvent method super.dispatchEvent('buffer:sentence', { @@ -284,11 +265,5 @@ class TextBufferModule extends BaseModule { // Create the singleton instance const TextBuffer = new TextBufferModule(); -// Register with the module registry -moduleRegistry.register(TextBuffer); - // Export the module export { TextBuffer }; - -// Keep a reference in window for loader system -window.TextBuffer = TextBuffer; \ No newline at end of file diff --git a/public/js/text-processor.js b/public/js/text-processor-module.js similarity index 98% rename from public/js/text-processor.js rename to public/js/text-processor-module.js index 41cfca2..02fa077 100644 --- a/public/js/text-processor.js +++ b/public/js/text-processor-module.js @@ -3,7 +3,6 @@ * Handles text formatting and typography enhancements like smart quotes and hyphenation */ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; import Hyphenopoly from './hyphenopoly.module.js'; class TextProcessorModule extends BaseModule { @@ -15,6 +14,9 @@ class TextProcessorModule extends BaseModule { this.hyphenatorReady = false; this.locale = 'en-us'; + // Add localization as a dependency + this.dependencies = ['localization']; + // Bind methods using parent's bindMethods utility this.bindMethods([ 'loadSmartyPantsScript', @@ -25,9 +27,6 @@ class TextProcessorModule extends BaseModule { 'setLocale', 'handleLocaleChanged' ]); - - // Add localization as a dependency - this.dependencies = ['localization']; } /** @@ -320,11 +319,5 @@ class TextProcessorModule extends BaseModule { // Create the singleton instance const TextProcessor = new TextProcessorModule(); -// Register with the module registry -moduleRegistry.register(TextProcessor); - // Export the module export { TextProcessor }; - -// Keep a reference in window for loader system -window.TextProcessor = TextProcessor; diff --git a/public/js/tts-factory.js b/public/js/tts-factory-module.js similarity index 87% rename from public/js/tts-factory.js rename to public/js/tts-factory-module.js index c857601..86a76c0 100644 --- a/public/js/tts-factory.js +++ b/public/js/tts-factory-module.js @@ -11,7 +11,14 @@ class TTSFactoryModule extends BaseModule { constructor() { super('tts-factory', 'TTS Factory'); - this.dependencies = ['persistence-manager', 'localization']; + this.dependencies = [ + 'persistence-manager', + 'localization', + 'browser-tts', // Browser TTS handler + 'kokoro-tts', // Kokoro TTS handler + 'elevenlabs-tts',// ElevenLabs TTS handler + 'openai-tts' // OpenAI TTS handler + ]; this.handlers = {}; this.initStatus = {}; this.activeHandler = null; @@ -122,7 +129,7 @@ class TTSFactoryModule extends BaseModule { // Load preferences this.reportProgress(40, 'Loading TTS preferences'); - await this.loadPreferences(); + const preferences = await this.loadPreferences(); // Check for TTS handlers this.reportProgress(60, 'Finding TTS handlers'); @@ -131,15 +138,27 @@ class TTSFactoryModule extends BaseModule { // Set default status this.ttsAvailable = false; + // Set up event handlers - do this before initializing handlers + // so we can listen for events during initialization + this.setupEvents(); + // Initialize preferred or fallback handler this.reportProgress(80, 'Initializing TTS handler'); await this.initializeHandlerSystem(); - // Set up event handlers - this.setupEvents(); + // Apply configuration from preferences + if (preferences) { + console.log('TTS Factory: Applying saved configuration'); + // Apply speed setting + this.configure({ speed: preferences.speed }); + + // Update TTS availability based on active handler + this.updateTTSAvailability(); + } - // Debug: Log all registered modules + // Debug: Log all registered modules and handlers this.debugLogAllRegisteredModules(); + this.debugTTSHandlers(); this.reportProgress(100, 'TTS Factory initialized'); console.log(`TTS Factory: Initialization complete, TTS available: ${this.ttsAvailable}`); @@ -284,69 +303,39 @@ class TTSFactoryModule extends BaseModule { getAvailableHandlers() { const availableHandlers = []; - // The key handlers we want to ALWAYS include in the dropdown for API configuration - const apiHandlerIds = ['elevenlabs', 'openai']; + // Always include a 'none' option + availableHandlers.push({ + id: 'none', + handler: null, + displayName: 'None' + }); - // First, add all API-based handlers to make sure they're always available in the UI - // even if they're not registered or initialized - for (const id of apiHandlerIds) { - // If the handler is registered in our handlers object, use it - if (this.handlers[id]) { - console.log(`TTS Factory: Adding API handler ${id} to available handlers list`); - availableHandlers.push({ - id: id, - handler: this.handlers[id] - }); - } else { - // If the handler isn't registered yet, still include it in the list - // This ensures API handlers always show up in the UI for configuration - console.log(`TTS Factory: Adding placeholder for API handler ${id} to available handlers list`); - availableHandlers.push({ - id: id, - handler: null - }); - } - } - - // Add Kokoro handler - it's not an API handler but we want it to always appear - if (this.handlers['kokoro']) { - console.log('TTS Factory: Adding Kokoro handler to available handlers list'); + // Always add all registered handlers to the dropdown, regardless of ready state + for (const id in this.handlers) { + const handler = this.handlers[id]; availableHandlers.push({ - id: 'kokoro', - handler: this.handlers['kokoro'] + id: id, + handler: handler, + isReady: handler.isReady === true }); } - // Then add any other non-API handlers that are initialized/ready - for (const id in this.handlers) { - // Skip handlers we've already added - if (apiHandlerIds.includes(id) || id === 'kokoro') { - continue; - } - - const handler = this.handlers[id]; - - // Only include non-API handlers if they're properly initialized - const isAvailable = this.initStatus[id] === true || handler.isReady === true; - - if (handler && isAvailable) { - console.log(`TTS Factory: Adding non-API handler ${id} to available handlers list`); - // Check if this handler is already in the list - if (!availableHandlers.some(h => h.id === id)) { - availableHandlers.push({ - id: id, - handler: handler - }); - } + // Add placeholder entries for important API handlers that might not be registered yet + const apiHandlerIds = ['elevenlabs', 'openai']; + for (const id of apiHandlerIds) { + // Only add if not already in the list + if (!this.handlers[id] && !availableHandlers.some(h => h.id === id)) { + console.log(`TTS Factory: Adding placeholder for API handler ${id} to available handlers list`); + availableHandlers.push({ + id: id, + handler: null, + isReady: false + }); } } - if (availableHandlers.length === 0) { - console.warn('TTS Factory: No available handlers found - something is wrong!'); - } else { - console.log(`TTS Factory: Found ${availableHandlers.length} available handlers:`, - availableHandlers.map(h => h.id).join(', ')); - } + console.log(`TTS Factory: Returning ${availableHandlers.length} handlers for UI (including 'none'):`, + availableHandlers.map(h => h.id).join(', ')); return availableHandlers; } @@ -369,14 +358,59 @@ class TTSFactoryModule extends BaseModule { async loadPreferences() { // Get the persistence manager for preferences const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - // Load speed preference - const savedSpeed = persistenceManager.getPreference('tts', 'speed'); - if (typeof savedSpeed === 'number') { - this.speed = savedSpeed; - console.log(`TTS Factory: Loaded speed preference: ${this.speed}`); + if (!persistenceManager) { + console.warn('TTS Factory: No persistence manager available, using default settings'); + return; + } + + // Default settings for first run + const defaults = { + 'speed': 0.5, // Default speech rate (0-1 range) + 'preferred_handler': 'kokoro', // Default to Kokoro TTS + 'enabled': false, // TTS disabled by default + 'voice': '', // Empty default - will be selected based on handler + 'language': 'en-US', // Default language + 'volume': 1.0, // Default volume + 'elevenlabs_api_key': '', // Empty API key by default + 'elevenlabs_api_url': 'https://api.elevenlabs.io/v1', // Default ElevenLabs API URL + 'openai_api_key': '', // Empty API key by default + 'openai_api_url': 'https://api.openai.com/v1' // Default OpenAI API URL + }; + + // Ensure all defaults are set in persistence if they don't exist + for (const [key, value] of Object.entries(defaults)) { + if (persistenceManager.getPreference('tts', key) === undefined) { + console.log(`TTS Factory: Setting default for '${key}': ${value}`); + persistenceManager.updatePreference('tts', key, value); } } + + // Load speech rate preference + const savedSpeed = persistenceManager.getPreference('tts', 'speed'); + if (typeof savedSpeed === 'number') { + this.speed = savedSpeed; + console.log(`TTS Factory: Loaded speed preference: ${this.speed}`); + } else { + this.speed = defaults.speed; + console.log(`TTS Factory: Using default speed: ${this.speed}`); + } + + // Load other preferences we need for initialization + const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler'); + console.log(`TTS Factory: Loaded preferred handler: ${preferredHandler || 'none'}`); + + // We'll handle the preferred handler in initializeHandlerSystem() + + // Check if TTS is enabled + const ttsEnabled = persistenceManager.getPreference('tts', 'enabled'); + console.log(`TTS Factory: TTS enabled: ${ttsEnabled}`); + + // Return the loaded preferences for convenience + return { + preferredHandler: preferredHandler || defaults.preferred_handler, + enabled: ttsEnabled !== undefined ? ttsEnabled : defaults.enabled, + speed: this.speed + }; } /** @@ -407,7 +441,7 @@ class TTSFactoryModule extends BaseModule { for (const [index, handler] of handlers.entries()) { try { console.log(`TTS Factory: Attempting to get module '${handler.id}'`); - const module = moduleRegistry.getModule(handler.id); + const module = this.getModule(handler.id); if (module) { console.log(`TTS Factory: Successfully got module '${handler.id}'`, module); @@ -436,22 +470,35 @@ class TTSFactoryModule extends BaseModule { console.log(`TTS Factory: Preferred handler from settings: ${preferredHandler || 'none'}`); } - // Try to initialize and set the preferred handler - if (preferredHandler && this.handlers[preferredHandler]) { - console.log(`TTS Factory: Attempting to initialize preferred handler: ${preferredHandler}`); - - // Try to initialize the preferred handler - const success = await this.initializeHandler(preferredHandler); - - if (success) { - console.log(`TTS Factory: Preferred handler ${preferredHandler} initialized successfully`); - return await this.setActiveHandler(preferredHandler); + // Special case for 'none' preference + if (preferredHandler === 'none') { + console.log('TTS Factory: User has disabled TTS (none selected)'); + this.activeHandler = null; + this.updateTTSAvailability(); + return true; + } + + // If user has a preferred handler, attempt to set it even if not initialized + if (preferredHandler) { + // Check if handler exists + if (this.handlers[preferredHandler]) { + console.log(`TTS Factory: Attempting to initialize preferred handler: ${preferredHandler}`); + + // Try to initialize the preferred handler + const success = await this.initializeHandler(preferredHandler); + + // Set as active regardless of initialization result + // TTS will be considered disabled if handler exists but isn't ready + console.log(`TTS Factory: Setting preferred handler ${preferredHandler} as active (init success: ${success})`); + await this.setActiveHandler(preferredHandler); + return true; } else { - console.warn(`TTS Factory: Preferred handler ${preferredHandler} initialization failed, trying fallbacks`); + console.log(`TTS Factory: Preferred handler ${preferredHandler} not registered yet, will be set when available`); + // We can't set it as active yet since it doesn't exist, but we've stored the preference } } - // If we couldn't initialize the preferred handler, try fallbacks + // If we don't have a preferred handler or it's not registered, try fallbacks return this.attemptFallbackHandler(); } @@ -526,13 +573,15 @@ class TTSFactoryModule extends BaseModule { */ registerHandler(id, handler) { if (!handler) { - console.warn(`TTS Factory: Cannot register null handler for id ${id}`); + console.error(`TTS Factory: Cannot register null handler for ${id}`); return; } console.log(`TTS Factory: Registering handler ${id}`); this.handlers[id] = handler; - this.initStatus[id] = false; + + // Note: Handlers now declare their own dependencies and access them via getModule() + // They no longer need dependencies to be provided by the factory } /** @@ -541,14 +590,49 @@ class TTSFactoryModule extends BaseModule { * @returns {boolean} - Success status */ async setActiveHandler(id) { - // Make sure the handler exists and is initialized - if (!this.handlers[id] || !this.initStatus[id]) { - console.error(`TTS Factory: Cannot set active handler to ${id} - not available`); + // Special case for 'none' option + if (id === 'none') { + console.log('TTS Factory: Disabling TTS (none selected)'); + this.activeHandler = null; + + // Save the preference + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'preferred_handler', 'none'); + } + + // Dispatch event + document.dispatchEvent(new CustomEvent('tts:handler:changed', { + detail: { handler: 'none', available: false } + })); + + this.updateTTSAvailability(); + return true; + } + + // Check if the handler exists + if (!this.handlers[id]) { + console.warn(`TTS Factory: Handler ${id} not registered - still setting as preferred`); + // We'll still set the preference but won't set as active until it's registered + + // Save the preference + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'preferred_handler', id); + } + + // We should not set this.activeHandler since the handler doesn't exist return false; } console.log(`TTS Factory: Setting active handler to ${id}`); + // Check if the handler is ready (just for logging) + const isReady = this.handlers[id].isReady === true; + if (!isReady) { + console.warn(`TTS Factory: Handler ${id} is not ready - TTS will be considered disabled until ready`); + } + // Stop any current speech if (this.activeHandler && this.handlers[this.activeHandler]) { this.handlers[this.activeHandler].stop(); @@ -565,10 +649,13 @@ class TTSFactoryModule extends BaseModule { // Dispatch event const event = new CustomEvent('tts:handler:changed', { - detail: { handler: id } + detail: { handler: id, available: isReady } }); document.dispatchEvent(event); + // Update overall TTS availability + this.updateTTSAvailability(); + return true; } @@ -591,8 +678,8 @@ class TTSFactoryModule extends BaseModule { */ speak(text, options = {}) { // Check if we have an active handler - if (!this.activeHandler || !this.ttsAvailable) { - console.warn('TTS Factory: No active handler or TTS not available'); + if (!this.activeHandler) { + console.warn('TTS Factory: No active handler set'); return false; } @@ -603,6 +690,12 @@ class TTSFactoryModule extends BaseModule { return false; } + // Check if the handler is ready + if (!handler.isReady) { + console.warn(`TTS Factory: Active handler ${this.activeHandler} is not ready`); + return false; + } + try { // Apply speed option if specified const effectiveOptions = { ...options }; @@ -804,35 +897,28 @@ class TTSFactoryModule extends BaseModule { * Update overall TTS availability */ updateTTSAvailability() { - // TTS is considered available if at least one handler is initialized const wasAvailable = this.ttsAvailable; - // Check if any handler is available (initialized and ready) - let anyHandlerAvailable = false; - for (const id in this.handlers) { - const handler = this.handlers[id]; - if (handler && this.initStatus[id] === true && handler.isReady === true) { - anyHandlerAvailable = true; - break; - } + // TTS is considered available only if the active handler exists and is ready + let ttsAvailable = false; + + if (this.activeHandler && this.handlers[this.activeHandler]) { + // Check if the active handler is ready + ttsAvailable = this.handlers[this.activeHandler].isReady === true; } - this.ttsAvailable = anyHandlerAvailable; + this.ttsAvailable = ttsAvailable; - console.log('TTS Factory: Availability updated:', this.ttsAvailable); - console.log('TTS Factory: Handler status:', JSON.stringify(this.initStatus)); - - // Handler details for debugging - for (const id in this.handlers) { - const handler = this.handlers[id]; - console.log(`TTS Factory: Handler ${id} status: initStatus=${this.initStatus[id]}, isReady=${handler ? handler.isReady : 'handler undefined'}`); - } + console.log(`TTS Factory: Availability updated: ${this.ttsAvailable} (active handler: ${this.activeHandler || 'none'})`); // Only dispatch event if availability changed if (wasAvailable !== this.ttsAvailable) { // Notify the UI about TTS availability const event = new CustomEvent('tts:availability', { - detail: { available: this.ttsAvailable } + detail: { + available: this.ttsAvailable, + activeHandler: this.activeHandler + } }); document.dispatchEvent(event); } @@ -1616,11 +1702,5 @@ class TTSFactoryModule extends BaseModule { // Create module instance const TTSFactory = new TTSFactoryModule(); -// Import the moduleRegistry for initial registration -// Note: This is the only place where direct import is appropriate, as we need to -// register the module before it can use the dependency system -import { moduleRegistry } from './module-registry.js'; -moduleRegistry.register(TTSFactory); - // Export the module export { TTSFactory }; \ No newline at end of file diff --git a/public/js/tts-handler.js b/public/js/tts-handler.js deleted file mode 100644 index 314eee5..0000000 --- a/public/js/tts-handler.js +++ /dev/null @@ -1,162 +0,0 @@ -/** - * TTS Handler Base Class - * Abstract base class defining the interface for all TTS handlers - */ -export class TTSHandler { - constructor() { - this.voiceOptions = {}; - this.isReady = false; - - // Set up event dispatcher - this.eventTarget = document.createElement('div'); - - // Module state tracking - conform to BaseModule interface - this.state = 'PENDING'; - } - - /** - * Get handler ID - * @returns {string} - Handler identifier - */ - getId() { - throw new Error('getId() must be implemented by subclass'); - } - - /** - * Initialize the TTS handler - * @param {Function} progressCallback - Optional progress callback - * @returns {Promise} - Resolves with success status - */ - async initialize(progressCallback = null) { - throw new Error('initialize() must be implemented by subclass'); - } - - /** - * Check if this TTS handler is available - * @returns {boolean} - True if handler is ready to use - */ - isAvailable() { - return this.isReady; - } - - /** - * Check if voice is currently speaking - * @returns {boolean} - True if speaking - */ - isSpeaking() { - return false; // Default implementation - } - - /** - * Speak text using this handler - * @param {string} text - The text to speak - * @param {Function} callback - Optional callback when speech completes - */ - speak(text, callback = null) { - throw new Error('speak() must be implemented by subclass'); - } - - /** - * Stop speech - */ - stop() { - throw new Error('stop() must be implemented by subclass'); - } - - /** - * Set voice options - * @param {Object} options - Voice options - */ - setVoiceOptions(options = {}) { - // Default implementation merges options - this.voiceOptions = { ...this.voiceOptions, ...options }; - } - - /** - * Get available voices - * @returns {Promise} - Resolves with array of voice objects - */ - async getVoices() { - return []; - } - - /** - * Get the current module state - * @returns {string} - Current state - */ - getState() { - return this.state; - } - - /** - * Change the module state - * @param {string} newState - The new state - */ - changeState(newState) { - this.state = newState; - - // Dispatch state change event - this.dispatchEvent('state:changed', { - state: newState - }); - } - - /** - * Dispatch a custom event - * @param {string} eventName - Name of the event - * @param {Object} detail - Event details - */ - dispatchEvent(eventName, detail = {}) { - const event = new CustomEvent(eventName, { - detail: { handlerId: this.getId(), ...detail }, - bubbles: true - }); - this.eventTarget.dispatchEvent(event); - } - - /** - * Add event listener - * @param {string} eventName - Name of the event - * @param {Function} callback - Event handler function - */ - addEventListener(eventName, callback) { - this.eventTarget.addEventListener(eventName, callback); - } - - /** - * Remove event listener - * @param {string} eventName - Name of the event - * @param {Function} callback - Event handler function - */ - removeEventListener(eventName, callback) { - this.eventTarget.removeEventListener(eventName, callback); - } - - /** - * Bind methods to this instance - * @param {Array} methodNames - Array of method names to bind - */ - bindMethods(methodNames) { - if (!Array.isArray(methodNames)) return; - - methodNames.forEach(methodName => { - if (typeof this[methodName] === 'function') { - this[methodName] = this[methodName].bind(this); - } - }); - } - - /** - * Get a unique identifier for the current voice configuration - * Used for caching purposes - * @returns {string} - Unique identifier for current voice - */ - getCurrentVoiceIdentifier() { - // Default implementation uses voice ID and rate/speed - const voiceId = this.voiceOptions.voice || 'default'; - const rate = this.voiceOptions.rate || this.voiceOptions.speed || 1.0; - - // Return a string that uniquely identifies this voice configuration - return `${voiceId}_${rate}`; - } -} diff --git a/public/js/tts-player.js b/public/js/tts-player-module.js similarity index 70% rename from public/js/tts-player.js rename to public/js/tts-player-module.js index c566292..fb9b12f 100644 --- a/public/js/tts-player.js +++ b/public/js/tts-player-module.js @@ -3,7 +3,6 @@ * Manages TTS functionality and interacts with available TTS handlers */ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; class TTSPlayerModule extends BaseModule { constructor() { @@ -75,9 +74,10 @@ class TTSPlayerModule extends BaseModule { if (!available) { this.enabled = false; // Notify UI that TTS is disabled - document.dispatchEvent(new CustomEvent('tts:stateChange', { - detail: { enabled: false, available: false } - })); + super.dispatchEvent('tts:stateChange', { + enabled: false, + available: false + }); } } }); @@ -86,9 +86,10 @@ class TTSPlayerModule extends BaseModule { this.addEventListener(document, 'tts:toggle', () => { this.toggle(); // Dispatch state change event for UI to update - document.dispatchEvent(new CustomEvent('tts:stateChange', { - detail: { enabled: this.enabled, available: ttsFactory.ttsAvailable } - })); + super.dispatchEvent('tts:stateChange', { + enabled: this.enabled, + available: ttsFactory.ttsAvailable + }); }); // Also listen for ui:tts:toggle events (from the main UI) @@ -101,28 +102,23 @@ class TTSPlayerModule extends BaseModule { } // Dispatch state change event for UI to update - document.dispatchEvent(new CustomEvent('tts:stateChange', { - detail: { enabled: this.enabled, available: ttsFactory.ttsAvailable } - })); + super.dispatchEvent('tts:stateChange', { + enabled: this.enabled, + available: ttsFactory.ttsAvailable + }); }); - // Listen for sentence ready events to preload TTS - this.addEventListener(document, 'buffer:sentence', (event) => { - if (event.detail && event.detail.sentence && this.enabled) { - // Add to preload queue - this.preloadSpeech(event.detail.sentence); - } - }); - - // Dispatch initial state to UI - document.dispatchEvent(new CustomEvent('tts:stateChange', { - detail: { enabled: this.enabled, available: ttsFactory.ttsAvailable } - })); + // Request available TTS voices + this.reportProgress(60, "Checking for available TTS voices"); + const voices = await ttsFactory.getVoices(); + console.log(`TTS Player: ${voices.length} voices available`); this.reportProgress(100, "TTS Player ready"); return true; + } catch (error) { console.error("Error initializing TTS Player:", error); + this.reportProgress(100, "TTS Player initialization failed"); return false; } } @@ -134,15 +130,15 @@ class TTSPlayerModule extends BaseModule { preloadSpeech(text) { if (!text || !this.enabled) return; - // Don't preload if already in cache - if (this.preloadedAudio.has(text)) return; + // Skip if already preloaded or in queue + if (this.preloadedAudio.has(text) || this.preloadQueue.includes(text)) { + return; + } - // Add to preload queue this.preloadQueue.push(text); - console.log(`TTS Player: Added to preload queue: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); - // Start processing the queue if not already processing - if (!this.isPreloading) { + // Start preloading if not already preloading and no active speech + if (!this.isPreloading && !this.currentSpeech) { this.processPreloadQueue(); } } @@ -150,44 +146,46 @@ class TTSPlayerModule extends BaseModule { /** * Process the preload queue */ - async processPreloadQueue() { - if (this.preloadQueue.length === 0 || this.isPreloading) return; + processPreloadQueue() { + if (this.preloadQueue.length === 0 || this.isPreloading) { + return; + } this.isPreloading = true; const text = this.preloadQueue.shift(); - try { - // Get TTSFactory from module registry - const ttsFactory = this.getModule('tts-factory'); - if (!ttsFactory) { - console.error("TTS Player: TTSFactory module not found in registry"); - this.isPreloading = false; - return; - } - - // Only preload if we're not currently speaking or the text is different from current speech - if (!this.isSpeaking() || (this.currentSpeech && this.currentSpeech !== text)) { - console.log(`TTS Player: Preloading speech for: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); - - // Use the preload method of the TTS factory - const preloadData = await ttsFactory.preloadSpeech(text); - if (preloadData) { - this.preloadedAudio.set(text, preloadData); - console.log(`TTS Player: Successfully preloaded speech for: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); - } else { - console.warn(`TTS Player: Failed to preload speech for: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); - } - } - } catch (error) { - console.warn("TTS Player: Error preloading speech:", error); - } finally { + // Skip if already preloaded + if (this.preloadedAudio.has(text)) { this.isPreloading = false; + this.processPreloadQueue(); + return; + } + + // Use TTS Factory to generate audio + const ttsFactory = this.getModule('tts-factory'); + if (ttsFactory) { + console.log(`Preloading TTS for: "${text.substring(0, 30)}${text.length > 30 ? '...' : ''}"`); - // Process next in queue if available - if (this.preloadQueue.length > 0) { - // Use requestAnimationFrame to prevent blocking - requestAnimationFrame(() => this.processPreloadQueue()); - } + ttsFactory.generateSpeech(text) + .then(audioData => { + if (audioData && audioData.success) { + this.preloadedAudio.set(text, audioData); + console.log(`TTS preloaded successfully for: "${text.substring(0, 30)}${text.length > 30 ? '...' : ''}"`); + } + }) + .catch(error => { + console.error("Error preloading TTS:", error); + }) + .finally(() => { + this.isPreloading = false; + + // Continue processing queue + if (this.preloadQueue.length > 0) { + this.processPreloadQueue(); + } + }); + } else { + this.isPreloading = false; } } @@ -198,28 +196,32 @@ class TTSPlayerModule extends BaseModule { * @returns {boolean} - Success status */ speak(text, callback = null) { - // Check if TTS is enabled if (!this.enabled) { if (callback) { - setTimeout(() => callback({ success: false, reason: 'tts_disabled' }), 0); + setTimeout(() => callback({ success: false, reason: 'disabled' }), 0); } return false; } - // Get TTSFactory from module registry + if (this.currentSpeech) { + // Stop current speech if any + this.stop(); + } + + this.currentSpeech = text; + this.pendingCallback = callback; + const ttsFactory = this.getModule('tts-factory'); if (ttsFactory) { - this.pendingCallback = callback; - this.currentSpeech = text; - - // Check if this text was preloaded - const preloadedData = this.preloadedAudio.get(text); - if (preloadedData) { - console.log("TTS Player: Using preloaded speech"); + // Check if we have this preloaded + if (this.preloadedAudio.has(text)) { + const preloadedAudio = this.preloadedAudio.get(text); this.preloadedAudio.delete(text); // Remove from cache after use - // Use the preloaded speech data - ttsFactory.speakPreloaded(preloadedData, (result) => { + console.log(`Using preloaded TTS for: "${text.substring(0, 30)}${text.length > 30 ? '...' : ''}"`); + + // Play the preloaded audio + ttsFactory.playAudio(preloadedAudio, (result) => { // Store the completed result this.currentSpeech = null; @@ -358,8 +360,5 @@ class TTSPlayerModule extends BaseModule { // Create the singleton instance const TTSPlayer = new TTSPlayerModule(); -// Register with the module registry -moduleRegistry.register(TTSPlayer); - // Export the module export { TTSPlayer }; diff --git a/public/js/ui-controller.js b/public/js/ui-controller-module.js similarity index 98% rename from public/js/ui-controller.js rename to public/js/ui-controller-module.js index 9603aab..94091a1 100644 --- a/public/js/ui-controller.js +++ b/public/js/ui-controller-module.js @@ -1,8 +1,7 @@ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; import { ModuleEvent } from './base-module.js'; -class UIController extends BaseModule { +class UIControllerModule extends BaseModule { constructor() { super('ui-controller', 'UI Controller'); @@ -450,7 +449,7 @@ class UIController extends BaseModule { break; case 'menu': // Toggle options menu - const optionsUI = moduleRegistry.getModule('options-ui'); + const optionsUI = this.getModule('options-ui'); if (optionsUI) { optionsUI.toggle(); } @@ -560,13 +559,7 @@ class UIController extends BaseModule { } // Create the singleton instance -const uiController = new UIController(); - -// Register with the module registry -moduleRegistry.register(uiController); +const uiController = new UIControllerModule(); // Export the module export { uiController as UIController }; - -// Keep a reference in window for loader system -window.UIController = uiController; diff --git a/public/js/ui-display-handler.js b/public/js/ui-display-handler-module.js similarity index 98% rename from public/js/ui-display-handler.js rename to public/js/ui-display-handler-module.js index 33e19c2..d5b7a1c 100644 --- a/public/js/ui-display-handler.js +++ b/public/js/ui-display-handler-module.js @@ -3,9 +3,8 @@ * Manages the display of text and UI elements */ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; -class UIDisplayHandler extends BaseModule { +class UIDisplayHandlerModule extends BaseModule { constructor() { super('ui-display-handler', 'UI Display Handler'); @@ -570,14 +569,7 @@ class UIDisplayHandler extends BaseModule { } // Create the singleton instance -const uiDisplayHandler = new UIDisplayHandler(); - -// Register with the module registry -moduleRegistry.register(uiDisplayHandler); +const uiDisplayHandler = new UIDisplayHandlerModule(); // Export the module export { uiDisplayHandler as UIDisplayHandler }; - -// Keep a reference in window for loader system -console.log('UIDisplayHandler: Registering with window'); -window.UIDisplayHandler = uiDisplayHandler; diff --git a/public/js/ui-effects.js b/public/js/ui-effects-module.js similarity index 96% rename from public/js/ui-effects.js rename to public/js/ui-effects-module.js index 65b3db8..d77ac8f 100644 --- a/public/js/ui-effects.js +++ b/public/js/ui-effects-module.js @@ -1,12 +1,11 @@ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; -class UIEffects extends BaseModule { +class UIEffectsModule extends BaseModule { constructor() { super('ui-effects', 'UI Effects'); // No external dependencies - this.dependencies = []; + this.dependencies = ['ui-display-handler']; // Effects state this.activeEffects = new Map(); @@ -291,7 +290,7 @@ class UIEffects extends BaseModule { applyTextEmphasis(text, options = {}) { // Use existing display handler to show emphasized text - const displayHandler = moduleRegistry.getModule('ui-display-handler'); + const displayHandler = this.getModule('ui-display-handler'); if (!displayHandler) return null; const style = { @@ -328,14 +327,7 @@ class UIEffects extends BaseModule { } // Create the singleton instance -const uiEffects = new UIEffects(); - -// Register with the module registry -moduleRegistry.register(uiEffects); +const uiEffects = new UIEffectsModule(); // Export the module export { uiEffects as UIEffects }; - -// Keep a reference in window for loader system -console.log('UIEffects: Registering with window'); -window.UIEffects = uiEffects; diff --git a/public/js/ui-helper.js b/public/js/ui-helper.js new file mode 100644 index 0000000..185cc7a --- /dev/null +++ b/public/js/ui-helper.js @@ -0,0 +1,170 @@ +/** + * UI Helper + * Provides utility functions for UI components + */ + +/** + * Create and append a UI element + * @param {string} type - Element type (div, input, button, etc.) + * @param {Object} attributes - Attributes to set on the element + * @param {string} [text] - Text content for the element + * @param {HTMLElement} parent - Parent element + * @returns {HTMLElement} - Created element + */ +export function createUIElement(type, attributes = {}, text = '', parent = null) { + const element = document.createElement(type); + + // Set attributes + for (const [key, value] of Object.entries(attributes || {})) { + if (key === 'className') { + element.className = value; + } else { + element.setAttribute(key, value); + } + } + + // Set text content if provided + if (text) { + element.textContent = text; + } + + // Append to parent if provided + if (parent) { + parent.appendChild(element); + } + + return element; +} + +/** + * Populate a dropdown with options + * @param {HTMLSelectElement} dropdown - Dropdown element + * @param {Array} items - Array of items + * @param {string} valueKey - Key for item value + * @param {string} textKey - Key for item text + * @param {string} [selectedValue] - Value to select + */ +export function populateDropdown(dropdown, items, valueKey, textKey, selectedValue) { + // Clear existing options + dropdown.innerHTML = ''; + + // Add options + items.forEach(item => { + const option = document.createElement('option'); + option.value = item[valueKey]; + option.textContent = item[textKey]; + dropdown.appendChild(option); + }); + + // Set selected value if provided + if (selectedValue !== undefined && selectedValue !== null) { + dropdown.value = selectedValue; + } +} + +/** + * Register an event handler for a UI element + * @param {HTMLElement} element - UI element + * @param {string} eventType - Event type (change, input, click, etc.) + * @param {Function} handler - Event handler function + */ +export function registerHandler(element, eventType, handler) { + if (element) { + element.addEventListener(eventType, handler); + } +} + +/** + * Create a preference binding between a UI element and persistence manager + * @param {HTMLElement} element - UI element + * @param {Object} persistenceManager - Persistence manager instance + * @param {string} category - Preference category + * @param {string} key - Preference key + * @param {Function} [updateUIFunc] - Function to update UI when preference changes + * @param {Function} [valueTransform] - Function to transform value before saving + */ +export function createPreferenceBinding(element, persistenceManager, category, key, updateUIFunc, valueTransform) { + if (!element || !persistenceManager) return; + + // Get initial value from persistence + const value = persistenceManager.getPreference(category, key); + + // Update UI initially if value exists and update function provided + if (value !== null && value !== undefined && updateUIFunc) { + updateUIFunc(element, value); + } else { + // Default UI update based on element type + if (element.type === 'checkbox') { + element.checked = !!value; + } else if (element.tagName === 'SELECT' || element.type === 'text' || element.type === 'password') { + element.value = value !== null && value !== undefined ? value : ''; + } else if (element.type === 'range') { + element.value = value !== null && value !== undefined ? value : 50; + } + } + + // Add event listener + const eventType = element.type === 'checkbox' ? 'change' : 'input'; + element.addEventListener(eventType, () => { + let newValue; + + if (element.type === 'checkbox') { + newValue = element.checked; + } else if (element.type === 'range' || element.type === 'number') { + newValue = parseFloat(element.value); + } else { + newValue = element.value; + } + + // Apply transform if provided + if (valueTransform) { + newValue = valueTransform(newValue, element); + } + + // Save to persistence + persistenceManager.updatePreference(category, key, newValue); + + // Dispatch event for other modules + document.dispatchEvent(new CustomEvent('preference:changed', { + detail: { + category, + key, + value: newValue + } + })); + }); +} + +/** + * Set up common modal behaviors (show/hide/close) + * @param {HTMLElement} modal - Modal element + * @param {HTMLElement} [closeButton] - Close button element + * @returns {Object} - Modal control functions + */ +export function setupModalControls(modal, closeButton) { + if (!modal) return {}; + + const show = () => { + modal.style.display = 'flex'; + document.body.classList.add('modal-open'); + }; + + const hide = () => { + modal.style.display = 'none'; + document.body.classList.remove('modal-open'); + }; + + // Set up close button if provided + if (closeButton) { + closeButton.addEventListener('click', hide); + } + + // Set up ESC key to close + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal.style.display === 'flex') { + hide(); + } + }); + + return { show, hide, toggle: () => modal.style.display === 'flex' ? hide() : show() }; +} diff --git a/public/js/ui-input-handler.js b/public/js/ui-input-handler-module.js similarity index 97% rename from public/js/ui-input-handler.js rename to public/js/ui-input-handler-module.js index 451efdf..b661c14 100644 --- a/public/js/ui-input-handler.js +++ b/public/js/ui-input-handler-module.js @@ -1,7 +1,6 @@ import { BaseModule } from './base-module.js'; -import { moduleRegistry } from './module-registry.js'; -class UIInputHandler extends BaseModule { +class UIInputHandlerModule extends BaseModule { constructor() { super('ui-input-handler', 'UI Input Handler'); @@ -386,14 +385,7 @@ class UIInputHandler extends BaseModule { } // Create the singleton instance -const uiInputHandler = new UIInputHandler(); - -// Register with the module registry -moduleRegistry.register(uiInputHandler); +const uiInputHandler = new UIInputHandlerModule(); // Export the module export { uiInputHandler as UIInputHandler }; - -// Keep a reference in window for loader system -console.log('UIInputHandler: Registering with window'); -window.UIInputHandler = uiInputHandler;