Checkpoint current interactive fiction state

This commit is contained in:
2026-05-14 21:17:43 +02:00
parent c745efd1d2
commit 873049f7e6
183 changed files with 13755 additions and 1459 deletions
+931
View File
@@ -0,0 +1,931 @@
/**
* @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
@@ -0,0 +1,347 @@
/**
* @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);
+21 -84
View File
@@ -10,19 +10,18 @@ class AnimationQueueModule extends BaseModule {
super('animation-queue', 'Animation Queue');
// Module dependencies
this.dependencies = ['tts-player'];
this.dependencies = [];
// Queue of scheduled animations/functions
this.timeoutQueue = [];
// Animation timing properties - use parent's config system
this.updateConfig({
speed: 0.05, // Base animation speed (seconds per character)
speed: 1.0, // Speed multiplier for delays (1.0 = no scaling, delays are pre-calculated)
fastForwardEnabled: false
});
this.delay = 0; // Current accumulated delay
this.tts = null; // TTS module reference
// Bind methods using parent's bindMethods utility
this.bindMethods([
@@ -33,22 +32,23 @@ class AnimationQueueModule extends BaseModule {
'beginFastForward',
'endFastForward',
'emitAnimationComplete',
'cleanupStaleTasks',
'isAnyTtsSpeaking'
'cleanupStaleTasks'
]);
}
async initialize() {
try {
this.reportProgress(20, "Initializing Animation Queue");
// Try to get the TTS module, but it's not a hard dependency
// We'll check for it again at runtime when needed
this.tts = this.getModule('tts-player');
if (!this.tts) {
console.log("Animation Queue: TTS Player module not found yet, will try again when needed");
}
// Listen for speed changes from UI
document.addEventListener('animation:speed:change', (event) => {
if (event.detail && typeof event.detail.speed === 'number') {
// Speed from UI is a rate multiplier (0.5-2.0 typically)
this.config.speed = event.detail.speed;
console.log(`AnimationQueue: Speed updated to ${this.config.speed}`);
}
});
this.reportProgress(100, "Animation Queue ready");
return true;
} catch (error) {
@@ -76,29 +76,6 @@ class AnimationQueueModule extends BaseModule {
// Record the delay for tracking
this.delay = Math.max(this.delay, delay);
// If we don't have a reference to the TTS module yet, try to get it
if (!this.tts) {
this.tts = this.getModule('tts-player');
}
// Handle TTS if text is provided and TTS is available and enabled
let ttsSpeaking = false;
if (options.text && this.tts && typeof this.tts.isEnabled === 'function' && this.tts.isEnabled()) {
// If we're fast forwarding, don't speak
if (!this.config.fastForwardEnabled) {
ttsSpeaking = true;
// Request TTS to speak the text
this.tts.speak(options.text, (result) => {
ttsSpeaking = false;
// Check if this was keeping the queue busy
if (this.timeoutQueue.length === 0) {
this.emitAnimationComplete();
}
});
}
}
// Create a timeout object
const timeoutObject = {
func: func,
@@ -106,8 +83,7 @@ class AnimationQueueModule extends BaseModule {
timeoutId: null,
executed: false,
startTime: Date.now(),
ttsSpeaking: ttsSpeaking,
// Add an execute method that marks the timeout as executed
execute: function() {
if (!this.executed) {
@@ -138,7 +114,7 @@ class AnimationQueueModule extends BaseModule {
}
// If queue is empty and no TTS is speaking, emit animation complete
if (this.timeoutQueue.length === 0 && !this.isAnyTtsSpeaking()) {
if (this.timeoutQueue.length === 0 && true) {
this.emitAnimationComplete();
}
@@ -152,7 +128,7 @@ class AnimationQueueModule extends BaseModule {
*/
emitAnimationComplete() {
// Only emit if queue is empty and no TTS is speaking
if (this.timeoutQueue.length === 0 && !this.isAnyTtsSpeaking()) {
if (this.timeoutQueue.length === 0 && true) {
// Use parent's dispatchEvent method
this.dispatchEvent('ui:animation:complete', {
timestamp: Date.now()
@@ -197,45 +173,16 @@ class AnimationQueueModule extends BaseModule {
}
/**
* Check if any TTS is currently speaking
* @returns {boolean} - True if TTS is speaking
*/
isAnyTtsSpeaking() {
// If we don't have a reference to the TTS module yet, try to get it
if (!this.tts) {
this.tts = this.getModule('tts-player');
}
// Check if TTS is speaking
if (this.tts && typeof this.tts.isSpeaking === 'function') {
return this.tts.isSpeaking();
}
// Default to false if TTS module is not available
return false;
}
/**
* Fast forward all pending animations and stop TTS
* Fast forward all pending animations
*/
fastForward() {
if (this.timeoutQueue.length === 0) {
console.log('AnimationQueue: No animations to fast forward');
return;
}
console.log(`AnimationQueue: Fast forwarding ${this.timeoutQueue.length} pending items`);
// If we don't have a reference to the TTS module yet, try to get it
if (!this.tts) {
this.tts = this.getModule('tts-player');
}
// Stop any active TTS
if (this.tts && typeof this.tts.stop === 'function') {
this.tts.stop();
}
// Execute all pending animations immediately
this.timeoutQueue.forEach(timeout => {
// Clear the timeout
@@ -275,23 +222,13 @@ class AnimationQueueModule extends BaseModule {
*/
beginFastForward() {
if (this.config.fastForwardEnabled) return;
// Update config using parent's updateConfig method
this.updateConfig({ fastForwardEnabled: true });
// If we don't have a reference to the TTS module yet, try to get it
if (!this.tts) {
this.tts = this.getModule('tts-player');
}
// Stop any active TTS
if (this.tts && typeof this.tts.stop === 'function') {
this.tts.stop();
}
// Use parent's dispatchEvent method
this.dispatchEvent('ui:animation:fastforward', { state: true });
console.log('AnimationQueue: Fast forward mode activated');
}
+189 -52
View File
@@ -23,6 +23,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
// State
this.currentAudio = null;
this.currentPlaybackFinish = null;
// Bind additional methods
this.bindMethods([
@@ -33,7 +34,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
'selectVoiceForLocale',
'selectDefaultVoice',
'generateSpeechAudio',
'preprocessText'
'preprocessText',
'getPlaybackVolume',
'applyCurrentVolume'
]);
}
@@ -77,6 +80,16 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
// Set up event listeners for API key and URL changes
document.addEventListener('tts:api:keyChanged', this.handleApiKeyChanged);
document.addEventListener('tts:api:urlChanged', this.handleApiUrlChanged);
this.addEventListener(document, 'preference-updated', (event) => {
const { category, key } = event.detail || {};
if (category !== 'audio') {
return;
}
if (['masterVolume', 'ttsVolume', 'master_volume', 'tts_volume'].includes(key)) {
this.applyCurrentVolume();
}
});
// Load voices
await this.loadVoices();
@@ -90,9 +103,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
this.isReady = !!this.apiKey;
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
console.info(`${this.name}: API key not configured; provider unavailable until configured`);
this.reportProgress(100, `${this.name} not configured`);
return true;
}
// Only mark as complete if we have an API key
@@ -190,49 +203,106 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
* Speak preloaded audio data
* @param {Object} preloadData - Preloaded audio data
* @param {Function} callback - Callback for when speech completes
* @returns {boolean} - Success status
* @returns {Promise<Object>} - Resolves when audio finishes playing
*/
speakPreloaded(preloadData, callback = null) {
async speakPreloaded(preloadData, callback = null) {
if (!preloadData || !preloadData.audioData) {
console.error(`${this.name}: Invalid preloaded data`);
if (callback) callback({ success: false, reason: 'invalid_data' });
return false;
const result = { success: false, reason: 'invalid_data' };
if (callback) callback(result);
return result;
}
// Create an audio element to play the audio
const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' });
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
// Set up state
this.isSpeaking = true;
this.currentAudio = audio;
// Set up event handlers
audio.onended = () => {
this.isSpeaking = false;
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;
this.currentAudio = null;
URL.revokeObjectURL(audioUrl);
if (callback) callback({ success: false, reason: 'playback_error', error });
};
// Play the audio
audio.play().catch(error => {
console.error(`${this.name}: Failed to play audio:`, error);
if (callback) callback({ success: false, reason: 'playback_error', error });
return new Promise((resolve) => {
// Create an audio element to play the audio
const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' });
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
let settled = false;
audio.volume = this.getPlaybackVolume();
console.log(`${this.name}: Playback volume set to ${audio.volume.toFixed(2)}`);
// Set up state
this.isSpeaking = true;
this.currentAudio = audio;
const finish = (result) => {
if (settled) {
return;
}
settled = true;
this.isSpeaking = false;
if (this.currentAudio === audio) {
this.currentAudio = null;
}
if (this.currentPlaybackFinish === finish) {
this.currentPlaybackFinish = null;
}
URL.revokeObjectURL(audioUrl);
if (callback) callback(result);
resolve(result);
};
this.currentPlaybackFinish = finish;
// Set up event handlers
audio.onended = () => {
finish({ success: true });
};
audio.onerror = (error) => {
console.error(`${this.name}: Audio playback error:`, error);
finish({ success: false, reason: 'playback_error', error });
};
// Play the audio
audio.play().then(() => {
document.dispatchEvent(new CustomEvent('tts:audio-started', {
detail: { provider: this.id || this.name }
}));
}).catch(error => {
console.error(`${this.name}: Failed to play audio:`, error);
finish({ success: false, reason: 'playback_error', error });
});
});
return true;
}
/**
* Get the current effective TTS playback volume.
* @returns {number} Volume from 0 to 1.
*/
getPlaybackVolume() {
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager) {
return 1.0;
}
const masterVolume = persistenceManager.getPreference(
'audio',
'masterVolume',
persistenceManager.getPreference('audio', 'master_volume', 1.0)
);
const ttsVolume = persistenceManager.getPreference(
'audio',
'ttsVolume',
persistenceManager.getPreference('audio', 'tts_volume', 1.0)
);
return Math.max(0, Math.min(1, masterVolume * ttsVolume));
}
/**
* Apply updated volume settings to currently playing audio.
*/
applyCurrentVolume() {
if (!this.currentAudio) {
return;
}
this.currentAudio.volume = this.getPlaybackVolume();
console.log(`${this.name}: Updated current playback volume to ${this.currentAudio.volume.toFixed(2)}`);
}
/**
@@ -245,6 +315,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
// Stop current audio
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
if (this.currentPlaybackFinish) {
this.currentPlaybackFinish({ success: false, reason: 'stopped' });
}
// Clean up
this.isSpeaking = false;
@@ -258,6 +331,33 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
}
return true; // Already stopped
}
fadeOutCurrentAudio(duration = 1000) {
if (!this.currentAudio) {
return Promise.resolve(true);
}
const audio = this.currentAudio;
const startVolume = audio.volume;
const startedAt = performance.now();
return new Promise((resolve) => {
const tick = () => {
const progress = Math.min(1, (performance.now() - startedAt) / duration);
audio.volume = startVolume * (1 - progress);
if (progress >= 1) {
this.stop();
resolve(true);
return;
}
requestAnimationFrame(tick);
};
tick();
});
}
/**
* Speak text
@@ -290,6 +390,29 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
return true;
}
/**
* Calculate audio duration from audio buffer
* @param {ArrayBuffer} audioData - Audio data buffer
* @returns {Promise<number>} - Duration in milliseconds
*/
async calculateAudioDuration(audioData) {
try {
// Use Web Audio API to decode audio and get duration
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = await audioContext.decodeAudioData(audioData.slice(0));
const durationMs = audioBuffer.duration * 1000;
// Close the audio context to free resources
await audioContext.close();
console.log(`${this.name}: Calculated audio duration: ${durationMs.toFixed(0)}ms`);
return durationMs;
} catch (error) {
console.warn(`${this.name}: Failed to calculate audio duration:`, error);
return 0;
}
}
/**
* Preload speech for later playback
* @param {string} text - Text to preload
@@ -299,20 +422,26 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
if (!this.isReady) {
return { success: false, reason: 'not_ready' };
}
try {
// Generate speech
const result = await this.generateSpeechAudio(text);
if (!result.success) {
return { success: false, reason: 'generation_failed' };
}
// Calculate actual audio duration if not provided
let duration = result.duration || 0;
if (duration === 0 && result.audioData) {
duration = await this.calculateAudioDuration(result.audioData);
}
return {
success: true,
audioData: result.audioData,
text,
duration: result.duration || 0
duration: duration
};
} catch (error) {
return { success: false, reason: 'generation_error', error };
@@ -366,7 +495,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
// Save to preferences
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
if (persistenceManager && oldKey !== newKey) {
persistenceManager.updatePreference('tts', `${this.id}_api_key`, newKey);
}
@@ -384,12 +513,16 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
// 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(() => {
this.loadVoices().then((voicesLoaded) => {
this.isReady = voicesLoaded !== false && !!this.apiKey;
// Then set up voice from preferences
this.setupVoiceFromPreferences().then(() => {
console.log(`${this.name}: Successfully initialized with new API key`);
console.log(`${this.name}: API key status: ${this.isReady ? 'ready' : 'not ready'}`);
// Notify the factory of our readiness change
ttsFactory.updateTTSAvailability();
document.dispatchEvent(new CustomEvent('tts:status:updated', {
detail: { provider: this.id, ready: this.isReady }
}));
});
});
} else {
@@ -415,7 +548,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
// Save to preferences
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
if (persistenceManager && oldUrl !== newUrl) {
persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl);
}
@@ -424,16 +557,20 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
console.log(`${this.name}: API URL changed, reinitializing`);
// Reload voices with the new API URL if we're ready
this.loadVoices().then(() => {
this.loadVoices().then((voicesLoaded) => {
this.isReady = voicesLoaded !== false && !!this.apiKey;
// Then set up voice from preferences
this.setupVoiceFromPreferences().then(() => {
console.log(`${this.name}: Successfully reinitialized with new API URL`);
console.log(`${this.name}: API URL status: ${this.isReady ? 'ready' : 'not ready'}`);
// Notify the TTS factory
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
ttsFactory.updateTTSAvailability();
}
document.dispatchEvent(new CustomEvent('tts:status:updated', {
detail: { provider: this.id, ready: this.isReady }
}));
});
});
}
+336 -26
View File
@@ -8,12 +8,24 @@ class AudioManagerModule extends BaseModule {
constructor() {
super('audio-manager', 'Audio Manager');
this.sounds = new Map();
this.sfxCache = new Map();
this.currentAudio = null;
this.currentLoop = null;
this.currentMusic = null;
this.queuedMusic = null;
this.masterVolume = 1.0;
this.musicVolume = 1.0;
this.sfxVolume = 1.0;
this.ttsVolume = 1.0;
this.musicDuckingFactor = 1.0;
this.activeTtsPlaybackCount = 0;
this.ttsQueueEmpty = true;
this.pendingMusicPlayback = null;
this.assetRoots = {
images: '/images/',
music: '/music/',
sounds: '/sounds/'
};
// Add persistence-manager as a dependency
this.dependencies = ['persistence-manager'];
@@ -41,6 +53,8 @@ class AudioManagerModule extends BaseModule {
try {
// Set up audio context if needed
this.setupAudioContext();
this.loadPersistedVolumes();
this.setupEventListeners();
// Load some basic sound effects
this.reportProgress(80, "Loading sound effects");
@@ -52,6 +66,60 @@ class AudioManagerModule extends BaseModule {
return false;
}
}
loadPersistedVolumes() {
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager) {
return;
}
this.masterVolume = this.clampVolume(persistenceManager.getPreference('audio', 'masterVolume', this.masterVolume));
this.musicVolume = this.clampVolume(persistenceManager.getPreference('audio', 'musicVolume', this.musicVolume));
this.sfxVolume = this.clampVolume(persistenceManager.getPreference('audio', 'sfxVolume', this.sfxVolume));
this.ttsVolume = this.clampVolume(persistenceManager.getPreference('audio', 'ttsVolume', this.ttsVolume));
}
setupEventListeners() {
this.addEventListener(document, 'story:media-cue', (event) => {
this.handleMediaCue(event.detail || {});
});
this.addEventListener(document, 'story:media-block', (event) => {
this.handleMediaBlock(event.detail || {});
});
this.addEventListener(document, 'preference-updated', (event) => {
const { category, key, value } = event.detail || {};
if (category !== 'audio') {
return;
}
if (key === 'masterVolume') this.setMasterVolume(value);
if (key === 'musicVolume') this.setMusicVolume(value);
if (key === 'sfxVolume') this.setSfxVolume(value);
if (key === 'ttsVolume') this.setTtsVolume(value);
});
this.addEventListener(document, 'tts:playback-start', () => {
this.activeTtsPlaybackCount += 1;
this.ttsQueueEmpty = false;
this.duckMusicForSpeech();
});
this.addEventListener(document, 'tts:playback-end', () => {
this.activeTtsPlaybackCount = Math.max(0, this.activeTtsPlaybackCount - 1);
this.restoreMusicIfSpeechFinished();
});
this.addEventListener(document, 'tts:queue-empty', () => {
this.ttsQueueEmpty = true;
this.restoreMusicIfSpeechFinished();
});
const unlock = () => this.unlockPendingAudio();
document.addEventListener('pointerdown', unlock, { passive: true });
document.addEventListener('keydown', unlock);
}
/**
* Set up Web Audio API context if needed
@@ -73,6 +141,7 @@ class AudioManagerModule extends BaseModule {
loadSound(id, url) {
return new Promise((resolve, reject) => {
const audio = new Audio(url);
audio.preload = 'auto';
audio.addEventListener('canplaythrough', () => {
this.sounds.set(id, audio);
resolve(audio);
@@ -105,13 +174,15 @@ class AudioManagerModule extends BaseModule {
audio.loop = true;
this.currentLoop = audio;
} else {
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
}
this.currentAudio = audio;
this.currentAudio = audio.cloneNode(true);
this.currentAudio.volume = this.getSfxVolume();
this.currentAudio.play().catch(error => {
console.error('Error playing audio:', error);
});
return this.currentAudio;
}
audio.volume = this.getMusicVolume();
audio.play().catch(error => {
console.error('Error playing audio:', error);
});
@@ -134,17 +205,14 @@ class AudioManagerModule extends BaseModule {
}
this.currentLoop = new Audio(url);
this.currentLoop.loop = true;
this.currentLoop.volume = this.getMusicVolume();
this.currentLoop.play().catch(error => {
console.error('Error playing audio loop:', error);
});
return this.currentLoop;
} else {
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio.removeAttribute('src');
this.currentAudio.load();
}
this.currentAudio = new Audio(url);
this.currentAudio.volume = this.getSfxVolume();
this.currentAudio.play().catch(error => {
console.error('Error playing audio:', error);
});
@@ -191,7 +259,7 @@ class AudioManagerModule extends BaseModule {
* @param {number} volume - The volume level (0.0 to 1.0)
*/
setMasterVolume(volume) {
this.masterVolume = Math.max(0, Math.min(1, volume));
this.masterVolume = this.clampVolume(volume);
this.updateVolumes();
}
@@ -200,11 +268,7 @@ class AudioManagerModule extends BaseModule {
* @param {number} volume - The volume level (0.0 to 1.0)
*/
setTtsVolume(volume) {
this.ttsVolume = Math.max(0, Math.min(1, volume));
// Apply to current non-loop audio if it exists
if (this.currentAudio) {
this.currentAudio.volume = this.masterVolume * this.ttsVolume;
}
this.ttsVolume = this.clampVolume(volume);
}
/**
@@ -212,11 +276,8 @@ class AudioManagerModule extends BaseModule {
* @param {number} volume - The volume level (0.0 to 1.0)
*/
setMusicVolume(volume) {
this.musicVolume = Math.max(0, Math.min(1, volume));
// Apply to current loop if it exists
if (this.currentLoop) {
this.currentLoop.volume = this.masterVolume * this.musicVolume;
}
this.musicVolume = this.clampVolume(volume);
this.updateVolumes();
}
/**
@@ -224,11 +285,8 @@ class AudioManagerModule extends BaseModule {
* @param {number} volume - The volume level (0.0 to 1.0)
*/
setSfxVolume(volume) {
this.sfxVolume = Math.max(0, Math.min(1, volume));
// Apply to current non-loop audio if it exists
if (this.currentAudio) {
this.currentAudio.volume = this.masterVolume * this.sfxVolume;
}
this.sfxVolume = this.clampVolume(volume);
this.updateVolumes();
}
/**
@@ -247,6 +305,258 @@ class AudioManagerModule extends BaseModule {
if (this.currentLoop) {
this.currentLoop.volume = this.masterVolume * this.musicVolume;
}
if (this.currentMusic) {
this.currentMusic.volume = this.getMusicVolume();
}
}
clampVolume(volume) {
return Math.max(0, Math.min(1, Number.isFinite(Number(volume)) ? Number(volume) : 1));
}
getSfxVolume() {
return this.masterVolume * this.sfxVolume;
}
getMusicVolume() {
return this.masterVolume * this.musicVolume * this.musicDuckingFactor;
}
getUnduckedMusicVolume() {
return this.masterVolume * this.musicVolume;
}
duckMusicForSpeech() {
console.log('AudioManager: Ducking music for TTS playback');
this.fadeMusicTo(0.7, 500);
}
restoreMusicAfterSpeech() {
console.log('AudioManager: Restoring music after TTS queue drained');
this.fadeMusicTo(1.0, 900);
}
restoreMusicIfSpeechFinished() {
if (this.activeTtsPlaybackCount === 0 && this.ttsQueueEmpty) {
this.restoreMusicAfterSpeech();
}
}
fadeMusicTo(factor, duration = 700) {
this.musicDuckingFactor = Math.max(0, Math.min(1, factor));
if (!this.currentMusic) {
return;
}
const audio = this.currentMusic;
const startVolume = audio.volume;
const targetVolume = this.getUnduckedMusicVolume() * this.musicDuckingFactor;
const start = performance.now();
const tick = () => {
const progress = Math.min(1, (performance.now() - start) / duration);
audio.volume = startVolume + ((targetVolume - startVolume) * progress);
if (progress < 1) {
requestAnimationFrame(tick);
}
};
tick();
}
getAssetUrl(kind, filename) {
const root = this.assetRoots[kind];
if (!root) {
throw new Error(`Unknown audio asset kind: ${kind}`);
}
const safeName = String(filename || '').replace(/\\/g, '/').replace(/^\/+/, '');
if (!safeName || safeName.includes('..') || /^[a-z]+:/i.test(safeName)) {
throw new Error(`Unsafe asset filename: ${filename}`);
}
return root + safeName.split('/').map(encodeURIComponent).join('/');
}
async preloadSfx(filename) {
const url = this.getAssetUrl('sounds', filename);
if (this.sfxCache.has(url)) {
return this.sfxCache.get(url);
}
const promise = new Promise((resolve, reject) => {
const audio = new Audio(url);
audio.preload = 'auto';
audio.volume = this.getSfxVolume();
audio.addEventListener('canplaythrough', () => resolve(audio), { once: true });
audio.addEventListener('error', () => reject(new Error(`Failed to preload sound effect: ${url}`)), { once: true });
audio.load();
});
this.sfxCache.set(url, promise);
return promise;
}
async preloadMediaCues(cues = []) {
const tasks = cues
.filter(cue => cue && cue.type === 'sfx' && cue.filename)
.map(cue => this.preloadSfx(cue.filename).catch(error => {
console.warn('AudioManager: SFX preload failed:', error);
return null;
}));
await Promise.all(tasks);
}
handleMediaCue(cue) {
if (!cue || !cue.type) {
return;
}
if (cue.type === 'sfx') {
this.playSfx(cue.filename);
} else if (cue.type === 'music') {
this.playMusic(cue.filename, cue.mode || 'crossfade', { loop: cue.loop !== false });
}
}
handleMediaBlock(block) {
if (!block || block.type !== 'music') {
return;
}
this.playMusic(block.filename, block.mode || 'crossfade', { loop: block.loop !== false });
}
async playSfx(filename) {
try {
const template = await this.preloadSfx(filename);
const audio = template.cloneNode(true);
audio.volume = this.getSfxVolume();
this.currentAudio = audio;
audio.addEventListener('ended', () => {
if (this.currentAudio === audio) {
this.currentAudio = null;
}
}, { once: true });
await audio.play();
console.log(`AudioManager: Playing sound effect ${filename}`);
return audio;
} catch (error) {
console.error('AudioManager: Failed to play sound effect:', error);
return null;
}
}
async playMusic(filename, mode = 'crossfade', options = {}) {
const url = this.getAssetUrl('music', filename);
const shouldLoop = options.loop !== false;
if (mode === 'queue' && this.currentMusic && !this.currentMusic.paused) {
this.queuedMusic = { filename, mode: 'cut', options: { loop: shouldLoop } };
this.currentMusic.addEventListener('ended', () => {
const queued = this.queuedMusic;
this.queuedMusic = null;
if (queued) this.playMusic(queued.filename, queued.mode, queued.options);
}, { once: true });
console.log(`AudioManager: Queued music ${filename}`);
return this.currentMusic;
}
const next = new Audio(url);
next.loop = shouldLoop;
next.volume = mode === 'crossfade' && this.currentMusic ? 0 : this.getMusicVolume();
next.addEventListener('ended', () => {
if (this.currentMusic === next) {
this.currentMusic = null;
}
});
if (mode === 'cut' || !this.currentMusic) {
this.stopCurrentMusic();
this.currentMusic = next;
await this.startMusicAudio(next, filename);
return next;
}
const previous = this.currentMusic;
this.currentMusic = next;
await this.startMusicAudio(next, filename);
this.crossfade(previous, next, 1500);
console.log(`AudioManager: Crossfading music to ${filename}`);
return next;
}
async startMusicAudio(audio, filename) {
try {
await audio.play();
console.log(`AudioManager: Playing music ${filename}`);
} catch (error) {
this.pendingMusicPlayback = { audio, filename };
console.warn('AudioManager: Music playback is waiting for user interaction:', error);
}
}
async unlockPendingAudio() {
if (this.audioContext && this.audioContext.state === 'suspended') {
try {
await this.audioContext.resume();
} catch (error) {
console.warn('AudioManager: Failed to resume audio context:', error);
}
}
if (!this.pendingMusicPlayback) {
return;
}
const pending = this.pendingMusicPlayback;
this.pendingMusicPlayback = null;
pending.audio.volume = this.getMusicVolume();
try {
await pending.audio.play();
console.log(`AudioManager: Resumed pending music ${pending.filename}`);
} catch (error) {
this.pendingMusicPlayback = pending;
console.warn('AudioManager: Pending music still blocked:', error);
}
}
stopCurrentMusic() {
if (!this.currentMusic) {
return;
}
this.currentMusic.pause();
this.currentMusic.currentTime = 0;
this.currentMusic = null;
}
crossfade(previous, next, duration = 1500) {
const start = performance.now();
const previousStart = previous ? previous.volume : 0;
const target = this.getMusicVolume();
const tick = () => {
const progress = Math.min(1, (performance.now() - start) / duration);
if (previous) previous.volume = previousStart * (1 - progress);
next.volume = target * progress;
if (progress < 1) {
requestAnimationFrame(tick);
return;
}
if (previous) {
previous.pause();
previous.currentTime = 0;
}
next.volume = this.getMusicVolume();
};
tick();
}
/**
+5 -7
View File
@@ -248,6 +248,9 @@ export class BrowserTTSModule extends TTSHandlerModule {
this.utteranceHandlers = {
start: () => {
this.isSpeaking = true;
document.dispatchEvent(new CustomEvent('tts:audio-started', {
detail: { provider: this.id || this.name }
}));
},
end: () => {
this.isSpeaking = false;
@@ -443,13 +446,8 @@ export class BrowserTTSModule extends TTSHandlerModule {
}
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);
}
// Web Speech rate uses 1.0 as normal, matching the app-wide slider.
this.voiceOptions.speed = Math.max(0.1, Math.min(10.0, options.speed));
}
if (typeof options.pitch === 'number') {
+19 -23
View File
@@ -9,7 +9,7 @@ export class DebugUtilsModule extends BaseModule {
super('debug-utils', 'Debug Utilities');
// Declare dependencies explicitly
this.dependencies = ['text-buffer', 'socket-client', 'tts-player', 'ui-controller', 'game-loop'];
this.dependencies = ['text-buffer', 'socket-client', 'tts-factory', 'ui-controller', 'game-loop'];
}
/**
@@ -83,31 +83,27 @@ export class DebugUtilsModule extends BaseModule {
*/
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");
// Get the TTS factory properly through dependency system
const ttsFactory = this.getModule('tts-factory');
if (!ttsFactory) {
console.error("Debug: TTS factory not found");
return false;
}
const wasEnabled = ttsPlayer.isEnabled();
// Enable TTS temporarily if it was disabled
if (!wasEnabled && ttsPlayer.toggle) {
ttsPlayer.toggle();
const activeHandler = ttsFactory.getActiveHandler();
if (!activeHandler) {
console.error("Debug: No active TTS handler");
return false;
}
// Speak the text
ttsPlayer.speak(text, (result) => {
ttsFactory.speak(text).then((result) => {
console.log("Debug: TTS completed with result:", result);
// Restore previous enabled state
if (!wasEnabled && ttsPlayer.toggle) {
ttsPlayer.toggle();
}
}).catch((error) => {
console.error("Debug: TTS error:", error);
});
return true;
}
@@ -123,10 +119,10 @@ export class DebugUtilsModule extends BaseModule {
const socketClient = this.getModule('socket-client');
const gameLoop = this.getModule('game-loop');
const textBuffer = this.getModule('text-buffer');
const ttsHandler = this.getModule('tts-player');
const ttsFactory = this.getModule('tts-factory');
// Check if all modules are available
if (!uiController || !socketClient || !gameLoop || !textBuffer || !ttsHandler) {
if (!uiController || !socketClient || !gameLoop || !textBuffer || !ttsFactory) {
console.error("Debug: One or more required modules not found");
return false;
}
+28 -14
View File
@@ -41,17 +41,25 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
// API key is already loaded in parent initialize() method
// Just check if it's available
if (!this.apiKey) {
console.error('ElevenLabs TTS: API key not configured');
return false;
console.info('ElevenLabs TTS: API key not configured; provider unavailable until configured');
this.isReady = false;
this.reportProgress(100, 'ElevenLabs TTS not configured');
return true;
}
// Load voices from ElevenLabs
try {
this.reportProgress(50, 'Loading ElevenLabs voices');
await this.loadVoices(this.apiKey);
const voicesLoaded = await this.loadVoices(this.apiKey);
if (!voicesLoaded) {
this.isReady = false;
this.reportProgress(100, 'ElevenLabs TTS not ready');
return true;
}
} catch (error) {
console.error('ElevenLabs TTS: Failed to load voices:', error);
return false;
this.isReady = false;
return true;
}
// Load preferences
@@ -65,9 +73,9 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
this.voiceOptions.model = preferredModel;
}
const preferredSpeed = persistenceManager.getPreference('tts', `${this.id}_speed`, this.voiceOptions.speed);
const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed);
if (typeof preferredSpeed === 'number') {
this.voiceOptions.speed = preferredSpeed;
this.voiceOptions.speed = this.getApiSpeed(preferredSpeed);
}
this.isReady = true;
@@ -119,7 +127,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
if (!response.ok) {
console.error(`ElevenLabs TTS: API error ${response.status} ${response.statusText}`);
return true; // Continue with default voices
return false;
}
const data = await response.json();
@@ -136,10 +144,10 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
return true;
}
return true; // Continue with default voices
return this.voices.length > 0;
} catch (error) {
console.error('ElevenLabs TTS: Error loading voices:', error);
return true; // Continue with default voices
return false;
}
}
@@ -188,7 +196,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
similarity_boost: 0.75,
style: 0.0,
use_speaker_boost: true,
speed: this.voiceOptions.speed || 1.0
speed: this.getApiSpeed(this.voiceOptions.speed)
}
};
@@ -198,13 +206,15 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
headers: {
'Content-Type': 'application/json',
'xi-api-key': this.apiKey,
'Accept': 'audio/wav'
'Accept': 'audio/mpeg'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
const errorText = await response.text();
console.error(`ElevenLabs API error ${response.status}: ${errorText}`);
throw new Error(`API error: ${response.status} ${response.statusText} - ${errorText}`);
}
// Get audio blob from response
@@ -244,7 +254,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
}
if (typeof options.speed === 'number') {
this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed));
this.voiceOptions.speed = this.getApiSpeed(options.speed);
}
// Handle ElevenLabs-specific options
@@ -258,6 +268,10 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
}
}
}
getApiSpeed(speed) {
return Math.max(0.7, Math.min(1.2, Number.isFinite(speed) ? speed : 1.0));
}
}
const elevenLabsTTSModule = new ElevenLabsTTSModule();
+100 -20
View File
@@ -9,7 +9,7 @@ class GameLoopModule extends BaseModule {
super('game-loop', 'Game Loop');
// Dependencies
this.dependencies = ['ui-controller', 'socket-client', 'tts-player', 'text-buffer'];
this.dependencies = ['ui-controller', 'socket-client', 'text-buffer'];
// Game state
this.gameState = {
@@ -25,9 +25,11 @@ class GameLoopModule extends BaseModule {
// Bind methods using parent's bindMethods utility
this.bindMethods([
'start',
'setupUiEventListeners',
'setupSocketEventListeners',
'updateGameState',
'updateUIState',
'refreshGameApiState',
'requestStartGame',
'requestSaveGame',
'requestLoadGame',
@@ -46,6 +48,7 @@ class GameLoopModule extends BaseModule {
try {
// The dependencies are now automatically available via getModule
this.updateUIState();
this.setupUiEventListeners();
console.log("GameLoop: Setting up socket listeners and connecting...");
@@ -58,6 +61,15 @@ class GameLoopModule extends BaseModule {
console.error("Error starting game loop:", error);
}
}
setupUiEventListeners() {
if (this.uiEventsBound) return;
this.uiEventsBound = true;
document.addEventListener('ui:game:restart', () => this.requestStartGame());
document.addEventListener('ui:game:save', () => this.requestSaveGame());
document.addEventListener('ui:game:load', () => this.requestLoadGame());
}
setupSocketEventListeners() {
// Get the socket client module using parent's getModule method
@@ -81,14 +93,7 @@ class GameLoopModule extends BaseModule {
socketClient.on('connect', () => {
console.log("GameLoop: Socket connected event received.");
// Request a new game start when we connect
// Only request start game if one isn't already in progress
if (!this.gameState.started) {
console.log("GameLoop: Requesting start game on connect.");
this.requestStartGame();
} else {
console.log("GameLoop: Game already started, skipping duplicate start request.");
}
this.refreshGameApiState();
});
// Listen for game state updates
@@ -107,9 +112,22 @@ class GameLoopModule extends BaseModule {
socketClient.on('gameIntroduction', (data) => {
console.log("GameLoop: Received gameIntroduction");
this.gameState.started = true;
this.gameState.canSave = true;
this.updateUIState();
// Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline
});
socketClient.on('gameSaved', () => {
this.gameState.canLoad = true;
this.updateUIState();
});
socketClient.on('gameLoaded', () => {
this.gameState.started = true;
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
});
// Connect to the socket server
socketClient.connect().then(success => {
@@ -120,6 +138,21 @@ class GameLoopModule extends BaseModule {
}
});
}
async refreshGameApiState() {
const socketClient = this.getModule('socket-client');
if (!socketClient || !socketClient.getConnectionStatus()) return;
const [running, hasSave] = await Promise.all([
socketClient.isGameRunning(),
socketClient.hasSaveGame(1)
]);
this.gameState.started = Boolean(running?.result);
this.gameState.canSave = this.gameState.started;
this.gameState.canLoad = Boolean(hasSave?.result);
this.updateUIState();
}
/**
* Update the game state
@@ -149,38 +182,85 @@ class GameLoopModule extends BaseModule {
if (!uiController) return;
// Update UI components based on game state
uiController.updateButtonStates(this.gameState);
const state = {
canRestart: true,
canSave: Boolean(this.gameState.started),
canLoad: Boolean(this.gameState.canLoad),
gameStarted: Boolean(this.gameState.started)
};
document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false';
uiController.updateButtonStates(state);
}
/**
* Request to start a new game
*/
requestStartGame() {
async requestStartGame() {
const socketClient = this.getModule('socket-client');
if (!socketClient) return;
socketClient.requestStartGame();
const uiController = this.getModule('ui-controller');
if (uiController) {
uiController.clearDisplay();
}
const textBuffer = this.getModule('text-buffer');
if (textBuffer && typeof textBuffer.clear === 'function') {
textBuffer.clear();
}
const response = await socketClient.newGame();
if (!response?.success) {
console.error('GameLoop: newGame failed', response);
return;
}
this.gameState.started = true;
this.gameState.canSave = true;
this.gameState.canLoad = Boolean(response.canLoad);
this.updateUIState();
}
/**
* Request to save the current game
*/
requestSaveGame() {
async requestSaveGame() {
const socketClient = this.getModule('socket-client');
if (!socketClient) return;
socketClient.requestSaveGame();
if (!socketClient || !this.gameState.started) return;
const response = await socketClient.saveGame(1);
if (response?.success) {
this.gameState.canLoad = true;
this.updateUIState();
}
}
/**
* Request to load a saved game
*/
requestLoadGame() {
async requestLoadGame() {
const socketClient = this.getModule('socket-client');
if (!socketClient) return;
socketClient.requestLoadGame();
const hasSave = await socketClient.hasSaveGame(1);
if (!hasSave?.result) {
this.gameState.canLoad = false;
this.updateUIState();
return;
}
const uiController = this.getModule('ui-controller');
if (uiController) {
uiController.clearDisplay();
}
const textBuffer = this.getModule('text-buffer');
if (textBuffer && typeof textBuffer.clear === 'function') {
textBuffer.clear();
}
const response = await socketClient.loadGame(1);
if (response?.success) {
this.gameState.started = true;
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
}
}
/**
+7 -7
View File
@@ -1,4 +1,4 @@
function kap(text, measureText, measure, hyphenation) {
function kap(text, measureText, measure, hyphenation) {
console.log("Typesetting hyphenated text:", text, measure);
if (!hyphenation) {
text = text.replace(/\|/g, '');
@@ -48,9 +48,9 @@ function kap(text, measureText, measure, hyphenation) {
let breaks = linebreak(nodes, measure, { tolerance: 3, demerits });
if (!breaks.length) {
breaks = linebreak(nodes, measure, { tolerance: 10, demerits });
}
return { nodes, breaks };
}
if (!breaks.length) {
breaks = linebreak(nodes, measure, { tolerance: 10, demerits });
}
return { nodes, breaks };
}
+47 -5
View File
@@ -20,6 +20,12 @@ export class KokoroTTSModule extends TTSHandlerModule {
this.lastProgressTime = null;
this.lastProgressValue = null;
this.modelLoaded = false;
// Options for playback
this.options = {
volume: 1.0,
rate: 1.0
};
// Bind additional methods beyond those in TTSHandlerModule
this.bindMethods([
@@ -30,7 +36,8 @@ export class KokoroTTSModule extends TTSHandlerModule {
'preprocessText',
'pause',
'resume',
'getDefaultVoices'
'getDefaultVoices',
'setVoiceOptions'
]);
}
@@ -51,6 +58,16 @@ export class KokoroTTSModule extends TTSHandlerModule {
console.error('Kokoro TTS: Required dependency persistence-manager not found');
return false;
}
const ttsEnabled = persistenceManager.getPreference('tts', 'enabled', false);
const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler', 'none');
if (!ttsEnabled || preferredHandler !== this.id) {
this.voices = this.getDefaultVoices();
this.isReady = false;
this.reportProgress(100, 'Kokoro TTS not selected');
console.log('Kokoro TTS: Skipping model load because provider is not selected');
return true;
}
// Try to check if the kokoro-js.js resource exists before proceeding
try {
@@ -206,9 +223,11 @@ export class KokoroTTSModule extends TTSHandlerModule {
if (!event.data.success || event.data.error) {
resolver.reject(new Error(event.data.error || 'Speech generation failed'));
} else {
const audioData = event.data.result && event.data.result.buffer;
console.log('Kokoro: Generation complete, audioData:', audioData ? `${audioData.byteLength} bytes` : 'UNDEFINED');
resolver.resolve({
success: true,
audioData: event.data.result && event.data.result.buffer,
audioData: audioData,
duration: event.data.duration || 0
});
}
@@ -333,6 +352,21 @@ export class KokoroTTSModule extends TTSHandlerModule {
return true;
}
setVoiceOptions(options = {}) {
if (options.voice) {
const voice = this.voices.find(v => v.id === options.voice) || { id: options.voice };
this.setVoice(voice);
}
if (typeof options.speed === 'number') {
this.setOptions({ rate: Math.max(0.5, Math.min(2.0, options.speed)) });
}
if (typeof options.volume === 'number') {
this.setOptions({ volume: Math.max(0, Math.min(1, options.volume)) });
}
}
/**
* Get available voices
@@ -435,7 +469,11 @@ export class KokoroTTSModule extends TTSHandlerModule {
// Start playback
this.currentAudio = audio;
this.isSpeaking = true;
audio.play().catch(error => {
audio.play().then(() => {
document.dispatchEvent(new CustomEvent('tts:audio-started', {
detail: { provider: this.id || this.name }
}));
}).catch(error => {
this.isSpeaking = false;
if (callback) {
callback({ success: false, reason: 'playback_error', error });
@@ -498,7 +536,11 @@ export class KokoroTTSModule extends TTSHandlerModule {
// Start playback
this.currentAudio = audio;
this.isSpeaking = true;
audio.play().catch(error => {
audio.play().then(() => {
document.dispatchEvent(new CustomEvent('tts:audio-started', {
detail: { provider: this.id || this.name }
}));
}).catch(error => {
this.isSpeaking = false;
if (callback) {
callback({ success: false, reason: 'playback_error', error });
@@ -645,4 +687,4 @@ export class KokoroTTSModule extends TTSHandlerModule {
const kokoroTTSModule = new KokoroTTSModule();
export { kokoroTTSModule };
export { kokoroTTSModule };
+204 -191
View File
@@ -9,7 +9,7 @@ class LayoutRendererModule extends BaseModule {
super('layout-renderer', 'Layout Renderer');
// Module dependencies
this.dependencies = ['animation-queue', 'tts-player'];
this.dependencies = [];
// Configuration
this.updateConfig({
@@ -22,8 +22,8 @@ class LayoutRendererModule extends BaseModule {
// Bind methods
this.bindMethods([
'renderParagraph',
'renderWord',
'scheduleWordAnimation'
'initializeContainers',
'adjustJustification'
]);
}
@@ -34,14 +34,6 @@ class LayoutRendererModule extends BaseModule {
async initialize() {
try {
this.reportProgress(10, "Initializing Layout Renderer");
// 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;
}
this.reportProgress(100, "Layout Renderer ready");
return true;
} catch (error) {
@@ -65,199 +57,212 @@ class LayoutRendererModule extends BaseModule {
}
/**
* Render a paragraph from layout data
* @param {Object} layout - Layout data from paragraph-layout
* Render a paragraph from layout data (pure DOM creation, no animation)
* @param {Object} layoutData - Layout data containing breaks, nodes, and measures
* @param {Object} options - Rendering options
* @returns {HTMLElement} - The created paragraph element
*/
renderParagraph(layout, options = {}) {
const animationQueue = this.getModule('animation-queue');
renderParagraph(layoutData, options = {}) {
const { id = `p-${Date.now()}` } = options;
const { breaks, nodes, measures, fontSize, fontFamily, lineHeightPx } = layoutData;
const {
container = document.getElementById('paragraphs'),
id = `p-${Date.now()}`,
className = '',
style = {},
animateWords = true,
animationSpeed = this.config.animation.defaultSpeed,
tts = false,
onComplete = null
} = options;
if (!layout || !layout.breaks || !layout.nodes || !container) {
console.error('Invalid layout data or container');
if (!breaks || !nodes) {
console.error('LayoutRenderer: Invalid layout data');
return null;
}
// Create paragraph element
const paragraphElement = document.createElement('p');
paragraphElement.id = id;
paragraphElement.className = `paragraph ${className}`.trim();
paragraphElement.style.position = 'relative';
// Get line height and container width for positioning
const lineHeight = parseFloat(window.getComputedStyle(document.querySelector('#story')).lineHeight) || 1.5;
const containerWidth = parseFloat(window.getComputedStyle(container).width);
// Calculate paragraph height based on number of lines
const numLines = layout.breaks.length - 1;
const paragraphHeight = numLines * lineHeight;
paragraphElement.style.height = `${paragraphHeight}em`;
// Apply custom style properties
for (const prop in style) {
paragraphElement.style[prop] = style[prop];
// Create paragraph container
const paragraph = document.createElement('p');
paragraph.id = id;
paragraph.className = [
layoutData.role ? `story-${layoutData.role}` : '',
layoutData.addTopSpace ? 'story-textblock-start' : '',
layoutData.dropCap ? 'story-dropcap-paragraph' : ''
].filter(Boolean).join(' ');
paragraph.style.position = 'relative';
paragraph.style.margin = '0';
if (fontSize) paragraph.style.fontSize = fontSize;
if (fontFamily) paragraph.style.fontFamily = fontFamily;
if (Array.isArray(measures) && measures.length > 0) {
paragraph.style.width = `${Math.max(...measures)}px`;
paragraph.style.maxWidth = '100%';
}
// Populate with words
const wordElements = [];
let lineIndex = 0;
let totalDelay = 0;
// Calculate each word's position based on layout data
for (let i = 0; i < layout.nodes.length; i++) {
const wordNode = layout.nodes[i];
// Get the current line index from breaks array
while (lineIndex < layout.breaks.length - 1 && i >= layout.breaks[lineIndex + 1]) {
lineIndex++;
}
// Create the word element
const wordElement = this.renderWord(wordNode.text, animateWords);
wordElements.push(wordElement);
// Position the word absolutely within paragraph
if (wordNode.x !== undefined && wordNode.y !== undefined) {
// Use calculated position
wordElement.style.position = 'absolute';
wordElement.style.left = `${wordNode.x}px`;
wordElement.style.top = `${lineIndex * lineHeight}em`;
} else {
// Fallback for missing positioning data
wordElement.style.position = 'relative';
wordElement.style.marginRight = '0.25em';
}
// Add to paragraph
paragraphElement.appendChild(wordElement);
// Handle whitespace after the word
if (wordNode.spaceAfter) {
const spaceElement = document.createElement('span');
spaceElement.className = 'space';
spaceElement.innerHTML = '&nbsp;';
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);
}
// Calculate paragraph height
const storyElement = document.getElementById('story');
if (!storyElement) {
console.error('LayoutRenderer: Story container not found');
return null;
}
// Add the paragraph to the container
container.appendChild(paragraphElement);
// Schedule animations for words if enabled
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)
wordElements.forEach((wordElement, index) => {
const delay = baseDelay + (index * wordDelay);
totalDelay = Math.max(totalDelay, delay);
this.scheduleWordAnimation(wordElement, delay, animationSpeed);
});
// Schedule TTS if enabled - start it earlier in the animation sequence
if (tts) {
const ttsPlayer = this.getModule('tts-player');
if (ttsPlayer) {
// Get the full text for TTS
const fullText = layout.originalText || layout.processedText || paragraphElement.textContent;
// Schedule TTS with the animation queue - start after just a few words appear
animationQueue.schedule(() => {
ttsPlayer.speak(fullText, (result) => {
if (!result || !result.success) {
console.warn('TTS playback issue:', result ? result.reason : 'unknown');
}
});
}, Math.min(100, wordDelay * 3)); // Start TTS earlier
const lineHeight = lineHeightPx || parseFloat(window.getComputedStyle(paragraph).lineHeight) || 24;
const maxLineWidth = Array.isArray(measures) && measures.length > 0
? Math.max(...measures)
: storyElement.clientWidth;
// Height should include all lines (breaks.length represents number of lines)
const numLines = breaks.length - 1;
paragraph.style.height = `${lineHeight * numLines}px`;
console.log(`LayoutRenderer: Rendering paragraph ${id} - ${breaks.length} breaks (${numLines} lines), lineHeight: ${lineHeight}px, total height: ${lineHeight * numLines}px`);
// Debug: log break ratios
breaks.forEach((brk, idx) => {
if (idx > 0) {
console.log(` Line ${idx - 1}: break position ${brk.position}, ratio ${brk.ratio ? brk.ratio.toFixed(3) : 'undefined'}`);
}
});
// Position words according to layout with proper justification
let wordCount = 0;
let lastChild = null;
let syllable = "";
const stack = [paragraph];
if (layoutData.dropCap && layoutData.dropCapText) {
const dropCap = document.createElement('span');
dropCap.className = 'drop-cap story-drop-cap';
dropCap.textContent = layoutData.dropCapText;
paragraph.appendChild(dropCap);
}
for (let i = 1; i < breaks.length; i++) {
const lineIndex = i - 1;
const lineWidth = measures[Math.min(lineIndex, measures.length - 1)];
const lineOffset = maxLineWidth - lineWidth;
const currentBreak = breaks[i];
const isFinalLine = i === breaks.length - 1;
const ratio = isFinalLine ? 0 : (currentBreak.ratio || 0);
let currentLeft = 0;
lastChild = null;
// Iterate through nodes on this line (break positions are inclusive)
for (let j = breaks[i-1].position; j <= currentBreak.position; j++) {
const node = nodes[j];
if (node.type === 'box' && node.value !== '' && j < currentBreak.position) {
const followsGlue = j > 0 && nodes[j - 1].type === 'glue';
const isTrailingPunctuation = /^[,.;:!?)]$/.test(node.value) && !followsGlue;
// Check if this box follows a penalty (hyphenation point)
if (lastChild && isTrailingPunctuation) {
syllable += node.value;
lastChild.innerHTML = syllable;
currentLeft += node.width;
} else if (j > breaks[i-1].position + 1 &&
nodes[j-1].type === 'penalty' &&
lastChild) {
// Combine with previous syllable using zero-width non-joiner
syllable += '\u200c' + node.value;
lastChild.innerHTML = syllable;
currentLeft += node.width;
} else {
// Create new word span
const word = document.createElement('span');
word.className = 'word';
word.style.position = 'absolute';
word.style.display = 'inline-block';
word.style.whiteSpace = 'nowrap';
word.dataset.line = String(lineIndex);
word.dataset.lineStart = String(lineOffset);
word.dataset.lineWidth = String(lineWidth);
// Calculate position with proper line and justification
const topPercent = (lineIndex * lineHeight * 100) / parseFloat(paragraph.style.height);
const leftPercent = ((lineOffset + currentLeft) * 100) / maxLineWidth;
word.style.top = `${topPercent}%`;
word.style.left = `${leftPercent}%`;
word.style.opacity = '0'; // Hidden until animated
word.style.visibility = 'hidden';
syllable = node.value;
word.innerHTML = syllable;
lastChild = word;
if (wordCount < 5 || (wordCount % 20 === 0)) {
console.log(` Word ${wordCount} "${node.value}" at line ${lineIndex}, top: ${topPercent.toFixed(1)}%, left: ${leftPercent.toFixed(1)}%`);
}
wordCount++;
stack[stack.length - 1].appendChild(word);
currentLeft += node.width;
}
} else if (node.type === 'tag') {
if (node.value.substr(0, 2) === '</') {
if (stack.length > 1) stack.pop();
} else {
const template = document.createElement('div');
template.innerHTML = node.value;
const tag = template.firstChild;
if (tag) {
tag.style.display = 'contents';
stack[stack.length - 1].appendChild(tag);
stack.push(tag);
}
}
} else if (node.type === 'glue' && j > breaks[i-1].position && node.width !== 0 && j <= currentBreak.position) {
// Apply justification: adjust glue width based on line's ratio
let adjustedWidth = node.width;
if (ratio > 0) {
// Line needs stretching
adjustedWidth = node.width + (node.stretch * ratio);
} else if (ratio < 0) {
// Line needs shrinking
adjustedWidth = node.width + (node.shrink * ratio);
}
// If ratio === 0, line fits perfectly, use natural width
if (wordCount < 3) {
// Debug first line's glue adjustments
console.log(` Glue at position ${j}: natural=${node.width.toFixed(2)}px, adjusted=${adjustedWidth.toFixed(2)}px, ratio=${ratio.toFixed(3)}, left before: ${currentLeft.toFixed(2)}px`);
}
// Increment position by the adjusted glue width
currentLeft += adjustedWidth;
} else if (node.type === 'penalty' && node.penalty === 100 && j === currentBreak.position) {
// Add hyphen at line break
if (lastChild) {
lastChild.innerHTML = `${lastChild.innerHTML}-`;
continue;
}
const word = document.createElement('span');
word.className = 'word';
word.style.position = 'absolute';
word.style.display = 'inline-block';
word.style.whiteSpace = 'nowrap';
word.dataset.line = String(lineIndex);
word.dataset.lineStart = String(lineOffset);
word.dataset.lineWidth = String(lineWidth);
const topPercent = (lineIndex * lineHeight * 100) / parseFloat(paragraph.style.height);
const leftPercent = ((lineOffset + currentLeft) * 100) / maxLineWidth;
word.style.top = `${topPercent}%`;
word.style.left = `${leftPercent}%`;
word.style.opacity = '0';
word.style.visibility = 'hidden';
word.innerHTML = "-";
stack[stack.length - 1].appendChild(word);
}
}
// Schedule completion callback
if (onComplete && typeof onComplete === 'function') {
const completionDelay = totalDelay + 200; // Reduced completion delay
animationQueue.schedule(onComplete, completionDelay);
}
} else if (onComplete && typeof onComplete === 'function') {
// If not animating, call onComplete immediately
setTimeout(onComplete, 0);
}
return paragraphElement;
return paragraph;
}
/**
* Paragraph positions are already computed from browser DOM measurements.
* Keep this hook for callers that still invoke it, but do not reflow the
* prototype layout after rendering.
* @param {HTMLElement} paragraph - Rendered paragraph element
*/
adjustJustification(paragraph) {
return paragraph;
}
/**
* Render a single word
* @param {string} word - Word to render
* @param {boolean} animate - Whether to prepare for animation
* @returns {HTMLElement} - The created word element
*/
renderWord(word, animate = true) {
const wordElement = this.createWordElement(word);
// Apply initial styles for animation
if (animate) {
wordElement.style.opacity = '0';
wordElement.style.transform = 'translateY(5px)';
wordElement.style.display = 'inline-block';
}
return wordElement;
}
/**
* Create a word element
* @param {string} word - Word to render
* @returns {HTMLElement} - The created word element
*/
createWordElement(word) {
const wordElement = document.createElement('span');
wordElement.className = 'word';
wordElement.textContent = word;
return wordElement;
}
/**
* Schedule a word animation with the animation queue
* @param {HTMLElement} wordElement - Word element to animate
* @param {number} delay - Delay before animation starts
* @param {number} speed - Animation speed factor
*/
scheduleWordAnimation(wordElement, delay, speed) {
const animationQueue = this.getModule('animation-queue');
if (!animationQueue) return;
const actualDelay = delay * speed;
animationQueue.schedule(() => {
wordElement.style.opacity = '1';
wordElement.style.transform = 'translateY(0)';
wordElement.style.transition = `opacity 0.2s ease-out, transform 0.3s ease-out`;
}, actualDelay);
}
}
// Create the singleton instance
@@ -265,3 +270,11 @@ const LayoutRenderer = new LayoutRendererModule();
// Export the module
export { LayoutRenderer };
// Register with the module registry
if (window.moduleRegistry) {
window.moduleRegistry.register(LayoutRenderer);
}
// Keep a reference in window for loader system
window.LayoutRenderer = LayoutRenderer;
+38 -13
View File
@@ -24,6 +24,8 @@ const ModuleState = {
ERROR: 'ERROR'
};
const MODULE_CACHE_BUSTER = '20260514-new-game-click';
/**
* Module Loader - Manages the loading of all modules
*/
@@ -61,7 +63,10 @@ const ModuleLoader = (function() {
// Load available module scripts
loadModuleScripts().then(() => {
// Once scripts are loaded, initialize modules
initializeModules();
initializeModules().catch(error => {
console.error('Module Loader: Initialization failed:', error);
finalizeLoading();
});
});
}
@@ -98,17 +103,19 @@ const ModuleLoader = (function() {
{ 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: 'markup-parser', script: '/js/markup-parser-module.js', weight: 5 },
{ id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 },
{ id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 },
{ 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 },
{ id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS
// Audio and TTS modules
{ 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: 'kokoro-tts', script: '/js/kokoro-tts-module.js', weight: 50 },
{ id: 'browser-tts', script: '/js/browser-tts-module.js', weight: 12 },
{ id: 'elevenlabs-tts', script: '/js/elevenlabs-tts-module.js', weight: 12 },
{ id: 'openai-tts', 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
// UI and interaction modules
@@ -310,7 +317,7 @@ const ModuleLoader = (function() {
}
});
return result.reverse(); // Reverse to get correct order
return result; // Dependencies are pushed before dependents.
};
const optimalOrder = calculateOptimalLoadOrder();
@@ -404,7 +411,7 @@ const ModuleLoader = (function() {
}
});
return result.reverse(); // Reverse for correct dependency order
return result; // Dependencies are pushed before dependents.
}
/**
@@ -442,7 +449,8 @@ const ModuleLoader = (function() {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.type = 'module';
script.src = src;
const separator = src.includes('?') ? '&' : '?';
script.src = `${src}${separator}v=${MODULE_CACHE_BUSTER}`;
// Monitor loading progress using a fake progress indicator (0-10%)
if (moduleId && moduleWeights[moduleId]) {
@@ -492,7 +500,7 @@ const ModuleLoader = (function() {
/**
* Initialize all registered modules
*/
function initializeModules() {
async function initializeModules() {
const modules = moduleRegistry.getAllModules();
// Find the game loop module instance
@@ -505,8 +513,19 @@ const ModuleLoader = (function() {
console.log(`${moduleId} depends on: ${dependencies.length ? dependencies.join(', ') : 'none'}`);
});
// For each registered module, start initialization
Object.values(modules).forEach(async (module) => {
const moduleList = Object.values(modules);
const initializationOrder = sortModulesByDependencies(moduleList);
console.group('%cActual Module Initialization Order', 'color: green; font-weight: bold');
initializationOrder.forEach((module, index) => {
console.log(`${index + 1}. ${module.id} (${module.name})`);
});
console.groupEnd();
// Initialize in dependency order. This makes the loader guarantee that
// by the time the overlay disappears, every module has run its own
// initialize() after its declared dependencies are available.
for (const module of initializationOrder) {
try {
// Create a progress callback for this module
const progressCallback = (percent, message) => {
@@ -539,7 +558,13 @@ const ModuleLoader = (function() {
} catch (error) {
console.error(`Error initializing module ${module.id}:`, error);
}
});
if (module.id === 'game-loop') {
gameLoopModule = module;
}
}
checkAllFinished();
}
/**
+1
View File
@@ -147,6 +147,7 @@ class LocalizationModule extends BaseModule {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('app', 'locale', normalizedLocale);
persistenceManager.updatePreference('tts', 'language', normalizedLocale);
}
// Dispatch locale change event
+274
View File
@@ -0,0 +1,274 @@
/**
* Markup Parser Module
* Parses author-facing story markup into renderable blocks and timed cue metadata.
*/
import { BaseModule } from './base-module.js';
class MarkupParserModule extends BaseModule {
constructor() {
super('markup-parser', 'Markup Parser');
this.dependencies = [];
this.assetRoots = {
images: '/images/',
music: '/music/',
sounds: '/sounds/'
};
this.bindMethods([
'parse',
'parseParagraph',
'parseInline',
'parseMusicOptions',
'markdownToHtml',
'smartypants',
'escapeHtml',
'normalizeParagraph',
'buildParagraphBlock',
'resolveAssetUrl'
]);
}
async initialize() {
this.reportProgress(100, "Markup parser ready");
return true;
}
parse(input) {
const text = String(input || '').replace(/\r\n?/g, '\n');
const blocks = [];
let paragraphBuffer = [];
let nextParagraphRole = null;
const flushParagraph = () => {
if (paragraphBuffer.length === 0) return;
const raw = paragraphBuffer.join('\n');
paragraphBuffer = [];
const paragraph = this.parseParagraph(raw);
if (!paragraph.text) return;
const role = nextParagraphRole || 'body';
nextParagraphRole = null;
blocks.push(this.buildParagraphBlock(paragraph, role));
};
text.split('\n').forEach((line) => {
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
return;
}
const chapter = trimmed.match(/^::chapter(?:\[(.*?)\]|\s+(.+))$/i);
if (chapter) {
flushParagraph();
const heading = (chapter[1] || chapter[2] || '').trim();
if (heading) {
blocks.push({
type: 'heading',
text: this.normalizeParagraph(heading),
layoutText: this.markdownToHtml(this.normalizeParagraph(heading)),
role: 'chapter-heading'
});
}
nextParagraphRole = 'chapter-first';
return;
}
const section = trimmed.match(/^::(?:section|textblock)(?:\[(.*?)\]|\s+(.+))?$/i);
if (section) {
flushParagraph();
const heading = (section[1] || section[2] || '').trim();
if (heading) {
blocks.push({
type: 'heading',
text: this.normalizeParagraph(heading),
layoutText: this.markdownToHtml(this.normalizeParagraph(heading)),
role: 'section-heading'
});
}
nextParagraphRole = 'textblock-first';
return;
}
const image = trimmed.match(/^::image\[(widescreen|portrait)\]\(([^)]+)\)$/i);
if (image) {
flushParagraph();
blocks.push({
type: 'image',
size: image[1].toLowerCase(),
filename: image[2].trim(),
url: this.resolveAssetUrl('images', image[2].trim())
});
return;
}
const music = trimmed.match(/^::music(?:\[([^\]]*)\])?\(([^)]+)\)$/i);
if (music) {
flushParagraph();
const options = this.parseMusicOptions(music[1] || 'crossfade');
blocks.push({
type: 'music',
...options,
filename: music[2].trim(),
url: this.resolveAssetUrl('music', music[2].trim())
});
return;
}
paragraphBuffer.push(line);
});
flushParagraph();
return blocks;
}
parseMusicOptions(optionText) {
const options = {
mode: 'crossfade',
loop: true,
leadInSeconds: 0
};
String(optionText || '')
.split(/[,\s]+/)
.map(token => token.trim())
.filter(Boolean)
.forEach(token => {
const lower = token.toLowerCase();
const [key, value] = lower.split('=');
if (['queue', 'crossfade', 'cut'].includes(lower)) {
options.mode = lower;
} else if (['loop', 'looped', 'repeat'].includes(lower)) {
options.loop = true;
} else if (['once', 'single', 'no-loop', 'noloop'].includes(lower)) {
options.loop = false;
} else if (key === 'loop') {
options.loop = !['false', '0', 'no', 'once'].includes(value);
} else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro'].includes(key)) {
const seconds = Number(value);
options.leadInSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
} else if (/^\d+(\.\d+)?s?$/.test(lower)) {
options.leadInSeconds = Number(lower.replace(/s$/, ''));
}
});
return options;
}
parseParagraph(rawText) {
const inline = this.parseInline(this.normalizeParagraph(rawText));
return {
text: inline.text,
layoutText: this.markdownToHtml(inline.text),
cueMarkers: inline.cueMarkers
};
}
buildParagraphBlock(paragraph, role) {
return {
type: 'paragraph',
text: paragraph.text,
layoutText: paragraph.layoutText,
cueMarkers: paragraph.cueMarkers,
role,
isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first',
dropCap: role === 'chapter-first',
addTopSpace: role === 'textblock-first'
};
}
parseInline(text) {
const cueMarkers = [];
let output = '';
let cursor = 0;
const markerPattern = /\{\{\s*(sfx|music)\s*:\s*(?:(queue|crossfade|cut)\s*:\s*)?([^}]+?)\s*\}\}/gi;
for (const match of text.matchAll(markerPattern)) {
output += text.slice(cursor, match.index);
const charIndex = output.length;
const wordIndex = this.countWords(output);
const type = match[1].toLowerCase();
const mode = type === 'music' ? (match[2] || 'crossfade').toLowerCase() : null;
cueMarkers.push({
type,
mode,
filename: match[3].trim(),
url: this.resolveAssetUrl(type === 'sfx' ? 'sounds' : 'music', match[3].trim()),
charIndex,
wordIndex
});
cursor = match.index + match[0].length;
}
output += text.slice(cursor);
return {
text: output.replace(/\s{2,}/g, ' ').trim(),
cueMarkers
};
}
markdownToHtml(text) {
const escaped = this.smartypants(this.escapeHtml(text));
return escaped
.replace(/\*\*\*([^*]+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/___([^_]+?)___/g, '<strong><em>$1</em></strong>')
.replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_]+?)__/g, '<strong>$1</strong>')
.replace(/\*([^*\s][^*]*?)\*/g, '<em>$1</em>')
.replace(/_([^_\s][^_]*?)_/g, '<em>$1</em>');
}
smartypants(text) {
return String(text)
.replace(/---/g, '\u2014')
.replace(/--/g, '\u2013')
.replace(/\.\.\./g, '\u2026')
.replace(/(^|[\s([{\u2014])"([^"]*)"/g, '$1\u201c$2\u201d')
.replace(/(^|[\s([{\u2014])'([^']*)'/g, '$1\u2018$2\u2019');
}
escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
normalizeParagraph(text) {
return String(text).replace(/\s*\n\s*/g, ' ').trim();
}
countWords(text) {
const words = String(text).trim().match(/\S+/g);
return words ? words.length : 0;
}
resolveAssetUrl(kind, filename) {
const root = this.assetRoots[kind];
const safeName = String(filename || '').replace(/\\/g, '/').replace(/^\/+/, '');
if (!root || !safeName || safeName.includes('..') || /^[a-z]+:/i.test(safeName)) {
return '';
}
return root + safeName.split('/').map(encodeURIComponent).join('/');
}
}
const MarkupParser = new MarkupParserModule();
export { MarkupParser };
if (window.moduleRegistry) {
window.moduleRegistry.register(MarkupParser);
}
window.MarkupParser = MarkupParser;
+87 -36
View File
@@ -7,6 +7,18 @@ import { ApiTTSModuleBase } from './api-tts-module-base.js';
export class OpenAITTSModule extends ApiTTSModuleBase {
constructor() {
super('openai-tts', 'OpenAI TTS');
this.supportedVoices = [
{ id: 'alloy', name: 'Alloy', language: 'en' },
{ id: 'ash', name: 'Ash', language: 'en' },
{ id: 'coral', name: 'Coral', language: 'en' },
{ id: 'echo', name: 'Echo', language: 'en' },
{ id: 'fable', name: 'Fable', language: 'en' },
{ id: 'nova', name: 'Nova', language: 'en' },
{ id: 'onyx', name: 'Onyx', language: 'en' },
{ id: 'sage', name: 'Sage', language: 'en' },
{ id: 'shimmer', name: 'Shimmer', language: 'en' }
];
// Voice options specific to OpenAI
this.voiceOptions = {
@@ -16,20 +28,8 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
response_format: 'mp3' // OpenAI supports mp3, opus, aac, and flac (not wav)
};
// Predefined voices - OpenAI has a fixed set
this.voices = [
{ id: 'alloy', name: 'Alloy', language: 'en' },
{ id: 'ash', name: 'Ash', language: 'en' },
{ id: 'ballad', name: 'Ballad', language: 'en' },
{ id: 'coral', name: 'Coral', 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: 'sage', name: 'Sage', language: 'en' },
{ id: 'shimmer', name: 'Shimmer', language: 'en' },
{ id: 'verse', name: 'Verse', language: 'en' }
];
// OpenAI has a documented fixed voice set for this speech endpoint.
this.voices = [...this.supportedVoices];
}
/**
@@ -65,14 +65,16 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
// API key is already loaded in parent initialize() method
// Just check if it's available
if (!this.apiKey) {
console.error('OpenAI TTS: API key not configured');
return false;
console.info('OpenAI TTS: API key not configured; provider unavailable until configured');
this.isReady = false;
this.reportProgress(100, 'OpenAI TTS not configured');
return true;
}
// Load preferences
const preferredVoice = persistenceManager.getPreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
if (preferredVoice) {
this.voiceOptions.voice = preferredVoice;
this.voiceOptions.voice = this.normalizeVoiceId(preferredVoice);
}
const preferredModel = persistenceManager.getPreference('tts', `${this.id}_model`, this.voiceOptions.model);
@@ -80,13 +82,17 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
this.voiceOptions.model = preferredModel;
}
const preferredSpeed = persistenceManager.getPreference('tts', `${this.id}_speed`, this.voiceOptions.speed);
const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed);
if (typeof preferredSpeed === 'number') {
this.voiceOptions.speed = preferredSpeed;
this.voiceOptions.speed = this.getApiSpeed(preferredSpeed);
}
// Setup available voices
this.voices = this.getAvailableVoices();
const apiReachable = await this.loadVoices();
if (!apiReachable) {
this.isReady = false;
this.reportProgress(100, 'OpenAI TTS not ready');
return true;
}
this.isReady = true;
this.reportProgress(100, 'OpenAI TTS initialized');
@@ -103,8 +109,31 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
* @returns {Promise<boolean>} - Resolves with success status
*/
async loadVoices() {
// OpenAI has a fixed set of voices, no need to fetch them
return true;
// OpenAI exposes a documented fixed TTS voice set, not a voice-list
// endpoint. Use /models as a lightweight credential/endpoint check.
this.voices = this.getAvailableVoices();
if (!this.apiKey) {
return true;
}
try {
const response = await fetch(`${this.apiBaseUrl}/models`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`
}
});
if (!response.ok) {
console.error(`OpenAI TTS: API validation failed ${response.status} ${response.statusText}`);
return false;
}
return true;
} catch (error) {
console.error('OpenAI TTS: API validation error:', error);
return false;
}
}
/**
@@ -135,6 +164,7 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
* @returns {Array} - Array of voice objects
*/
getAvailableVoices() {
this.voices = [...this.supportedVoices];
return this.voices;
}
@@ -156,9 +186,9 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
const payload = {
model: this.voiceOptions.model || 'tts-1',
input: processedText,
voice: this.voiceOptions.voice || 'alloy',
voice: this.normalizeVoiceId(this.voiceOptions.voice),
response_format: this.voiceOptions.response_format || 'mp3',
speed: this.voiceOptions.speed || 1.0
speed: this.getApiSpeed(this.voiceOptions.speed)
};
// Make API request
@@ -203,24 +233,19 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
setVoiceOptions(options = {}) {
// Handle common options
if (options.voice) {
this.voiceOptions.voice = options.voice;
this.voiceOptions.voice = this.normalizeVoiceId(options.voice);
// Save voice preference
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', `${this.id}_voice`, options.voice);
persistenceManager.updatePreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
}
}
if (typeof options.speed === 'number') {
// OpenAI API supports speed values from 0.25 to 4.0 with 1 as default
if (options.speed <= 0.5) {
// Map [0, 0.5] -> [0.25, 1]
this.voiceOptions.speed = 0.25 + (1 - 0.25) * (options.speed / 0.5);
} else {
// Map [0.5, 1] -> [1, 4]
this.voiceOptions.speed = 1 + (4 - 1) * ((options.speed - 0.5) / 0.5);
}
// OpenAI speech speed uses 1.0 as normal. The app-wide slider also
// uses 1.0 as normal, so only clamp at the provider API boundary.
this.voiceOptions.speed = this.getApiSpeed(options.speed);
}
// Handle OpenAI-specific options
@@ -248,8 +273,34 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
}
}
}
getVoiceId(voice) {
if (!voice) return '';
if (typeof voice === 'string') return voice;
return voice.id || voice.name || '';
}
normalizeVoiceId(voice) {
const voiceId = this.getVoiceId(voice).toLowerCase();
const supported = new Set(this.supportedVoices.map(item => item.id));
if (supported.has(voiceId)) {
return voiceId;
}
if (voiceId) {
console.warn(`OpenAI TTS: Unsupported voice "${voiceId}", falling back to alloy`);
}
return 'alloy';
}
getApiSpeed(speed) {
const value = Number.isFinite(speed) ? speed : 1.0;
return Math.max(0.25, Math.min(4.0, value));
}
}
const openAITTSModule = new OpenAITTSModule();
export { openAITTSModule };
export { openAITTSModule };
+127 -14
View File
@@ -45,7 +45,8 @@ class OptionsUIModule extends BaseModule {
'setupInitialState',
'dispatchApiChangeEvent',
'getPreference',
'updatePreference'
'updatePreference',
'renderProviderStatuses'
]);
}
@@ -183,20 +184,21 @@ class OptionsUIModule extends BaseModule {
const speedValue = document.createElement('span');
speedValue.className = 'slider-value';
speedValue.textContent = '100%';
this.elements.ttsSpeedValue = speedValue;
speedContainer.appendChild(speedValue);
this.elements.ttsSpeed = createUIElement('input', {
type: 'range',
min: 50,
max: 200,
max: 150,
value: 100,
'data-pref-bind': 'app.speed',
'data-pref-transform': 'range:0.5,2.0'
'data-pref-bind': 'tts.speed',
'data-pref-transform': 'centered-speed'
}, null, speedContainer);
// Update displayed value when slider changes
this.elements.ttsSpeed.addEventListener('input', () => {
speedValue.textContent = `${this.elements.ttsSpeed.value}%`;
this.updateSpeedDisplay();
});
appSettingsSection.appendChild(speedContainer);
@@ -239,6 +241,11 @@ class OptionsUIModule extends BaseModule {
}, null, ttsSystemContainer);
ttsSection.appendChild(ttsSystemContainer);
const providerStatusContainer = document.createElement('div');
providerStatusContainer.className = 'provider-status-list';
this.elements.providerStatus = providerStatusContainer;
ttsSection.appendChild(providerStatusContainer);
// TTS Voice
const ttsVoiceContainer = document.createElement('div');
@@ -510,7 +517,12 @@ class OptionsUIModule extends BaseModule {
if (this.elements.ttsSystem) {
this.elements.ttsSystem.addEventListener('change', async (event) => {
this.updateApiSettingsVisibility(event.target.value);
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
await ttsFactory.refreshHandlerStatus(event.target.value);
}
await this.populateVoices();
this.renderProviderStatuses();
});
}
@@ -595,6 +607,7 @@ class OptionsUIModule extends BaseModule {
// Update API settings visibility
this.updateApiSettingsVisibility(this.elements.ttsSystem.value);
this.renderProviderStatuses();
}
/**
@@ -604,8 +617,10 @@ class OptionsUIModule extends BaseModule {
const ttsFactory = this.getModule('tts-factory');
if (!ttsFactory || !this.elements.ttsVoice) return;
// Get voices for current TTS system
const voices = await ttsFactory.getVoices() || [];
const selectedHandler = this.elements.ttsSystem?.value || this.getPreference('tts', 'preferred_handler', 'none');
const voices = typeof ttsFactory.getVoicesForHandler === 'function'
? await ttsFactory.getVoicesForHandler(selectedHandler) || []
: await ttsFactory.getVoices() || [];
console.log('Options UI: TTS voices:', voices);
// Populate dropdown
@@ -614,9 +629,35 @@ class OptionsUIModule extends BaseModule {
voices,
'id',
'name',
this.getPreference('tts', 'voice', '')
this.getPreference('tts', `${selectedHandler}_voice`, this.getPreference('tts', 'voice', ''))
);
}
renderProviderStatuses() {
const container = this.elements.providerStatus;
const ttsFactory = this.getModule('tts-factory');
if (!container || !ttsFactory || typeof ttsFactory.getHandlerStatuses !== 'function') {
return;
}
container.innerHTML = '';
const statuses = ttsFactory.getHandlerStatuses();
statuses.forEach(status => {
const row = document.createElement('div');
row.className = 'provider-status-row';
const name = document.createElement('span');
name.textContent = status.name;
row.appendChild(name);
const value = document.createElement('span');
value.className = 'provider-status-value';
value.textContent = `${status.ready ? 'ready' : 'not ready'} - ${status.message}`;
row.appendChild(value);
container.appendChild(row);
});
}
/**
* Populate the languages dropdown
@@ -737,6 +778,7 @@ class OptionsUIModule extends BaseModule {
document.addEventListener('tts:voices:updated', () => {
console.log('Options UI: Received tts:voices:updated event, updating voice dropdown');
this.populateVoices();
this.renderProviderStatuses();
});
// Set up language change listener
@@ -750,6 +792,36 @@ class OptionsUIModule extends BaseModule {
console.log('Options UI: Received TTS engine change event:', event.detail);
await this.populateVoices();
this.updateApiSettingsVisibility(this.elements.ttsSystem.value);
this.renderProviderStatuses();
});
document.addEventListener('tts:status:updated', () => {
this.renderProviderStatuses();
});
document.addEventListener('tts:enabled:change', async (event) => {
if (!event.detail || typeof event.detail.enabled !== 'boolean') {
return;
}
if (this.elements.ttsEnabled) {
this.elements.ttsEnabled.checked = event.detail.enabled;
}
const ttsFactory = this.getModule('tts-factory');
if (!ttsFactory) {
return;
}
if (event.detail.enabled) {
const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none');
if (preferredHandler !== 'none') {
await ttsFactory.setActiveHandler(preferredHandler);
}
} else {
await ttsFactory.disableAfterCurrentPlayback();
}
this.renderProviderStatuses();
});
}
@@ -766,6 +838,7 @@ class OptionsUIModule extends BaseModule {
// Setup all bindings in the modal
this.bindings = persistenceManager.setupBindings('#options-modal');
console.log('Options UI: Preference bindings set up', this.bindings.length);
this.updateSpeedDisplay();
// Add event listeners for side effects when preferences change
document.addEventListener('preference-updated', (event) => {
@@ -793,16 +866,46 @@ class OptionsUIModule extends BaseModule {
if (!ttsFactory) return;
if (key === 'preferred_handler') {
this.populateVoices();
const enabled = this.getPreference('tts', 'enabled', false);
const activation = enabled && value !== 'none'
? ttsFactory.setActiveHandler(value)
: Promise.resolve(ttsFactory.disableAfterCurrentPlayback());
activation.then(() => {
this.populateVoices();
this.renderProviderStatuses();
});
this.updateApiSettingsVisibility(value);
} else if (key === 'voice') {
ttsFactory.configure({ voice: value });
} else if (key === 'speed') {
ttsFactory.configure({ speed: value });
} else if (key === 'language') {
ttsFactory.configure({ language: value });
} else if (key === 'enabled') {
ttsFactory.configure({ enabled: value });
}
if (!value) {
ttsFactory.disableAfterCurrentPlayback();
} else {
const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none');
if (preferredHandler !== 'none') {
ttsFactory.setActiveHandler(preferredHandler);
}
}
document.dispatchEvent(new CustomEvent('tts:enabled:change', {
detail: { enabled: value }
}));
} else if (key.endsWith('_api_key')) {
const provider = key.replace('_api_key', '');
this.dispatchApiChangeEvent('api:keyChanged', provider, 'key', value);
ttsFactory.refreshHandlerStatus(provider).then(() => this.renderProviderStatuses());
} else if (key.endsWith('_api_url')) {
const provider = key.replace('_api_url', '');
this.dispatchApiChangeEvent('api:urlChanged', provider, 'url', value);
ttsFactory.refreshHandlerStatus(provider).then(() => this.renderProviderStatuses());
}
if (key === 'speed' && this.elements.ttsSpeed) {
this.updateSpeedDisplay();
}
}
// Handle locale changes
if (category === 'app' && key === 'locale') {
@@ -810,16 +913,26 @@ class OptionsUIModule extends BaseModule {
if (localization) {
localization.setLocale(value);
}
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
ttsFactory.configure({ language: value });
}
this.updatePreference('tts', 'language', value);
}
});
}
updateSpeedDisplay() {
if (!this.elements.ttsSpeed || !this.elements.ttsSpeedValue) {
return;
}
this.elements.ttsSpeedValue.textContent = `${this.elements.ttsSpeed.value}%`;
}
}
// Create the singleton instance
const OptionsUI = new OptionsUIModule();
// Register with the module registry
moduleRegistry.register(OptionsUI);
// Export the module
export { OptionsUI };
+123 -135
View File
@@ -8,14 +8,12 @@ import { BaseModule } from './base-module.js';
class ParagraphLayoutModule extends BaseModule {
constructor() {
super('paragraph-layout', 'Paragraph Layout');
// Module dependencies
this.dependencies = ['text-processor'];
// Caching canvas context for text measurements
this.textMeasureCtx = null;
// Configuration - use parent's config system
this.ruler = null;
this.rulerStack = [];
this.updateConfig({
maxLineWidth: 600,
hyphenationEnabled: true,
@@ -24,8 +22,7 @@ class ParagraphLayoutModule extends BaseModule {
defaultLineHeight: 1.5,
debugMode: false
});
// Bind methods using parent's bindMethods utility
this.bindMethods([
'calculateLayout',
'measureText',
@@ -37,27 +34,20 @@ class ParagraphLayoutModule extends BaseModule {
'loadLayoutDependencies'
]);
}
async initialize() {
try {
this.reportProgress(20, "Initializing paragraph layout");
// Get text processor using parent's getModule method
const textProcessor = this.getModule('text-processor');
if (!textProcessor) {
console.warn("Paragraph Layout: Text Processor not found, will use fallback processing");
console.warn("Paragraph Layout: Text Processor not found, will use unprocessed text");
}
// Load required dependencies
await this.loadLayoutDependencies();
// Create off-screen canvas for text measurements
this.initializeTextMeasurement();
// Set up event listeners for config changes
this.setupEventListeners();
this.reportProgress(100, "Paragraph layout ready");
return true;
} catch (error) {
@@ -65,23 +55,15 @@ class ParagraphLayoutModule extends BaseModule {
return false;
}
}
/**
* Load required dependencies for layout calculations
*/
async loadLayoutDependencies() {
try {
this.reportProgress(30, "Loading layout dependencies");
// Load LinkedList.js first as it's required by linebreak.js
await this.loadScript('/js/linked-list.js');
// Load linebreak.js which is required by knuth-and-plass.js
await this.loadScript('/js/linebreak.js');
// Load knuth-and-plass.js which contains the kap function
await this.loadScript('/js/knuth-and-plass.js');
this.reportProgress(50, "Layout dependencies loaded");
return true;
} catch (error) {
@@ -89,148 +71,163 @@ class ParagraphLayoutModule extends BaseModule {
return false;
}
}
/**
* Initialize text measurement canvas
*/
initializeTextMeasurement() {
// Create off-screen canvas for text measurements
const canvas = document.createElement('canvas');
canvas.width = 2000;
canvas.height = 100;
this.textMeasureCtx = canvas.getContext('2d');
// Set default font
let ruler = document.getElementById('ruler');
if (!ruler) {
ruler = document.createElement('span');
ruler.id = 'ruler';
document.body.appendChild(ruler);
}
Object.assign(ruler.style, {
visibility: 'hidden',
position: 'absolute',
top: '-8000px',
left: '-8000px',
width: 'auto',
display: 'inline',
textIndent: '0',
textAlign: 'left',
hyphens: 'none',
marginBlockEnd: '0'
});
this.ruler = ruler;
this.rulerStack = [ruler];
this.updateFont(this.config.defaultFontSize, this.config.defaultFontFamily);
}
setupEventListeners() {
// Use parent's addEventListener for automatic cleanup
this.addEventListener(document, 'ui:font:change', (event) => {
if (event.detail) {
const { fontSize, fontFamily } = event.detail;
if (fontSize || fontFamily) {
this.updateFont(
fontSize || this.config.defaultFontSize,
fontSize || this.config.defaultFontSize,
fontFamily || this.config.defaultFontFamily
);
}
}
});
// Listen for config changes
this.addEventListener(document, 'ui:hyphenation:toggle', (event) => {
if (event.detail && typeof event.detail.enabled === 'boolean') {
this.updateConfig({ hyphenationEnabled: event.detail.enabled });
}
});
}
/**
* Update the font for text measurements
* @param {string} fontSize - Font size (with units)
* @param {string} fontFamily - Font family
*/
updateFont(fontSize, fontFamily) {
if (!this.textMeasureCtx) {
console.warn("Text measurement context not initialized");
if (!this.ruler) {
console.warn("Text measurement ruler not initialized");
return;
}
// Update config if values are provided
if (fontSize) this.updateConfig({ defaultFontSize: fontSize });
if (fontFamily) this.updateConfig({ defaultFontFamily: fontFamily });
// Set font on measurement context
this.textMeasureCtx.font = `${fontSize} ${fontFamily}`;
this.ruler.style.fontSize = fontSize;
this.ruler.style.fontFamily = fontFamily;
this.ruler.style.fontFeatureSettings = "'kern' on, 'liga' on, 'onum' on, 'clig' on, 'hlig' on";
if (this.config.debugMode) {
console.log(`Font updated: ${fontSize} ${fontFamily}`);
}
}
/**
* Measure text width using canvas context
* @param {string} text - Text to measure
* @returns {number} - Text width in pixels
*/
measureText(text) {
if (!this.textMeasureCtx) return 0;
if (!this.ruler) return 0;
if (!text) return 0;
const metrics = this.textMeasureCtx.measureText(text);
return metrics.width;
if (text.substr(0, 2) === '</') {
if (this.rulerStack.length > 1) {
const child = this.rulerStack.pop();
const parent = this.rulerStack[this.rulerStack.length - 1];
if (child.parentElement === parent) {
parent.removeChild(child);
}
}
return 0;
}
if (text.substr(0, 1) === '<') {
const template = document.createElement('div');
template.innerHTML = text;
const child = template.firstChild;
if (child) {
const parent = this.rulerStack[this.rulerStack.length - 1];
this.rulerStack.push(child);
parent.appendChild(child);
}
return 0;
}
if (text === '|') return 0;
if (text === ' ') text = '\u00A0';
const parent = this.rulerStack[this.rulerStack.length - 1];
const textNode = document.createTextNode(text);
parent.appendChild(textNode);
const rect = parent.getClientRects()[0] || parent.getBoundingClientRect();
const width = rect ? rect.width : 0;
parent.removeChild(textNode);
return width;
}
/**
* Process text for layout (apply hyphenation and smartypants)
* @param {string} text - Text to process
* @returns {string} - Processed text
*/
processTextForLayout(text) {
if (!text) return '';
let processedText = text;
const textProcessor = this.getModule('text-processor');
// Apply text processing if available
if (textProcessor) {
// Apply smartypants (typographic punctuation) if available
if (typeof textProcessor.applySmartypants === 'function') {
processedText = textProcessor.applySmartypants(processedText);
}
// Apply hyphenation if enabled and available
if (this.config.hyphenationEnabled && typeof textProcessor.hyphenateText === 'function') {
processedText = textProcessor.hyphenateText(processedText);
}
if (textProcessor && typeof textProcessor.process === 'function') {
processedText = textProcessor.process(processedText, {
smartypants: true,
hyphenate: this.config.hyphenationEnabled,
hyphenSelector: '.hyphenatePipe'
});
} else if (this.config.debugMode) {
console.log("Text processor not available, skipping text processing");
}
return processedText;
}
/**
* Calculate layout for a paragraph using Knuth and Plass algorithm
* @param {string} text - Text to layout
* @param {Object} options - Layout options
* @returns {Object} - Layout data with line breaks
*/
calculateLayout(text, options = {}) {
if (!text) return null;
try {
// Check if the kap function is available
if (typeof window.kap !== 'function') {
console.error("Paragraph Layout: kap function not available. Make sure knuth-and-plass.js is loaded.");
return null;
}
// Process text for layout (hyphenation, etc)
const processedText = this.processTextForLayout(text);
// Prepare options by merging with defaults
const layoutOptions = {
width: options.width || this.config.maxLineWidth,
fontSize: options.fontSize || this.config.defaultFontSize,
fontFamily: options.fontFamily || this.config.defaultFontFamily,
lineHeight: options.lineHeight || this.config.defaultLineHeight,
tolerance: options.tolerance || 3, // Tolerance for line breaking algorithm
lineHeightPx: options.lineHeightPx,
tolerance: options.tolerance || 3,
demerits: options.demerits || {
line: 10, // Demerits for each line break
flagged: 100, // Demerits for flagged break points (like hyphens)
fitness: 3000 // Demerits for consecutive lines with very different looseness/tightness
line: 10,
flagged: 100,
fitness: 3000
}
};
// Update font for measurement
this.updateFont(layoutOptions.fontSize, layoutOptions.fontFamily);
// Create measure array - this is crucial for proper line breaking
// The first value is the full width, subsequent values can be for indented lines
const measure = [layoutOptions.width];
const numericFontSize = parseFloat(layoutOptions.fontSize) || 16;
const lineHeightPx = layoutOptions.lineHeightPx || (numericFontSize * layoutOptions.lineHeight);
const measure = options.measures || [layoutOptions.width];
if (this.config.debugMode) {
console.log("Paragraph Layout: Calculating layout for text", {
text: processedText,
@@ -238,8 +235,7 @@ class ParagraphLayoutModule extends BaseModule {
options: layoutOptions
});
}
// Use the global Knuth and Plass algorithm function with proper parameters
const layout = window.kap(
processedText,
this.measureText.bind(this),
@@ -248,55 +244,47 @@ class ParagraphLayoutModule extends BaseModule {
layoutOptions.tolerance,
layoutOptions.demerits
);
// If layout failed, return null
if (!layout || !layout.breaks || !layout.nodes) {
console.warn("Paragraph Layout: Failed to calculate layout for text");
return null;
}
if (this.config.debugMode) {
console.log("Paragraph Layout: Layout calculated", {
breaks: layout.breaks.length,
nodes: layout.nodes.length
});
}
// Return layout data with original text for reference
return {
...layout,
originalText: text,
processedText: processedText,
processedText,
width: layoutOptions.width,
lineHeight: layoutOptions.lineHeight
lineHeight: layoutOptions.lineHeight,
lineHeightPx,
fontSize: layoutOptions.fontSize,
fontFamily: layoutOptions.fontFamily
};
} catch (error) {
console.error("Error calculating layout:", error);
return null;
}
}
/**
* Set debug mode
* @param {boolean} enabled - Whether debug mode should be enabled
*/
setDebugMode(enabled) {
// Use parent's updateConfig method
this.updateConfig({ debugMode: enabled });
console.log(`Paragraph Layout: Debug mode ${enabled ? 'enabled' : 'disabled'}`);
}
}
// Create the singleton instance
const ParagraphLayout = new ParagraphLayoutModule();
// Register with the module registry
if (window.moduleRegistry) {
window.moduleRegistry.register(ParagraphLayout);
}
// Export the module
export { ParagraphLayout };
// Keep a reference in window for loader system
window.ParagraphLayout = ParagraphLayout;
Binary file not shown.
+18 -1
View File
@@ -32,6 +32,8 @@ class PersistenceManagerModule extends BaseModule {
tts: {
enabled: false,
preferred_handler: 'none',
speed: 1.0,
language: 'en-us',
voice: '',
'elevenlabs-tts_api_key': '',
'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1',
@@ -260,6 +262,11 @@ class PersistenceManagerModule extends BaseModule {
if (!this.preferences[category]) {
this.preferences[category] = {};
}
if (Object.prototype.hasOwnProperty.call(this.preferences[category], setting) &&
Object.is(this.preferences[category][setting], value)) {
return true;
}
// Store value
this.preferences[category][setting] = value;
@@ -612,7 +619,17 @@ class PersistenceManagerModule extends BaseModule {
if (element.dataset.prefTransform) {
try {
// Check if it's a range transformer in format 'range:min,max'
if (element.dataset.prefTransform.startsWith('range:')) {
if (element.dataset.prefTransform === 'centered-speed') {
transformer = {
toElement: (value) => Math.round(((Number(value) || 1) * 50) + 50),
toPreference: (value) => Math.max(0.5, Math.min(2.0, (parseInt(value, 10) - 50) / 50))
};
} else if (element.dataset.prefTransform === 'multiplier-percent') {
transformer = {
toElement: (value) => Math.round((Number(value) || 1) * 100),
toPreference: (value) => Math.max(0.25, Math.min(4.0, parseInt(value, 10) / 100))
};
} else if (element.dataset.prefTransform.startsWith('range:')) {
const rangeValues = element.dataset.prefTransform.substring(6).split(',');
if (rangeValues.length === 2) {
const min = parseFloat(rangeValues[0]);
+356
View File
@@ -0,0 +1,356 @@
/**
* Playback Coordinator Module
* Synchronizes text animation with TTS audio playback to ensure exact timing match
*/
import { BaseModule } from './base-module.js';
class PlaybackCoordinatorModule extends BaseModule {
constructor() {
super('playback-coordinator', 'Playback Coordinator');
// Module dependencies
this.dependencies = ['animation-queue', 'tts-factory'];
// Current playback state
this.isPlaying = false;
this.currentSentence = null;
// Bind methods
this.bindMethods([
'play',
'calculateWordTimings',
'animateWords',
'waitForAudioStart',
'fastForward',
'stop'
]);
}
/**
* Initialize the module
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize() {
try {
this.reportProgress(50, "Initializing Playback Coordinator");
// Verify dependencies
const animQueue = this.getModule('animation-queue');
const ttsFactory = this.getModule('tts-factory');
if (!animQueue || !ttsFactory) {
console.error("PlaybackCoordinator: Missing required dependencies");
return false;
}
this.reportProgress(100, "Playback Coordinator ready");
return true;
} catch (error) {
console.error("Error initializing Playback Coordinator:", error);
return false;
}
}
/**
* Play a sentence with synchronized animation + TTS
* @param {Object} sentence - Prepared sentence object with layout, TTS, and animation data
* @returns {Promise<void>} - Resolves when playback completes
*/
async play(sentence) {
if (this.isPlaying) {
console.warn('PlaybackCoordinator: Already playing, canceling previous');
await this.stop();
}
this.isPlaying = true;
this.currentSentence = sentence;
try {
// Start TTS first, then begin text animation when the audio element
// confirms playback has started. Sentence preparation/prefetching is
// handled by SentenceQueue and can still run while this sentence plays.
const ttsPromise = this.playTTS(sentence);
await this.waitForAudioStart(sentence, ttsPromise);
const animPromise = this.animateWords(sentence);
// Wait for both to complete
await Promise.all([ttsPromise, animPromise]);
console.log(`PlaybackCoordinator: Completed sentence ${sentence.id}`);
} catch (error) {
console.error('PlaybackCoordinator: Error during playback:', error);
throw error;
} finally {
this.isPlaying = false;
this.currentSentence = null;
}
}
/**
* Play TTS audio for a sentence
* @param {Object} sentence - Sentence object with TTS data
* @returns {Promise<void>} - Resolves when TTS completes
*/
async playTTS(sentence) {
if (!sentence.tts || !sentence.tts.enabled) {
// TTS disabled, return immediately
return Promise.resolve();
}
try {
document.dispatchEvent(new CustomEvent('tts:playback-start', {
detail: { sentenceId: sentence.id }
}));
if (typeof sentence.tts.play === 'function') {
await sentence.tts.play();
} else {
console.warn('PlaybackCoordinator: TTS play function not available');
}
} catch (error) {
console.error('PlaybackCoordinator: TTS playback error:', error);
// Don't throw - allow animation to continue
} finally {
document.dispatchEvent(new CustomEvent('tts:playback-end', {
detail: { sentenceId: sentence.id }
}));
}
}
async waitForAudioStart(sentence, ttsPromise) {
if (!sentence.tts || !sentence.tts.enabled) {
return;
}
return new Promise((resolve) => {
let settled = false;
const cleanup = () => {
document.removeEventListener('tts:audio-started', onStarted);
document.removeEventListener('tts:playback-end', onEnded);
clearTimeout(timeout);
};
const finish = (reason) => {
if (settled) {
return;
}
settled = true;
cleanup();
console.log(`PlaybackCoordinator: Animation start released (${reason}) for ${sentence.id}`);
resolve();
};
const onStarted = () => finish('audio-started');
const onEnded = (event) => {
if (!event.detail || event.detail.sentenceId === sentence.id) {
finish('tts-ended-before-start');
}
};
const timeout = setTimeout(() => finish('audio-start-timeout'), 1500);
document.addEventListener('tts:audio-started', onStarted, { once: true });
document.addEventListener('tts:playback-end', onEnded);
Promise.resolve(ttsPromise).then(() => finish('tts-promise-resolved')).catch(() => finish('tts-promise-rejected'));
});
}
/**
* Animate words using calculated timings
* @param {Object} sentence - Sentence object with animation data and DOM element
* @returns {Promise<void>} - Resolves when animation completes
*/
async animateWords(sentence) {
if (!sentence.element || !sentence.animation || !sentence.animation.wordTimings) {
console.error('PlaybackCoordinator: Missing animation data');
return Promise.resolve();
}
const animQueue = this.getModule('animation-queue');
if (!animQueue) {
console.error('PlaybackCoordinator: Animation queue not available');
return Promise.resolve();
}
const wordElements = sentence.element.querySelectorAll('.word');
let wordTimings = sentence.animation.wordTimings;
let cueTimings = sentence.animation.cueTimings || [];
if (wordElements.length !== wordTimings.length) {
console.info(`PlaybackCoordinator: Word count mismatch (DOM: ${wordElements.length}, timings: ${wordTimings.length}); recalculating timings from rendered words`);
const renderedWords = Array.from(wordElements).map(word => word.textContent || '');
const duration = sentence.tts?.duration || sentence.animation.totalDuration || 0;
wordTimings = this.calculateWordTimings(renderedWords, duration).wordTimings;
cueTimings = cueTimings.map(cue => {
const wordIndex = Math.max(0, Math.min(cue.wordIndex || 0, wordTimings.length - 1));
return {
...cue,
delay: (wordTimings[wordIndex] || { delay: duration }).delay
};
});
}
return new Promise((resolve) => {
const totalDuration = wordTimings.length > 0
? Math.max(...wordTimings.map(timing => timing.delay + timing.duration))
: 0;
console.log(`PlaybackCoordinator: Animating ${wordTimings.length} words over ${totalDuration}ms`);
if (wordTimings.length > 0) {
console.log(` First word delay: ${wordTimings[0].delay}ms, Last word delay: ${wordTimings[wordTimings.length-1].delay}ms`);
}
// Schedule each word animation
wordTimings.forEach((timing, i) => {
if (i < wordElements.length) {
animQueue.schedule(() => {
const word = wordElements[i];
const duration = Math.max(0, timing.duration || 0);
const transitionDuration = `${duration}ms`;
word.style.transition = `opacity ${transitionDuration} linear, transform ${transitionDuration} ease-out`;
word.style.visibility = 'visible';
word.style.opacity = '1';
word.style.transform = 'translateY(0)';
}, timing.delay);
}
});
cueTimings.forEach(cue => {
animQueue.schedule(() => {
document.dispatchEvent(new CustomEvent('story:media-cue', {
detail: {
sentenceId: sentence.id,
...cue
}
}));
}, cue.delay || 0);
});
// Schedule completion callback
animQueue.schedule(() => {
resolve();
}, totalDuration + 100); // Small buffer
});
}
/**
* Calculate word-level timing to match total TTS duration
* This is a utility method that can be called by SentenceQueue during preparation
* @param {Array<string>} words - Array of words to animate
* @param {number} totalDuration - Total duration in milliseconds
* @returns {Object} - Object with wordTimings array and totalDuration
*/
calculateWordTimings(words, totalDuration) {
if (!words || words.length === 0) {
return {
wordTimings: [],
totalDuration: 0
};
}
// Calculate characters per word
const totalChars = words.reduce((sum, word) => sum + word.length, 0);
if (totalChars === 0) {
// Edge case: all empty words
return {
wordTimings: words.map(word => ({ word, delay: 0, duration: 0 })),
totalDuration: 0
};
}
const msPerChar = totalDuration / totalChars;
let currentDelay = 0;
const wordTimings = words.map(word => {
const duration = word.length * msPerChar;
const timing = {
word: word,
delay: currentDelay,
duration: duration
};
currentDelay += duration;
return timing;
});
return {
wordTimings,
totalDuration: Math.round(currentDelay)
};
}
/**
* Fast forward current playback
* Completes all animations immediately and stops TTS
*/
async fastForward() {
if (!this.isPlaying || !this.currentSentence) {
return;
}
console.log('PlaybackCoordinator: Fast forwarding');
const animQueue = this.getModule('animation-queue');
if (animQueue) {
animQueue.fastForward();
}
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory && typeof ttsFactory.fadeOut === 'function') {
await ttsFactory.fadeOut(1000);
} else if (this.currentSentence.tts && typeof this.currentSentence.tts.stop === 'function') {
await new Promise(resolve => {
setTimeout(() => {
this.currentSentence.tts.stop();
resolve();
}, 1000);
});
}
// Complete all word animations immediately
if (this.currentSentence.element) {
const wordElements = this.currentSentence.element.querySelectorAll('.word');
wordElements.forEach(word => {
word.style.opacity = '1';
word.style.transform = 'translateY(0)';
});
}
}
/**
* Stop current playback
*/
async stop() {
if (!this.isPlaying) {
return;
}
console.log('PlaybackCoordinator: Stopping');
// Stop TTS
if (this.currentSentence && this.currentSentence.tts && typeof this.currentSentence.tts.stop === 'function') {
this.currentSentence.tts.stop();
}
// Clear animation queue
const animQueue = this.getModule('animation-queue');
if (animQueue) {
animQueue.clearAll();
}
this.isPlaying = false;
this.currentSentence = null;
}
}
// Create the singleton instance
const PlaybackCoordinator = new PlaybackCoordinatorModule();
// Export the module
export { PlaybackCoordinator };
// Register with the module registry
if (window.moduleRegistry) {
window.moduleRegistry.register(PlaybackCoordinator);
}
// Keep a reference in window for loader system
window.PlaybackCoordinator = PlaybackCoordinator;
+356 -50
View File
@@ -9,20 +9,29 @@ class SentenceQueueModule extends BaseModule {
super('sentence-queue', 'Sentence Queue');
// Dependencies
this.dependencies = ['text-buffer', 'tts-factory', 'tts-player'];
this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager'];
// Queue state
this.sentenceQueue = [];
this.isProcessing = false;
this.onSentenceReadyCallback = null;
// Cache for prefetched sentences
this.preparedCache = new Map();
// Bind methods
this.bindMethods([
'initialize',
'addSentence',
'processNextSentence',
'setOnSentenceReady',
'completeSentence'
'completeSentence',
'prepareSentence',
'prepareLayout',
'extractWords',
'getDropCapText',
'extractDropCapText',
'calculateAnimationTiming'
]);
}
@@ -69,9 +78,13 @@ class SentenceQueueModule extends BaseModule {
* @param {Function} callback - Callback to call when sentence is processed
*/
addSentence(sentence, callback) {
const queueItem = typeof sentence === 'object' && sentence !== null
? { ...sentence, callback }
: { text: sentence, callback };
this.sentenceQueue.push({
text: sentence,
callback: callback
...queueItem,
text: String(queueItem.text || '').trim()
});
// Process the queue if not already processing
@@ -87,36 +100,78 @@ class SentenceQueueModule extends BaseModule {
if (this.sentenceQueue.length === 0 || this.isProcessing) {
return;
}
this.isProcessing = true;
const item = this.sentenceQueue[0]; // Don't remove yet
const item = this.sentenceQueue[0];
try {
// Get TTS Factory
const ttsFactory = this.getModule('tts-factory');
if (!ttsFactory) {
console.error("SentenceQueue: TTSFactory dependency not found");
this.completeSentence(item, { success: false, reason: 'no_tts_factory' });
return;
}
// Create a speech metadata object
const speechMetadata = await this.prepareSpeechMetadata(item.text);
// If we have a callback for ready sentences, call it with the metadata
if (this.onSentenceReadyCallback) {
this.onSentenceReadyCallback(item.text, speechMetadata, () => {
// Remove from queue and process next
this.completeSentence(item, { success: true });
});
// Check if sentence is already in cache
const cacheKey = `${item.id || ''}:${item.text}`;
let sentence = this.preparedCache.get(cacheKey);
if (!sentence) {
// Prepare complete sentence object (TTS + layout in parallel)
sentence = await this.prepareSentence(item);
} else {
// No callback, just complete
this.completeSentence(item, { success: true });
console.log('SentenceQueue: Using cached sentence');
this.preparedCache.delete(cacheKey);
}
// Prefetch next sentence while current displays
if (this.sentenceQueue.length > 1) {
const nextItem = this.sentenceQueue[1];
const nextCacheKey = `${nextItem.id || ''}:${nextItem.text}`;
if (!this.preparedCache.has(nextCacheKey)) {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-generating', reason: 'prefetch-start', sentenceId: nextItem.id }
}));
console.log('Process state: playing-generating', { reason: 'prefetch-start', sentenceId: nextItem.id });
this.prepareSentence(nextItem)
.then(prepared => {
this.preparedCache.set(nextCacheKey, prepared);
console.log('SentenceQueue: Prefetched next sentence');
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id }
}));
console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id });
})
.catch(err => console.warn('SentenceQueue: Prefetch failed:', err));
}
} else {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: item.id }
}));
console.log('Process state: playing-ready', { reason: 'no-prefetch-needed', sentenceId: item.id });
}
// Notify display handler with complete sentence
if (this.onSentenceReadyCallback) {
await new Promise(resolve => {
sentence.onComplete = resolve;
this.onSentenceReadyCallback(sentence, resolve);
});
}
// Remove from queue and continue
this.sentenceQueue.shift();
if (item.callback) item.callback({ success: true });
} catch (error) {
console.error("Error processing sentence:", error);
this.completeSentence(item, { success: false, reason: error.message });
console.error("SentenceQueue: Error processing sentence:", error);
if (item.callback) item.callback({ success: false, error });
} finally {
this.isProcessing = false;
if (this.sentenceQueue.length > 0) {
this.processNextSentence();
} else {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'queue-empty' }
}));
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
detail: { reason: 'sentence-queue-empty' }
}));
console.log('Process state: ready', { reason: 'queue-empty' });
}
}
}
@@ -127,14 +182,14 @@ class SentenceQueueModule extends BaseModule {
*/
async prepareSpeechMetadata(text) {
const ttsFactory = this.getModule('tts-factory');
const ttsPlayer = this.getModule('tts-player');
if (!ttsFactory || !ttsPlayer) {
if (!ttsFactory) {
throw new Error("TTS dependencies not found");
}
// Check if TTS is enabled
const isTtsEnabled = ttsPlayer.isEnabled();
// Check if TTS is enabled via active handler
const activeHandler = ttsFactory.getActiveHandler();
const isTtsEnabled = activeHandler !== null;
// If TTS is disabled, estimate duration based on character count
if (!isTtsEnabled) {
@@ -175,26 +230,19 @@ class SentenceQueueModule extends BaseModule {
* @returns {Object} - Speech metadata object with estimated duration
*/
estimateSpeechDuration(text) {
// Average reading speed is about 14-15 characters per second
// We'll use a slightly slower rate for TTS
// Average aloud narration is around 12 characters per second at 1x.
const charactersPerSecond = 12;
const ttsPlayer = this.getModule('tts-player');
// Get the current speed setting if available
let speedMultiplier = 1.0;
if (ttsPlayer) {
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
// Get the current speed setting (typically 0.5-2.0)
const speed = ttsFactory.speed || 1.0;
speedMultiplier = speed;
}
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
speedMultiplier = Number.isFinite(ttsFactory.speed) ? Math.max(0.25, ttsFactory.speed) : 1.0;
}
// Calculate estimated duration in milliseconds
const charCount = text.length;
const durationSeconds = charCount / (charactersPerSecond * speedMultiplier);
const durationMs = Math.max(durationSeconds * 1000, 500); // Minimum 500ms
const durationMs = Math.max(durationSeconds * 1000, 800);
return {
text: text,
@@ -207,6 +255,264 @@ class SentenceQueueModule extends BaseModule {
};
}
/**
* Prepare a complete sentence object with TTS and layout
* @param {string} text - Text to prepare
* @returns {Promise<Object>} - Complete sentence object
*/
async prepareSentence(item) {
const text = typeof item === 'string' ? item : item.text;
const id = item.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const metadata = typeof item === 'object' && item !== null ? item : {};
try {
if (metadata.type && metadata.type !== 'paragraph') {
if (metadata.type === 'music') {
const audioManager = this.getModule('audio-manager');
if (audioManager && typeof audioManager.playMusic === 'function') {
audioManager.getAssetUrl('music', metadata.filename);
}
}
return {
id,
kind: metadata.type,
text: text || '',
status: 'ready',
metadata,
tts: { duration: 0, provider: null, audioData: null, play: null, stop: null, enabled: false },
animation: { wordTimings: [], cueTimings: [], totalDuration: 0 },
element: null,
onComplete: null
};
}
const audioManager = this.getModule('audio-manager');
if (audioManager && typeof audioManager.preloadMediaCues === 'function') {
await audioManager.preloadMediaCues(metadata.cueMarkers || []);
}
// Prepare TTS and layout in parallel
const [ttsData, layoutData] = await Promise.all([
this.prepareSpeechMetadata(text),
this.prepareLayout(text, metadata)
]);
// Calculate animation timing based on TTS duration
const words = this.extractWords(layoutData.nodes);
const animation = this.calculateAnimationTiming(words, ttsData.duration, metadata.cueMarkers || []);
console.log(`SentenceQueue: Prepared sentence "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms, Words: ${words.length}, Animation total: ${animation.totalDuration}ms, Layout breaks: ${layoutData.breaks.length}`);
return {
id,
text,
paragraphIndex: metadata.paragraphIndex ?? null,
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
role: metadata.role || 'body',
dropCap: Boolean(metadata.dropCap),
addTopSpace: Boolean(metadata.addTopSpace),
cueMarkers: metadata.cueMarkers || [],
status: 'ready',
layout: layoutData,
tts: {
duration: ttsData.duration,
provider: ttsData.handler,
audioData: ttsData.audioData || null,
play: ttsData.play,
stop: ttsData.stop,
enabled: ttsData.isTtsEnabled
},
animation: animation,
element: null,
onComplete: null
};
} catch (error) {
console.error('SentenceQueue: Error preparing sentence:', error);
throw error;
}
}
/**
* Prepare layout for a sentence
* @param {string} text - Text to prepare layout for
* @returns {Promise<Object>} - Layout data
*/
async prepareLayout(text, metadata = {}) {
const paragraphLayout = this.getModule('paragraph-layout');
if (!paragraphLayout) {
throw new Error("ParagraphLayout module not found");
}
try {
if (document.fonts && document.fonts.ready) {
await document.fonts.ready;
}
// Calculate layout with Knuth-Plass
const storyElement = document.getElementById('story');
if (!storyElement) {
throw new Error("Story container not found");
}
// Get actual CSS values from the paragraph typography rule, not the
// container. The measured font and rendered font must be identical.
const containerWidth = storyElement.clientWidth;
const probe = document.createElement('p');
probe.style.visibility = 'hidden';
probe.style.position = 'absolute';
probe.style.left = '-8000px';
probe.style.top = '-8000px';
storyElement.appendChild(probe);
const computedStyle = window.getComputedStyle(probe);
const fontSize = parseFloat(computedStyle.fontSize);
const lineHeight = parseFloat(computedStyle.lineHeight);
const fontFamily = computedStyle.fontFamily;
probe.remove();
console.log(`SentenceQueue: Container metrics - width: ${containerWidth}px, fontSize: ${fontSize}px, lineHeight: ${lineHeight}px`);
// Standard book indentation: no indent on the first chapter paragraph,
// first-line indent on following paragraphs.
const dropCapLines = metadata.dropCap ? 2 : 0;
const dropCapWidth = metadata.dropCap ? lineHeight * 1.45 : 0;
const indentWidth = (metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
const layoutText = metadata.layoutText || text;
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
// Measures are consumed in line order by the line breaker.
const measures = metadata.dropCap
? [
Math.max(120, containerWidth - dropCapWidth),
Math.max(120, containerWidth - dropCapWidth),
containerWidth
]
: [
Math.max(120, containerWidth - indentWidth),
containerWidth,
containerWidth
];
console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}]`);
const layout = paragraphLayout.calculateLayout(layoutPlainText, {
measures,
fontSize: `${fontSize}px`,
fontFamily,
lineHeight: lineHeight / fontSize,
lineHeightPx: lineHeight
});
if (!layout) {
throw new Error('Paragraph layout calculation failed');
}
return {
breaks: layout.breaks,
nodes: layout.nodes,
processedText: layout.processedText || text,
sourceLayoutText: layoutText,
measures,
indentWidth,
dropCap: Boolean(metadata.dropCap),
dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '',
dropCapLines,
addTopSpace: Boolean(metadata.addTopSpace),
role: metadata.role || 'body',
fontSize: layout.fontSize,
fontFamily: layout.fontFamily,
lineHeight: layout.lineHeight,
lineHeightPx: layout.lineHeightPx
};
} catch (error) {
console.error('SentenceQueue: Error preparing layout:', error);
throw error;
}
}
/**
* Extract words from layout nodes
* @param {Array} nodes - Layout nodes from Knuth-Plass algorithm
* @returns {Array<string>} - Array of words
*/
extractWords(nodes) {
if (!nodes || !Array.isArray(nodes)) {
return [];
}
return nodes
.filter(node => node.type === 'box')
.map(node => node.value || '');
}
getDropCapText(text) {
const plain = String(text || '').replace(/<[^>]+>/g, '');
const match = plain.match(/^([“"']?[A-Za-zÀ-ÖØ-öø-ÿ])/u);
return match ? match[1] : '';
}
extractDropCapText(text) {
const dropCap = this.getDropCapText(text);
if (!dropCap) return text;
return String(text).replace(dropCap, '').trimStart();
}
/**
* Calculate animation timing based on TTS duration
* @param {Array<string>} words - Array of words to animate
* @param {number} totalDuration - Total duration in milliseconds
* @returns {Object} - Animation timing data
*/
calculateAnimationTiming(words, totalDuration, cueMarkers = []) {
if (!words || words.length === 0) {
return {
wordTimings: [],
cueTimings: [],
totalDuration: 0
};
}
const totalChars = words.reduce((sum, word) => sum + word.length, 0);
if (totalChars === 0) {
return {
wordTimings: words.map(word => ({ word, delay: 0, duration: 0 })),
cueTimings: [],
totalDuration: 0
};
}
const msPerChar = totalDuration / totalChars;
let currentDelay = 0;
const wordTimings = words.map(word => {
const duration = word.length * msPerChar;
const timing = {
word: word,
delay: currentDelay,
duration: duration
};
currentDelay += duration;
return timing;
});
const cueTimings = (cueMarkers || []).map(cue => {
const wordIndex = Math.max(0, Math.min(cue.wordIndex || 0, wordTimings.length - 1));
const timing = wordTimings[wordIndex] || { delay: currentDelay };
return {
...cue,
delay: timing.delay
};
});
return {
wordTimings,
cueTimings,
totalDuration: Math.round(currentDelay)
};
}
/**
* Complete processing of a sentence
* @param {Object} item - Queue item
+63 -36
View File
@@ -27,6 +27,13 @@ class SocketClientModule extends BaseModule {
'disconnect',
'send',
'sendCommand',
'callGameApi',
'newGame',
'loadGame',
'saveGame',
'hasSaveGame',
'getSaveGames',
'isGameRunning',
'requestStartGame',
'requestSaveGame',
'requestLoadGame',
@@ -186,6 +193,9 @@ class SocketClientModule extends BaseModule {
// Add text to the buffer if available
if (this.textBuffer) {
console.log(`Socket Client: Processing text fragment: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'waiting-generating', reason: 'server-response-received' }
}));
this.textBuffer.addText(text);
} else {
console.error('Socket Client: Text buffer not available');
@@ -277,6 +287,9 @@ class SocketClientModule extends BaseModule {
try {
this.socket.emit('playerCommand', { command });
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'command-waiting', reason: 'command-sent', command }
}));
return true;
} catch (error) {
console.error('Socket Client: Error sending command:', error);
@@ -284,23 +297,57 @@ class SocketClientModule extends BaseModule {
}
}
async callGameApi(method, args = []) {
if (!this.isConnected || !this.socket) {
const connected = await this.connect();
if (!connected || !this.socket) {
return { success: false, error: 'not_connected' };
}
}
return new Promise((resolve) => {
if (!this.socket) {
resolve({ success: false, error: 'not_connected' });
return;
}
this.socket.emit('gameApi', { method, args }, (response) => {
resolve(response || { success: false, error: 'empty_response' });
});
});
}
newGame() {
return this.callGameApi('newGame', []);
}
loadGame(slot = 1) {
return this.callGameApi('loadGame', [slot]);
}
saveGame(slot = 1) {
return this.callGameApi('saveGame', [slot]);
}
hasSaveGame(slot = 1) {
return this.callGameApi('hasSaveGame', [slot]);
}
getSaveGames() {
return this.callGameApi('getSaveGames', []);
}
isGameRunning() {
return this.callGameApi('isGameRunning', []);
}
/**
* Request to start a new game
* @returns {boolean} - Success status
*/
requestStartGame() {
if (!this.isConnected || !this.socket) {
console.error('Socket Client: Not connected, cannot start game');
return false;
}
try {
this.socket.emit('startGame');
return true;
} catch (error) {
console.error('Socket Client: Error starting game:', error);
return false;
}
this.newGame();
return true;
}
/**
@@ -308,18 +355,8 @@ class SocketClientModule extends BaseModule {
* @returns {boolean} - Success status
*/
requestSaveGame() {
if (!this.isConnected || !this.socket) {
console.error('Socket Client: Not connected, cannot save game');
return false;
}
try {
this.socket.emit('saveGame');
return true;
} catch (error) {
console.error('Socket Client: Error saving game:', error);
return false;
}
this.saveGame(1);
return true;
}
/**
@@ -327,18 +364,8 @@ class SocketClientModule extends BaseModule {
* @returns {boolean} - Success status
*/
requestLoadGame() {
if (!this.isConnected || !this.socket) {
console.error('Socket Client: Not connected, cannot load game');
return false;
}
try {
this.socket.emit('loadGame');
return true;
} catch (error) {
console.error('Socket Client: Error loading game:', error);
return false;
}
this.loadGame(1);
return true;
}
/**
+83 -13
View File
@@ -13,10 +13,15 @@ class TextBufferModule extends BaseModule {
this.processingLock = false;
this.processingQueue = [];
this.isProcessingActive = false;
this.paragraphCounter = 0;
this.currentTextBlockId = 0;
this.markupParser = null;
this.dependencies = ['markup-parser'];
// Bind methods using parent's bindMethods utility
this.bindMethods([
'addText',
'splitIntoParagraphs',
'processNextFromQueue',
'processSentences',
'processNextSentence',
@@ -33,6 +38,11 @@ class TextBufferModule extends BaseModule {
*/
async initialize() {
try {
this.markupParser = this.getModule('markup-parser');
if (!this.markupParser) {
console.warn("TextBuffer: Markup parser not found, using plain paragraph splitting");
}
// Use parent's addEventListener for automatic cleanup
this.addEventListener(document, 'ui:paragraph:complete', (event) => {
if (this.processingQueue.length > 0 && !this.isProcessingActive) {
@@ -83,9 +93,39 @@ class TextBufferModule extends BaseModule {
if (!text) return;
console.log(`TextBuffer: Adding text: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
// Add to processing queue instead of directly to buffer
this.processingQueue.push(text);
const blocks = this.markupParser && typeof this.markupParser.parse === 'function'
? this.markupParser.parse(text)
: this.splitIntoParagraphs(text).map(paragraphText => ({
type: 'paragraph',
text: paragraphText,
layoutText: paragraphText,
cueMarkers: [],
role: 'body',
isFirstParagraphInChapter: false
}));
blocks.forEach(block => {
if (block.type === 'paragraph') {
const paragraphId = `paragraph-${this.paragraphCounter + 1}`;
this.processingQueue.push({
...block,
id: paragraphId,
paragraphIndex: this.paragraphCounter,
textBlockId: this.currentTextBlockId
});
this.paragraphCounter += 1;
} else {
this.processingQueue.push({
...block,
id: `${block.type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
});
if (block.type === 'image') {
this.currentTextBlockId += 1;
}
}
});
// Process the queue if not already processing
if (!this.isProcessingActive && this.onSentenceReadyCallback) {
@@ -94,6 +134,20 @@ class TextBufferModule extends BaseModule {
console.log('TextBuffer: Text queued for processing');
}
}
/**
* Split an incoming narrative fragment into book paragraphs.
* Single newlines inside a paragraph are normalized to spaces; blank lines
* mark distinct paragraphs.
* @param {string} text - Raw text fragment
* @returns {Array<string>} - Normalized paragraph strings
*/
splitIntoParagraphs(text) {
return String(text)
.split(/\n\s*\n/g)
.map(paragraph => paragraph.replace(/\s*\n\s*/g, ' ').trim())
.filter(Boolean);
}
/**
* Process the next text fragment from the queue
@@ -104,18 +158,32 @@ class TextBufferModule extends BaseModule {
}
this.isProcessingActive = true;
const text = this.processingQueue.shift();
const paragraph = this.processingQueue.shift();
console.log(`TextBuffer: Processing next fragment from queue, remaining: ${this.processingQueue.length}`);
// Add text to buffer
this.buffer += text;
// If we have a trailing newline as a complete sentence, add a period
this.buffer = this.buffer.replace(/\n$/g, '.\n');
// Process sentences
this.processSentences();
this.buffer = paragraph.text;
super.dispatchEvent('buffer:sentence', {
sentence: paragraph.text,
paragraph,
remaining: this.processingQueue.length
});
if (this.onSentenceReadyCallback) {
this.onSentenceReadyCallback(paragraph, () => {});
this.buffer = '';
this.isProcessingActive = false;
this.processingLock = false;
if (this.processingQueue.length > 0) {
queueMicrotask(() => this.processNextFromQueue());
} else {
super.dispatchEvent('buffer:empty', {});
}
} else {
this.isProcessingActive = false;
}
}
/**
@@ -238,6 +306,8 @@ class TextBufferModule extends BaseModule {
this.processingQueue = [];
this.isProcessingActive = false;
this.processingLock = false;
this.paragraphCounter = 0;
this.currentTextBlockId = 0;
}
/**
+94 -102
View File
@@ -3,7 +3,6 @@
* Handles text formatting and typography enhancements like smart quotes and hyphenation
*/
import { BaseModule } from './base-module.js';
import Hyphenopoly from './hyphenopoly.module.js';
class TextProcessorModule extends BaseModule {
constructor() {
@@ -25,7 +24,9 @@ class TextProcessorModule extends BaseModule {
'isHyphenationAvailable',
'hyphenate',
'setLocale',
'handleLocaleChanged'
'handleLocaleChanged',
'loadHyphenopolyLoader',
'normalizeHyphenationLocale'
]);
}
@@ -161,104 +162,89 @@ class TextProcessorModule extends BaseModule {
}
/**
* Initialize hyphenation using Hyphenopoly module
* Initialize hyphenation using the browser Hyphenopoly loader used by the prototype.
* @returns {Promise<boolean>} - Resolves when hyphenation is initialized
*/
initializeHyphenation() {
return new Promise((resolve, reject) => {
try {
console.log("Initializing hyphenation with Hyphenopoly module");
// Configure Hyphenopoly with our requirements
const hyphenatorPromise = Hyphenopoly.config({
require: [this.locale],
hyphen: '\u00AD', // Soft hyphen character
minWordLength: 5,
leftmin: 2,
rightmin: 2,
compound: "hyphen",
// Define a custom loader for the patterns
loader: (file) => {
return new Promise((resolve, reject) => {
// Determine correct pattern file based on locale
let patternFile = file;
// Special handling for 'en' locale - use en-us.wasm if available
if (file === 'en.wasm') {
patternFile = 'en-us.wasm';
}
const patternPath = `/js/patterns/${patternFile}`;
console.log(`Loading hyphenation pattern: ${patternPath}`);
fetch(patternPath)
.then(response => {
if (!response.ok) {
throw new Error(`Failed to load ${file}: ${response.status} ${response.statusText}`);
}
return response.arrayBuffer();
})
.then(arrayBuffer => {
resolve(arrayBuffer);
})
.catch(error => {
console.error(`Error loading hyphenation pattern ${file}:`, error);
reject(error);
});
});
},
handleEvent: {
error: (e) => {
console.warn(`Hyphenopoly error: ${e.msg}`);
},
engineReady: (e) => {
console.log(`Hyphenopoly engine ready for ${e.msg}`);
}
async initializeHyphenation() {
try {
console.log("Initializing hyphenation with browser Hyphenopoly loader");
const locale = this.normalizeHyphenationLocale(this.locale);
this.hyphenator = null;
this.hyphenatorReady = false;
await this.loadHyphenopolyLoader();
window.Hyphenopoly.config({
require: {
[locale]: "FORCEHYPHENOPOLY"
},
paths: {
maindir: "/js/",
patterndir: "/js/patterns/"
},
setup: {
hide: "element",
selectors: {
".hyphenate": { hyphen: "\u00AD" },
".hyphenatePipe": { hyphen: "|" }
}
});
// Get the hyphenator for our locale
hyphenatorPromise.get(this.locale)
.then(hyphenator => {
this.hyphenator = hyphenator;
this.hyphenatorReady = true;
console.log(`Hyphenator ready for ${this.locale}`);
// Dispatch event that hyphenation is ready
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
resolve(true); // Successfully initialized
})
.catch(error => {
console.error(`Failed to initialize hyphenator for ${this.locale}:`, error);
// Try to fall back to en-us if the current locale failed
if (this.locale !== 'en-us') {
console.log("Falling back to en-us hyphenation");
return hyphenatorPromise.get('en-us');
}
throw error;
})
.then(fallbackHyphenator => {
if (fallbackHyphenator) {
this.hyphenator = fallbackHyphenator;
this.hyphenatorReady = true;
console.log("Using fallback en-us hyphenator");
// Dispatch event that hyphenation is ready
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
resolve(true); // Successfully initialized with fallback
}
})
.catch(error => {
console.error("Failed to initialize hyphenation even with fallback:", error);
reject(error); // Failed to initialize
});
} catch (error) {
console.error("Error setting up hyphenation:", error);
reject(error); // Failed to initialize
},
handleEvent: {
error: (event) => {
console.warn(`Hyphenopoly error: ${event.msg || event.message || event.type}`);
},
engineReady: (event) => {
console.log(`Hyphenopoly engine ready: ${event.msg || locale}`);
}
}
});
this.hyphenator = await window.Hyphenopoly.hyphenators[locale];
this.hyphenatorReady = true;
this.locale = locale;
console.log(`Hyphenator ready for ${locale}`);
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
return true;
} catch (error) {
this.hyphenator = null;
this.hyphenatorReady = false;
console.error("Failed to initialize Hyphenopoly browser hyphenation:", error);
throw error;
}
}
loadHyphenopolyLoader() {
return new Promise((resolve, reject) => {
if (window.Hyphenopoly && typeof window.Hyphenopoly.config === 'function') {
resolve();
return;
}
const existingScript = document.querySelector('script[src="/js/Hyphenopoly_Loader.js"]');
if (existingScript) {
existingScript.addEventListener('load', () => resolve(), { once: true });
existingScript.addEventListener('error', () => reject(new Error('Failed to load Hyphenopoly loader')), { once: true });
return;
}
const script = document.createElement('script');
script.src = '/js/Hyphenopoly_Loader.js';
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Hyphenopoly loader'));
document.head.appendChild(script);
});
}
normalizeHyphenationLocale(locale) {
const normalized = String(locale || 'en-us').toLowerCase();
if (normalized === 'en') return 'en-us';
if (normalized === 'de-de') return 'de';
return normalized;
}
/**
* Check if hyphenation is available
* @returns {boolean} - True if hyphenation is available
@@ -270,15 +256,17 @@ class TextProcessorModule extends BaseModule {
/**
* Hyphenate a text using the Hyphenopoly module
* @param {string} text - The text to hyphenate
* @param {string} selector - Optional selector for Hyphenopoly
* @returns {string} - The hyphenated text
*/
hyphenate(text) {
hyphenate(text, selector = null) {
if (!this.isHyphenationAvailable()) {
return text;
}
try {
return this.hyphenator(text);
// If selector provided, pass it to hyphenator
return selector ? this.hyphenator(text, selector) : this.hyphenator(text);
} catch (error) {
console.error("Error hyphenating text:", error);
return text;
@@ -291,27 +279,31 @@ class TextProcessorModule extends BaseModule {
* @param {Object} options - Processing options
* @param {boolean} [options.smartypants=true] - Whether to apply SmartyPants processing
* @param {boolean} [options.hyphenate=true] - Whether to apply hyphenation
* @param {string} [options.hyphenSelector=null] - Selector for hyphen character (e.g., '.hyphenatePipe')
* @returns {string} - The processed text
*/
process(text, options = {}) {
const opts = {
smartypants: true,
hyphenate: true,
hyphenSelector: null,
...options
};
let result = text;
// Apply SmartyPants if available and requested
if (opts.smartypants && this.smartyPants) {
if (opts.smartypants && this.smartypantsu) {
result = this.smartypantsu(result, 1);
} else if (opts.smartypants && this.smartyPants) {
result = this.smartyPants(result);
}
// Apply hyphenation if available and requested
if (opts.hyphenate && this.isHyphenationAvailable()) {
result = this.hyphenate(result);
result = this.hyphenate(result, opts.hyphenSelector);
}
return result;
}
}
+351 -91
View File
@@ -23,7 +23,10 @@ class TTSFactoryModule extends BaseModule {
this.initStatus = {};
this.activeHandler = null;
this.ttsAvailable = false;
this.speed = 1; // Default speed
this.speed = 1; // Speech speed multiplier. 1.0 is normal speed.
this.language = 'en-us';
this.voice = '';
this.volume = 1.0;
// IndexedDB Cache Configuration
this.db = null; // Will hold the DB connection
@@ -48,6 +51,7 @@ class TTSFactoryModule extends BaseModule {
'stop',
'pause',
'resume',
'fadeOut',
'getVoices',
'getPreference',
'isSpeaking',
@@ -76,22 +80,29 @@ class TTSFactoryModule extends BaseModule {
'registerHandlers',
'initializeHandlerSystem',
'debugLogAllRegisteredModules',
'debugTTSHandlers' // Added method
'debugTTSHandlers',
'emitProcessState',
'getEffectiveVoiceId',
'disableAfterCurrentPlayback',
'getHandlerStatuses',
'getVoicesForHandler',
'refreshHandlerStatus'
]);
// Listen for kokoro:ready event
document.addEventListener('kokoro:ready', (event) => {
if (event.detail && typeof event.detail.success === 'boolean') {
console.log('TTS Factory: Received kokoro:ready event with success =', event.detail.success);
this.initStatus['kokoro'] = event.detail.success;
this.initStatus['kokoro-tts'] = event.detail.success;
// If this is the current active handler or we don't have an active handler yet,
// try to activate Kokoro if it's now ready
if ((this.activeHandler === 'kokoro' || !this.activeHandler) && event.detail.success) {
// Only attempt to set active handler if TTS is enabled
if ((this.activeHandler === 'kokoro-tts' || !this.activeHandler) && event.detail.success) {
// Only activate Kokoro when it was explicitly selected.
const ttsEnabled = this.getPreference('tts', 'enabled', false);
if (ttsEnabled) {
this.setActiveHandler('kokoro');
const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none');
if (ttsEnabled && preferredHandler === 'kokoro-tts') {
this.setActiveHandler('kokoro-tts');
}
}
@@ -99,6 +110,14 @@ class TTSFactoryModule extends BaseModule {
this.updateTTSAvailability();
}
});
document.addEventListener('tts:speechCompleted', () => {
const persistenceManager = this.getModule('persistence-manager');
const enabled = persistenceManager?.getPreference('tts', 'enabled', false);
if (!enabled && this.activeHandler) {
this.setActiveHandler('none');
}
});
// Listen for handler availability changes
document.addEventListener('tts:handler:availabilityChanged', (event) => {
@@ -198,15 +217,29 @@ class TTSFactoryModule extends BaseModule {
}
});
// Listen for speed change events from UI
document.addEventListener('tts:speed:change', (event) => {
if (event.detail && typeof event.detail.speed === 'number') {
this.configure({ speed: event.detail.speed });
console.log(`TTS Factory: Speed updated to ${this.speed} from UI event`);
}
});
document.addEventListener('locale-changed', (event) => {
if (event.detail?.locale) {
this.configure({ language: event.detail.locale });
}
});
// Listen for kokoro error events
document.addEventListener('kokoro:error', (event) => {
console.error('TTS Factory: Received kokoro error event:', event.detail);
if (this.handlers['kokoro']) {
this.initStatus['kokoro'] = false;
if (this.handlers['kokoro-tts']) {
this.initStatus['kokoro-tts'] = false;
this.updateTTSAvailability();
// If kokoro was our active handler, try fallback
if (this.activeHandler === 'kokoro') {
if (this.activeHandler === 'kokoro-tts') {
console.warn('TTS Factory: Kokoro handler failed, falling back');
this.attemptFallbackHandler();
}
@@ -367,8 +400,8 @@ class TTSFactoryModule extends BaseModule {
// Default settings for first run
const defaults = {
'speed': 0.5, // Default speech rate (0-1 range)
'preferred_handler': 'kokoro', // Default to Kokoro TTS
'speed': 1.0, // Default speech speed multiplier
'preferred_handler': 'none', // Development default: TTS disabled
'enabled': false, // TTS disabled by default
'voice': '', // Empty default - will be selected based on handler
'language': 'en-US', // Default language
@@ -387,9 +420,10 @@ class TTSFactoryModule extends BaseModule {
}
}
// Load speech rate preference
// Load speech rate preference exactly as persisted. Do not migrate or
// rewrite this value on load; the UI must reflect the browser state.
const savedSpeed = persistenceManager.getPreference('tts', 'speed');
if (typeof savedSpeed === 'number') {
if (Number.isFinite(savedSpeed)) {
this.speed = savedSpeed;
console.log(`TTS Factory: Loaded speed preference: ${this.speed}`);
} else {
@@ -400,6 +434,9 @@ class TTSFactoryModule extends BaseModule {
// Load other preferences we need for initialization
const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler');
console.log(`TTS Factory: Loaded preferred handler: ${preferredHandler || 'none'}`);
this.language = persistenceManager.getPreference('tts', 'language', defaults.language);
this.voice = persistenceManager.getPreference('tts', 'voice', defaults.voice);
this.volume = persistenceManager.getPreference('tts', 'volume', defaults.volume);
// We'll handle the preferred handler in initializeHandlerSystem()
@@ -469,7 +506,14 @@ class TTSFactoryModule extends BaseModule {
if (persistenceManager) {
preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler');
const ttsEnabled = persistenceManager.getPreference('tts', 'enabled', false);
console.log(`TTS Factory: Preferred handler from settings: ${preferredHandler || 'none'}`);
if (!ttsEnabled) {
console.log('TTS Factory: TTS toggle is disabled, starting with no active handler');
this.activeHandler = null;
this.updateTTSAvailability();
return true;
}
}
// Special case for 'none' preference
@@ -500,8 +544,11 @@ class TTSFactoryModule extends BaseModule {
}
}
// If we don't have a preferred handler or it's not registered, try fallbacks
return this.attemptFallbackHandler();
// Default to no TTS. Games or users can explicitly select a provider later.
console.log('TTS Factory: No preferred TTS handler selected, defaulting to none');
this.activeHandler = null;
this.updateTTSAvailability();
return true;
}
/**
@@ -509,8 +556,8 @@ class TTSFactoryModule extends BaseModule {
* @returns {Promise<boolean>} - Success status
*/
async attemptFallbackHandler() {
// Fallback order: Kokoro -> Browser -> None
const fallbackOrder = ['kokoro', 'browser'];
// Providers are opt-in. Keep the baseline as text-only unless explicitly selected.
const fallbackOrder = [];
// Try each fallback in order
for (const handlerId of fallbackOrder) {
@@ -529,8 +576,8 @@ class TTSFactoryModule extends BaseModule {
}
}
// If all fallbacks failed, update TTS availability
console.warn('TTS Factory: All handlers failed to initialize, TTS will be unavailable');
// If no explicit provider is selected, update TTS availability and continue.
console.log('TTS Factory: No fallback handler selected, TTS will be unavailable');
this.activeHandler = null;
this.updateTTSAvailability();
@@ -603,11 +650,16 @@ class TTSFactoryModule extends BaseModule {
persistenceManager.updatePreference('tts', 'preferred_handler', 'none');
}
// Dispatch event
// Dispatch events
document.dispatchEvent(new CustomEvent('tts:handler:changed', {
detail: { handler: 'none', available: false }
}));
// Also dispatch tts:engine:change for compatibility with Options UI
document.dispatchEvent(new CustomEvent('tts:engine:change', {
detail: { engine: 'none', handler: 'none', available: false }
}));
this.updateTTSAvailability();
return true;
}
@@ -629,17 +681,17 @@ class TTSFactoryModule extends BaseModule {
console.log(`TTS Factory: Setting active handler to ${id}`);
// Check if the handler is ready (just for logging)
if (this.handlers[id].isReady !== true) {
console.log(`TTS Factory: Initializing handler ${id} before activation`);
await this.initializeHandler(id);
}
// Check if the handler is ready after initialization
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();
}
// Set the new active handler
this.activeHandler = id;
@@ -647,17 +699,34 @@ class TTSFactoryModule extends BaseModule {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'preferred_handler', id);
this.voice = persistenceManager.getPreference('tts', 'voice', this.voice || '');
this.language = persistenceManager.getPreference('tts', 'language', this.language || 'en-us');
this.speed = persistenceManager.getPreference('tts', 'speed', this.speed || 1.0);
}
const handler = this.handlers[id];
if (handler && typeof handler.setVoiceOptions === 'function') {
handler.setVoiceOptions({
voice: this.voice,
speed: this.speed,
language: this.language
});
}
// Dispatch event
// Dispatch events
const event = new CustomEvent('tts:handler:changed', {
detail: { handler: id, available: isReady }
});
document.dispatchEvent(event);
// Also dispatch tts:engine:change for compatibility with Options UI
document.dispatchEvent(new CustomEvent('tts:engine:change', {
detail: { engine: id, handler: id, available: isReady }
}));
// Update overall TTS availability
this.updateTTSAvailability();
return true;
}
@@ -725,7 +794,7 @@ class TTSFactoryModule extends BaseModule {
const preloadData = await handler.preloadSpeech(text);
if (preloadData && preloadData.success) {
// Cache the speech
await this.cacheSpeech(hash, preloadData);
await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration);
// Speak the preloaded speech
return handler.speakPreloaded(preloadData, result => {
@@ -790,13 +859,13 @@ class TTSFactoryModule extends BaseModule {
// If the handler has a preloadSpeech method, use it
if (typeof this.handlers[this.activeHandler].preloadSpeech === 'function') {
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text);
// Cache the generated speech data
if (preloadData) {
await this.cacheSpeech(hash, preloadData);
// Cache the generated speech data (extract audioData from result object)
if (preloadData && preloadData.audioData) {
await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration);
console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`);
}
return preloadData;
} else {
console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`);
@@ -862,6 +931,24 @@ class TTSFactoryModule extends BaseModule {
return false;
}
}
fadeOut(duration = 1000) {
const handlers = Object.values(this.handlers);
const fades = handlers.map(handler => {
if (!handler) return Promise.resolve(false);
if (typeof handler.fadeOutCurrentAudio === 'function') {
return handler.fadeOutCurrentAudio(duration);
} else if (handler.isSpeaking && typeof handler.stop === 'function') {
return new Promise(resolve => {
setTimeout(() => {
resolve(handler.stop());
}, duration);
});
}
return Promise.resolve(false);
});
return Promise.all(fades);
}
/**
* Get voices from the active handler
@@ -957,41 +1044,170 @@ class TTSFactoryModule extends BaseModule {
document.dispatchEvent(event);
}
}
disableAfterCurrentPlayback() {
const previousHandler = this.activeHandler;
if (previousHandler) {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'last_enabled_handler', previousHandler);
}
}
this.activeHandler = null;
console.log('TTS Factory: TTS disabled for future generation; current playback may finish');
document.dispatchEvent(new CustomEvent('tts:handler:changed', {
detail: { handler: 'none', available: false, previousHandler }
}));
document.dispatchEvent(new CustomEvent('tts:engine:change', {
detail: { engine: 'none', handler: 'none', available: false, previousHandler }
}));
this.updateTTSAvailability();
return true;
}
getHandlerStatuses() {
const statuses = [{
id: 'none',
name: 'None',
ready: true,
active: !this.activeHandler,
message: 'Text-only mode'
}];
for (const id in this.handlers) {
const handler = this.handlers[id];
statuses.push({
id,
name: typeof handler.getName === 'function' ? handler.getName() : id,
ready: handler.isReady === true,
active: this.activeHandler === id,
message: this.getHandlerStatusMessage(id, handler)
});
}
return statuses;
}
getHandlerStatusMessage(id, handler) {
if (!handler) return 'Not registered';
if (handler.isReady === true) return 'Ready';
if (id === 'kokoro-tts') return handler.state === 'INITIALIZING' ? 'Loading model' : 'Not loaded';
if (handler.apiKey === '') return 'API key missing';
if (handler.apiKey && handler.isReady !== true) return 'API unavailable or invalid settings';
return 'Not ready';
}
async refreshHandlerStatus(id) {
if (!id || id === 'none') {
this.updateTTSAvailability();
return true;
}
if (!this.handlers[id]) {
return false;
}
const handler = this.handlers[id];
if (id === 'kokoro-tts') {
this.updateTTSAvailability();
return handler.isReady === true;
}
const success = await this.initializeHandler(id);
this.updateTTSAvailability();
document.dispatchEvent(new CustomEvent('tts:status:updated', {
detail: { statuses: this.getHandlerStatuses() }
}));
return success;
}
async getVoicesForHandler(handlerId) {
if (!handlerId || handlerId === 'none' || !this.handlers[handlerId]) {
return [];
}
const handler = this.handlers[handlerId];
if (handler.isReady !== true && handlerId !== 'kokoro-tts') {
await this.initializeHandler(handlerId);
}
if (typeof handler.getVoices === 'function') {
return await handler.getVoices() || [];
}
return Array.isArray(handler.voices) ? handler.voices : [];
}
/**
* Configure TTS settings for all handlers
* @param {Object} options - TTS options
* @param {number} [options.speed] - Normalized speech rate (0-1 range)
* @param {number} [options.speed] - Speech speed multiplier
*/
configure(options = {}) {
if (!options || typeof options !== 'object') {
return;
}
const persistenceManager = this.getModule('persistence-manager');
const voiceOptions = {};
// Handle speed option
if (typeof options.speed === 'number') {
// Save speed setting
this.speed = Math.max(0.1, Math.min(3.0, options.speed));
// Save to preferences
const persistenceManager = this.getModule('persistence-manager');
this.speed = Math.max(0.5, Math.min(2.0, options.speed));
voiceOptions.speed = this.speed;
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'speed', this.speed);
}
// Update all handlers
for (const id in this.handlers) {
const handler = this.handlers[id];
if (handler && typeof handler.setVoiceOptions === 'function') {
handler.setVoiceOptions({ speed: this.speed });
}
if (typeof options.voice === 'string' && options.voice) {
this.voice = options.voice;
voiceOptions.voice = options.voice;
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'voice', options.voice);
if (this.activeHandler) {
persistenceManager.updatePreference('tts', `${this.activeHandler}_voice`, options.voice);
}
}
}
if (typeof options.language === 'string' && options.language) {
this.language = options.language.toLowerCase();
voiceOptions.language = this.language;
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'language', this.language);
}
}
if (typeof options.volume === 'number') {
this.volume = Math.max(0, Math.min(1, options.volume));
voiceOptions.volume = this.volume;
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'volume', this.volume);
}
}
for (const id in this.handlers) {
const handler = this.handlers[id];
if (handler && typeof handler.setVoiceOptions === 'function') {
handler.setVoiceOptions(voiceOptions);
} else if (handler && typeof handler.configure === 'function') {
handler.configure(voiceOptions);
}
if (voiceOptions.language && !voiceOptions.voice && handler && typeof handler.selectVoiceForLocale === 'function') {
handler.selectVoiceForLocale(voiceOptions.language);
}
}
// Update UI that TTS settings have changed
document.dispatchEvent(new CustomEvent('tts:configured', {
detail: {
options: { speed: this.speed },
options: {
speed: this.speed,
voice: this.voice,
language: this.language,
volume: this.volume
},
activeHandler: this.activeHandler
}
}));
@@ -1016,6 +1232,7 @@ class TTSFactoryModule extends BaseModule {
const cachedData = await this.getCachedSpeech(hash);
if (cachedData) {
console.log(`TTS Factory: Using cached speech for hash ${hash} (hits: ${this.cacheHits}, misses: ${this.cacheMisses})`);
this.emitProcessState('playing-ready', { reason: 'tts-cache-hit', hash });
// Move this item to the end of the Map to mark it as most recently used
// this.audioCache.delete(hash);
// this.audioCache.set(hash, cachedData);
@@ -1025,17 +1242,19 @@ class TTSFactoryModule extends BaseModule {
// Cache miss - need to generate new speech data
this.cacheMisses++;
this.emitProcessState('waiting-generating', { reason: 'tts-cache-miss', hash });
// If the handler has a preloadSpeech method, use it
if (typeof this.handlers[this.activeHandler].preloadSpeech === 'function') {
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text);
// Cache the generated speech data
if (preloadData) {
await this.cacheSpeech(hash, preloadData);
// Cache the generated speech data (extract audioData from result object)
if (preloadData && preloadData.audioData) {
await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration);
console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`);
this.emitProcessState('playing-ready', { reason: 'tts-generated', hash });
}
return preloadData;
} else {
console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`);
@@ -1053,21 +1272,18 @@ class TTSFactoryModule extends BaseModule {
* @returns {Promise<string>} - Hash string
*/
async generateSpeechHash(text) {
// Get the active handler for voice information
const handler = this.getActiveHandler();
// Include handler ID and voice options in the hash to ensure uniqueness across voices
let voiceInfo = '';
if (handler && handler.voiceOptions && handler.voiceOptions.voice) {
// Use the voice ID or name to identify the voice
voiceInfo = handler.voiceOptions.voice.id || handler.voiceOptions.voice;
}
// Also include speed setting in the hash
const provider = this.activeHandler || 'none';
const voiceInfo = this.getEffectiveVoiceId(handler);
const speed = this.speed || 1.0;
// Create a composite key for hashing
const key = `${text}|${this.activeHandler}|${voiceInfo}|${speed}`;
const language = this.language || 'en-us';
const key = JSON.stringify({
provider,
voice: voiceInfo,
speed,
language,
text
});
try {
const encoder = new TextEncoder();
@@ -1117,17 +1333,22 @@ class TTSFactoryModule extends BaseModule {
console.warn('TTS Factory: Cache not ready, cannot retrieve cached speech');
return null;
}
try {
const item = await this._getDBItem(hash);
if (item && item.audioData) {
console.log(`TTS Factory: Found cached speech for hash ${hash}`);
return item.audioData;
// Return in the same format as handlers' preloadSpeech() method
return {
success: true,
audioData: item.audioData,
duration: item.duration || 0
};
}
} catch (error) {
console.error('TTS Factory: Error retrieving cached speech:', error);
}
return null;
}
@@ -1137,24 +1358,26 @@ class TTSFactoryModule extends BaseModule {
* @param {ArrayBuffer} audioData - Audio data to cache
* @returns {Promise<boolean>} - Success status
*/
async cacheSpeech(hash, audioData) {
async cacheSpeech(hash, audioData, duration = 0) {
if (!this.db || this.cacheStatus !== 'ready') {
console.warn('TTS Factory: Cache not ready, cannot cache speech');
return false;
}
if (!audioData) {
console.error('TTS Factory: No audio data provided to cache');
return false;
}
console.log(`TTS Factory: cacheSpeech called with audioData:`, audioData ? `${audioData.byteLength} bytes` : 'UNDEFINED', 'duration:', duration);
try {
// Make sure we have room in the cache
await this.manageCacheSize(audioData.byteLength);
// Store the speech data
await this._putDBItem(hash, audioData);
// Store the speech data with duration
await this._putDBItem(hash, audioData, duration);
console.log(`TTS Factory: Cached speech for hash ${hash}`);
return true;
} catch (error) {
@@ -1243,6 +1466,20 @@ class TTSFactoryModule extends BaseModule {
return false;
}
}
getEffectiveVoiceId(handler = this.getActiveHandler()) {
if (!handler) return this.voice || '';
const voice = handler.voiceOptions?.voice || handler.currentVoice || this.voice || '';
if (typeof voice === 'string') return voice;
return voice.id || voice.name || '';
}
emitProcessState(state, detail = {}) {
console.log(`Process state: ${state}`, detail);
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state, ...detail }
}));
}
/**
* Opens and initializes the IndexedDB database.
@@ -1365,9 +1602,10 @@ class TTSFactoryModule extends BaseModule {
* Adds or updates an item in the IndexedDB store.
* @param {string} hash - The key (hash) of the item to store.
* @param {ArrayBuffer} audioData - The audio data to cache.
* @param {number} duration - Duration of the audio in milliseconds.
* @returns {Promise<void>}
*/
async _putDBItem(hash, audioData) {
async _putDBItem(hash, audioData, duration = 0) {
if (!this.db || this.cacheStatus !== 'ready') {
console.warn("IndexedDB not ready, cannot put item.");
return Promise.reject(new Error("IndexedDB not ready"));
@@ -1380,7 +1618,7 @@ class TTSFactoryModule extends BaseModule {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.put({ hash, audioData, size: audioData.byteLength, lastAccessed: Date.now() });
const request = store.put({ hash, audioData, size: audioData.byteLength, duration: duration || 0, lastAccessed: Date.now() });
request.onerror = (event) => {
console.error("Error putting item into IndexedDB:", event.target.error);
@@ -1459,8 +1697,27 @@ class TTSFactoryModule extends BaseModule {
if (typeof cursor.value.size === 'number') {
totalSize += cursor.value.size;
} else {
console.warn(`Item with hash ${cursor.key} missing or invalid size property.`);
// Optionally try to get blob size here, but might be slow
// Old cache entry without size - calculate it
if (cursor.value.audioData && cursor.value.audioData.byteLength) {
const calculatedSize = cursor.value.audioData.byteLength;
totalSize += calculatedSize;
// Update the entry with the size property
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const updatedValue = {
...cursor.value,
size: calculatedSize
};
store.put(updatedValue);
console.log(`Updated cache entry ${cursor.key} with size: ${calculatedSize} bytes`);
} else {
console.warn(`Item with hash ${cursor.key} missing size and cannot calculate - will be deleted`);
// Delete invalid entry
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
store.delete(cursor.key);
}
}
cursor.continue();
} else {
@@ -1593,8 +1850,11 @@ class TTSFactoryModule extends BaseModule {
}
}
// If we couldn't initialize the preferred handler, try fallbacks
return this.attemptFallbackHandler();
// Default to no TTS. Games or users can explicitly select a provider later.
console.log('TTS Factory: No preferred TTS handler selected, defaulting to none');
this.activeHandler = null;
this.updateTTSAvailability();
return true;
}
/**
@@ -1602,8 +1862,8 @@ class TTSFactoryModule extends BaseModule {
* @returns {Promise<boolean>} - Success status
*/
async attemptFallbackHandler() {
// Fallback order: Kokoro -> Browser -> None
const fallbackOrder = ['kokoro', 'browser'];
// Providers are opt-in. Keep the baseline as text-only unless explicitly selected.
const fallbackOrder = [];
// Try each fallback in order
for (const handlerId of fallbackOrder) {
@@ -1622,8 +1882,8 @@ class TTSFactoryModule extends BaseModule {
}
}
// If all fallbacks failed, update TTS availability
console.warn('TTS Factory: All handlers failed to initialize, TTS will be unavailable');
// If no explicit provider is selected, update TTS availability and continue.
console.log('TTS Factory: No fallback handler selected, TTS will be unavailable');
this.activeHandler = null;
this.updateTTSAvailability();
@@ -1737,4 +1997,4 @@ class TTSFactoryModule extends BaseModule {
const TTSFactory = new TTSFactoryModule();
// Export the module
export { TTSFactory };
export { TTSFactory };
+312 -157
View File
@@ -7,7 +7,7 @@ class UIControllerModule extends BaseModule {
// Remove 'tts' from direct dependencies to break circular dependency
// UI Controller will access TTS through the Game Loop instead
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client'];
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client', 'sentence-queue', 'playback-coordinator'];
// References to sub-modules
this.displayHandler = null;
@@ -43,6 +43,12 @@ class UIControllerModule extends BaseModule {
'applyBookSizing',
'setupEventListeners',
'setupMainUI',
'bindTopControls',
'syncTopControls',
'getStoredTtsPreference',
'setStoredTtsPreference',
'sliderValueFromSpeed',
'speedFromSliderValue',
'initializeTextBuffer',
'showUI',
'hideUI',
@@ -103,15 +109,25 @@ class UIControllerModule extends BaseModule {
return false;
}
this.reportProgress(50, 'Setting up event listeners');
// Set up event listeners between components
this.setupEventListeners();
this.reportProgress(70, 'Setting up main UI');
this.reportProgress(50, 'Setting up main UI');
// Initialize main UI container
await this.setupMainUI();
this.reportProgress(70, 'Setting up event listeners');
// Set up event listeners after the display handler has created controls
this.setupEventListeners();
this.bindTopControls();
this.syncTopControls();
requestAnimationFrame(() => {
this.bindTopControls();
this.syncTopControls();
});
setTimeout(() => {
this.bindTopControls();
this.syncTopControls();
}, 250);
this.reportProgress(80, 'Initializing text buffer');
@@ -146,22 +162,45 @@ class UIControllerModule extends BaseModule {
this.applyBookSizing();
// Set up window resize handler
window.addEventListener('resize', () => this.applyBookSizing());
const handleViewportResize = () => this.applyBookSizing();
window.addEventListener('resize', handleViewportResize);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', handleViewportResize);
}
if (window.ResizeObserver && document.body) {
this.bodyResizeObserver = new ResizeObserver(handleViewportResize);
this.bodyResizeObserver.observe(document.body);
}
}
applyBookSizing() {
// Apply book sizing based on viewport dimensions
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const aspectRatio = viewportWidth / viewportHeight;
const viewportAspectRatio = viewportWidth / viewportHeight;
const bookWidth = 2727;
const bookHeight = 1691;
const bookScale = Math.min(viewportWidth / bookWidth, viewportHeight / bookHeight);
document.documentElement.style.setProperty('--viewport-aspect-ratio', aspectRatio);
const maxBookHeight = viewportHeight * 0.9;
document.documentElement.style.setProperty('--book-height', `${maxBookHeight}px`);
const bookWidth = maxBookHeight * Math.min(aspectRatio, 1.613);
document.documentElement.style.setProperty('--book-width', `${bookWidth}px`);
document.documentElement.style.setProperty('--book-height', `${bookHeight}px`);
document.documentElement.style.setProperty('--book-scale', bookScale);
document.documentElement.style.setProperty('--viewport-aspect-ratio', viewportAspectRatio);
document.documentElement.style.setProperty(
'--viewport-dimension',
viewportWidth / viewportHeight > bookWidth / bookHeight ? 'vw' : 'vh'
);
document.dispatchEvent(new CustomEvent('book:scaled', {
detail: {
bookWidth,
bookHeight,
bookScale,
displayWidth: bookWidth * bookScale,
displayHeight: bookHeight * bookScale,
viewportWidth,
viewportHeight
}
}));
}
setupEventListeners() {
@@ -169,62 +208,46 @@ class UIControllerModule extends BaseModule {
const saveButton = document.getElementById('save');
const loadButton = document.getElementById('reload');
const restartButton = document.getElementById('rewind');
const speechToggle = document.getElementById('speech');
const optionsButton = document.getElementById('options');
// Get persistence manager module
const persistenceManager = this.getModule('persistence-manager');
const ttsFactory = this.getModule('tts-factory');
// Set up save button
if (saveButton) {
saveButton.addEventListener('click', () => {
saveButton.addEventListener('click', (event) => {
event.preventDefault();
if (saveButton.getAttribute('disabled') === 'disabled') return;
document.dispatchEvent(new CustomEvent('ui:game:save'));
});
}
// Set up load button
if (loadButton) {
loadButton.addEventListener('click', () => {
loadButton.addEventListener('click', (event) => {
event.preventDefault();
if (loadButton.getAttribute('disabled') === 'disabled') return;
document.dispatchEvent(new CustomEvent('ui:game:load'));
});
}
// Set up restart button
if (restartButton) {
restartButton.addEventListener('click', () => {
restartButton.addEventListener('click', (event) => {
event.preventDefault();
event.__newGameHandled = true;
document.dispatchEvent(new CustomEvent('ui:game:restart'));
});
}
// Set up speech toggle button
if (speechToggle) {
// Initialize ttsEnabled from persistence manager
if (persistenceManager) {
const prefs = persistenceManager.getAllPreferences();
this.ttsEnabled = prefs.tts?.enabled ?? false;
// Update button state
this.updateButtonStates();
this.addEventListener(document, 'click', (event) => {
if (event.target && event.target.closest && event.target.closest('#rewind')) {
event.preventDefault();
if (event.__newGameHandled) return;
document.dispatchEvent(new CustomEvent('ui:game:restart'));
}
speechToggle.addEventListener('click', () => {
// Toggle TTS state
this.ttsEnabled = !this.ttsEnabled;
// Update UI
this.updateButtonStates();
// Save preference
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
}
// Notify other components
document.dispatchEvent(new CustomEvent('ui:tts:toggle', {
detail: { enabled: this.ttsEnabled }
}));
});
}
});
// Set up options button
if (optionsButton) {
@@ -232,9 +255,31 @@ class UIControllerModule extends BaseModule {
document.dispatchEvent(new CustomEvent('ui:options:toggle'));
});
}
this.addEventListener(document, 'ui:command', (event) => {
if (!event.detail || event.detail.moduleId === this.id) return;
this.handleCommand(event.detail);
});
this.addEventListener(document, 'click', (event) => {
if (event.target && event.target.closest && event.target.closest('#options-modal, #controls, #player_input, #command_input')) {
return;
}
const playbackCoordinator = this.getModule('playback-coordinator');
if (playbackCoordinator && playbackCoordinator.isPlaying) {
this.handleCommand({ type: 'continue', source: 'book-click' });
}
if (this.inputHandler && typeof this.inputHandler.focusInput === 'function') {
this.inputHandler.focusInput();
}
});
// Listen for book events
document.addEventListener('book:ready', () => {
this.bindTopControls();
this.syncTopControls();
this.updateButtonStates({
canSave: true,
canLoad: true,
@@ -275,8 +320,9 @@ class UIControllerModule extends BaseModule {
this.updateButtonStates();
// Ensure persistence is updated
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
const currentPersistenceManager = this.getModule('persistence-manager');
if (currentPersistenceManager) {
currentPersistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
}
}
});
@@ -294,64 +340,26 @@ class UIControllerModule extends BaseModule {
this.updateButtonStates();
// Ensure persistence is updated
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
const currentPersistenceManager = this.getModule('persistence-manager');
if (currentPersistenceManager) {
currentPersistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
}
}
});
// Set up speed slider in main UI
const speedSlider = document.getElementById('speed');
const speedReset = document.getElementById('speed_reset');
if (speedSlider) {
// Initialize speed from persistence manager
if (persistenceManager) {
const prefs = persistenceManager.getAllPreferences();
// Get the unified speed value (0-1 range)
const speed = prefs.tts?.speed ?? 0.5;
// Convert to slider range (0-100)
speedSlider.value = Math.round(speed * 100);
document.addEventListener('preference-updated', (event) => {
const { category, key, value } = event.detail || {};
if (category !== 'tts') {
return;
}
speedSlider.addEventListener('input', (e) => {
// Convert slider value (0-100) to normalized speed (0-1)
const speed = parseInt(e.target.value) / 100;
// Scale for different TTS engines
// This value is used for real-time preview only
const rate = this.ttsEnabled ? speed * 2 : 1;
// Update animation speed
document.dispatchEvent(new CustomEvent('animation:speed:change', {
detail: { speed: rate }
}));
// Update TTS speed
document.dispatchEvent(new CustomEvent('tts:speed:change', {
detail: { speed: speed }
}));
// Save preference
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'speed', speed);
}
});
}
if (speedReset) {
speedReset.addEventListener('click', () => {
// Reset to default speed (0.5)
if (speedSlider) {
// Default value is 0.5 in normalized form (0-1),
// which is 50 in slider range (0-100)
speedSlider.value = 50;
// Trigger the input event to update all components
speedSlider.dispatchEvent(new Event('input'));
}
});
}
if (key === 'enabled') {
this.ttsEnabled = value === true;
this.syncTopControls();
} else if (key === 'speed') {
this.syncTopControls();
}
});
// Listen for speed change events from other components
document.addEventListener('tts:speed:change', (event) => {
@@ -359,17 +367,157 @@ class UIControllerModule extends BaseModule {
// Update the main UI speed slider
const speedSlider = document.getElementById('speed');
if (speedSlider) {
// Convert normalized speed (0-1) to slider range (0-100)
speedSlider.value = Math.round(event.detail.speed * 100);
speedSlider.value = this.sliderValueFromSpeed(event.detail.speed);
}
// Save to persistence manager
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'speed', event.detail.speed);
const currentPersistenceManager = this.getModule('persistence-manager');
if (currentPersistenceManager) {
currentPersistenceManager.updatePreference('tts', 'speed', event.detail.speed);
}
}
});
}
sliderValueFromSpeed(speed) {
const value = Number.isFinite(Number(speed)) ? Number(speed) : 1;
return Math.round((Math.max(0.5, Math.min(2.0, value)) * 50) + 50);
}
speedFromSliderValue(value) {
const sliderValue = Number.isFinite(Number(value)) ? Number(value) : 50;
return Math.max(0.5, Math.min(2.0, (sliderValue - 50) / 50));
}
bindTopControls() {
const speechToggle = document.getElementById('speech');
const speedSlider = document.getElementById('speed');
const speedReset = document.getElementById('speed_reset');
if (speechToggle && speechToggle.dataset.uiControllerBound !== 'true') {
speechToggle.dataset.uiControllerBound = 'true';
speechToggle.removeAttribute('disabled');
speechToggle.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
const persistenceManager = this.getModule('persistence-manager');
const ttsFactory = this.getModule('tts-factory');
const currentEnabled = this.getStoredTtsPreference('enabled', this.ttsEnabled);
const nextEnabled = !currentEnabled;
this.ttsEnabled = nextEnabled;
console.log(`UIController: Top speech toggle set to ${nextEnabled ? 'enabled' : 'disabled'}`);
this.setStoredTtsPreference('enabled', nextEnabled);
if (ttsFactory) {
if (nextEnabled) {
const preferredHandler = persistenceManager?.getPreference('tts', 'preferred_handler', 'none') || 'none';
if (preferredHandler !== 'none') {
await ttsFactory.setActiveHandler(preferredHandler);
}
} else {
await ttsFactory.disableAfterCurrentPlayback();
}
}
this.syncTopControls();
document.dispatchEvent(new CustomEvent('tts:enabled:change', {
detail: { enabled: nextEnabled, source: 'topbar' }
}));
});
}
if (speedSlider && speedSlider.dataset.uiControllerBound !== 'true') {
speedSlider.dataset.uiControllerBound = 'true';
speedSlider.min = speedSlider.min || '50';
speedSlider.max = speedSlider.max || '150';
speedSlider.addEventListener('input', (event) => {
const persistenceManager = this.getModule('persistence-manager');
const speed = this.speedFromSliderValue(event.target.value);
document.dispatchEvent(new CustomEvent('animation:speed:change', {
detail: { speed: 1 }
}));
document.dispatchEvent(new CustomEvent('tts:speed:change', {
detail: { speed }
}));
this.setStoredTtsPreference('speed', speed);
});
}
if (speedReset && speedReset.dataset.uiControllerBound !== 'true') {
speedReset.dataset.uiControllerBound = 'true';
speedReset.addEventListener('click', () => {
const slider = document.getElementById('speed');
if (slider) {
slider.value = this.sliderValueFromSpeed(1);
slider.dispatchEvent(new Event('input'));
}
});
}
}
syncTopControls() {
this.bindTopControls();
this.ttsEnabled = this.getStoredTtsPreference('enabled', this.ttsEnabled) === true;
const speedSlider = document.getElementById('speed');
if (speedSlider) {
const speed = this.getStoredTtsPreference('speed', 1);
const value = String(this.sliderValueFromSpeed(speed));
if (speedSlider.value !== value) {
speedSlider.value = value;
}
}
this.updateButtonStates();
}
getStoredTtsPreference(key, defaultValue) {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
const value = persistenceManager.getPreference('tts', key, undefined);
if (typeof value !== 'undefined' && value !== null) {
return value;
}
}
try {
const raw = localStorage.getItem('ai-interactive-fiction-preferences');
if (raw) {
const prefs = JSON.parse(raw);
if (prefs && prefs.tts && Object.prototype.hasOwnProperty.call(prefs.tts, key)) {
return prefs.tts[key];
}
}
} catch (error) {
console.warn('UIController: Failed to read TTS preference fallback:', error);
}
return defaultValue;
}
setStoredTtsPreference(key, value) {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.updatePreference === 'function') {
persistenceManager.updatePreference('tts', key, value);
}
try {
const storageKey = 'ai-interactive-fiction-preferences';
const raw = localStorage.getItem(storageKey);
const prefs = raw ? JSON.parse(raw) : {};
prefs.tts = prefs.tts || {};
prefs.tts[key] = value;
localStorage.setItem(storageKey, JSON.stringify(prefs));
} catch (error) {
console.warn('UIController: Failed to write TTS preference fallback:', error);
}
}
async setupMainUI() {
// Ensure all UI components exist
@@ -383,36 +531,45 @@ class UIControllerModule extends BaseModule {
this.rightPage = document.getElementById('page_right');
this.storyElement = document.getElementById('story');
}
if (this.inputHandler && typeof this.inputHandler.focusInput === 'function') {
requestAnimationFrame(() => this.inputHandler.focusInput());
}
}
initializeTextBuffer() {
// Initialize text buffer handling
if (this.textBuffer) {
console.log('UIController: Setting up text buffer callback');
this.textBuffer.setOnSentenceReady((text, callback) => {
console.log('UIController: Received sentence from text buffer, displaying');
// Use the display handler to show text with proper formatting and TTS
this.displayText(text)
.then(() => {
console.log('UIController: Display of sentence completed, continuing...');
// Signal that we're ready to process the next sentence
if (typeof callback === 'function') {
// Use a small timeout to prevent potential stack overflow with many sentences
setTimeout(() => callback(), 10);
}
})
.catch(error => {
console.error('UIController: Error displaying text:', error);
// Continue anyway to prevent blocking
if (typeof callback === 'function') callback();
});
});
console.log('UIController: Text buffer callback set up');
} else {
console.warn('UIController: Text buffer module not found');
// Connect SentenceQueue to UIDisplayHandler
const sentenceQueue = this.getModule('sentence-queue');
const displayHandler = this.getModule('ui-display-handler');
if (!sentenceQueue || !displayHandler) {
console.error('UIController: Required modules not found (sentence-queue or ui-display-handler)');
return;
}
console.log('UIController: Setting up SentenceQueue → UIDisplayHandler pipeline');
// Set up callback for when sentences are ready to display
sentenceQueue.setOnSentenceReady(async (sentence, callback) => {
try {
console.log(`UIController: Rendering sentence ${sentence.id}`);
await displayHandler.renderSentence(sentence);
console.log(`UIController: Sentence ${sentence.id} rendered successfully`);
// Signal completion to process next sentence
if (typeof callback === 'function') {
callback();
}
} catch (error) {
console.error('UIController: Error rendering sentence:', error);
// Still proceed to prevent blocking
if (typeof callback === 'function') {
callback();
}
}
});
console.log('UIController: SentenceQueue pipeline configured');
}
handleCommand(command) {
@@ -425,8 +582,13 @@ class UIControllerModule extends BaseModule {
this.effects.processCommand(command);
break;
case 'continue':
if (this.animationQueue) {
this.animationQueue.fastForward();
{
const playbackCoordinator = this.getModule('playback-coordinator');
if (playbackCoordinator && playbackCoordinator.isPlaying) {
playbackCoordinator.fastForward();
} else if (this.animationQueue) {
this.animationQueue.fastForward();
}
}
break;
case 'input':
@@ -473,7 +635,7 @@ class UIControllerModule extends BaseModule {
const speechToggle = document.getElementById('speech');
// Update save button state
if (saveButton) {
if (saveButton && typeof canSave === 'boolean') {
if (canSave) {
saveButton.removeAttribute('disabled');
} else {
@@ -482,7 +644,7 @@ class UIControllerModule extends BaseModule {
}
// Update load button state
if (loadButton) {
if (loadButton && typeof canLoad === 'boolean') {
if (canLoad) {
loadButton.removeAttribute('disabled');
} else {
@@ -491,35 +653,28 @@ class UIControllerModule extends BaseModule {
}
// Update restart button state
if (restartButton) {
if (restartButton && typeof canRestart === 'boolean') {
if (canRestart) {
restartButton.removeAttribute('disabled');
} else {
restartButton.setAttribute('disabled', 'disabled');
}
}
document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false';
// Update speech toggle button state
if (speechToggle) {
// Update the button appearance based on TTS state using existing styles
if (!this.ttsAvailable) {
// TTS is not available, disable the button
speechToggle.setAttribute('disabled', 'disabled');
speechToggle.title = 'Text-to-speech is not available';
speechToggle.removeAttribute('disabled');
if (this.ttsEnabled) {
speechToggle.style.fontWeight = 'bold';
speechToggle.style.color = '#000';
speechToggle.title = this.ttsAvailable ? 'Disable speech' : 'Speech enabled, selected provider is not ready';
} else {
// TTS is available, remove disabled attribute
speechToggle.removeAttribute('disabled');
// Update based on whether TTS is enabled
if (this.ttsEnabled) {
speechToggle.style.fontWeight = 'bold';
speechToggle.style.color = '#000';
speechToggle.title = 'Disable speech';
} else {
speechToggle.style.fontWeight = 'normal';
speechToggle.style.color = '#999';
speechToggle.title = 'Enable speech';
}
speechToggle.style.fontWeight = 'normal';
speechToggle.style.color = '#999';
speechToggle.title = 'Enable speech';
}
}
}
+248 -24
View File
@@ -9,13 +9,17 @@ class UIDisplayHandlerModule extends BaseModule {
super('ui-display-handler', 'UI Display Handler');
// Module dependencies
this.dependencies = ['paragraph-layout', 'layout-renderer', 'animation-queue'];
this.dependencies = ['layout-renderer', 'playback-coordinator'];
// DOM elements
this.container = null;
this.pageLeft = null;
this.pageRight = null;
this.paragraphContainer = null;
this.renderedItems = [];
this.resizeTimer = null;
this.storyResizeObserver = null;
this.lastStoryMetrics = null;
// Resources to preload
this.cssPath = '/css/style.css';
@@ -28,6 +32,12 @@ class UIDisplayHandlerModule extends BaseModule {
this.bindMethods([
'initializeContainers',
'displayText',
'renderSentence',
'renderHeading',
'handleDeferredMediaBlock',
'rerenderStory',
'clear',
'scheduleRerender',
'measureText',
'loadCSS',
'showChoices',
@@ -49,9 +59,8 @@ class UIDisplayHandlerModule extends BaseModule {
this.reportProgress(30, "Getting module dependencies");
// Get references to required modules using parent's getModule method
this.paragraphLayout = this.getModule('paragraph-layout');
this.layoutRenderer = this.getModule('layout-renderer');
this.animationQueue = this.getModule('animation-queue');
this.playbackCoordinator = this.getModule('playback-coordinator');
this.reportProgress(50, "Initializing display containers");
@@ -61,6 +70,40 @@ class UIDisplayHandlerModule extends BaseModule {
this.reportProgress(70, "Setting up typography");
this.reportProgress(90, "Setting up event listeners");
this.addEventListener(document, 'book:resized', () => {
this.scheduleRerender();
});
if (window.ResizeObserver && this.paragraphContainer) {
this.storyResizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const computedStyle = window.getComputedStyle(this.paragraphContainer);
const metrics = {
width: Math.round(entry.contentRect.width),
fontSize: computedStyle.fontSize,
lineHeight: computedStyle.lineHeight
};
if (!this.lastStoryMetrics) {
this.lastStoryMetrics = metrics;
return;
}
const changed = metrics.width !== this.lastStoryMetrics.width ||
metrics.fontSize !== this.lastStoryMetrics.fontSize ||
metrics.lineHeight !== this.lastStoryMetrics.lineHeight;
this.lastStoryMetrics = metrics;
if (changed) {
this.scheduleRerender();
}
});
this.storyResizeObserver.observe(this.paragraphContainer);
}
this.reportProgress(100, "UI Display Handler ready");
return true;
@@ -70,6 +113,11 @@ class UIDisplayHandlerModule extends BaseModule {
}
}
scheduleRerender() {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => this.rerenderStory(), 80);
}
/**
* Load CSS file asynchronously and wait for it to be applied
@@ -160,9 +208,9 @@ class UIDisplayHandlerModule extends BaseModule {
controls.id = 'controls';
controls.className = 'buttons';
controls.innerHTML = `
<a class="l10n-speech" id="speech" title="Toggle text to speech" disabled="disabled">speech</a>
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="0" max="100" value="50" id="speed" name="speed" /></span>
<a class="l10n-restart" id="rewind" title="Restart story from beginning" disabled="disabled">restart</a>
<a class="l10n-speech" id="speech" title="Toggle text to speech">speech</a>
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span>
<a class="l10n-restart" id="rewind" title="Start a new game">new game</a>
<a class="l10n-save" id="save" title="Save progress">save</a>
<a class="l10n-load" id="reload" title="Reload from save point" disabled="disabled">load</a>
<a class="l10n-options" id="options" title="Options">options</a>
@@ -220,6 +268,13 @@ class UIDisplayHandlerModule extends BaseModule {
this.container.className = 'container';
this.pageRight.appendChild(this.container);
}
if (!document.getElementById('start_prompt')) {
const startPrompt = document.createElement('div');
startPrompt.id = 'start_prompt';
startPrompt.textContent = 'Klick on new game or load to start the game';
this.pageRight.appendChild(startPrompt);
}
// Create paragraph container inside story container
this.paragraphContainer = document.getElementById('paragraphs');
@@ -272,33 +327,194 @@ class UIDisplayHandlerModule extends BaseModule {
/**
* Display text in the UI
* Display text in the UI (backward compatibility)
* Note: Text should flow through SentenceQueue instead
* @param {string} text - Text to display
* @param {Object} options - Display options
* @returns {Promise<HTMLElement>} - Promise resolving to the displayed paragraph element
*/
displayText(text, options = {}) {
if (!text) return Promise.resolve(null);
return new Promise((resolve) => {
// Generate a unique ID for this paragraph
const paragraphId = `p-${Date.now()}-${this.currentParagraphId++}`;
// Add to pending paragraphs queue
this.pendingParagraphs.push({
id: paragraphId,
text,
options,
resolve
// For backward compatibility, delegate to sentence queue
console.warn('UIDisplayHandler.displayText called directly, text should flow through SentenceQueue');
const sentenceQueue = this.getModule('sentence-queue');
if (sentenceQueue) {
return new Promise(resolve => {
sentenceQueue.addSentence(text, () => resolve(null));
});
// If this is the only paragraph, process it immediately
if (this.pendingParagraphs.length === 1) {
this.processNextParagraph();
} else {
console.log(`UIDisplayHandler: Queued paragraph (${this.pendingParagraphs.length} total)`);
}
return Promise.resolve(null);
}
/**
* Render a prepared sentence to the display
* @param {Object} sentence - Prepared sentence object from SentenceQueue
* @returns {Promise<HTMLElement>} - Promise resolving to the paragraph element
*/
async renderSentence(sentence) {
if (!sentence || !sentence.layout) {
if (sentence && sentence.kind === 'heading') {
return this.renderHeading(sentence);
}
if (sentence && (sentence.kind === 'image' || sentence.kind === 'music')) {
return this.handleDeferredMediaBlock(sentence);
}
console.error('UIDisplayHandler: Invalid sentence object');
return null;
}
try {
// Render DOM from layout data
const paragraphElement = this.layoutRenderer.renderParagraph(
sentence.layout,
{ id: sentence.id }
);
// Append to container
if (this.paragraphContainer) {
this.paragraphContainer.appendChild(paragraphElement);
if (typeof this.layoutRenderer.adjustJustification === 'function') {
this.layoutRenderer.adjustJustification(paragraphElement);
}
} else {
console.error('UIDisplayHandler: Paragraph container not found');
return null;
}
// Store element reference in sentence
sentence.element = paragraphElement;
this.renderedItems.push({
type: 'paragraph',
id: sentence.id,
text: sentence.text,
metadata: {
layoutText: sentence.layout?.sourceLayoutText || sentence.text,
cueMarkers: sentence.cueMarkers || [],
role: sentence.role || 'body',
isFirstParagraphInChapter: sentence.isFirstParagraphInChapter,
dropCap: sentence.dropCap,
addTopSpace: sentence.addTopSpace,
paragraphIndex: sentence.paragraphIndex
}
});
// Start coordinated playback (animation + TTS)
await this.playbackCoordinator.play(sentence);
// Scroll to bottom
if (this.pageRight) {
this.pageRight.scrollTop = this.pageRight.scrollHeight;
}
// Call completion callback
if (sentence.onComplete) {
sentence.onComplete();
}
return paragraphElement;
} catch (error) {
console.error('UIDisplayHandler: Error rendering sentence:', error);
throw error;
}
}
async renderHeading(sentence) {
const heading = document.createElement('p');
heading.id = sentence.id;
heading.className = 'story-chapter-heading';
heading.innerHTML = sentence.metadata?.layoutText || sentence.text;
this.renderedItems.push({
type: 'heading',
id: sentence.id,
text: sentence.text,
layoutText: sentence.metadata?.layoutText || sentence.text
});
if (this.paragraphContainer) {
this.paragraphContainer.appendChild(heading);
}
if (sentence.onComplete) {
sentence.onComplete();
}
return heading;
}
async rerenderStory() {
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
const sentenceQueue = this.getModule('sentence-queue');
if (!sentenceQueue || typeof sentenceQueue.prepareLayout !== 'function') return;
console.log('UIDisplayHandler: Re-typesetting story after page resize');
const scrollTop = this.pageRight ? this.pageRight.scrollTop : 0;
this.paragraphContainer.innerHTML = '';
for (const item of this.renderedItems) {
if (item.type === 'heading') {
const heading = document.createElement('p');
heading.id = item.id;
heading.className = 'story-chapter-heading';
heading.innerHTML = item.layoutText || item.text;
this.paragraphContainer.appendChild(heading);
continue;
}
if (item.type !== 'paragraph') continue;
const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
const paragraph = this.layoutRenderer.renderParagraph(layout, { id: item.id });
paragraph.querySelectorAll('.word').forEach(word => {
word.style.transition = 'none';
word.style.visibility = 'visible';
word.style.opacity = '1';
word.style.transform = 'translateY(0)';
});
this.paragraphContainer.appendChild(paragraph);
}
if (this.pageRight) {
this.pageRight.scrollTop = scrollTop;
}
}
async handleDeferredMediaBlock(sentence) {
document.dispatchEvent(new CustomEvent('story:media-block', {
detail: {
id: sentence.id,
type: sentence.kind,
...(sentence.metadata || {})
}
}));
if (sentence.kind === 'music') {
const leadInSeconds = Number(sentence.metadata?.leadInSeconds || sentence.metadata?.leadIn || 0);
if (leadInSeconds > 0) {
console.log(`UIDisplayHandler: Waiting ${leadInSeconds}s before continuing after music block`);
await new Promise(resolve => setTimeout(resolve, leadInSeconds * 1000));
}
}
if (sentence.onComplete) {
sentence.onComplete();
}
return null;
}
clear() {
if (this.container) {
this.container.innerHTML = '';
this.paragraphContainer = document.createElement('div');
this.paragraphContainer.id = 'paragraphs';
this.container.appendChild(this.paragraphContainer);
}
this.renderedItems = [];
}
/**
@@ -376,3 +592,11 @@ const uiDisplayHandler = new UIDisplayHandlerModule();
// Export the module
export { uiDisplayHandler as UIDisplayHandler };
// Register with the module registry
if (window.moduleRegistry) {
window.moduleRegistry.register(uiDisplayHandler);
}
// Keep a reference in window for loader system
window.UIDisplayHandler = uiDisplayHandler;
+150 -16
View File
@@ -5,7 +5,7 @@ class UIInputHandlerModule extends BaseModule {
super('ui-input-handler', 'UI Input Handler');
// Explicitly declare ui-display-handler as a dependency
this.dependencies = ['ui-display-handler'];
this.dependencies = ['ui-display-handler', 'markup-parser', 'playback-coordinator'];
// Input elements
this.inputArea = null;
@@ -28,7 +28,10 @@ class UIInputHandlerModule extends BaseModule {
'handleKeyboardInput',
'submitCommand',
'addToHistory',
'resetCursorPosition'
'formatCommandHistory',
'resetCursorPosition',
'focusInput',
'setProcessState'
]);
console.log('UIInputHandler: Constructor initialized');
@@ -53,6 +56,9 @@ class UIInputHandlerModule extends BaseModule {
this.reportProgress(60, 'Setting up input elements');
this.setupInputElements();
this.addEventListener(document, 'story:process-state', (event) => {
this.setProcessState(event.detail?.state || 'ready', event.detail || {});
});
this.reportProgress(100, 'UI Input Handler ready');
return true;
@@ -67,14 +73,47 @@ class UIInputHandlerModule extends BaseModule {
* @param {KeyboardEvent} event - The keyboard event
*/
handleKeyboardInput(event) {
// Handle global keyboard shortcuts here
// This is different from the input field's specific key handling
// For example: Escape key to blur the input
if (!this.playerInput) return;
if (event.key === 'Escape') {
if (document.activeElement === this.playerInput) {
this.playerInput.blur();
this.playerInput.blur();
return;
}
const optionsModal = document.getElementById('options-modal');
if (optionsModal && optionsModal.style.display !== 'none') {
return;
}
if (event.key === ' ' && this.isPlaybackActive()) {
document.dispatchEvent(new CustomEvent('ui:command', {
detail: { type: 'continue', source: 'spacebar' }
}));
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
if (document.body.dataset.gameRunning !== 'true') {
return;
}
this.submitCommand();
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
if (event.key.length === 1 && document.activeElement !== this.playerInput) {
if (document.body.dataset.gameRunning !== 'true') {
return;
}
event.preventDefault();
this.focusInput();
const start = this.playerInput.selectionStart ?? this.playerInput.value.length;
const end = this.playerInput.selectionEnd ?? start;
this.playerInput.setRangeText(event.key, start, end, 'end');
this.playerInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
@@ -144,8 +183,8 @@ class UIInputHandlerModule extends BaseModule {
playerInput.style.whiteSpace = 'pre-wrap';
inputWrapper.appendChild(playerInput);
this.playerInput = playerInput;
}
this.playerInput = playerInput;
// Create the cursor if needed
let cursor = document.getElementById('cursor');
@@ -153,8 +192,8 @@ class UIInputHandlerModule extends BaseModule {
cursor = document.createElement('span');
cursor.id = 'cursor';
inputWrapper.appendChild(cursor);
this.cursor = cursor;
}
this.cursor = cursor;
// Set up input event handlers
if (playerInput) {
@@ -171,16 +210,94 @@ class UIInputHandlerModule extends BaseModule {
// Position the cursor
if (playerInput && cursor) {
this.positionCursor(playerInput, cursor);
// Focus the input to let user start typing immediately
setTimeout(() => {
playerInput.focus();
}, 100);
this.setProcessState('ready', { reason: 'input-initialized' });
this.focusInput();
requestAnimationFrame(() => this.focusInput());
setTimeout(() => this.focusInput(), 250);
}
console.log('UIInputHandler: Input elements setup complete');
}
focusInput() {
if (document.body.dataset.gameRunning !== 'true') {
return;
}
if (!this.playerInput) {
this.playerInput = document.getElementById('player_input');
}
if (this.playerInput && !this.playerInput.disabled) {
this.playerInput.focus();
}
}
setProcessState(state, detail = {}) {
const knownStates = [
'ready',
'command-waiting',
'waiting-generating',
'playing-generating',
'playing-ready'
];
const nextState = knownStates.includes(state) ? state : 'ready';
this.applyMouseCursor(nextState);
if (this.cursor) {
knownStates.forEach(value => this.cursor.classList.remove(`cursor-${value}`));
this.cursor.classList.add(`cursor-${nextState}`);
this.cursor.dataset.processState = nextState;
this.cursor.setAttribute('aria-label', 'text input cursor');
this.cursor.innerHTML = '';
}
console.log(`Cursor process state: ${nextState}`, detail);
}
applyMouseCursor(state) {
const root = document.documentElement;
if (!root) {
return;
}
root.dataset.processState = state;
const cursor = this.getMouseCursor(state);
if (cursor) {
root.style.setProperty('--process-cursor', cursor);
} else {
root.style.removeProperty('--process-cursor');
}
}
getMouseCursor(state) {
if (state === 'ready') {
return '';
}
const svg = this.getMouseCursorSvg(state);
const fallback = state === 'command-waiting' ? 'wait' : 'progress';
return `url("${this.toCursorDataUrl(svg)}") 12 12, ${fallback}`;
}
getMouseCursorSvg(state) {
const stroke = '#222222';
const common = `xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`;
const icons = {
'command-waiting': `<svg ${common}><path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>`,
'waiting-generating': `<svg ${common}><path d="M12 2v4"/><path d="M12 18v4"/><path d="m4.93 4.93 2.83 2.83"/><path d="m16.24 16.24 2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="m4.93 19.07 2.83-2.83"/><path d="m16.24 7.76 2.83-2.83"/></svg>`,
'playing-generating': `<svg ${common}><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M12 2v3"/><path d="M12 19v3"/></svg>`,
'playing-ready': `<svg ${common}><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`
};
return icons[state] || icons['waiting-generating'];
}
toCursorDataUrl(svg) {
return `data:image/svg+xml,${encodeURIComponent(svg.replace(/\s+/g, ' ').trim())}`;
}
/**
* Handle player input changes
* @param {Event} e - Input event
@@ -271,7 +388,7 @@ class UIInputHandlerModule extends BaseModule {
if (this.commandHistoryElement && this.commandHistoryElement.appendChild) {
const historyItem = document.createElement('div');
historyItem.className = 'history-item';
historyItem.textContent = `> ${command}`;
historyItem.innerHTML = `&gt; ${this.formatCommandHistory(command)}`;
this.commandHistoryElement.appendChild(historyItem);
// Limit visible history items
@@ -284,6 +401,23 @@ class UIInputHandlerModule extends BaseModule {
}
}
formatCommandHistory(command) {
const parser = this.getModule('markup-parser') || window.MarkupParser;
if (parser && typeof parser.markdownToHtml === 'function') {
return parser.markdownToHtml(command);
}
return String(command)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
isPlaybackActive() {
const playbackCoordinator = this.getModule('playback-coordinator') || window.PlaybackCoordinator;
return Boolean(playbackCoordinator && playbackCoordinator.isPlaying);
}
/**
* Resets the cursor position to the start.
*/