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
+186 -38
View File
@@ -112,6 +112,9 @@ body.switched {
}
:root {
--book-width: 2727px;
--book-height: 1691px;
--book-scale: 1;
font-size: calc(var(--book-height)/(34 * 1.5));
--img-aspect-ratio: 1.613;
--aspect-ratio: min(var(--viewport-aspect-ratio), var(--img-aspect-ratio));
@@ -194,7 +197,7 @@ h3 {
p, #ruler,#indent {
font-size: 1.2rem;
line-height: 1.2;
line-height: 1.45;
color: rgba(0,0,0,0.9);
margin-block-end: 0;
margin-block-start: 0;
@@ -234,6 +237,14 @@ img {
text-transform: uppercase;
}
.story-drop-cap {
position: absolute;
left: 0;
top: 0;
font-size: calc(2 * 1.45em);
line-height: 1;
}
.drop-quote {
font-size: 180%;
line-height: 1;
@@ -366,7 +377,9 @@ ol.choice {
}
#book {
position: relative;
position: fixed;
left: 50%;
top: 50%;
width: var(--book-width);
height: var(--book-height);
background-image: url('../images/book-3057904.png');
@@ -375,6 +388,8 @@ ol.choice {
background-repeat: no-repeat; /* Prevents repeating the image when aspect ratio doesn't match */
perspective: 500px;
perspective-origin: 50% 50%;
transform: translate(-50%, -50%) scale(var(--book-scale));
transform-origin: center center;
}
#page_left, #page_right {
@@ -410,7 +425,7 @@ ol.choice {
#page_right {
/* background-color: rgba(200,200,200,0.5); */
right: 7%;
height: calc(28 * 1.2 * 1.2rem);
height: calc(28 * 1.45 * 1.2rem);
padding-bottom: 0;
/* transform: translateX(-1%) translateY(2%) rotateX(0deg) rotateY(-1deg) rotateZ(0deg); */
}
@@ -554,16 +569,22 @@ ol.choice {
/* Command history */
#command_history {
max-height: 120px;
max-height: 8rem;
overflow-y: auto;
font-size: 16px;
margin-bottom: 15px;
font-size: 1rem;
line-height: 1.2;
margin-bottom: 1rem;
border-top: 1px solid #d1c8b9;
padding-top: 10px;
padding-top: 0.6rem;
scrollbar-width: thin;
scrollbar-color: #8b7765 rgba(255, 255, 255, 0.1);
}
#command_history .history-item {
margin-bottom: 0.25rem;
color: rgba(0, 0, 0, 0.82);
}
#command_history::-webkit-scrollbar {
width: 6px;
}
@@ -643,6 +664,20 @@ ol.choice {
z-index: 1; /* Ensure cursor appears above text */
}
html[data-process-state="command-waiting"],
html[data-process-state="command-waiting"] * {
cursor: var(--process-cursor, wait) !important;
}
html[data-process-state="waiting-generating"],
html[data-process-state="waiting-generating"] *,
html[data-process-state="playing-generating"],
html[data-process-state="playing-generating"] *,
html[data-process-state="playing-ready"],
html[data-process-state="playing-ready"] * {
cursor: var(--process-cursor, progress) !important;
}
/* Placeholder styling - lighter and italic, with padding to avoid cursor overlap */
#player_input::placeholder {
color: #aaa;
@@ -691,13 +726,37 @@ ol.choice {
text-align: justify;
text-justify: inter-word;
margin-bottom: 1.2em;
line-height: 1.5;
line-height: 1.45;
}
#story p.story-chapter-heading {
position: relative;
height: auto;
margin: 0 0 1.45em 0;
text-align: center;
font-size: 1.2rem;
font-style: italic;
line-height: 1.45;
}
#story p.story-textblock-start {
margin-top: 1.45em;
}
/* Typography for word elements in rendered paragraphs */
.word {
display: inline-block;
position: absolute;
opacity: 0;
visibility: hidden;
}
strong {
font-weight: bold;
}
em {
font-style: italic;
}
/* Typography for hyphen at line breaks */
@@ -722,6 +781,29 @@ ol.choice {
margin: 0 5px;
}
body:not([data-game-running="true"]) #command_input {
display: none;
}
#start_prompt {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 12%;
font-family: 'EB Garamond', var(--book-font), serif;
font-size: 34px;
font-style: italic;
color: rgba(45, 34, 24, 0.78);
pointer-events: none;
}
body:not([data-game-running="true"]) #start_prompt {
display: flex;
}
/* Options Modal Styling */
.modal {
position: fixed;
@@ -734,17 +816,21 @@ ol.choice {
align-items: center;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.5);
box-sizing: border-box;
padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
}
.modal-content {
background-color: rgba(255, 252, 245, 0.97);
border-radius: 0.4rem;
box-shadow: 0 0 1.5rem rgba(0, 0, 0, 0.4);
width: calc(var(--book-height) * 0.7);
max-width: 90%;
max-height: calc(var(--book-height) * 0.9);
overflow-y: auto;
padding: 1.5rem;
width: min(42rem, calc(100vw - 2rem));
max-width: 100%;
max-height: calc(100vh - 2rem);
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
font-family: 'EB Garamond', var(--book-font), serif;
color: #333;
position: relative;
@@ -756,11 +842,12 @@ ol.choice {
.modal-header {
display: flex;
flex: 0 0 auto;
justify-content: space-between;
align-items: center;
margin-bottom: 1.2rem;
margin-bottom: 0;
border-bottom: 1px solid #d9c8a9;
padding-bottom: 0.7rem;
padding: 1rem 1.5rem 0.7rem 1.5rem;
}
.modal-header h2 {
@@ -779,6 +866,7 @@ ol.choice {
cursor: pointer;
color: #7a6e59;
font-family: 'EB Garamond', var(--book-font), serif;
line-height: 1;
}
.close:hover {
@@ -789,6 +877,55 @@ ol.choice {
margin-bottom: 1.5rem;
}
.modal-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 1rem 1.5rem;
}
.modal-footer {
flex: 0 0 auto;
border-top: 1px solid #d9c8a9;
padding: 0.8rem 1.5rem 1rem;
text-align: right;
}
.modal-footer button,
.option-item input[type="text"],
.option-item input[type="password"],
.option-item input[type="url"] {
background-color: transparent;
border: 1px solid black;
border-radius: 0.25rem;
color: rgba(0,0,0,0.9);
font-family: 'EB Garamond', var(--book-font), serif;
font-size: 1rem;
}
.modal-footer button {
padding: 0.35rem 1rem;
cursor: pointer;
}
.modal-footer button:hover {
background-color: rgba(0,0,0,0.06);
}
.option-item input[type="text"],
.option-item input[type="password"],
.option-item input[type="url"] {
box-sizing: border-box;
width: min(18rem, 60%);
padding: 0.3rem 0.5rem;
}
.option-item input[type="text"]:focus,
.option-item input[type="password"]:focus,
.option-item input[type="url"]:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(90, 57, 33, 0.14);
}
.options-section h3 {
font-family: 'EB Garamond', var(--book-font), serif;
font-weight: normal;
@@ -804,6 +941,7 @@ ol.choice {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 0.7rem;
padding: 0.25rem 0;
}
@@ -812,6 +950,36 @@ ol.choice {
font-family: 'EB Garamond', var(--book-font), serif;
font-size: 1rem;
color: #4a4234;
flex: 1 1 auto;
}
.option-item .slider-value {
min-width: 3rem;
text-align: right;
}
.provider-status-list {
margin: 0.5rem 0 1rem;
border: 1px solid #e9ddc8;
border-radius: 0.25rem;
}
.provider-status-row {
display: grid;
grid-template-columns: minmax(7rem, 1fr) auto;
gap: 1rem;
padding: 0.35rem 0.5rem;
border-bottom: 1px solid #eee4d2;
font-size: 0.9rem;
}
.provider-status-row:last-child {
border-bottom: none;
}
.provider-status-value {
font-style: italic;
text-align: right;
}
/* Elegant checkboxes */
@@ -912,29 +1080,9 @@ ol.choice {
text-align: center;
}
/* API Settings in options Panel */
.api-settings-container {
margin-top: 10px;
padding: 10px;
border: 1px solid rgba(200, 200, 200, 0.2);
border-radius: 5px;
background-color: rgba(50, 50, 60, 0.3);
}
.api-settings-container input[type="text"],
.api-settings-container input[type="password"] {
width: 100%;
padding: 8px;
border: 1px solid #555;
border-radius: 4px;
background-color: rgba(30, 30, 35, 0.8);
color: #eee;
font-family: monospace;
}
.api-settings-container input[type="text"]::placeholder,
.api-settings-container input[type="password"]::placeholder {
color: #888;
.api-settings {
margin-top: 1rem;
padding: 0;
}
.elevenlabs-setting,
+22
View File
@@ -0,0 +1,22 @@
Story Images
============
Story image paths resolve relative to this directory.
Image block markup:
```text
::image[widescreen](image-name.jpg)
::image[portrait](image-name.jpg)
```
Sizes:
- `widescreen`: exactly 100% of the page width and 50% of the page height.
- `portrait`: exactly 100% of the page width and 100% of the page height.
Image markup is parsed and queued by the story markup system, but final image rendering is still future work. Keep assets ready for that renderer by using browser-friendly formats such as `.jpg`, `.png`, `.webp`, or `.avif`.
Use file names that are stable and story-facing, for example `mansion-door-rain.webp` rather than temporary export names.
Document third-party source and license information here or next to the file.
+2 -2
View File
@@ -297,6 +297,6 @@
originalLog.apply(console, args);
};
</script>
<script type="module" src="/js/loader.js"></script>
<script type="module" src="/js/loader.js?v=20260514-new-game-click"></script>
</body>
</html>
</html>
+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.
*/
+11 -5
View File
@@ -185,15 +185,21 @@
window.KokoroLoader.instance.generate(data.text, { voice: data.voice, speed: data.speed })
.then(result => {
log(`Generation successful for request ${data.id}`, 'success');
// Convert the result to a proper format for the parent window
const audio = new Uint8Array(result.buffer);
// Kokoro returns a Uint8Array directly, not an object with .buffer
const audio = result instanceof Uint8Array ? result : new Uint8Array(result.buffer || result);
log(`Audio data: ${audio.length} bytes (Uint8Array)`);
// Create a copy of the underlying ArrayBuffer
const bufferCopy = audio.buffer.slice(0);
log(`Sending audio buffer: ${bufferCopy.byteLength} bytes`);
window.parent.postMessage({
type: 'kokoro-generated',
id: data.id,
success: true,
result: { buffer: audio.buffer }
result: { buffer: bufferCopy },
duration: 0 // Duration not provided by Kokoro, will be estimated
}, '*');
})
.catch(error => {
Binary file not shown.
Binary file not shown.
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
Music
=====
Story music paths resolve relative to this directory.
Block music markup:
```text
::music[crossfade, loop, lead=4](track-name.ogg)
```
Inline music cue:
```text
The candles gutter. {{music:cut:danger.ogg}} Something moves upstairs.
```
Supported modes:
- `queue`: start the new track after the current track ends.
- `crossfade`: fade from the current track into the new track.
- `cut`: stop the current track and start the new track immediately.
Supported playback flags:
- `loop`: repeat the track.
- `once`: play the track one time.
- `lead=<seconds>`: for block music, let the music play alone for this many seconds before the next text/TTS paragraph starts.
Music volume is controlled by master volume and music volume in the options menu. While TTS is playing, music is ducked to 70% of its configured volume and restored when TTS playback is idle.
Browsers require a user interaction before audio can play, so music should begin after `new game` or `load`, not during passive page load.
Document third-party source and license information here or next to the file.
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
Sound Effects
=============
Story sound effect paths resolve relative to this directory.
Use inline sound effect markup inside narrative text:
```text
The old door opens {{sfx:squeaky-door.ogg}} into the dark.
```
The marker is removed from displayed text and from TTS input. It is kept as a timed media cue, preloaded by the client, and played when the text animation reaches that cue position.
Supported browser-friendly formats are recommended: `.ogg`, `.mp3`, and `.wav`. Keep files small enough for responsive preload.
Sound effect loudness is controlled by the master volume and sound effects volume sliders in the options menu.
Document third-party source and license information here or next to the file.
Current test asset:
- `squeaky-door.ogg`: Wikimedia Commons, "Squeaky door.ogg", sourced from PDSounds and marked public domain.
Binary file not shown.