Refactored modules and updated loader.

This commit is contained in:
2025-04-06 18:35:04 +00:00
parent fc693ae695
commit 0ab639fd25
37 changed files with 3530 additions and 5989 deletions
+124 -19
View File
@@ -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);
}
</style>
</head>
<body>
@@ -188,6 +278,21 @@
console.log(message);
};
</script>
<script>
// Redefine console.log to expose browser logs to model
const originalLog = console.log;
console.log = function(message) {
if (typeof debug !== 'undefined' && debug) {
const debugContent = document.getElementById('debug-content');
if (debugContent) {
const logMsg = document.createElement('div');
logMsg.textContent = message;
debugContent.appendChild(logMsg);
}
}
originalLog(message);
};
</script>
<script type="module" src="/js/loader.js"></script>
</body>
</html>
-931
View File
@@ -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);
-347
View File
@@ -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);
@@ -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 };
-506
View File
@@ -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<boolean>} - 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<boolean>} - 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<boolean>} - 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<Blob|null>} - 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<Blob|null>} - 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<Blob|null>} - 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<Array>} - 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;
}
}
}
+88 -39
View File
@@ -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();
}
});
});
}
}
}
}
@@ -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;
+31 -47
View File
@@ -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<boolean>} - 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<boolean>} - Resolves when dependencies are ready
*/
async waitForDependencies() {
// This is now handled by _waitForModuleDependencies
return Promise.resolve(true);
}
/**
* Initialize the module - Override this in child classes
* @returns {Promise<boolean>} - 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}`);
}
-789
View File
@@ -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<boolean>} - 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<void>}
*/
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<void>}
*/
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<Object>} - Preloaded speech data
*/
async preloadSpeech(text) {
if (!this.available || !text || !this.voiceOptions.voice) {
return null;
}
try {
// Process text for TTS
const processedText = this.preprocessText(text);
console.log(`Browser TTS: Preloading speech for: "${processedText.substring(0, 50)}${processedText.length > 50 ? '...' : ''}"`);
// 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>} - 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));
}
}
}
+362 -434
View File
@@ -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<boolean>} - 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;
}
this.reportProgress(30, 'Loading browser voices');
await this.loadVoices();
// 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', '');
// Set up voice from preferences
this.reportProgress(70, 'Setting up voice preferences');
await this.setupVoiceFromPreferences();
// Set voice based on current locale
const currentLocale = localization.getLocale() || 'en-us';
await this.selectVoiceForLocale(currentLocale, preferredVoice);
// Set up event listeners
document.addEventListener('tts:browser:voicePreferenceChanged', this.handleVoicePreferenceChanged);
// Listen for locale changes
document.addEventListener('locale:changed', async (event) => {
if (event.detail && event.detail.locale) {
await this.selectVoiceForLocale(event.detail.locale);
}
});
// 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<boolean>} - 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...');
// 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
}));
// 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 = [];
// 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<boolean>} - 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<boolean>} - 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();
try {
// 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<Object>} - 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>} - Object with audio data
*/
async synthesizeToWav(text) {
return new Promise((resolve) => {
if (!this.isReady || !window.speechSynthesis) {
resolve({ success: false, reason: 'not_ready' });
return;
}
// 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();
setVoiceOptions(options = {}) {
if (options.voice) {
this.voiceOptions.voice = options.voice;
// 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';
// 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<Object>} - 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 };
+187
View File
@@ -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');
}
-152
View File
@@ -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 };
-332
View File
@@ -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<boolean>} - 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<boolean>} - 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<Object>} - 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<Array>} - 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();
+25 -30
View File
@@ -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 };
@@ -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;
-789
View File
@@ -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<boolean>} - 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<void>}
*/
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(/&nbsp;/g, ' ');
processed = processed.replace(/&amp;/g, '&');
processed = processed.replace(/&lt;/g, '<');
processed = processed.replace(/&gt;/g, '>');
processed = processed.replace(/&quot;/g, '"');
processed = processed.replace(/&#39;/g, "'");
return processed;
}
/**
* Preload speech for later playback
* @param {string} text - Text to preload
* @returns {Promise<Object>} - 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<boolean>} - 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<Object>} - 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' }
];
}
}
+7 -15
View File
@@ -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 };
@@ -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++;
}
// 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);
// Create the word element
const wordElement = this.renderWord(wordNode.text, animateWords);
wordElements.push(wordElement);
// Position the word within the line
wordElement.style.position = 'absolute';
wordElement.style.left = `${xPosition * 100 / containerWidth}%`;
wordElement.style.top = `${(i - 1) * lineHeight}px`;
// 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';
}
// Update x position for next word
xPosition += node.width;
// Add to paragraph
paragraphElement.appendChild(wordElement);
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;
// Handle whitespace after the word
if (wordNode.spaceAfter) {
const spaceElement = document.createElement('span');
spaceElement.className = 'space';
spaceElement.innerHTML = '&nbsp;';
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;
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
this.animationQueue.schedule(() => {
this.ttsPlayer.speak(fullText, (result) => {
if (!result || !result.success) {
console.warn('TTS playback issue:', result ? result.reason : 'unknown');
}
});
}, Math.min(100, wordDelay * 3)); // Start TTS earlier
// Schedule 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;
+502 -87
View File
@@ -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 = `
<span class="module-name">${name}</span>
<span class="module-status status-pending">Pending</span>
`;
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,24 +743,33 @@ 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');
}
}
@@ -376,6 +779,9 @@ const ModuleLoader = (function() {
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
@@ -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 };
-240
View File
@@ -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<boolean>} - 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<boolean>} - 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<Object>} - 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<Array>} - 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();
+16 -25
View File
@@ -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<Object>} - 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 };
+974
View File
@@ -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<boolean>} - 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<boolean>} - 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 };
File diff suppressed because it is too large Load Diff
@@ -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 };
@@ -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] = {};
// Create a new object to avoid modifying the input objects
const merged = {};
// 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];
}
// 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 };
@@ -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;
@@ -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,14 +112,9 @@ 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();
}
@@ -128,43 +122,30 @@ class TextBufferModule extends BaseModule {
* 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();
}
/**
@@ -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;
@@ -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;
@@ -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 });
// Debug: Log all registered modules
// Update TTS availability based on active handler
this.updateTTSAvailability();
}
// 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}`);
// 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;
}
// Try to initialize the preferred handler
const success = await this.initializeHandler(preferredHandler);
// 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}`);
if (success) {
console.log(`TTS Factory: Preferred handler ${preferredHandler} initialized successfully`);
return await this.setActiveHandler(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 };
-162
View File
@@ -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<boolean>} - 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<Array>} - 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<string>} methodNames - Array of method names to bind
*/
bindMethods(methodNames) {
if (!Array.isArray(methodNames)) return;
methodNames.forEach(methodName => {
if (typeof this[methodName] === 'function') {
this[methodName] = this[methodName].bind(this);
}
});
}
/**
* 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}`;
}
}
@@ -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;
}
// Process next in queue if available
if (this.preloadQueue.length > 0) {
// Use requestAnimationFrame to prevent blocking
requestAnimationFrame(() => this.processPreloadQueue());
}
// 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 ? '...' : ''}"`);
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 };
@@ -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;
@@ -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;
@@ -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;
+170
View File
@@ -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() };
}
@@ -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;