Refactored modules and updated loader.
This commit is contained in:
@@ -1,931 +0,0 @@
|
||||
/**
|
||||
* @license Hyphenopoly 5.2.0-beta.1 - client side hyphenation for webbrowsers
|
||||
* ©2023 Mathias Nater, Güttingen (mathiasnater at gmail dot com)
|
||||
* https://github.com/mnater/Hyphenopoly
|
||||
*
|
||||
* Released under the MIT license
|
||||
* http://mnater.github.io/Hyphenopoly/LICENSE
|
||||
*/
|
||||
|
||||
/* globals Hyphenopoly:readonly */
|
||||
((w, o) => {
|
||||
"use strict";
|
||||
const SOFTHYPHEN = "\u00AD";
|
||||
|
||||
/**
|
||||
* Event
|
||||
*/
|
||||
const event = ((H) => {
|
||||
const knownEvents = new Map([
|
||||
["afterElementHyphenation", []],
|
||||
["beforeElementHyphenation", []],
|
||||
["engineReady", []],
|
||||
[
|
||||
"error", [
|
||||
(e) => {
|
||||
if (e.runDefault) {
|
||||
w.console.warn(e);
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
["hyphenopolyEnd", []],
|
||||
["hyphenopolyStart", []]
|
||||
]);
|
||||
if (H.hev) {
|
||||
const userEvents = new Map(o.entries(H.hev));
|
||||
knownEvents.forEach((eventFuncs, eventName) => {
|
||||
if (userEvents.has(eventName)) {
|
||||
eventFuncs.unshift(userEvents.get(eventName));
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
"fire": ((eventName, eventData) => {
|
||||
eventData.runDefault = true;
|
||||
eventData.preventDefault = () => {
|
||||
eventData.runDefault = false;
|
||||
};
|
||||
knownEvents.get(eventName).forEach((eventFn) => {
|
||||
eventFn(eventData);
|
||||
});
|
||||
})
|
||||
};
|
||||
})(Hyphenopoly);
|
||||
|
||||
/**
|
||||
* Register copy event on element
|
||||
* @param {Object} el The element
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function registerOnCopy(el) {
|
||||
el.addEventListener(
|
||||
"copy",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const sel = w.getSelection();
|
||||
const div = document.createElement("div");
|
||||
div.appendChild(sel.getRangeAt(0).cloneContents());
|
||||
e.clipboardData.setData("text/plain", sel.toString().replace(RegExp(SOFTHYPHEN, "g"), ""));
|
||||
e.clipboardData.setData("text/html", div.innerHTML.replace(RegExp(SOFTHYPHEN, "g"), ""));
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert settings from H.setup-Object to Map
|
||||
* This is a IIFE to keep complexity low.
|
||||
*/
|
||||
((H) => {
|
||||
/**
|
||||
* Create a Map with a default Map behind the scenes. This mimics
|
||||
* kind of a prototype chain of an object, but without the object-
|
||||
* injection security risk.
|
||||
*
|
||||
* @param {Map} defaultsMap - A Map with default values
|
||||
* @returns {Proxy} - A Proxy for the Map (dot-notation or get/set)
|
||||
*/
|
||||
function createMapWithDefaults(defaultsMap) {
|
||||
const userMap = new Map();
|
||||
|
||||
/**
|
||||
* The get-trap: get the value from userMap or else from defaults
|
||||
* @param {Sring} key - The key to retrieve the value for
|
||||
* @returns {*}
|
||||
*/
|
||||
function get(key) {
|
||||
return (userMap.has(key))
|
||||
? userMap.get(key)
|
||||
: defaultsMap.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The set-trap: set the value to userMap and don't touch defaults
|
||||
* @param {Sring} key - The key for the value
|
||||
* @param {*} value - The value
|
||||
* @returns {*}
|
||||
*/
|
||||
function set(key, value) {
|
||||
userMap.set(key, value);
|
||||
}
|
||||
return new Proxy(defaultsMap, {
|
||||
"get": (_target, prop) => {
|
||||
if (prop === "set") {
|
||||
return set;
|
||||
}
|
||||
if (prop === "get") {
|
||||
return get;
|
||||
}
|
||||
return get(prop);
|
||||
},
|
||||
"ownKeys": () => {
|
||||
return [
|
||||
...new Set(
|
||||
[...defaultsMap.keys(), ...userMap.keys()]
|
||||
)
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const settings = createMapWithDefaults(new Map([
|
||||
["defaultLanguage", "en-us"],
|
||||
[
|
||||
"dontHyphenate", (() => {
|
||||
const list = "abbr,acronym,audio,br,button,code,img,input,kbd,label,math,option,pre,samp,script,style,sub,sup,svg,textarea,var,video";
|
||||
return createMapWithDefaults(
|
||||
new Map(list.split(",").map((val) => {
|
||||
return [val, true];
|
||||
}))
|
||||
);
|
||||
})()
|
||||
],
|
||||
["dontHyphenateClass", "donthyphenate"],
|
||||
["exceptions", new Map()],
|
||||
["keepAlive", true],
|
||||
["normalize", false],
|
||||
["processShadows", false],
|
||||
["safeCopy", true],
|
||||
["substitute", new Map()],
|
||||
["timeout", 1000]
|
||||
]));
|
||||
o.entries(H.s).forEach(([key, value]) => {
|
||||
switch (key) {
|
||||
case "selectors":
|
||||
// Set settings.selectors to array of selectors
|
||||
settings.set("selectors", o.keys(value));
|
||||
|
||||
/*
|
||||
* For each selector add a property to settings with
|
||||
* selector specific settings
|
||||
*/
|
||||
o.entries(value).forEach(([sel, selSettings]) => {
|
||||
const selectorSettings = createMapWithDefaults(new Map([
|
||||
["compound", "hyphen"],
|
||||
["hyphen", SOFTHYPHEN],
|
||||
["leftmin", 0],
|
||||
["leftminPerLang", 0],
|
||||
["minWordLength", 6],
|
||||
["mixedCase", true],
|
||||
["orphanControl", 1],
|
||||
["rightmin", 0],
|
||||
["rightminPerLang", 0]
|
||||
]));
|
||||
o.entries(selSettings).forEach(
|
||||
([selSetting, setVal]) => {
|
||||
if (typeof setVal === "object") {
|
||||
selectorSettings.set(
|
||||
selSetting,
|
||||
new Map(o.entries(setVal))
|
||||
);
|
||||
} else {
|
||||
selectorSettings.set(selSetting, setVal);
|
||||
}
|
||||
}
|
||||
);
|
||||
settings.set(sel, selectorSettings);
|
||||
});
|
||||
break;
|
||||
case "dontHyphenate":
|
||||
case "exceptions":
|
||||
o.entries(value).forEach(([k, v]) => {
|
||||
settings.get(key).set(k, v);
|
||||
});
|
||||
break;
|
||||
case "substitute":
|
||||
o.entries(value).forEach(([lang, subst]) => {
|
||||
settings.substitute.set(
|
||||
lang,
|
||||
new Map(o.entries(subst))
|
||||
);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
settings.set(key, value);
|
||||
}
|
||||
});
|
||||
H.c = settings;
|
||||
})(Hyphenopoly);
|
||||
|
||||
((H) => {
|
||||
const C = H.c;
|
||||
let mainLanguage = null;
|
||||
|
||||
event.fire(
|
||||
"hyphenopolyStart",
|
||||
{
|
||||
"msg": "hyphenopolyStart"
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Factory for elements
|
||||
* @returns {Object} elements-object
|
||||
*/
|
||||
function makeElementCollection() {
|
||||
const list = new Map();
|
||||
|
||||
/*
|
||||
* Counter counts the elements to be hyphenated.
|
||||
* Needs to be an object (Pass by reference)
|
||||
*/
|
||||
const counter = [0];
|
||||
|
||||
/**
|
||||
* Add element to elements
|
||||
* @param {object} el The element
|
||||
* @param {string} lang The language of the element
|
||||
* @param {string} sel The selector of the element
|
||||
* @returns {Object} An element-object
|
||||
*/
|
||||
function add(el, lang, sel) {
|
||||
const elo = {
|
||||
"element": el,
|
||||
"selector": sel
|
||||
};
|
||||
if (!list.has(lang)) {
|
||||
list.set(lang, []);
|
||||
}
|
||||
list.get(lang).push(elo);
|
||||
counter[0] += 1;
|
||||
return elo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes elements from the list and updates the counter
|
||||
* @param {string} lang - The lang of the elements to remove
|
||||
*/
|
||||
function rem(lang) {
|
||||
let langCount = 0;
|
||||
if (list.has(lang)) {
|
||||
langCount = list.get(lang).length;
|
||||
list.delete(lang);
|
||||
counter[0] -= langCount;
|
||||
if (counter[0] === 0) {
|
||||
event.fire(
|
||||
"hyphenopolyEnd",
|
||||
{
|
||||
"msg": "hyphenopolyEnd"
|
||||
}
|
||||
);
|
||||
if (!C.keepAlive) {
|
||||
window.Hyphenopoly = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
add,
|
||||
counter,
|
||||
list,
|
||||
rem
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language of element by searching its parents or fallback
|
||||
* @param {Object} el The element
|
||||
* @param {string} parentLang Lang of parent if available
|
||||
* @param {boolean} fallback Will falback to mainlanguage
|
||||
* @returns {string|null} The language or null
|
||||
*/
|
||||
function getLang(el, parentLang = "", fallback = true) {
|
||||
// Find closest el with lang attr not empty
|
||||
el = el.closest("[lang]:not([lang=''])");
|
||||
if (el && el.lang) {
|
||||
return el.lang.toLowerCase();
|
||||
}
|
||||
if (parentLang) {
|
||||
return parentLang;
|
||||
}
|
||||
return (fallback)
|
||||
? mainLanguage
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect elements that have a selector defined in C.selectors
|
||||
* and add them to elements.
|
||||
* @param {Object} [parent = null] The start point element
|
||||
* @param {string} [selector = null] The selector matching the parent
|
||||
* @returns {Object} elements-object
|
||||
*/
|
||||
function collectElements(parent = null, selector = null) {
|
||||
const elements = makeElementCollection();
|
||||
|
||||
const dontHyphenateSelector = (() => {
|
||||
let s = "." + C.dontHyphenateClass;
|
||||
o.getOwnPropertyNames(C.dontHyphenate).forEach((tag) => {
|
||||
if (C.dontHyphenate.get(tag)) {
|
||||
s += "," + tag;
|
||||
}
|
||||
});
|
||||
return s;
|
||||
})();
|
||||
const matchingSelectors = C.selectors.join(",") + "," + dontHyphenateSelector;
|
||||
|
||||
/**
|
||||
* Recursively walk all elements in el, lending lang and selName
|
||||
* add them to elements if necessary.
|
||||
* @param {Object} el The element to scan
|
||||
* @param {string} pLang The language of the parent element
|
||||
* @param {string} sel The selector of the parent element
|
||||
* @param {boolean} isChild If el is a child element
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function processElements(el, pLang, sel, isChild = false) {
|
||||
const eLang = getLang(el, pLang);
|
||||
const langDef = H.cf.langs.get(eLang);
|
||||
if (langDef === "H9Y") {
|
||||
elements.add(el, eLang, sel);
|
||||
if (!isChild && C.safeCopy) {
|
||||
registerOnCopy(el);
|
||||
}
|
||||
} else if (!langDef && eLang !== "zxx") {
|
||||
event.fire(
|
||||
"error",
|
||||
Error(`Element with '${eLang}' found, but '${eLang}.wasm' not loaded. Check language tags!`)
|
||||
);
|
||||
}
|
||||
el.childNodes.forEach((n) => {
|
||||
if (n.nodeType === 1 && !n.matches(matchingSelectors)) {
|
||||
processElements(n, eLang, sel, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches the DOM for each sel
|
||||
* @param {object} root The DOM root
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function getElems(root) {
|
||||
C.selectors.forEach((sel) => {
|
||||
root.querySelectorAll(sel).forEach((n) => {
|
||||
processElements(n, getLang(n), sel, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (parent === null) {
|
||||
if (C.processShadows) {
|
||||
w.document.querySelectorAll("*").forEach((m) => {
|
||||
if (m.shadowRoot) {
|
||||
getElems(m.shadowRoot);
|
||||
}
|
||||
});
|
||||
}
|
||||
getElems(w.document);
|
||||
} else {
|
||||
processElements(parent, getLang(parent), selector);
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
const wordHyphenatorPool = new Map();
|
||||
|
||||
/**
|
||||
* Factory for hyphenatorFunctions for a specific language and selector
|
||||
* @param {Object} lo Language-Object
|
||||
* @param {string} lang The language
|
||||
* @param {string} sel The selector
|
||||
* @returns {function} The hyphenate function
|
||||
*/
|
||||
function createWordHyphenator(lo, lang, sel) {
|
||||
const poolKey = lang + "-" + sel;
|
||||
if (wordHyphenatorPool.has(poolKey)) {
|
||||
return wordHyphenatorPool.get(poolKey);
|
||||
}
|
||||
|
||||
const selSettings = C.get(sel);
|
||||
lo.cache.set(sel, new Map());
|
||||
|
||||
/**
|
||||
* HyphenateFunction for non-compound words
|
||||
* @param {string} word The word
|
||||
* @returns {string} The hyphenated word
|
||||
*/
|
||||
function hyphenateNormal(word) {
|
||||
if (word.length > 61) {
|
||||
event.fire(
|
||||
"error",
|
||||
Error("Found word longer than 61 characters")
|
||||
);
|
||||
} else if (!lo.reNotAlphabet.test(word)) {
|
||||
return lo.hyphenate(
|
||||
word,
|
||||
selSettings.hyphen.charCodeAt(0),
|
||||
selSettings.leftminPerLang.get(lang),
|
||||
selSettings.rightminPerLang.get(lang)
|
||||
);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
/**
|
||||
* HyphenateFunction for compound words
|
||||
* @param {string} word The word
|
||||
* @returns {string} The hyphenated compound word
|
||||
*/
|
||||
function hyphenateCompound(word) {
|
||||
const zeroWidthSpace = "\u200B";
|
||||
let parts = null;
|
||||
let wordHyphenator = null;
|
||||
if (selSettings.compound === "auto" ||
|
||||
selSettings.compound === "all") {
|
||||
wordHyphenator = createWordHyphenator(lo, lang, sel);
|
||||
parts = word.split("-").map((p) => {
|
||||
if (p.length >= selSettings.minWordLength) {
|
||||
return wordHyphenator(p);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
if (selSettings.compound === "auto") {
|
||||
word = parts.join("-");
|
||||
} else {
|
||||
word = parts.join("-" + zeroWidthSpace);
|
||||
}
|
||||
} else {
|
||||
word = word.replace("-", "-" + zeroWidthSpace);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is mixed case
|
||||
* @param {string} s The string
|
||||
* @returns {boolean} true if s is mixed case
|
||||
*/
|
||||
function isMixedCase(s) {
|
||||
return [...s].map((c) => {
|
||||
return (c === c.toLowerCase());
|
||||
}).some((v, i, a) => {
|
||||
return (v !== a[0]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HyphenateFunction for words (compound or not)
|
||||
* @param {string} word The word
|
||||
* @returns {string} The hyphenated word
|
||||
*/
|
||||
function hyphenator(word) {
|
||||
let hw = lo.cache.get(sel).get(word);
|
||||
if (!hw) {
|
||||
if (lo.exc.has(word)) {
|
||||
hw = lo.exc.get(word).replace(
|
||||
/-/g,
|
||||
selSettings.hyphen
|
||||
);
|
||||
} else if (!selSettings.mixedCase && isMixedCase(word)) {
|
||||
hw = word;
|
||||
} else if (word.indexOf("-") === -1) {
|
||||
hw = hyphenateNormal(word);
|
||||
} else {
|
||||
hw = hyphenateCompound(word);
|
||||
}
|
||||
lo.cache.get(sel).set(word, hw);
|
||||
}
|
||||
return hw;
|
||||
}
|
||||
wordHyphenatorPool.set(poolKey, hyphenator);
|
||||
return hyphenator;
|
||||
}
|
||||
|
||||
const orphanControllerPool = new Map();
|
||||
|
||||
/**
|
||||
* Factory for function that handles orphans
|
||||
* @param {string} sel The selector
|
||||
* @returns {function} The function created
|
||||
*/
|
||||
function createOrphanController(sel) {
|
||||
if (orphanControllerPool.has(sel)) {
|
||||
return orphanControllerPool.get(sel);
|
||||
}
|
||||
const selSettings = C.get(sel);
|
||||
|
||||
/**
|
||||
* Function template
|
||||
* @param {string} ignore unused result of replace
|
||||
* @param {string} leadingWhiteSpace The leading whiteSpace
|
||||
* @param {string} lastWord The last word
|
||||
* @param {string} trailingWhiteSpace The trailing whiteSpace
|
||||
* @returns {string} Treated end of text
|
||||
*/
|
||||
function controlOrphans(
|
||||
ignore,
|
||||
leadingWhiteSpace,
|
||||
lastWord,
|
||||
trailingWhiteSpace
|
||||
) {
|
||||
if (selSettings.orphanControl === 3 && leadingWhiteSpace === " ") {
|
||||
// \u00A0 = no-break space (nbsp)
|
||||
leadingWhiteSpace = "\u00A0";
|
||||
}
|
||||
return leadingWhiteSpace + lastWord.replace(RegExp(selSettings.hyphen, "g"), "") + trailingWhiteSpace;
|
||||
}
|
||||
orphanControllerPool.set(sel, controlOrphans);
|
||||
return controlOrphans;
|
||||
}
|
||||
|
||||
const wordRegExpPool = new Map();
|
||||
|
||||
/**
|
||||
* Hyphenate an entitiy (text string or Element-Object)
|
||||
* @param {string} lang - the language of the string
|
||||
* @param {string} sel - the selectorName of settings
|
||||
* @param {string} entity - the entity to be hyphenated
|
||||
* @returns {string | null} hyphenated str according to setting of sel
|
||||
*/
|
||||
function hyphenate(lang, sel, entity) {
|
||||
const lo = H.languages.get(lang);
|
||||
const selSettings = C.get(sel);
|
||||
const minWordLength = selSettings.minWordLength;
|
||||
|
||||
|
||||
const regExpWord = (() => {
|
||||
const key = lang + minWordLength;
|
||||
if (wordRegExpPool.has(key)) {
|
||||
return wordRegExpPool.get(key);
|
||||
}
|
||||
|
||||
/*
|
||||
* Transpiled RegExp of
|
||||
* /[${alphabet}\p{Mn}Subset\p{Letter}\00AD-]
|
||||
* {${minwordlength},}/gui
|
||||
*/
|
||||
const reWord = RegExp(
|
||||
`[${lo.alphabet}a-z\u0300-\u036F\u0483-\u0487\u00DF-\u00F6\u00F8-\u00FE\u0101\u0103\u0105\u0107\u0109\u010D\u010F\u0111\u0113\u0117\u0119\u011B\u011D\u011F\u0123\u0125\u012B\u012F\u0131\u0135\u0137\u013C\u013E\u0142\u0144\u0146\u0148\u014D\u0151\u0153\u0155\u0159\u015B\u015D\u015F\u0161\u0165\u016B\u016D\u016F\u0171\u0173\u017A\u017C\u017E\u017F\u01CE\u01D0\u01D2\u01D4\u01D6\u01D8\u01DA\u01DC\u0219\u021B\u02BC\u0390\u03AC-\u03CE\u03D0\u03E3\u03E5\u03E7\u03E9\u03EB\u03ED\u03EF\u03F2\u0430-\u044F\u0451-\u045C\u045E\u045F\u0491\u04AF\u04E9\u0561-\u0585\u0587\u0905-\u090C\u090F\u0910\u0913-\u0928\u092A-\u0930\u0932\u0933\u0935-\u0939\u093D\u0960\u0961\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A85-\u0A8B\u0A8F\u0A90\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AE0\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B60\u0B61\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB5\u0BB7-\u0BB9\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D60\u0D61\u0D7A-\u0D7F\u0E01-\u0E2E\u0E30\u0E32\u0E33\u0E40-\u0E45\u10D0-\u10F0\u1200-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u1E0D\u1E37\u1E41\u1E43\u1E45\u1E47\u1E6D\u1F00-\u1F07\u1F10-\u1F15\u1F20-\u1F27\u1F30-\u1F37\u1F40-\u1F45\u1F50-\u1F57\u1F60-\u1F67\u1F70-\u1F7D\u1F80-\u1F87\u1F90-\u1F97\u1FA0-\u1FA7\u1FB2-\u1FB4\u1FB6\u1FB7\u1FC2-\u1FC4\u1FC6\u1FC7\u1FD2\u1FD3\u1FD6\u1FD7\u1FE2-\u1FE7\u1FF2-\u1FF4\u1FF6\u1FF7\u2C81\u2C83\u2C85\u2C87\u2C89\u2C8D\u2C8F\u2C91\u2C93\u2C95\u2C97\u2C99\u2C9B\u2C9D\u2C9F\u2CA1\u2CA3\u2CA5\u2CA7\u2CA9\u2CAB\u2CAD\u2CAF\u2CB1\u2CC9\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\u00AD\u200B-\u200D-]{${minWordLength},}`, "gui"
|
||||
);
|
||||
wordRegExpPool.set(key, reWord);
|
||||
return reWord;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Hyphenate text according to setting in sel
|
||||
* @param {string} text - the strint to be hyphenated
|
||||
* @returns {string} hyphenated string according to setting of sel
|
||||
*/
|
||||
function hyphenateText(text) {
|
||||
if (C.normalize) {
|
||||
text = text.normalize("NFC");
|
||||
}
|
||||
let tn = text.replace(
|
||||
regExpWord,
|
||||
createWordHyphenator(lo, lang, sel)
|
||||
);
|
||||
if (selSettings.orphanControl !== 1) {
|
||||
tn = tn.replace(
|
||||
/(\u0020*)(\S+)(\s*)$/,
|
||||
createOrphanController(sel)
|
||||
);
|
||||
}
|
||||
return tn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hyphenate element according to setting in sel
|
||||
* @param {object} el - the HTMLElement to be hyphenated
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function hyphenateElement(el) {
|
||||
event.fire(
|
||||
"beforeElementHyphenation",
|
||||
{
|
||||
el,
|
||||
lang
|
||||
}
|
||||
);
|
||||
el.childNodes.forEach((n) => {
|
||||
if (
|
||||
n.nodeType === 3 &&
|
||||
(/\S/).test(n.data) &&
|
||||
n.data.length >= minWordLength
|
||||
) {
|
||||
n.data = hyphenateText(n.data);
|
||||
}
|
||||
});
|
||||
H.res.els.counter[0] -= 1;
|
||||
event.fire(
|
||||
"afterElementHyphenation",
|
||||
{
|
||||
el,
|
||||
lang
|
||||
}
|
||||
);
|
||||
}
|
||||
let r = null;
|
||||
if (typeof entity === "string") {
|
||||
r = hyphenateText(entity);
|
||||
} else if (entity instanceof HTMLElement) {
|
||||
hyphenateElement(entity);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a language-specific string hyphenator
|
||||
* @param {String} lang - The language this hyphenator hyphenates
|
||||
*/
|
||||
function createStringHyphenator(lang) {
|
||||
return ((entity, sel = ".hyphenate") => {
|
||||
if (typeof entity !== "string") {
|
||||
event.fire(
|
||||
"error",
|
||||
Error("This use of hyphenators is deprecated. See https://mnater.github.io/Hyphenopoly/Hyphenators.html")
|
||||
);
|
||||
}
|
||||
return hyphenate(lang, sel, entity);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a polyglot HTML hyphenator
|
||||
*/
|
||||
function createDOMHyphenator() {
|
||||
return ((entity, sel = ".hyphenate") => {
|
||||
collectElements(entity, sel).list.forEach((els, l) => {
|
||||
els.forEach((elo) => {
|
||||
hyphenate(l, elo.selector, elo.element);
|
||||
});
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
H.unhyphenate = () => {
|
||||
H.res.els.list.forEach((els) => {
|
||||
els.forEach((elo) => {
|
||||
const n = elo.element.firstChild;
|
||||
n.data = n.data.replace(RegExp(C[elo.selector].hyphen, "g"), "");
|
||||
});
|
||||
});
|
||||
return Promise.resolve(H.res.els);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hyphenate all elements with a given language
|
||||
* @param {string} lang The language
|
||||
* @param {Array} elArr Array of elements
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function hyphenateLangElements(lang, elements) {
|
||||
const elArr = elements.list.get(lang);
|
||||
if (elArr) {
|
||||
elArr.forEach((elo) => {
|
||||
hyphenate(lang, elo.selector, elo.element);
|
||||
});
|
||||
} else {
|
||||
event.fire(
|
||||
"error",
|
||||
Error(`Engine for language '${lang}' loaded, but no elements found.`)
|
||||
);
|
||||
}
|
||||
if (elements.counter[0] === 0) {
|
||||
w.clearTimeout(H.timeOutHandler);
|
||||
H.hide(0, null);
|
||||
event.fire(
|
||||
"hyphenopolyEnd",
|
||||
{
|
||||
"msg": "hyphenopolyEnd"
|
||||
}
|
||||
);
|
||||
if (!C.keepAlive) {
|
||||
window.Hyphenopoly = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the exceptions from user input to Map
|
||||
* @param {string} lang - The language for which the Map is created
|
||||
* @return {Map}
|
||||
*/
|
||||
function createExceptionMap(lang) {
|
||||
let exc = "";
|
||||
if (C.exceptions.has(lang)) {
|
||||
exc = C.exceptions.get(lang);
|
||||
}
|
||||
if (C.exceptions.has("global")) {
|
||||
if (exc === "") {
|
||||
exc = C.exceptions.get("global");
|
||||
} else {
|
||||
exc += ", " + C.exceptions.get("global");
|
||||
}
|
||||
}
|
||||
if (exc === "") {
|
||||
return new Map();
|
||||
}
|
||||
return new Map(exc.split(", ").map((e) => {
|
||||
return [e.replace(/-/g, ""), e];
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup lo
|
||||
* @param {string} lang The language
|
||||
* @param {function} hyphenateFunction The hyphenateFunction
|
||||
* @param {string} alphabet List of used characters
|
||||
* @param {number} leftmin leftmin
|
||||
* @param {number} rightmin rightmin
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function prepareLanguagesObj(
|
||||
lang,
|
||||
hyphenateFunction,
|
||||
alphabet,
|
||||
patternLeftmin,
|
||||
patternRightmin
|
||||
) {
|
||||
C.selectors.forEach((sel) => {
|
||||
const selSettings = C.get(sel);
|
||||
if (selSettings.leftminPerLang === 0) {
|
||||
selSettings.set("leftminPerLang", new Map());
|
||||
}
|
||||
if (selSettings.rightminPerLang === 0) {
|
||||
selSettings.set("rightminPerLang", new Map());
|
||||
}
|
||||
selSettings.leftminPerLang.set(lang, Math.max(
|
||||
patternLeftmin,
|
||||
selSettings.leftmin,
|
||||
Number(selSettings.leftminPerLang.get(lang)) || 0
|
||||
));
|
||||
|
||||
selSettings.rightminPerLang.set(lang, Math.max(
|
||||
patternRightmin,
|
||||
selSettings.rightmin,
|
||||
Number(selSettings.rightminPerLang.get(lang)) || 0
|
||||
));
|
||||
});
|
||||
if (!H.languages) {
|
||||
H.languages = new Map();
|
||||
}
|
||||
alphabet = alphabet.replace(/\\*-/g, "\\-");
|
||||
H.languages.set(lang, {
|
||||
alphabet,
|
||||
"cache": new Map(),
|
||||
"exc": createExceptionMap(lang),
|
||||
"hyphenate": hyphenateFunction,
|
||||
"ready": true,
|
||||
"reNotAlphabet": RegExp(`[^${alphabet}]`, "i")
|
||||
});
|
||||
H.hy6ors.get(lang).resolve(createStringHyphenator(lang));
|
||||
event.fire(
|
||||
"engineReady",
|
||||
{
|
||||
lang
|
||||
}
|
||||
);
|
||||
if (H.res.els) {
|
||||
hyphenateLangElements(lang, H.res.els);
|
||||
}
|
||||
}
|
||||
|
||||
const decode = (() => {
|
||||
const utf16ledecoder = new TextDecoder("utf-16le");
|
||||
return ((ui16) => {
|
||||
return utf16ledecoder.decode(ui16);
|
||||
});
|
||||
})();
|
||||
|
||||
/**
|
||||
* Setup env for hyphenateFunction
|
||||
* @param {ArrayBuffer} buf Memory buffer
|
||||
* @param {function} hyphenateFunc hyphenateFunction
|
||||
* @returns {function} hyphenateFunction with closured environment
|
||||
*/
|
||||
function encloseHyphenateFunction(buf, hyphenateFunc) {
|
||||
const wordStore = new Uint16Array(buf, 0, 64);
|
||||
return ((word, hyphencc, leftmin, rightmin) => {
|
||||
wordStore.set([
|
||||
...[...word].map((c) => {
|
||||
return c.charCodeAt(0);
|
||||
}),
|
||||
0
|
||||
]);
|
||||
const len = hyphenateFunc(leftmin, rightmin, hyphencc);
|
||||
if (len > 0) {
|
||||
word = decode(
|
||||
new Uint16Array(buf, 0, len)
|
||||
);
|
||||
}
|
||||
return word;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate Wasm Engine
|
||||
* @param {string} lang The language
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function instantiateWasmEngine(heProm, lang) {
|
||||
const wa = window.WebAssembly;
|
||||
|
||||
/**
|
||||
* Register character substitutions in the .wasm-hyphenEngine
|
||||
* @param {number} alphalen - The length of the alphabet
|
||||
* @param {object} exp - Export-object of the hyphenEngine
|
||||
*/
|
||||
function registerSubstitutions(alphalen, exp) {
|
||||
if (C.substitute.has(lang)) {
|
||||
const subst = C.substitute.get(lang);
|
||||
subst.forEach((substituer, substituted) => {
|
||||
const substitutedU = substituted.toUpperCase();
|
||||
const substitutedUcc = (substitutedU === substituted)
|
||||
? 0
|
||||
: substitutedU.charCodeAt(0);
|
||||
alphalen = exp.subst(
|
||||
substituted.charCodeAt(0),
|
||||
substitutedUcc,
|
||||
substituer.charCodeAt(0)
|
||||
);
|
||||
});
|
||||
}
|
||||
return alphalen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the hyphenEngine
|
||||
* @param {object} res - The fetched ressource
|
||||
*/
|
||||
function handleWasm(res) {
|
||||
const exp = res.instance.exports;
|
||||
// eslint-disable-next-line multiline-ternary
|
||||
let alphalen = (wa.Global) ? exp.lct.value : exp.lct;
|
||||
alphalen = registerSubstitutions(alphalen, exp);
|
||||
heProm.l.forEach((l) => {
|
||||
prepareLanguagesObj(
|
||||
l,
|
||||
encloseHyphenateFunction(
|
||||
exp.mem.buffer,
|
||||
exp.hyphenate
|
||||
),
|
||||
decode(new Uint16Array(exp.mem.buffer, 1408, alphalen)),
|
||||
/* eslint-disable multiline-ternary */
|
||||
(wa.Global) ? exp.lmi.value : exp.lmi,
|
||||
(wa.Global) ? exp.rmi.value : exp.rmi
|
||||
/* eslint-enable multiline-ternary */
|
||||
);
|
||||
});
|
||||
}
|
||||
heProm.w.then((response) => {
|
||||
if (response.ok) {
|
||||
if (
|
||||
wa.instantiateStreaming &&
|
||||
(response.headers.get("Content-Type") === "application/wasm")
|
||||
) {
|
||||
return wa.instantiateStreaming(response);
|
||||
}
|
||||
return response.arrayBuffer().then((ab) => {
|
||||
return wa.instantiate(ab);
|
||||
});
|
||||
}
|
||||
return Promise.reject(Error(`File ${lang}.wasm can't be loaded from ${H.paths.patterndir}`));
|
||||
}).then(handleWasm, (e) => {
|
||||
event.fire("error", e);
|
||||
H.res.els.rem(lang);
|
||||
});
|
||||
}
|
||||
|
||||
H.main = () => {
|
||||
H.res.DOM.then(() => {
|
||||
mainLanguage = getLang(w.document.documentElement, "", false);
|
||||
if (!mainLanguage && C.defaultLanguage !== "") {
|
||||
mainLanguage = C.defaultLanguage;
|
||||
}
|
||||
const elements = collectElements();
|
||||
H.res.els = elements;
|
||||
elements.list.forEach((ignore, lang) => {
|
||||
if (H.languages &&
|
||||
H.languages.has(lang) &&
|
||||
H.languages.get(lang).ready
|
||||
) {
|
||||
hyphenateLangElements(lang, elements);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
H.res.he.forEach(instantiateWasmEngine);
|
||||
|
||||
Promise.all(
|
||||
// Make sure all lang specific hyphenators and DOM are ready
|
||||
[...H.hy6ors.entries()].
|
||||
reduce((accumulator, value) => {
|
||||
if (value[0] !== "HTML") {
|
||||
return accumulator.concat(value[1]);
|
||||
}
|
||||
return accumulator;
|
||||
}, []).
|
||||
concat(H.res.DOM)
|
||||
).then(() => {
|
||||
H.hy6ors.get("HTML").resolve(createDOMHyphenator());
|
||||
}, (e) => {
|
||||
event.fire("error", e);
|
||||
});
|
||||
};
|
||||
H.main();
|
||||
})(Hyphenopoly);
|
||||
})(window, Object);
|
||||
@@ -1,347 +0,0 @@
|
||||
/**
|
||||
* @license Hyphenopoly_Loader 5.2.0-beta.1 - client side hyphenation
|
||||
* ©2023 Mathias Nater, Güttingen (mathiasnater at gmail dot com)
|
||||
* https://github.com/mnater/Hyphenopoly
|
||||
*
|
||||
* Released under the MIT license
|
||||
* http://mnater.github.io/Hyphenopoly/LICENSE
|
||||
*/
|
||||
/* globals Hyphenopoly:readonly */
|
||||
window.Hyphenopoly = {};
|
||||
|
||||
((w, d, H, o) => {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Shortcut for new Map
|
||||
* @param {any} init - initialiser for new Map
|
||||
* @returns {Map}
|
||||
*/
|
||||
const mp = (init) => {
|
||||
return new Map(init);
|
||||
};
|
||||
|
||||
const scriptName = "Hyphenopoly_Loader.js";
|
||||
const thisScript = d.currentScript.src;
|
||||
const store = sessionStorage;
|
||||
let mainScriptLoaded = false;
|
||||
|
||||
/**
|
||||
* The main function runs the feature test and loads Hyphenopoly if
|
||||
* necessary.
|
||||
*/
|
||||
const main = (() => {
|
||||
const shortcuts = {
|
||||
"ac": "appendChild",
|
||||
"ce": "createElement",
|
||||
"ct": "createTextNode"
|
||||
};
|
||||
|
||||
/**
|
||||
* Create deferred Promise
|
||||
*
|
||||
* From http://lea.verou.me/2016/12/resolve-promises-externally-with-
|
||||
* this-one-weird-trick/
|
||||
* @return {promise}
|
||||
*/
|
||||
const defProm = () => {
|
||||
let res = null;
|
||||
let rej = null;
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
res = resolve;
|
||||
rej = reject;
|
||||
});
|
||||
promise.resolve = res;
|
||||
promise.reject = rej;
|
||||
return promise;
|
||||
};
|
||||
|
||||
H.ac = new AbortController();
|
||||
const fetchOptions = {
|
||||
"credentials": H.s.CORScredentials,
|
||||
"signal": H.ac.signal
|
||||
};
|
||||
|
||||
let stylesNode = null;
|
||||
|
||||
/**
|
||||
* Define function H.hide.
|
||||
* This function hides (state = 1) or unhides (state = 0)
|
||||
* the whole document (mode == 0) or
|
||||
* each selected element (mode == 1) or
|
||||
* text of each selected element (mode == 2) or
|
||||
* nothing (mode == -1)
|
||||
* @param {integer} state - State
|
||||
* @param {integer} mode - Mode
|
||||
*/
|
||||
H.hide = (state, mode) => {
|
||||
if (state) {
|
||||
let vis = "{visibility:hidden!important}";
|
||||
stylesNode = d[shortcuts.ce]("style");
|
||||
let myStyle = "";
|
||||
if (mode === 0) {
|
||||
myStyle = "html" + vis;
|
||||
} else if (mode !== -1) {
|
||||
if (mode === 2) {
|
||||
vis = "{color:transparent!important}";
|
||||
}
|
||||
o.keys(H.s.selectors).forEach((sel) => {
|
||||
myStyle += sel + vis;
|
||||
});
|
||||
}
|
||||
stylesNode[shortcuts.ac](d[shortcuts.ct](myStyle));
|
||||
d.head[shortcuts.ac](stylesNode);
|
||||
} else if (stylesNode) {
|
||||
stylesNode.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const tester = (() => {
|
||||
let fakeBody = null;
|
||||
return {
|
||||
|
||||
/**
|
||||
* Append fakeBody with tests to document
|
||||
* @returns {Object|null} The body element or null, if no tests
|
||||
*/
|
||||
"ap": () => {
|
||||
if (fakeBody) {
|
||||
d.documentElement[shortcuts.ac](fakeBody);
|
||||
return fakeBody;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove fakeBody
|
||||
* @returns {undefined}
|
||||
*/
|
||||
"cl": () => {
|
||||
if (fakeBody) {
|
||||
fakeBody.remove();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create and append div with CSS-hyphenated word
|
||||
* @param {string} lang Language
|
||||
* @returns {undefined}
|
||||
*/
|
||||
"cr": (lang) => {
|
||||
if (H.cf.langs.has(lang)) {
|
||||
return;
|
||||
}
|
||||
fakeBody = fakeBody || d[shortcuts.ce]("body");
|
||||
const testDiv = d[shortcuts.ce]("div");
|
||||
const ha = "hyphens:auto";
|
||||
testDiv.lang = lang;
|
||||
testDiv.style.cssText = `visibility:hidden;-webkit-${ha};-ms-${ha};${ha};width:48px;font-size:12px;line-height:12px;border:none;padding:0;word-wrap:normal`;
|
||||
testDiv[shortcuts.ac](
|
||||
d[shortcuts.ct](H.lrq.get(lang).wo.toLowerCase())
|
||||
);
|
||||
fakeBody[shortcuts.ac](testDiv);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
/**
|
||||
* Checks if hyphens (ev.prefixed) is set to auto for the element.
|
||||
* @param {Object} elm - the element
|
||||
* @returns {Boolean} result of the check
|
||||
*/
|
||||
const checkCSSHyphensSupport = (elmStyle) => {
|
||||
const h = elmStyle.hyphens ||
|
||||
elmStyle.webkitHyphens ||
|
||||
elmStyle.msHyphens;
|
||||
return (h === "auto");
|
||||
};
|
||||
|
||||
H.res = {
|
||||
"he": mp()
|
||||
};
|
||||
|
||||
/**
|
||||
* Load hyphenEngines to H.res.he
|
||||
*
|
||||
* Make sure each .wasm is loaded exactly once, even for fallbacks
|
||||
* Store a list of languages to by hyphenated with each .wasm
|
||||
* @param {string} lang The language
|
||||
* @returns {undefined}
|
||||
*/
|
||||
const loadhyphenEngine = (lang) => {
|
||||
const fn = H.lrq.get(lang).fn;
|
||||
H.cf.pf = true;
|
||||
H.cf.langs.set(lang, "H9Y");
|
||||
if (H.res.he.has(fn)) {
|
||||
H.res.he.get(fn).l.push(lang);
|
||||
} else {
|
||||
H.res.he.set(
|
||||
fn,
|
||||
{
|
||||
"l": [lang],
|
||||
"w": w.fetch(H.paths.patterndir + fn + ".wasm", fetchOptions)
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
H.lrq.forEach((value, lang) => {
|
||||
if (value.wo === "FORCEHYPHENOPOLY" || H.cf.langs.get(lang) === "H9Y") {
|
||||
loadhyphenEngine(lang);
|
||||
} else {
|
||||
tester.cr(lang);
|
||||
}
|
||||
});
|
||||
const testContainer = tester.ap();
|
||||
if (testContainer) {
|
||||
testContainer.querySelectorAll("div").forEach((n) => {
|
||||
if (checkCSSHyphensSupport(n.style) && n.offsetHeight > 12) {
|
||||
H.cf.langs.set(n.lang, "CSS");
|
||||
} else {
|
||||
loadhyphenEngine(n.lang);
|
||||
}
|
||||
});
|
||||
tester.cl();
|
||||
}
|
||||
const hev = H.hev;
|
||||
if (H.cf.pf) {
|
||||
H.res.DOM = new Promise((res) => {
|
||||
if (d.readyState === "loading") {
|
||||
d.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
res,
|
||||
{
|
||||
"once": true,
|
||||
"passive": true
|
||||
}
|
||||
);
|
||||
} else {
|
||||
res();
|
||||
}
|
||||
});
|
||||
H.hide(1, H.s.hide);
|
||||
H.timeOutHandler = w.setTimeout(() => {
|
||||
H.hide(0, null);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (H.s.timeout & 1) {
|
||||
H.ac.abort();
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(scriptName + " timed out.");
|
||||
}, H.s.timeout);
|
||||
if (mainScriptLoaded) {
|
||||
H.main();
|
||||
} else {
|
||||
// Load main script
|
||||
fetch(H.paths.maindir + "Hyphenopoly.js", fetchOptions).
|
||||
then((response) => {
|
||||
if (response.ok) {
|
||||
response.blob().then((blb) => {
|
||||
const script = d[shortcuts.ce]("script");
|
||||
script.src = URL.createObjectURL(blb);
|
||||
d.head[shortcuts.ac](script);
|
||||
mainScriptLoaded = true;
|
||||
URL.revokeObjectURL(script.src);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
H.hy6ors = mp();
|
||||
H.cf.langs.forEach((langDef, lang) => {
|
||||
if (langDef === "H9Y") {
|
||||
H.hy6ors.set(lang, defProm());
|
||||
}
|
||||
});
|
||||
H.hy6ors.set("HTML", defProm());
|
||||
H.hyphenators = new Proxy(H.hy6ors, {
|
||||
"get": (target, key) => {
|
||||
return target.get(key);
|
||||
},
|
||||
"set": () => {
|
||||
// Inhibit setting of hyphenators
|
||||
return true;
|
||||
}
|
||||
});
|
||||
(() => {
|
||||
if (hev && hev.polyfill) {
|
||||
hev.polyfill();
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
(() => {
|
||||
if (hev && hev.tearDown) {
|
||||
hev.tearDown();
|
||||
}
|
||||
w.Hyphenopoly = null;
|
||||
})();
|
||||
}
|
||||
(() => {
|
||||
if (H.cft) {
|
||||
store.setItem(scriptName, JSON.stringify(
|
||||
{
|
||||
"langs": [...H.cf.langs.entries()],
|
||||
"pf": H.cf.pf
|
||||
}
|
||||
));
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
H.config = (c) => {
|
||||
/**
|
||||
* Sets default properties for an Object
|
||||
* @param {object} obj - The object to set defaults to
|
||||
* @param {object} defaults - The defaults to set
|
||||
* @returns {object}
|
||||
*/
|
||||
const setDefaults = (obj, defaults) => {
|
||||
if (obj) {
|
||||
o.entries(defaults).forEach(([k, v]) => {
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
obj[k] = obj[k] || v;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
return defaults;
|
||||
};
|
||||
|
||||
H.cft = Boolean(c.cacheFeatureTests);
|
||||
if (H.cft && store.getItem(scriptName)) {
|
||||
H.cf = JSON.parse(store.getItem(scriptName));
|
||||
H.cf.langs = mp(H.cf.langs);
|
||||
} else {
|
||||
H.cf = {
|
||||
"langs": mp(),
|
||||
"pf": false
|
||||
};
|
||||
}
|
||||
|
||||
const maindir = thisScript.slice(0, (thisScript.lastIndexOf("/") + 1));
|
||||
const patterndir = maindir + "patterns/";
|
||||
H.paths = setDefaults(c.paths, {
|
||||
maindir,
|
||||
patterndir
|
||||
});
|
||||
H.s = setDefaults(c.setup, {
|
||||
"CORScredentials": "include",
|
||||
"hide": "all",
|
||||
"selectors": {".hyphenate": {}},
|
||||
"timeout": 1000
|
||||
});
|
||||
// Change mode string to mode int
|
||||
H.s.hide = ["all", "element", "text"].indexOf(H.s.hide);
|
||||
if (c.handleEvent) {
|
||||
H.hev = c.handleEvent;
|
||||
}
|
||||
|
||||
const fallbacks = mp(o.entries(c.fallbacks || {}));
|
||||
H.lrq = mp();
|
||||
o.entries(c.require).forEach(([lang, wo]) => {
|
||||
H.lrq.set(lang.toLowerCase(), {
|
||||
"fn": fallbacks.get(lang) || lang,
|
||||
wo
|
||||
});
|
||||
});
|
||||
|
||||
main();
|
||||
};
|
||||
})(window, document, Hyphenopoly, Object);
|
||||
@@ -4,14 +4,13 @@
|
||||
* and synchronization with TTS
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class AnimationQueueModule extends BaseModule {
|
||||
constructor() {
|
||||
super('animation-queue', 'Animation Queue');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = [];
|
||||
this.dependencies = ['tts-player'];
|
||||
|
||||
// Queue of scheduled animations/functions
|
||||
this.timeoutQueue = [];
|
||||
@@ -43,15 +42,12 @@ class AnimationQueueModule extends BaseModule {
|
||||
try {
|
||||
this.reportProgress(20, "Initializing Animation Queue");
|
||||
|
||||
// We'll try to get the TTS module, but it's not a hard dependency
|
||||
// Try to get the TTS module, but it's not a hard dependency
|
||||
// We'll check for it again at runtime when needed
|
||||
setTimeout(() => {
|
||||
// Try to get TTS module after a delay to allow it to initialize
|
||||
this.tts = this.getModule('tts-player');
|
||||
if (!this.tts) {
|
||||
console.log("Animation Queue: TTS Player module not found yet, will try again when needed");
|
||||
}
|
||||
}, 500);
|
||||
this.tts = this.getModule('tts-player');
|
||||
if (!this.tts) {
|
||||
console.log("Animation Queue: TTS Player module not found yet, will try again when needed");
|
||||
}
|
||||
|
||||
this.reportProgress(100, "Animation Queue ready");
|
||||
return true;
|
||||
@@ -385,8 +381,5 @@ class AnimationQueueModule extends BaseModule {
|
||||
// Create the singleton instance
|
||||
const AnimationQueue = new AnimationQueueModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(AnimationQueue);
|
||||
|
||||
// Export the module
|
||||
export { AnimationQueue };
|
||||
@@ -1,506 +0,0 @@
|
||||
/**
|
||||
* API TTS Handler Base Class
|
||||
* Base class for API-based TTS handlers
|
||||
*/
|
||||
import { TTSHandler } from './tts-handler.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
export class ApiTTSHandlerBase extends TTSHandler {
|
||||
constructor(id, name) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
|
||||
// Base voice options
|
||||
this.voiceOptions = {
|
||||
speed: 1.0
|
||||
};
|
||||
|
||||
// State
|
||||
this.available = false;
|
||||
this.isReady = false;
|
||||
this.currentAudio = null;
|
||||
|
||||
// Common API settings
|
||||
this.apiKey = '';
|
||||
this.apiBaseUrl = '';
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['localization', 'persistence-manager'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the API TTS handler
|
||||
* @param {Function} progressCallback - Callback for progress updates
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize(progressCallback = null) {
|
||||
try {
|
||||
if (progressCallback) {
|
||||
progressCallback(10, `Initializing ${this.name}`);
|
||||
}
|
||||
|
||||
this.changeState('LOADING');
|
||||
|
||||
// Check for required dependencies
|
||||
const localization = this.getModule('localization');
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
|
||||
if (!localization) {
|
||||
console.error(`${this.name}: Required dependency 'localization' not found`);
|
||||
this.changeState('ERROR');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!persistenceManager) {
|
||||
console.error(`${this.name}: Required dependency 'persistence-manager' not found`);
|
||||
this.changeState('ERROR');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(20, `${this.name} dependencies loaded`);
|
||||
}
|
||||
|
||||
// Set up API key from preferences - should be empty by default
|
||||
this.apiKey = persistenceManager.getPreference('tts', `${this.id}_api_key`) || '';
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(30, `${this.name} API key loaded`);
|
||||
}
|
||||
|
||||
// Get default API URL
|
||||
const defaultApiUrl = this.getDefaultApiBaseUrl();
|
||||
console.log(`${this.name}: Default API URL: ${defaultApiUrl}`);
|
||||
|
||||
// Set up API base URL from preferences or use default
|
||||
const savedApiUrl = persistenceManager.getPreference('tts', `${this.id}_api_url`);
|
||||
this.apiBaseUrl = savedApiUrl || defaultApiUrl;
|
||||
|
||||
// If no API URL was saved in preferences, save the default
|
||||
if (!savedApiUrl && defaultApiUrl) {
|
||||
console.log(`${this.name}: Saving default API URL to preferences: ${defaultApiUrl}`);
|
||||
persistenceManager.updatePreference('tts', `${this.id}_api_url`, defaultApiUrl);
|
||||
}
|
||||
|
||||
// Log the current values for debugging
|
||||
console.log(`${this.name} API KEY: ${this.apiKey ? '[SET]' : '[EMPTY]'}`);
|
||||
console.log(`${this.name} API URL: ${this.apiBaseUrl}`);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(40, `${this.name} API URL set to: ${this.apiBaseUrl}`);
|
||||
}
|
||||
|
||||
// Set up event listeners for API key and URL changes
|
||||
this.addEventListener('tts:api:keyChanged', this.handleApiKeyChanged);
|
||||
this.addEventListener('tts:api:urlChanged', this.handleApiUrlChanged);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(50, `${this.name} event listeners registered`);
|
||||
}
|
||||
|
||||
// Load available voices
|
||||
const voicesLoaded = await this.loadVoices();
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(70, `${this.name} voices loaded`);
|
||||
}
|
||||
|
||||
// Set up voice based on preferences
|
||||
await this.setupVoiceFromPreferences();
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(90, `${this.name} voice preferences loaded`);
|
||||
}
|
||||
|
||||
// Set availability based on API key presence
|
||||
this.available = true;
|
||||
this.isReady = true;
|
||||
|
||||
if (progressCallback) {
|
||||
const statusMessage = this.apiKey ?
|
||||
`${this.name} initialized successfully` :
|
||||
`${this.name} initialized but unavailable (API key missing)`;
|
||||
progressCallback(100, statusMessage);
|
||||
}
|
||||
|
||||
this.changeState(this.available ? 'FINISHED' : 'WAITING');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`${this.name}: Initialization error:`, error);
|
||||
if (progressCallback) {
|
||||
progressCallback(100, `${this.name} initialization failed - ${error.message}`);
|
||||
}
|
||||
this.changeState('ERROR');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a module from the registry
|
||||
* @param {string} moduleId - ID of the module to get
|
||||
* @returns {Object|null} - The module or null if not found
|
||||
*/
|
||||
getModule(moduleId) {
|
||||
return moduleRegistry.getModule(moduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default API base URL for this provider
|
||||
* @returns {string} - Default API base URL
|
||||
*/
|
||||
getDefaultApiBaseUrl() {
|
||||
// Should be implemented by subclasses
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up voice based on preferences and locale
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async setupVoiceFromPreferences() {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
const localization = this.getModule('localization');
|
||||
|
||||
if (!persistenceManager || !localization) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get current locale
|
||||
const locale = localization.getLocale();
|
||||
|
||||
// Try to get voice preference for this specific provider
|
||||
const voiceId = persistenceManager.getPreference('tts', `${this.id}_voice`);
|
||||
|
||||
if (voiceId) {
|
||||
// Set voice from preference
|
||||
this.voiceOptions.voice = voiceId;
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no specific voice preference, try to select a voice for the current locale
|
||||
return this.selectVoiceForLocale(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available voices from API
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async loadVoices() {
|
||||
// Should be implemented by subclasses
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a voice for the given locale
|
||||
* @param {string} locale - Locale code
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectVoiceForLocale(locale) {
|
||||
// Should be implemented by subclasses
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a default voice
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectDefaultVoice() {
|
||||
// Should be implemented by subclasses
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech audio blob for the given text using the API.
|
||||
* Does not handle caching or playback, returns the Blob directly.
|
||||
* @param {string} text - The text to synthesize.
|
||||
* @returns {Promise<Blob|null>} - A promise that resolves with the audio Blob, or null on failure.
|
||||
*/
|
||||
async generateSpeechAudio(text) {
|
||||
if (!this.apiKey) {
|
||||
console.error(`${this.name}: API key is not set.`);
|
||||
return null;
|
||||
}
|
||||
if (!this.isReady || !this.currentVoice) {
|
||||
console.error(`${this.name}: Handler not ready or no voice selected.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestUrl = this.getApiRequestUrl();
|
||||
const requestBody = this.getApiRequestBody(text);
|
||||
const requestHeaders = this.getApiRequestHeaders();
|
||||
|
||||
console.log(`${this.name}: Requesting speech generation...`);
|
||||
// Log sensitive info only if debug enabled (assuming a global DEBUG flag or similar)
|
||||
// if (DEBUG) {
|
||||
// console.debug(`${this.name}: URL: ${requestUrl}`);
|
||||
// console.debug(`${this.name}: Headers:`, JSON.stringify(requestHeaders));
|
||||
// console.debug(`${this.name}: Body:`, JSON.stringify(requestBody));
|
||||
// }
|
||||
|
||||
try {
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorBody = 'Unknown error';
|
||||
try {
|
||||
errorBody = await response.text(); // Try to get text first
|
||||
const errorJson = JSON.parse(errorBody); // Try to parse as JSON
|
||||
errorBody = errorJson.error?.message || errorJson.detail || JSON.stringify(errorJson);
|
||||
} catch (e) {
|
||||
// If parsing fails or it's not JSON, use the raw text
|
||||
console.warn(`${this.name}: Could not parse error response as JSON. Raw text: ${errorBody}`);
|
||||
}
|
||||
throw new Error(`API Error (${response.status} ${response.statusText}): ${errorBody}`);
|
||||
}
|
||||
|
||||
// --- Response Handling (Specific to API - Override if necessary) ---
|
||||
// Default assumes response IS the audio blob
|
||||
const audioBlob = await response.blob();
|
||||
console.log(`${this.name}: Received audio blob, size: ${audioBlob.size}`);
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
if (!audioBlob || audioBlob.size === 0) {
|
||||
throw new Error('Received empty audio blob from API.');
|
||||
}
|
||||
|
||||
// Return the audio data blob
|
||||
return audioBlob;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`${this.name}: Error generating speech audio:`, error);
|
||||
this.handleApiError(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays preloaded audio data.
|
||||
* @param {Blob} audioData - The audio data Blob to play.
|
||||
* @param {Function} [callback=null] - Optional callback function.
|
||||
*/
|
||||
speakPreloaded(audioData, callback = null) {
|
||||
// This method might now be redundant if the factory handles all playback.
|
||||
// However, keeping it in case direct playback of preloaded data is needed elsewhere.
|
||||
// Or, it could be simplified to just return the blob if factory always handles play.
|
||||
// For now, let's keep the playback logic but it might be unused by the factory flow.
|
||||
console.log(`${this.name}: Playing preloaded audio...`);
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
if (audioManager && audioData) {
|
||||
// This assumes audioManager.play handles Blobs
|
||||
audioManager.play(audioData, callback);
|
||||
} else {
|
||||
console.error(`${this.name}: AudioManager not found or no audio data to play.`);
|
||||
if (callback) callback(false, "Playback error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the currently playing audio.
|
||||
*/
|
||||
stop() {
|
||||
console.log(`${this.name}: Stop requested.`);
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
if (audioManager) {
|
||||
audioManager.stop();
|
||||
}
|
||||
// Reset any internal state if needed
|
||||
this.currentAudio = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak the given text using the API.
|
||||
* This method now primarily calls generateSpeechAudio and returns the result.
|
||||
* Caching and playback are handled by TTSFactoryModule.
|
||||
* @param {string} text - The text to speak.
|
||||
* @returns {Promise<Blob|null>} - A promise resolving to the audio Blob or null on failure.
|
||||
*/
|
||||
async speak(text) {
|
||||
console.log(`${this.name}: speak called for text: ${text.substring(0, 30)}...`);
|
||||
try {
|
||||
// Generate audio data
|
||||
const audioData = await this.generateSpeechAudio(text);
|
||||
|
||||
if (!audioData) {
|
||||
console.error(`${this.name}: Failed to generate audio for speak.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return the Blob for the factory to handle
|
||||
return audioData;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`${this.name}: Error in speak method:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads speech for the given text.
|
||||
* Generates the audio data but does not play it.
|
||||
* Returns the generated Blob for the factory to cache.
|
||||
* @param {string} text - The text to preload.
|
||||
* @returns {Promise<Blob|null>} - A promise resolving to the audio Blob or null on failure.
|
||||
*/
|
||||
async preloadSpeech(text) {
|
||||
console.log(`${this.name}: preloadSpeech called for text: ${text.substring(0, 30)}...`);
|
||||
try {
|
||||
// Generate audio data using the main generation method
|
||||
const audioData = await this.generateSpeechAudio(text);
|
||||
|
||||
if (audioData) {
|
||||
console.log(`${this.name}: Successfully preloaded speech (blob generated).`);
|
||||
return audioData; // Return the Blob for the factory
|
||||
} else {
|
||||
console.error(`${this.name}: Failed to generate audio for preload.`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${this.name}: Error during preloadSpeech:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess text for TTS
|
||||
* @param {string} text - Text to preprocess
|
||||
* @returns {string} - Processed text
|
||||
*/
|
||||
preprocessText(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Trim whitespace
|
||||
let processed = text.trim();
|
||||
|
||||
// Replace multiple spaces with a single space
|
||||
processed = processed.replace(/\s+/g, ' ');
|
||||
|
||||
// Add a period at the end if there's no punctuation
|
||||
if (!/[.!?]$/.test(processed)) {
|
||||
processed += '.';
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TTS is available
|
||||
* @returns {boolean} - True if TTS is available
|
||||
*/
|
||||
isAvailable() {
|
||||
return this.available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handler ID
|
||||
* @returns {string} - Handler ID
|
||||
*/
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Promise<Array>} - Resolves with array of voice objects
|
||||
*/
|
||||
async getVoices() {
|
||||
// Should be implemented by subclasses
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
if (options.voice) {
|
||||
this.voiceOptions.voice = options.voice;
|
||||
|
||||
// Save the voice preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', `${this.id}_voice`, options.voice);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof options.speed === 'number') {
|
||||
// Clamp speed between 0.5 and 2.0
|
||||
this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed));
|
||||
}
|
||||
|
||||
// Additional provider-specific options should be handled by subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API key change event
|
||||
* @param {Event} event - Event object
|
||||
*/
|
||||
handleApiKeyChanged(event) {
|
||||
if (event && event.detail && event.detail.provider === this.id) {
|
||||
const newKey = event.detail.key || '';
|
||||
|
||||
// Security check - never use a URL as an API key
|
||||
if (newKey && newKey.startsWith('http')) {
|
||||
console.error(`${this.name}: Received URL instead of API key, ignoring it`);
|
||||
return; // Don't update API key
|
||||
}
|
||||
|
||||
// Update API key
|
||||
this.apiKey = newKey;
|
||||
|
||||
// Save to preferences
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', `${this.id}_api_key`, newKey);
|
||||
}
|
||||
|
||||
// Update functionality status but don't make it unavailable
|
||||
// We want it to stay in the dropdown for configuration
|
||||
const wasFullyFunctional = this.available;
|
||||
const isFullyFunctional = !!this.apiKey;
|
||||
|
||||
// Only update internal state - don't change availability for UI purposes
|
||||
if (isFullyFunctional) {
|
||||
this.changeState('FINISHED');
|
||||
} else {
|
||||
// Not WAITING - we want it to stay in dropdown
|
||||
this.changeState('CONFIGURING');
|
||||
}
|
||||
|
||||
// Log the key change but don't affect availability for UI
|
||||
console.log(`${this.name}: API key ${newKey ? 'set' : 'cleared'}. Fully functional: ${isFullyFunctional}`);
|
||||
|
||||
// Always stay available in the UI dropdown
|
||||
this.available = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API URL change event
|
||||
* @param {Event} event - Event object
|
||||
*/
|
||||
handleApiUrlChanged(event) {
|
||||
if (event && event.detail && event.detail.provider === this.id) {
|
||||
const newUrl = event.detail.url || this.getDefaultApiBaseUrl();
|
||||
|
||||
// Update API URL
|
||||
this.apiBaseUrl = newUrl;
|
||||
|
||||
// Save to preferences
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl);
|
||||
}
|
||||
|
||||
// Log the URL change but don't affect availability
|
||||
console.log(`${this.name}: API URL updated to ${newUrl}`);
|
||||
|
||||
// Always stay available in the UI dropdown
|
||||
this.available = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
constructor(id, name) {
|
||||
super(id, name);
|
||||
|
||||
// Declare proper dependencies according to architecture principles
|
||||
this.dependencies = ['persistence-manager', 'localization'];
|
||||
|
||||
// Basic voice options
|
||||
this.voiceOptions = {
|
||||
speed: 1.0,
|
||||
@@ -86,8 +89,13 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
// Check if we have an API key
|
||||
this.isReady = !!this.apiKey;
|
||||
|
||||
// Always mark as available for UI configuration purposes
|
||||
// (even if not ready due to missing API key)
|
||||
if (!this.isReady) {
|
||||
console.error(`${this.name}: Missing API key, initialization failed`);
|
||||
this.reportProgress(100, `${this.name} initialization failed - missing API key`);
|
||||
return false; // Properly report failure when API key is missing
|
||||
}
|
||||
|
||||
// Only mark as complete if we have an API key
|
||||
this.reportProgress(100, `${this.name} initialization complete`);
|
||||
|
||||
return true;
|
||||
@@ -111,6 +119,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
const localization = this.getModule('localization');
|
||||
|
||||
if (!persistenceManager || !localization) {
|
||||
console.error(`${this.name}: Required dependencies not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -120,16 +129,13 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
// Get current locale
|
||||
const currentLocale = localization.getLocale();
|
||||
|
||||
// If we have a preferred voice and available voices, use it
|
||||
if (preferredVoiceId && this.voices && this.voices.length > 0) {
|
||||
const voice = this.voices.find(v => v.id === preferredVoiceId);
|
||||
if (voice) {
|
||||
this.voiceOptions.voice = voice;
|
||||
return true;
|
||||
}
|
||||
// If we have a preferred voice ID, use it
|
||||
if (preferredVoiceId && this.voices.some(v => v.id === preferredVoiceId)) {
|
||||
this.voiceOptions.voice = this.voices.find(v => v.id === preferredVoiceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise select a voice based on locale
|
||||
// Otherwise, select voice based on locale
|
||||
if (currentLocale) {
|
||||
return this.selectVoiceForLocale(currentLocale);
|
||||
}
|
||||
@@ -163,7 +169,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectDefaultVoice() {
|
||||
if (this.voices && this.voices.length > 0) {
|
||||
if (this.voices.length > 0) {
|
||||
this.voiceOptions.voice = this.voices[0];
|
||||
return true;
|
||||
}
|
||||
@@ -188,50 +194,42 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
*/
|
||||
speakPreloaded(preloadData, callback = null) {
|
||||
if (!preloadData || !preloadData.audioData) {
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'invalid_data' });
|
||||
}
|
||||
console.error(`${this.name}: Invalid preloaded data`);
|
||||
if (callback) callback({ success: false, reason: 'invalid_data' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop any ongoing speech
|
||||
this.stop();
|
||||
|
||||
// Create audio blob
|
||||
// Create an audio element to play the audio
|
||||
const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' });
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
// Create audio element
|
||||
const audio = new Audio(audioUrl);
|
||||
|
||||
// Set up state
|
||||
this.isSpeaking = true;
|
||||
this.currentAudio = audio;
|
||||
|
||||
// Set up event handlers
|
||||
audio.onended = () => {
|
||||
this.isSpeaking = false;
|
||||
if (callback) {
|
||||
callback({ success: true });
|
||||
}
|
||||
this.currentAudio = null;
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
|
||||
if (callback) callback({ success: true });
|
||||
};
|
||||
|
||||
audio.onerror = (error) => {
|
||||
console.error(`${this.name}: Audio playback error:`, error);
|
||||
this.isSpeaking = false;
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'playback_error', error });
|
||||
}
|
||||
this.currentAudio = null;
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
|
||||
if (callback) callback({ success: false, reason: 'playback_error', error });
|
||||
};
|
||||
|
||||
// Start playback
|
||||
this.currentAudio = audio;
|
||||
this.isSpeaking = true;
|
||||
|
||||
// Handle play error
|
||||
// Play the audio
|
||||
audio.play().catch(error => {
|
||||
this.isSpeaking = false;
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'playback_error', error });
|
||||
}
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
console.error(`${this.name}: Failed to play audio:`, error);
|
||||
if (callback) callback({ success: false, reason: 'playback_error', error });
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -244,17 +242,21 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
stop() {
|
||||
if (this.currentAudio) {
|
||||
try {
|
||||
// Stop current audio
|
||||
this.currentAudio.pause();
|
||||
this.currentAudio.currentTime = 0;
|
||||
this.currentAudio = null;
|
||||
|
||||
// Clean up
|
||||
this.isSpeaking = false;
|
||||
this.currentAudio = null;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`${this.name}: Error stopping speech:`, error);
|
||||
console.error(`${this.name}: Error stopping audio:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return true; // Already stopped
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -351,6 +353,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
handleApiKeyChanged(event) {
|
||||
if (event && event.detail && event.detail.provider === this.id) {
|
||||
const newKey = event.detail.key || '';
|
||||
const oldKey = this.apiKey;
|
||||
|
||||
// Security check - never use a URL as an API key
|
||||
if (newKey && newKey.startsWith('http')) {
|
||||
@@ -368,7 +371,33 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
}
|
||||
|
||||
// Update ready state
|
||||
const wasReady = this.isReady;
|
||||
this.isReady = !!this.apiKey;
|
||||
|
||||
// If state changed (now ready/not-ready), notify the TTS factory
|
||||
if (wasReady !== this.isReady) {
|
||||
console.log(`${this.name}: TTS ready state changed to ${this.isReady ? 'ready' : 'not ready'} after API key change`);
|
||||
|
||||
// Find and notify the TTS factory
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
// If we have a key now (and didn't before), try initializing voices
|
||||
if (this.isReady && !wasReady) {
|
||||
// Reload voices with the new API key
|
||||
this.loadVoices().then(() => {
|
||||
// Then set up voice from preferences
|
||||
this.setupVoiceFromPreferences().then(() => {
|
||||
console.log(`${this.name}: Successfully initialized with new API key`);
|
||||
// Notify the factory of our readiness change
|
||||
ttsFactory.updateTTSAvailability();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Just update the availability
|
||||
ttsFactory.updateTTSAvailability();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +407,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
*/
|
||||
handleApiUrlChanged(event) {
|
||||
if (event && event.detail && event.detail.provider === this.id) {
|
||||
const oldUrl = this.apiBaseUrl;
|
||||
const newUrl = event.detail.url || this.getDefaultApiBaseUrl();
|
||||
|
||||
// Update API URL
|
||||
@@ -388,6 +418,25 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl);
|
||||
}
|
||||
|
||||
// Only reinitialize if the URL actually changed and we have an API key
|
||||
if (oldUrl !== newUrl && this.isReady) {
|
||||
console.log(`${this.name}: API URL changed, reinitializing`);
|
||||
|
||||
// Reload voices with the new API URL if we're ready
|
||||
this.loadVoices().then(() => {
|
||||
// Then set up voice from preferences
|
||||
this.setupVoiceFromPreferences().then(() => {
|
||||
console.log(`${this.name}: Successfully reinitialized with new API URL`);
|
||||
|
||||
// Notify the TTS factory
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
ttsFactory.updateTTSAvailability();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Manages loading and playback of non-TTS audio effects triggered by tags.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class AudioManagerModule extends BaseModule {
|
||||
constructor() {
|
||||
@@ -374,11 +373,5 @@ class AudioManagerModule extends BaseModule {
|
||||
// Create the singleton instance
|
||||
const AudioManager = new AudioManagerModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(AudioManager);
|
||||
|
||||
// Export the module
|
||||
export { AudioManager };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.AudioManager = AudioManager;
|
||||
+31
-47
@@ -2,6 +2,8 @@
|
||||
* Base Module Class
|
||||
* Provides common functionality and enforces a consistent interface for all modules
|
||||
*/
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
export class BaseModule {
|
||||
constructor(id, name) {
|
||||
this.id = id;
|
||||
@@ -27,6 +29,9 @@ export class BaseModule {
|
||||
// Dependencies
|
||||
this.dependencies = [];
|
||||
this._loadedDependencies = new Map();
|
||||
|
||||
// Auto-register with module registry
|
||||
moduleRegistry.register(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,23 +89,7 @@ export class BaseModule {
|
||||
this.reportProgress(15, "Waiting for dependencies");
|
||||
|
||||
// Get moduleRegistry - first try import then fallback to window
|
||||
const registry = window.moduleRegistry;
|
||||
if (!registry) {
|
||||
console.error(`${this.id}: Module registry not found, will retry`);
|
||||
|
||||
// Retry after a short delay to allow registry to be initialized
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Try again
|
||||
const retryRegistry = window.moduleRegistry;
|
||||
if (!retryRegistry) {
|
||||
console.error(`${this.id}: Module registry still not found after retry`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`${this.id}: Found module registry after retry`);
|
||||
return this._continueWaitForDependencies(retryRegistry);
|
||||
}
|
||||
const registry = moduleRegistry;
|
||||
|
||||
return this._continueWaitForDependencies(registry);
|
||||
} catch (error) {
|
||||
@@ -121,17 +110,30 @@ export class BaseModule {
|
||||
const results = await registry.waitForModules(this.dependencies);
|
||||
|
||||
// Store references to dependencies
|
||||
let hasErroredDependencies = false;
|
||||
for (let i = 0; i < this.dependencies.length; i++) {
|
||||
const depId = this.dependencies[i];
|
||||
const depModule = registry.getModule(depId);
|
||||
if (depModule) {
|
||||
this._loadedDependencies.set(depId, depModule);
|
||||
|
||||
// Check if this dependency is in ERROR state
|
||||
if (depModule.state === 'ERROR') {
|
||||
hasErroredDependencies = true;
|
||||
console.warn(`${this.id}: Dependency ${depId} is in ERROR state but will be considered resolved`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allDepsReady = results.every(ready => ready === true);
|
||||
if (allDepsReady) {
|
||||
this.reportProgress(20, "Dependencies ready");
|
||||
// Check if all dependencies have resolved (either success or error)
|
||||
// We consider a module with ERROR state as resolved
|
||||
const allDepsResolved = results.every(result => result === true || result === false);
|
||||
if (allDepsResolved) {
|
||||
if (hasErroredDependencies) {
|
||||
this.reportProgress(20, "Dependencies resolved with some errors");
|
||||
} else {
|
||||
this.reportProgress(20, "Dependencies ready");
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
this.reportProgress(15, "Some dependencies not ready");
|
||||
@@ -143,26 +145,6 @@ export class BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backwards compatibility
|
||||
* @deprecated Use dependencies array property instead
|
||||
* @returns {Promise<boolean>} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
// This is now handled by _waitForModuleDependencies
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backwards compatibility
|
||||
* @deprecated No longer needed as waitForDependencies is handled automatically
|
||||
* @returns {Promise<boolean>} - Resolves when dependencies are ready
|
||||
*/
|
||||
async waitForDependencies() {
|
||||
// This is now handled by _waitForModuleDependencies
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module - Override this in child classes
|
||||
* @returns {Promise<boolean>} - Resolves when initialization is complete
|
||||
@@ -186,7 +168,7 @@ export class BaseModule {
|
||||
* @param {string} message - Status message
|
||||
*/
|
||||
reportProgress(percent, message) {
|
||||
this.progress = percent;
|
||||
this.progress = Math.min(100, Math.max(0, percent));
|
||||
|
||||
if (this.progressCallback && typeof this.progressCallback === 'function') {
|
||||
this.progressCallback(percent, message);
|
||||
@@ -268,10 +250,6 @@ export class BaseModule {
|
||||
if (this._loadedDependencies.has(moduleId)) {
|
||||
return this._loadedDependencies.get(moduleId);
|
||||
}
|
||||
|
||||
// Then check in the registry
|
||||
return window.moduleRegistry ?
|
||||
window.moduleRegistry.getModule(moduleId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,7 +261,7 @@ export class BaseModule {
|
||||
if (typeof this[methodName] === 'function') {
|
||||
this[methodName] = this[methodName].bind(this);
|
||||
} else {
|
||||
console.warn(`Method ${methodName} not found on ${this.id} module`);
|
||||
console.warn(`Method ${methodName} not found on ${this.id} module.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -548,7 +526,13 @@ export class BaseModule {
|
||||
_updateResourceProgress() {
|
||||
if (this._totalResources === 0) return;
|
||||
|
||||
const percent = Math.round((this._loadedResources / this._totalResources) * 100);
|
||||
// Change to FETCHING state when loading resources
|
||||
if (this.state === 'LOADING' && this._loadedResources === 0) {
|
||||
this.changeState('FETCHING');
|
||||
}
|
||||
|
||||
// Scale resource loading to 10% to 50% range of total module progress
|
||||
const percent = Math.round((this._loadedResources / this._totalResources) * 40) + 10;
|
||||
this.reportProgress(percent, `Loading resources: ${this._loadedResources}/${this._totalResources}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,789 +0,0 @@
|
||||
/**
|
||||
* BrowserTTSHandler for AI Interactive Fiction
|
||||
* Implementation using the browser's Web Speech API
|
||||
*/
|
||||
import { TTSHandler } from './tts-handler.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
export class BrowserTTSHandler extends TTSHandler {
|
||||
constructor() {
|
||||
super();
|
||||
this.id = 'browser';
|
||||
this.name = 'Browser TTS Handler';
|
||||
|
||||
// Voice options
|
||||
this.voiceOptions = {
|
||||
voice: null, // Will be set during initialization
|
||||
rate: 1.0,
|
||||
pitch: 1.0,
|
||||
volume: 1.0
|
||||
};
|
||||
|
||||
// State
|
||||
this.available = false;
|
||||
this.voices = [];
|
||||
this.currentUtterance = null;
|
||||
|
||||
// Add dependencies
|
||||
this.dependencies = ['localization', 'persistence-manager'];
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'speak',
|
||||
'speakPreloaded',
|
||||
'preloadSpeech',
|
||||
'stop',
|
||||
'isAvailable',
|
||||
'getId',
|
||||
'getVoices',
|
||||
'setVoiceOptions',
|
||||
'onVoicesChanged',
|
||||
'getModule'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a module from the registry
|
||||
* @param {string} moduleId - ID of the module to get
|
||||
* @returns {Object|null} - The module or null if not found
|
||||
*/
|
||||
getModule(moduleId) {
|
||||
return moduleRegistry.getModule(moduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the browser TTS handler
|
||||
* @param {Function} progressCallback - Callback for progress updates
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize(progressCallback = null) {
|
||||
try {
|
||||
if (progressCallback) {
|
||||
progressCallback(10, 'Initializing Browser TTS');
|
||||
}
|
||||
|
||||
this.changeState('LOADING');
|
||||
|
||||
// Check for browser support
|
||||
if (!window.speechSynthesis) {
|
||||
console.warn('Browser TTS: Speech synthesis not available in this browser');
|
||||
if (progressCallback) {
|
||||
progressCallback(100, 'Browser TTS not available');
|
||||
}
|
||||
this.changeState('ERROR');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(30, 'Browser TTS supported');
|
||||
}
|
||||
|
||||
// Check for required dependencies
|
||||
const localization = this.getModule('localization');
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
|
||||
if (!localization) {
|
||||
console.error('Browser TTS: Required dependency \'localization\' not found');
|
||||
this.changeState('ERROR');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!persistenceManager) {
|
||||
console.error('Browser TTS: Required dependency \'persistence-manager\' not found');
|
||||
this.changeState('ERROR');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(40, 'Browser TTS dependencies loaded');
|
||||
}
|
||||
|
||||
// Load voices - but don't fail initialization if no voices are found yet
|
||||
// The browser may provide voices later
|
||||
try {
|
||||
await this.loadVoices();
|
||||
console.log(`Browser TTS: Loaded ${this.voices.length} voices initially`);
|
||||
} catch (error) {
|
||||
console.warn('Browser TTS: Error loading voices initially:', error);
|
||||
// Don't fail initialization - voices may become available later
|
||||
this.voices = [];
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(60, `Browser TTS loaded ${this.voices.length} voices`);
|
||||
}
|
||||
|
||||
// Set speech options from preferences
|
||||
try {
|
||||
const rate = persistenceManager.getPreference('tts', 'speed', 1.0);
|
||||
const pitch = persistenceManager.getPreference('tts', 'pitch', 1.0);
|
||||
const volume = persistenceManager.getPreference('tts', 'volume', 1.0);
|
||||
|
||||
this.options.rate = parseFloat(rate);
|
||||
this.options.pitch = parseFloat(pitch);
|
||||
this.options.volume = parseFloat(volume);
|
||||
|
||||
// Log all available voices for debugging
|
||||
console.log('Browser TTS: Available voices:', this.voices.map(v => `${v.name} (${v.lang})`));
|
||||
|
||||
// Set voice based on locale
|
||||
const locale = localization.getLocale();
|
||||
console.log(`Browser TTS: Setting voice for locale: ${locale}`);
|
||||
const preferredVoice = persistenceManager.getPreference('tts', 'browser_voice');
|
||||
await this.selectVoiceForLocale(locale, preferredVoice);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(80, 'Browser TTS voice selected');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Browser TTS: Error setting speech options:', error);
|
||||
// Don't fail initialization due to voice selection issues
|
||||
}
|
||||
|
||||
// If voices were loaded but no voice is selected, try to set a default
|
||||
if (this.voices.length > 0 && !this.voiceOptions.voice) {
|
||||
console.warn('Browser TTS: No voice selected after initialization, trying fallback');
|
||||
this.voiceOptions.voice = this.voices[0];
|
||||
}
|
||||
|
||||
// Always mark as available if speech synthesis is supported, regardless of voice selection
|
||||
// This ensures the Browser TTS option always appears in the dropdown
|
||||
this.available = true;
|
||||
this.isReady = true;
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(100, 'Browser TTS initialized');
|
||||
}
|
||||
|
||||
this.changeState('FINISHED');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Browser TTS: Initialization error:', error);
|
||||
if (progressCallback) {
|
||||
progressCallback(100, `Browser TTS initialization failed - ${error.message}`);
|
||||
}
|
||||
this.changeState('ERROR');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle voices changed event
|
||||
*/
|
||||
async onVoicesChanged() {
|
||||
await this.loadVoices();
|
||||
const localization = this.getModule('localization');
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
let currentLocale = localization ? localization.getLocale() : 'en-us';
|
||||
let preferredVoice = persistenceManager ? persistenceManager.getPreference('tts', 'voice', '') : '';
|
||||
await this.selectVoiceForLocale(currentLocale, preferredVoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available voices
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadVoices() {
|
||||
return new Promise(resolve => {
|
||||
// Helper function to filter and sort voices
|
||||
const processVoices = () => {
|
||||
this.voices = speechSynthesis.getVoices() || [];
|
||||
|
||||
// Log all available voices for debugging
|
||||
console.log('Browser TTS: Raw loaded voices:',
|
||||
this.voices.map(v => `${v.name} (${v.lang})`));
|
||||
|
||||
// Ensure we have at least one voice
|
||||
if (this.voices.length === 0) {
|
||||
console.warn('Browser TTS: No voices available from speech synthesis');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort voices to prioritize English voices first
|
||||
this.voices.sort((a, b) => {
|
||||
// Put English voices first
|
||||
const aIsEnglish = a.lang.toLowerCase().startsWith('en');
|
||||
const bIsEnglish = b.lang.toLowerCase().startsWith('en');
|
||||
|
||||
if (aIsEnglish && !bIsEnglish) return -1;
|
||||
if (!aIsEnglish && bIsEnglish) return 1;
|
||||
|
||||
// Then sort by language
|
||||
return a.lang.localeCompare(b.lang);
|
||||
});
|
||||
|
||||
console.log('Browser TTS: Sorted voices:',
|
||||
this.voices.map(v => `${v.name} (${v.lang})`));
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Some browsers need a timeout to get voices
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.voices.length === 0) {
|
||||
console.log('Browser TTS: Using timeout fallback to get voices');
|
||||
processVoices();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Try to get voices immediately
|
||||
this.voices = speechSynthesis.getVoices() || [];
|
||||
if (this.voices.length > 0) {
|
||||
clearTimeout(timeoutId);
|
||||
console.log(`Browser TTS: Loaded ${this.voices.length} voices immediately`);
|
||||
processVoices();
|
||||
} else {
|
||||
// If no voices are available yet, set up the onvoiceschanged event
|
||||
speechSynthesis.onvoiceschanged = () => {
|
||||
clearTimeout(timeoutId);
|
||||
console.log('Browser TTS: Voices changed event fired');
|
||||
processVoices();
|
||||
speechSynthesis.onvoiceschanged = null;
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice based on locale
|
||||
* @param {string} locale - Locale code (e.g., 'en-us', 'de', 'fr')
|
||||
* @param {string} preferredVoice - Optional preferred voice name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async selectVoiceForLocale(locale = 'en-us', preferredVoice = '') {
|
||||
// Debug voice selection process
|
||||
console.log(`Browser TTS: Selecting voice for locale ${locale}, preferred voice: ${preferredVoice || 'none'}`);
|
||||
console.log(`Browser TTS: Available voices:`, this.voices.map(v => `${v.name} (${v.lang})`));
|
||||
|
||||
// Normalize locale for comparison
|
||||
const normalizedLocale = locale.toLowerCase();
|
||||
const languageCode = normalizedLocale.split('-')[0]; // e.g., 'en' from 'en-us'
|
||||
|
||||
console.log(`Browser TTS: Normalized locale: ${normalizedLocale}, language code: ${languageCode}`);
|
||||
|
||||
// If we have a preferred voice, try to use it first
|
||||
if (preferredVoice) {
|
||||
const matchingVoice = this.voices.find(voice =>
|
||||
voice.name === preferredVoice ||
|
||||
voice.voiceURI === preferredVoice
|
||||
);
|
||||
|
||||
if (matchingVoice) {
|
||||
this.voiceOptions.voice = matchingVoice;
|
||||
console.log(`Browser TTS: Using preferred voice: ${matchingVoice.name}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find voices exactly matching the locale (e.g., 'en-us')
|
||||
const exactLocaleVoices = this.voices.filter(voice => {
|
||||
const voiceLocale = voice.lang.toLowerCase();
|
||||
return voiceLocale === normalizedLocale;
|
||||
});
|
||||
|
||||
console.log(`Browser TTS: Found ${exactLocaleVoices.length} exact locale matches for ${normalizedLocale}`);
|
||||
|
||||
if (exactLocaleVoices.length > 0) {
|
||||
// Use the first matching voice
|
||||
this.voiceOptions.voice = exactLocaleVoices[0];
|
||||
console.log(`Browser TTS: Using exact locale match for ${normalizedLocale}: ${this.voiceOptions.voice.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find voices matching the language code (e.g., 'en')
|
||||
const languageVoices = this.voices.filter(voice => {
|
||||
const voiceLocale = voice.lang.toLowerCase();
|
||||
console.log(`Browser TTS: Comparing voice lang ${voiceLocale} with language code ${languageCode}`);
|
||||
return voiceLocale.startsWith(languageCode) ||
|
||||
(voiceLocale.length === 2 && languageCode.startsWith(voiceLocale));
|
||||
});
|
||||
|
||||
console.log(`Browser TTS: Found ${languageVoices.length} language matches for ${languageCode}`);
|
||||
|
||||
if (languageVoices.length > 0) {
|
||||
// Use the first matching voice
|
||||
this.voiceOptions.voice = languageVoices[0];
|
||||
console.log(`Browser TTS: Using language match for ${languageCode}: ${this.voiceOptions.voice.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If current language is not English and no matching voice found, try to find English voices
|
||||
if (languageCode !== 'en') {
|
||||
const englishVoices = this.voices.filter(voice =>
|
||||
voice.lang.toLowerCase().startsWith('en')
|
||||
);
|
||||
|
||||
console.log(`Browser TTS: Found ${englishVoices.length} English voices as fallback`);
|
||||
|
||||
if (englishVoices.length > 0) {
|
||||
this.voiceOptions.voice = englishVoices[0];
|
||||
console.log(`Browser TTS: No ${languageCode} voice found, using English voice: ${this.voiceOptions.voice.name}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// As a last resort, use any available voice
|
||||
if (this.voices.length > 0) {
|
||||
this.voiceOptions.voice = this.voices[0];
|
||||
console.log(`Browser TTS: No matching voice found, using first available voice: ${this.voiceOptions.voice.name}`);
|
||||
} else {
|
||||
console.log("Browser TTS: No voices available");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload speech for a text
|
||||
* @param {string} text - Text to preload
|
||||
* @returns {Promise<Object>} - Preloaded speech data
|
||||
*/
|
||||
async preloadSpeech(text) {
|
||||
if (!this.available || !text || !this.voiceOptions.voice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Process text for TTS
|
||||
const processedText = this.preprocessText(text);
|
||||
|
||||
console.log(`Browser TTS: Preloading speech for: "${processedText.substring(0, 50)}${processedText.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Use MediaRecorder to capture audio output to WAV
|
||||
const audioData = await this.synthesizeToWav(processedText);
|
||||
if (!audioData) {
|
||||
console.warn("Browser TTS: Failed to generate WAV audio");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create audio element from blob
|
||||
const audio = new Audio(URL.createObjectURL(audioData.blob));
|
||||
|
||||
// Store preloaded data in the centralized TTSFactory cache
|
||||
const preloadData = {
|
||||
audio: audio,
|
||||
blob: audioData.blob,
|
||||
text: processedText
|
||||
};
|
||||
|
||||
// Use the TTSFactory's cache instead of a local cache
|
||||
// this.preloadCache.set(text, preloadData);
|
||||
// Instead, return the preloaded data to be stored in the TTSFactory's cache
|
||||
return preloadData;
|
||||
} catch (error) {
|
||||
console.warn("Browser TTS: Error preloading speech:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert speech synthesis to WAV format
|
||||
* @param {string} text - Text to synthesize
|
||||
* @returns {Promise<Object>} - Object with WAV blob
|
||||
*/
|
||||
synthesizeToWav(text) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Create utterance
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
|
||||
// Set voice and options
|
||||
utterance.voice = this.voiceOptions.voice;
|
||||
utterance.rate = this.voiceOptions.rate;
|
||||
utterance.pitch = this.voiceOptions.pitch;
|
||||
utterance.volume = this.voiceOptions.volume;
|
||||
utterance.lang = this.voiceOptions.voice.lang;
|
||||
|
||||
// Use Web Audio API to capture the speech output
|
||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
const mediaRecorder = new MediaRecorder(destination.stream);
|
||||
const audioChunks = [];
|
||||
|
||||
// Capture the audio chunks
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
// When recording completes
|
||||
mediaRecorder.onstop = () => {
|
||||
// Create a WAV blob from the audio chunks
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
|
||||
resolve({ blob: audioBlob });
|
||||
};
|
||||
|
||||
// Set up speech synthesis events
|
||||
utterance.onstart = () => {
|
||||
console.log("Browser TTS: Started synthesizing audio to WAV");
|
||||
mediaRecorder.start();
|
||||
};
|
||||
|
||||
utterance.onend = () => {
|
||||
console.log("Browser TTS: Finished synthesizing audio to WAV");
|
||||
mediaRecorder.stop();
|
||||
};
|
||||
|
||||
utterance.onerror = (error) => {
|
||||
console.error("Browser TTS: Error synthesizing audio:", error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
// Start the speech synthesis
|
||||
speechSynthesis.speak(utterance);
|
||||
|
||||
// If synthesis doesn't start within a reasonable timeout, reject the promise
|
||||
const timeout = setTimeout(() => {
|
||||
if (mediaRecorder.state === 'inactive') {
|
||||
console.warn("Browser TTS: Synthesis to WAV timed out");
|
||||
reject(new Error("Synthesis timed out"));
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Clear timeout when synthesis starts
|
||||
utterance.onstart = () => {
|
||||
clearTimeout(timeout);
|
||||
console.log("Browser TTS: Started synthesizing audio to WAV");
|
||||
mediaRecorder.start();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Browser TTS: Error setting up WAV synthesis:", error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using preloaded utterance
|
||||
* @param {Object} preloadData - Preloaded speech data
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
speakPreloaded(preloadData, callback = null) {
|
||||
if (!this.available || !preloadData || !preloadData.audio) {
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'no_preloaded_data' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
const { audio, text } = preloadData;
|
||||
|
||||
// Dispatch start event
|
||||
this.dispatchEvent('tts:speak:start', { text });
|
||||
|
||||
// Set up event listeners
|
||||
audio.onended = () => {
|
||||
this.currentUtterance = null;
|
||||
|
||||
// Dispatch end event
|
||||
this.dispatchEvent('tts:speak:end', { text });
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true });
|
||||
}
|
||||
};
|
||||
|
||||
audio.onerror = (error) => {
|
||||
this.currentUtterance = null;
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
text,
|
||||
error: error.error || 'Unknown error'
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'audio_error', error });
|
||||
}
|
||||
};
|
||||
|
||||
// Store reference to current utterance
|
||||
this.currentUtterance = audio;
|
||||
|
||||
// Play the audio
|
||||
audio.play();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Browser TTS: Error playing preloaded speech:", error);
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
text: preloadData.text,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'audio_error', error }), 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
async speak(text, callback = null) {
|
||||
if (!this.available || !text) {
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'not_available' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Process text for TTS
|
||||
const processedText = this.preprocessText(text);
|
||||
|
||||
// Use MediaRecorder to capture audio output to WAV
|
||||
const audioData = await this.synthesizeToWav(processedText);
|
||||
if (!audioData) {
|
||||
console.warn("Browser TTS: Failed to generate WAV audio");
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'synthesis_error' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create audio element from blob
|
||||
const audio = new Audio(URL.createObjectURL(audioData.blob));
|
||||
|
||||
// Dispatch start event
|
||||
this.dispatchEvent('tts:speak:start', { text: processedText });
|
||||
|
||||
// Set up event listeners
|
||||
audio.onended = () => {
|
||||
this.currentUtterance = null;
|
||||
|
||||
// Dispatch end event
|
||||
this.dispatchEvent('tts:speak:end', { text: processedText });
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true });
|
||||
}
|
||||
};
|
||||
|
||||
audio.onerror = (error) => {
|
||||
this.currentUtterance = null;
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
text: processedText,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'audio_error', error });
|
||||
}
|
||||
};
|
||||
|
||||
// Store the current utterance for stopping later
|
||||
this.currentUtterance = audio;
|
||||
|
||||
// Play the audio
|
||||
audio.play();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Browser TTS: Error speaking:", error);
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
text,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'synthesis_error', error }), 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess text for TTS
|
||||
* @param {string} text - Text to preprocess
|
||||
* @returns {string} - Processed text
|
||||
*/
|
||||
preprocessText(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Trim whitespace
|
||||
let processed = text.trim();
|
||||
|
||||
// Replace multiple spaces with a single space
|
||||
processed = processed.replace(/\s+/g, ' ');
|
||||
|
||||
// Add a period at the end if there's no punctuation
|
||||
if (!/[.!?]$/.test(processed)) {
|
||||
processed += '.';
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop speaking
|
||||
*/
|
||||
stop() {
|
||||
if (this.currentUtterance) {
|
||||
if (this.currentUtterance.stop) {
|
||||
this.currentUtterance.stop();
|
||||
} else if (this.currentUtterance.pause) {
|
||||
this.currentUtterance.pause();
|
||||
}
|
||||
this.currentUtterance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TTS is available
|
||||
* @returns {boolean} - True if TTS is available
|
||||
*/
|
||||
isAvailable() {
|
||||
return this.available && this.voiceOptions.voice !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handler ID
|
||||
* @returns {string} - Handler ID
|
||||
*/
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Array} - Array of voice objects
|
||||
*/
|
||||
getVoices() {
|
||||
// Get localization module for current locale
|
||||
const localization = this.getModule('localization');
|
||||
let currentLocale = localization ? localization.getLocale() : 'en-us';
|
||||
|
||||
// Create language code variations for matching
|
||||
const languageCode = currentLocale.split('-')[0]; // e.g., 'en' from 'en-us'
|
||||
|
||||
// Filter voices by current locale
|
||||
const filteredVoices = this.voices.filter(voice => {
|
||||
const voiceLang = voice.lang.toLowerCase();
|
||||
return voiceLang.startsWith(languageCode) ||
|
||||
voiceLang === currentLocale ||
|
||||
// For handling cases like 'en' matching 'en-us'
|
||||
(currentLocale.startsWith(voiceLang) && voiceLang.length === 2);
|
||||
});
|
||||
|
||||
// If matching voices found, use them
|
||||
if (filteredVoices.length > 0) {
|
||||
return filteredVoices.map(voice => ({
|
||||
id: voice.voiceURI,
|
||||
name: voice.name,
|
||||
lang: voice.lang,
|
||||
gender: this.inferVoiceGender(voice.name)
|
||||
}));
|
||||
}
|
||||
|
||||
// If no matching voices found and current locale isn't English,
|
||||
// try to fallback to English voices
|
||||
if (languageCode !== 'en') {
|
||||
const englishVoices = this.voices.filter(voice => {
|
||||
const voiceLang = voice.lang.toLowerCase();
|
||||
return voiceLang.startsWith('en');
|
||||
});
|
||||
|
||||
if (englishVoices.length > 0) {
|
||||
return englishVoices.map(voice => ({
|
||||
id: voice.voiceURI,
|
||||
name: voice.name,
|
||||
lang: voice.lang,
|
||||
gender: this.inferVoiceGender(voice.name)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// As a last resort, return all voices
|
||||
return this.voices.map(voice => ({
|
||||
id: voice.voiceURI,
|
||||
name: voice.name,
|
||||
lang: voice.lang,
|
||||
gender: this.inferVoiceGender(voice.name)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer voice gender from name
|
||||
* @param {string} name - Voice name
|
||||
* @returns {string} - Inferred gender ('male', 'female', or 'unknown')
|
||||
*/
|
||||
inferVoiceGender(name) {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
// Common terms indicating gender
|
||||
const maleTerms = ['male', 'man', 'guy', 'boy', 'mr', 'sir', 'him', 'his'];
|
||||
const femaleTerms = ['female', 'woman', 'lady', 'girl', 'ms', 'mrs', 'miss', 'her', 'hers'];
|
||||
|
||||
// Check for explicit gender terms in the name
|
||||
for (const term of maleTerms) {
|
||||
if (lowerName.includes(term)) return 'male';
|
||||
}
|
||||
|
||||
for (const term of femaleTerms) {
|
||||
if (lowerName.includes(term)) return 'female';
|
||||
}
|
||||
|
||||
// Common male/female voice names
|
||||
if (/(david|james|john|paul|mark|thomas|daniel|jack|william|george|michael|robert|peter|brian|richard|steve|bruce)/i.test(lowerName)) {
|
||||
return 'male';
|
||||
}
|
||||
|
||||
if (/(mary|sarah|emma|susan|julia|karen|lisa|anna|laura|amy|elizabeth|jennifer|maria|emily|jessica|alice|victoria)/i.test(lowerName)) {
|
||||
return 'female';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
if (options.voice) {
|
||||
// Find the voice by ID or name
|
||||
const voice = this.voices.find(v =>
|
||||
v.voiceURI === options.voice ||
|
||||
v.name === options.voice
|
||||
);
|
||||
|
||||
if (voice) {
|
||||
this.voiceOptions.voice = voice;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof options.rate === 'number') {
|
||||
// Clamp rate between 0.1 and 10
|
||||
this.voiceOptions.rate = Math.max(0.1, Math.min(10, options.rate));
|
||||
}
|
||||
|
||||
if (typeof options.pitch === 'number') {
|
||||
// Clamp pitch between 0 and 2
|
||||
this.voiceOptions.pitch = Math.max(0, Math.min(2, options.pitch));
|
||||
}
|
||||
|
||||
if (typeof options.volume === 'number') {
|
||||
// Clamp volume between 0 and 1
|
||||
this.voiceOptions.volume = Math.max(0, Math.min(1, options.volume));
|
||||
}
|
||||
}
|
||||
}
|
||||
+364
-436
@@ -1,60 +1,43 @@
|
||||
/**
|
||||
* BrowserTTSModule for AI Interactive Fiction
|
||||
* Implementation using the browser's Web Speech API
|
||||
* BrowserTTSModule
|
||||
* Provides TTS via Browser's Web Speech API
|
||||
*/
|
||||
import { TTSHandlerModule } from './tts-handler-module.js';
|
||||
|
||||
/**
|
||||
* Browser TTS Module - Uses the browser's Web Speech API for TTS
|
||||
*/
|
||||
export class BrowserTTSModule extends TTSHandlerModule {
|
||||
constructor() {
|
||||
super('browser', 'Browser TTS');
|
||||
super('browser-tts', 'Browser TTS');
|
||||
|
||||
// Declare proper dependencies according to architecture principles
|
||||
this.dependencies = ['persistence-manager', 'localization'];
|
||||
|
||||
// Voice options
|
||||
this.voiceOptions = {
|
||||
voice: null, // Will be set during initialization
|
||||
rate: 1.0,
|
||||
voice: null, // Will be set during initialization
|
||||
speed: 1.0,
|
||||
pitch: 1.0,
|
||||
volume: 1.0
|
||||
};
|
||||
|
||||
// State
|
||||
this.available = false;
|
||||
// State variables
|
||||
this.voices = [];
|
||||
this.voicesByLang = {};
|
||||
this.lastPreprocessedText = '';
|
||||
this.isSpeaking = false;
|
||||
this.currentUtterance = null;
|
||||
|
||||
// Ensure dependencies are correctly defined from parent class
|
||||
// this.dependencies should already contain ['persistence-manager', 'localization']
|
||||
|
||||
// Bind additional methods beyond those in TTSHandlerModule
|
||||
this.bindMethods([
|
||||
'onVoicesChanged',
|
||||
'loadVoices',
|
||||
'selectVoiceForLocale',
|
||||
'synthesizeToWav',
|
||||
'speakPreloaded',
|
||||
'speak',
|
||||
'preprocessText',
|
||||
'inferVoiceGender'
|
||||
]);
|
||||
// Bind additional methods
|
||||
this.bindMethods(['onVoicesChanged', 'handleVoicePreferenceChanged']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the browser TTS module
|
||||
* Initialize the Browser TTS module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(10, 'Initializing Browser TTS');
|
||||
|
||||
// Check for browser support
|
||||
if (!window.speechSynthesis) {
|
||||
console.error('Browser TTS: Speech synthesis not available in this browser');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.reportProgress(30, 'Browser TTS supported');
|
||||
|
||||
// Initialize parent
|
||||
const parentInit = await super.initialize();
|
||||
if (!parentInit) {
|
||||
@@ -62,201 +45,264 @@ export class BrowserTTSModule extends TTSHandlerModule {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get required dependencies
|
||||
// Get dependencies using proper pattern
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (!persistenceManager) {
|
||||
console.error('Browser TTS: Required dependency persistence-manager not found');
|
||||
console.error('Browser TTS: Persistence Manager dependency not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
const localization = this.getModule('localization');
|
||||
if (!localization) {
|
||||
console.error('Browser TTS: Required dependency localization not found');
|
||||
console.error('Browser TTS: Localization dependency not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if browser supports speech synthesis
|
||||
if (!window.speechSynthesis) {
|
||||
console.error('Browser TTS: Speech synthesis not available in this browser');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load voices
|
||||
const voicesLoaded = await this.loadVoices();
|
||||
if (!voicesLoaded) {
|
||||
console.error('Browser TTS: Failed to load voices');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set speech options from preferences
|
||||
this.voiceOptions.rate = persistenceManager.getPreference('tts', 'rate', 1.0);
|
||||
this.voiceOptions.pitch = persistenceManager.getPreference('tts', 'pitch', 1.0);
|
||||
this.voiceOptions.volume = persistenceManager.getPreference('tts', 'volume', 1.0);
|
||||
const preferredVoice = persistenceManager.getPreference('tts', 'browser_voice', '');
|
||||
this.reportProgress(30, 'Loading browser voices');
|
||||
await this.loadVoices();
|
||||
|
||||
// Set voice based on current locale
|
||||
const currentLocale = localization.getLocale() || 'en-us';
|
||||
await this.selectVoiceForLocale(currentLocale, preferredVoice);
|
||||
// Set up voice from preferences
|
||||
this.reportProgress(70, 'Setting up voice preferences');
|
||||
await this.setupVoiceFromPreferences();
|
||||
|
||||
// Listen for locale changes
|
||||
document.addEventListener('locale:changed', async (event) => {
|
||||
if (event.detail && event.detail.locale) {
|
||||
await this.selectVoiceForLocale(event.detail.locale);
|
||||
}
|
||||
});
|
||||
// Set up event listeners
|
||||
document.addEventListener('tts:browser:voicePreferenceChanged', this.handleVoicePreferenceChanged);
|
||||
|
||||
// Listen for voices changed events
|
||||
if (window.speechSynthesis.onvoiceschanged !== undefined) {
|
||||
window.speechSynthesis.onvoiceschanged = this.onVoicesChanged;
|
||||
}
|
||||
// Set up utterance handlers
|
||||
this.setupUtteranceHandlers();
|
||||
|
||||
// Mark as ready
|
||||
this.isReady = true;
|
||||
this.available = true;
|
||||
this.reportProgress(100, 'Browser TTS initialized');
|
||||
|
||||
this.reportProgress(100, 'Browser TTS initialization complete');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Browser TTS: Initialization error:', error);
|
||||
this.isReady = false;
|
||||
this.available = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle voices changed event
|
||||
*/
|
||||
async onVoicesChanged() {
|
||||
await this.loadVoices();
|
||||
|
||||
// Re-select voice based on current locale
|
||||
const localization = this.getModule('localization');
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
|
||||
if (localization && persistenceManager) {
|
||||
const currentLocale = localization.getLocale() || 'en-us';
|
||||
const preferredVoice = persistenceManager.getPreference('tts', 'browser_voice', '');
|
||||
await this.selectVoiceForLocale(currentLocale, preferredVoice);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available voices from the speech synthesis API
|
||||
* Load voices from browser speech synthesis API
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async loadVoices() {
|
||||
try {
|
||||
this.reportProgress(40, 'Loading browser voices');
|
||||
// Helper function to process voices
|
||||
const processVoices = () => {
|
||||
// Get all voices from speechSynthesis
|
||||
const synVoices = window.speechSynthesis.getVoices() || [];
|
||||
|
||||
// Try to get voices
|
||||
let voices = window.speechSynthesis.getVoices();
|
||||
if (synVoices.length === 0) {
|
||||
console.warn('Browser TTS: No voices available');
|
||||
return false;
|
||||
}
|
||||
|
||||
// If voices array is empty, wait for onvoiceschanged event
|
||||
if (!voices || voices.length === 0) {
|
||||
try {
|
||||
console.log('Browser TTS: No voices available immediately, waiting for voices to load...');
|
||||
|
||||
// Wait for voices to be loaded (with timeout)
|
||||
voices = await new Promise((resolve, reject) => {
|
||||
// Set a timeout in case voices never load
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('Browser TTS: Timeout waiting for voices');
|
||||
// Resolve with empty array instead of rejecting
|
||||
resolve([]);
|
||||
}, 3000);
|
||||
|
||||
// Listen for voices changed event
|
||||
window.speechSynthesis.onvoiceschanged = () => {
|
||||
clearTimeout(timeout);
|
||||
const loadedVoices = window.speechSynthesis.getVoices();
|
||||
console.log(`Browser TTS: Voices loaded, found ${loadedVoices.length} voices`);
|
||||
resolve(loadedVoices);
|
||||
};
|
||||
});
|
||||
} catch (voiceWaitError) {
|
||||
console.error('Browser TTS: Error waiting for voices:', voiceWaitError);
|
||||
// Continue with empty voices array
|
||||
voices = [];
|
||||
// Transform to our format
|
||||
this.voices = synVoices.map((voice, index) => ({
|
||||
id: voice.voiceURI || `voice-${index}`,
|
||||
name: voice.name,
|
||||
language: voice.lang,
|
||||
localService: voice.localService,
|
||||
default: voice.default,
|
||||
original: voice // Keep reference to original voice
|
||||
}));
|
||||
|
||||
// Group voices by language
|
||||
this.voicesByLang = {};
|
||||
this.voices.forEach(voice => {
|
||||
if (voice.language) {
|
||||
const langCode = voice.language.split('-')[0].toLowerCase();
|
||||
if (!this.voicesByLang[langCode]) {
|
||||
this.voicesByLang[langCode] = [];
|
||||
}
|
||||
this.voicesByLang[langCode].push(voice);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Store voices
|
||||
this.voices = voices || [];
|
||||
|
||||
// Log available voices for debugging
|
||||
console.log(`Browser TTS: Loaded ${this.voices.length} voices`);
|
||||
if (this.voices.length > 0) {
|
||||
console.log('Browser TTS: First few voices:', this.voices.slice(0, 3));
|
||||
}
|
||||
|
||||
// If no voices available but speech synthesis is supported, still return true
|
||||
// Some browsers may not expose voices but still support speech synthesis
|
||||
if (this.voices.length === 0) {
|
||||
console.warn('Browser TTS: No voices available, but continuing with default voice');
|
||||
// Create a default voice entry
|
||||
this.voices = [{
|
||||
default: true,
|
||||
lang: 'en-US',
|
||||
localService: true,
|
||||
name: 'Default Voice',
|
||||
voiceURI: 'default'
|
||||
}];
|
||||
}
|
||||
|
||||
this.reportProgress(60, 'Browser voices loaded');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Browser TTS: Error loading voices:', error);
|
||||
};
|
||||
|
||||
// If voices are already loaded, process them
|
||||
if (window.speechSynthesis.getVoices().length > 0) {
|
||||
return processVoices();
|
||||
}
|
||||
|
||||
// Otherwise, wait for voiceschanged event
|
||||
return new Promise(resolve => {
|
||||
// Set up timeout to handle browsers that don't trigger voiceschanged
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (window.speechSynthesis.getVoices().length > 0) {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', this.onVoicesChanged);
|
||||
resolve(processVoices());
|
||||
} else {
|
||||
console.warn('Browser TTS: Voices not loaded after timeout');
|
||||
resolve(false);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
this.onVoicesChanged = () => {
|
||||
clearTimeout(timeoutId);
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', this.onVoicesChanged);
|
||||
resolve(processVoices());
|
||||
};
|
||||
|
||||
window.speechSynthesis.addEventListener('voiceschanged', this.onVoicesChanged);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up voice based on preferences and locale
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async setupVoiceFromPreferences() {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
const localization = this.getModule('localization');
|
||||
|
||||
if (!persistenceManager || !localization || this.voices.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get preferred voice ID from preferences
|
||||
const preferredVoiceId = persistenceManager.getPreference('tts', 'browser_voice', '');
|
||||
|
||||
// Get current locale
|
||||
const currentLocale = localization.getLocale();
|
||||
|
||||
// If we have a preferred voice ID, use it
|
||||
if (preferredVoiceId && this.voices.some(v => v.id === preferredVoiceId)) {
|
||||
this.voiceOptions.voice = preferredVoiceId;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise, select voice based on locale
|
||||
if (currentLocale) {
|
||||
return this.selectVoiceForLocale(currentLocale);
|
||||
}
|
||||
|
||||
// Fall back to default voice
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a voice for the given locale
|
||||
* @param {string} locale - Locale code
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectVoiceForLocale(locale) {
|
||||
if (!locale || this.voices.length === 0) {
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
// Extract language code from locale (e.g., 'en-US' -> 'en')
|
||||
const langCode = locale.split('-')[0].toLowerCase();
|
||||
|
||||
// First try to find a voice that exactly matches the locale
|
||||
let matchedVoice = this.voices.find(v => v.language && v.language.toLowerCase() === locale.toLowerCase());
|
||||
|
||||
// If not found, try to find a voice for the language
|
||||
if (!matchedVoice && this.voicesByLang[langCode]) {
|
||||
// Prefer default voices if available
|
||||
matchedVoice = this.voicesByLang[langCode].find(v => v.default) || this.voicesByLang[langCode][0];
|
||||
}
|
||||
|
||||
if (matchedVoice) {
|
||||
this.voiceOptions.voice = matchedVoice.id;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fall back to default voice
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a default voice
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectDefaultVoice() {
|
||||
if (this.voices.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find a default English voice if available
|
||||
const defaultEnVoice = this.voices.find(v => v.default && v.language && v.language.startsWith('en'));
|
||||
|
||||
// Otherwise use any default voice
|
||||
const defaultVoice = defaultEnVoice || this.voices.find(v => v.default) || this.voices[0];
|
||||
|
||||
this.voiceOptions.voice = defaultVoice.id;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up utterance handlers for speech events
|
||||
*/
|
||||
setupUtteranceHandlers() {
|
||||
// Handler functions for utterance events
|
||||
this.utteranceHandlers = {
|
||||
start: () => {
|
||||
this.isSpeaking = true;
|
||||
},
|
||||
end: () => {
|
||||
this.isSpeaking = false;
|
||||
this.currentUtterance = null;
|
||||
},
|
||||
error: (event) => {
|
||||
console.error('Browser TTS: Speech error:', event);
|
||||
this.isSpeaking = false;
|
||||
this.currentUtterance = null;
|
||||
},
|
||||
pause: () => {
|
||||
this.isSpeaking = false;
|
||||
},
|
||||
resume: () => {
|
||||
this.isSpeaking = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle voice preference changed event
|
||||
* @param {Event} event - Event object
|
||||
*/
|
||||
handleVoicePreferenceChanged(event) {
|
||||
if (event && event.detail) {
|
||||
this.setVoiceOptions(event.detail);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice based on locale
|
||||
* @param {string} locale - Locale code (e.g., 'en-us', 'de', 'fr')
|
||||
* @param {string} preferredVoice - Optional preferred voice name
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
* Preprocess text for TTS
|
||||
* @param {string} text - Text to preprocess
|
||||
* @returns {string} - Processed text
|
||||
*/
|
||||
async selectVoiceForLocale(locale = 'en-us', preferredVoice = '') {
|
||||
// Normalize locale format
|
||||
locale = locale.toLowerCase().replace('_', '-');
|
||||
const languageCode = locale.split('-')[0];
|
||||
|
||||
// First try to use the preferred voice if specified
|
||||
if (preferredVoice) {
|
||||
const voice = this.voices.find(v =>
|
||||
v.name === preferredVoice ||
|
||||
v.voiceURI === preferredVoice
|
||||
);
|
||||
|
||||
if (voice) {
|
||||
this.voiceOptions.voice = voice;
|
||||
return true;
|
||||
}
|
||||
preprocessText(text) {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Try to find a voice that matches the exact locale
|
||||
const exactMatch = this.voices.find(v =>
|
||||
v.lang.toLowerCase() === locale
|
||||
);
|
||||
// Remove HTML tags
|
||||
let processed = text.replace(/<[^>]*>/g, ' ');
|
||||
|
||||
if (exactMatch) {
|
||||
this.voiceOptions.voice = exactMatch;
|
||||
return true;
|
||||
// Replace special characters
|
||||
processed = processed.replace(/&/g, ' and ');
|
||||
|
||||
// Normalize whitespace
|
||||
processed = processed.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Add trailing period if missing
|
||||
if (!/[.!?]$/.test(processed)) {
|
||||
processed += '.';
|
||||
}
|
||||
|
||||
// Try to find a voice that matches the language code
|
||||
const languageMatch = this.voices.find(v =>
|
||||
v.lang.toLowerCase().startsWith(languageCode)
|
||||
);
|
||||
|
||||
if (languageMatch) {
|
||||
this.voiceOptions.voice = languageMatch;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback to the first available voice
|
||||
if (this.voices.length > 0) {
|
||||
this.voiceOptions.voice = this.voices[0];
|
||||
return true;
|
||||
}
|
||||
|
||||
// No voices available
|
||||
return false;
|
||||
this.lastPreprocessedText = processed;
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,210 +312,64 @@ export class BrowserTTSModule extends TTSHandlerModule {
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
speak(text, callback = null) {
|
||||
if (!this.isReady || !window.speechSynthesis) {
|
||||
if (!this.isReady || !text) {
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'not_ready' });
|
||||
callback({ success: false, reason: 'not_ready_or_empty_text' });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop any ongoing speech
|
||||
this.stop();
|
||||
|
||||
const processedText = this.preprocessText(text);
|
||||
|
||||
// Create utterance
|
||||
const utterance = new SpeechSynthesisUtterance(processedText);
|
||||
|
||||
// Set options
|
||||
if (this.voiceOptions.voice) {
|
||||
utterance.voice = this.voiceOptions.voice;
|
||||
}
|
||||
|
||||
utterance.rate = this.voiceOptions.rate;
|
||||
utterance.pitch = this.voiceOptions.pitch;
|
||||
utterance.volume = this.voiceOptions.volume;
|
||||
|
||||
// Set up event handlers
|
||||
utterance.onend = () => {
|
||||
this.isSpeaking = false;
|
||||
if (callback) {
|
||||
callback({ success: true });
|
||||
}
|
||||
};
|
||||
|
||||
utterance.onerror = (error) => {
|
||||
this.isSpeaking = false;
|
||||
console.error('Browser TTS: Speech error', error);
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'synthesis_error', error });
|
||||
}
|
||||
};
|
||||
|
||||
// Store current utterance
|
||||
this.currentUtterance = utterance;
|
||||
this.isSpeaking = true;
|
||||
|
||||
// Start speaking
|
||||
window.speechSynthesis.speak(utterance);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload speech for a text
|
||||
* @param {string} text - Text to preload
|
||||
* @returns {Promise<Object>} - Preloaded speech data
|
||||
*/
|
||||
async preloadSpeech(text) {
|
||||
if (!this.isReady || !window.speechSynthesis) {
|
||||
return { success: false, reason: 'not_ready' };
|
||||
}
|
||||
|
||||
// Generate WAV audio data
|
||||
const wavResult = await this.synthesizeToWav(text);
|
||||
|
||||
if (!wavResult.success) {
|
||||
return { success: false, reason: 'synthesis_failed' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
audioData: wavResult.audioData,
|
||||
text,
|
||||
duration: wavResult.duration || 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert speech synthesis to WAV format
|
||||
* @param {string} text - Text to synthesize
|
||||
* @returns {Promise<Object>} - Object with audio data
|
||||
*/
|
||||
async synthesizeToWav(text) {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.isReady || !window.speechSynthesis) {
|
||||
resolve({ success: false, reason: 'not_ready' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Stop any ongoing speech
|
||||
this.stop();
|
||||
|
||||
// Process text for better synthesis
|
||||
// Process the text
|
||||
const processedText = this.preprocessText(text);
|
||||
|
||||
// Create audio context
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContext) {
|
||||
resolve({ success: false, reason: 'no_audio_context' });
|
||||
return;
|
||||
}
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
|
||||
// Create media stream destination
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
|
||||
// Create media recorder
|
||||
const mediaRecorder = new MediaRecorder(destination.stream);
|
||||
const audioChunks = [];
|
||||
|
||||
// Set up event handlers
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
// Create blob from chunks
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
|
||||
|
||||
// Convert blob to array buffer
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
resolve({
|
||||
success: true,
|
||||
audioData: reader.result
|
||||
});
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
resolve({ success: false, reason: 'blob_read_error' });
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(audioBlob);
|
||||
};
|
||||
|
||||
// Create utterance
|
||||
// Create a new utterance
|
||||
const utterance = new SpeechSynthesisUtterance(processedText);
|
||||
|
||||
// Set options
|
||||
// Set voice options
|
||||
if (this.voiceOptions.voice) {
|
||||
utterance.voice = this.voiceOptions.voice;
|
||||
const voice = this.voices.find(v => v.id === this.voiceOptions.voice);
|
||||
if (voice && voice.original) {
|
||||
utterance.voice = voice.original;
|
||||
}
|
||||
}
|
||||
|
||||
utterance.rate = this.voiceOptions.rate;
|
||||
utterance.pitch = this.voiceOptions.pitch;
|
||||
utterance.volume = this.voiceOptions.volume;
|
||||
utterance.rate = this.voiceOptions.speed || 1.0;
|
||||
utterance.pitch = this.voiceOptions.pitch || 1.0;
|
||||
utterance.volume = this.voiceOptions.volume || 1.0;
|
||||
|
||||
// Start recording
|
||||
mediaRecorder.start();
|
||||
|
||||
// Set up completion handling
|
||||
// Set up event handlers
|
||||
utterance.onstart = this.utteranceHandlers.start;
|
||||
utterance.onend = () => {
|
||||
mediaRecorder.stop();
|
||||
this.utteranceHandlers.end();
|
||||
if (callback) {
|
||||
callback({ success: true });
|
||||
}
|
||||
};
|
||||
|
||||
utterance.onerror = (error) => {
|
||||
console.error('Browser TTS: Synthesis error', error);
|
||||
mediaRecorder.stop();
|
||||
resolve({ success: false, reason: 'synthesis_error' });
|
||||
utterance.onerror = (event) => {
|
||||
this.utteranceHandlers.error(event);
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'synthesis_error', error: event });
|
||||
}
|
||||
};
|
||||
utterance.onpause = this.utteranceHandlers.pause;
|
||||
utterance.onresume = this.utteranceHandlers.resume;
|
||||
|
||||
// Start speaking
|
||||
window.speechSynthesis.speak(utterance);
|
||||
this.currentUtterance = utterance;
|
||||
speechSynthesis.speak(utterance);
|
||||
|
||||
// Set timeout in case onend never fires
|
||||
setTimeout(() => {
|
||||
if (mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
}, 30000); // 30-second timeout
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak preloaded audio data
|
||||
* @param {Object} preloadedData - Data from preloadSpeech
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
speakPreloaded(preloadedData, callback = null) {
|
||||
if (!preloadedData || !preloadedData.text) {
|
||||
console.error('Browser TTS: Invalid preloaded data');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Browser TTS: Failed to speak:', error);
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'speak_error', error });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// For browser TTS, we don't use the preloaded data directly
|
||||
// Instead, we just speak the text again
|
||||
return this.speak(preloadedData.text, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess text for TTS
|
||||
* @param {string} text - Text to preprocess
|
||||
* @returns {string} - Processed text
|
||||
*/
|
||||
preprocessText(text) {
|
||||
// Remove HTML tags
|
||||
text = text.replace(/<[^>]*>/g, ' ');
|
||||
|
||||
// Replace special characters with their spoken equivalents
|
||||
text = text.replace(/&/g, ' and ');
|
||||
|
||||
// Normalize whitespace
|
||||
text = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,94 +377,122 @@ export class BrowserTTSModule extends TTSHandlerModule {
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
stop() {
|
||||
if (window.speechSynthesis) {
|
||||
window.speechSynthesis.cancel();
|
||||
try {
|
||||
speechSynthesis.cancel();
|
||||
this.isSpeaking = false;
|
||||
this.currentUtterance = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Browser TTS: Failed to stop speech:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause speaking
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
pause() {
|
||||
try {
|
||||
if (this.isSpeaking) {
|
||||
speechSynthesis.pause();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Browser TTS: Failed to pause speech:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume speaking
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
resume() {
|
||||
try {
|
||||
speechSynthesis.resume();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Browser TTS: Failed to resume speech:', error);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Array} - Array of voice objects
|
||||
*/
|
||||
async getVoices() {
|
||||
if (!this.isReady) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const localization = this.getModule('localization');
|
||||
const currentLocale = localization ? localization.getLocale() : 'en-us';
|
||||
|
||||
// Normalize locale format
|
||||
const normalizedLocale = currentLocale.toLowerCase().replace('_', '-');
|
||||
const languageCode = normalizedLocale.split('-')[0];
|
||||
|
||||
// Filter voices by current locale
|
||||
const filteredVoices = this.voices.filter(voice => {
|
||||
const voiceLang = voice.lang.toLowerCase();
|
||||
return voiceLang.startsWith(languageCode) ||
|
||||
voiceLang === normalizedLocale ||
|
||||
(normalizedLocale.startsWith(voiceLang) && voiceLang.length === 2);
|
||||
});
|
||||
|
||||
// If matching voices found, use them
|
||||
if (filteredVoices.length > 0) {
|
||||
return filteredVoices.map(voice => ({
|
||||
id: voice.voiceURI,
|
||||
name: voice.name,
|
||||
lang: voice.lang,
|
||||
gender: this.inferVoiceGender(voice.name)
|
||||
}));
|
||||
}
|
||||
|
||||
// If no matching voices found, return all voices
|
||||
return this.voices.map(voice => ({
|
||||
id: voice.voiceURI,
|
||||
name: voice.name,
|
||||
lang: voice.lang,
|
||||
gender: this.inferVoiceGender(voice.name)
|
||||
}));
|
||||
getAvailableVoices() {
|
||||
return this.voices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer voice gender from name
|
||||
* @param {string} name - Voice name
|
||||
* @returns {string} - Inferred gender ('male', 'female', or 'unknown')
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
inferVoiceGender(name) {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
// Common terms indicating gender
|
||||
const maleTerms = ['male', 'man', 'guy', 'boy', 'mr', 'sir'];
|
||||
const femaleTerms = ['female', 'woman', 'lady', 'girl', 'ms', 'mrs', 'miss'];
|
||||
|
||||
// Check for explicit gender terms in the name
|
||||
for (const term of maleTerms) {
|
||||
if (lowerName.includes(term)) return 'male';
|
||||
setVoiceOptions(options = {}) {
|
||||
if (options.voice) {
|
||||
this.voiceOptions.voice = options.voice;
|
||||
|
||||
// Save voice preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'browser_voice', options.voice);
|
||||
}
|
||||
}
|
||||
|
||||
for (const term of femaleTerms) {
|
||||
if (lowerName.includes(term)) return 'female';
|
||||
if (typeof options.speed === 'number') {
|
||||
this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed));
|
||||
|
||||
// Save speed preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'browser_speed', options.speed);
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
if (typeof options.pitch === 'number') {
|
||||
this.voiceOptions.pitch = Math.max(0.5, Math.min(2.0, options.pitch));
|
||||
|
||||
// Save pitch preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'browser_pitch', options.pitch);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof options.volume === 'number') {
|
||||
this.voiceOptions.volume = Math.max(0, Math.min(1.0, options.volume));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload speech for later playback
|
||||
* Not applicable for the browser TTS (always returns null)
|
||||
* @param {string} text - Text to preload
|
||||
* @returns {Promise<Object>} - Promise that resolves to null
|
||||
*/
|
||||
async preloadSpeech(text) {
|
||||
// Browser TTS can't preload speech
|
||||
return { success: false, reason: 'not_supported' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak preloaded speech
|
||||
* Not applicable for the browser TTS (always returns false)
|
||||
* @param {Object} preloadData - Preloaded speech data
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status (always false)
|
||||
*/
|
||||
speakPreloaded(preloadData, callback = null) {
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'not_supported' });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Register the module with the module registry
|
||||
// Module registry MUST be accessed via window, not direct import
|
||||
if (window.moduleRegistry) {
|
||||
try {
|
||||
// Create instance first, then register it
|
||||
const browserTTSModule = new BrowserTTSModule();
|
||||
window.moduleRegistry.register(browserTTSModule);
|
||||
console.log('Browser TTS Module registered successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to register Browser TTS Module:', err);
|
||||
}
|
||||
} else {
|
||||
console.error('Module registry not available when attempting to register Browser TTS Module');
|
||||
}
|
||||
const browserTTSModule = new BrowserTTSModule();
|
||||
|
||||
export { browserTTSModule };
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Debug Utilities Module for AI Interactive Fiction
|
||||
* Provides debugging and testing tools
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
export class DebugUtilsModule extends BaseModule {
|
||||
constructor() {
|
||||
super('debug-utils', 'Debug Utilities');
|
||||
|
||||
// Declare dependencies explicitly
|
||||
this.dependencies = ['text-buffer', 'socket-client', 'tts-player', 'ui-controller', 'game-loop'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the debug utilities module
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
async initialize() {
|
||||
console.log('Debug Utilities: Initializing');
|
||||
|
||||
// Make utilities available globally for console access
|
||||
window.DebugUtils = {
|
||||
testTextPipeline: this.testTextPipeline.bind(this),
|
||||
testSocketConnection: this.testSocketConnection.bind(this),
|
||||
testTTS: this.testTTS.bind(this),
|
||||
forceReconnect: this.forceReconnect.bind(this)
|
||||
};
|
||||
|
||||
console.log('Debug Utilities: Debug tools are now available via window.DebugUtils');
|
||||
this.isReady = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the text processing pipeline with sample text
|
||||
* @param {string} text - Test text to process
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
testTextPipeline(text = "This is a test sentence. Let's see if it displays correctly!") {
|
||||
console.log("Debug: Testing text pipeline with:", text);
|
||||
|
||||
// Get the text buffer module properly through dependency system
|
||||
const textBuffer = this.getModule('text-buffer');
|
||||
if (!textBuffer) {
|
||||
console.error("Debug: TextBuffer module not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
textBuffer.addText(text);
|
||||
console.log("Debug: Text added to buffer");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the socket connection
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
testSocketConnection() {
|
||||
console.log("Debug: Testing socket connection");
|
||||
|
||||
// Get the socket client module properly through dependency system
|
||||
const socketClient = this.getModule('socket-client');
|
||||
if (!socketClient) {
|
||||
console.error("Debug: SocketClient module not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (socketClient.isConnected) {
|
||||
console.log("Debug: Socket is connected");
|
||||
return true;
|
||||
} else {
|
||||
console.log("Debug: Socket is not connected, attempting connection");
|
||||
socketClient.connect();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the TTS system
|
||||
* @param {string} text - Test text to speak
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
testTTS(text = "This is a test of the text to speech system.") {
|
||||
console.log("Debug: Testing TTS with:", text);
|
||||
|
||||
// Get the TTS module properly through dependency system
|
||||
const ttsPlayer = this.getModule('tts-player');
|
||||
if (!ttsPlayer) {
|
||||
console.error("Debug: TTS module not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
const wasEnabled = ttsPlayer.isEnabled();
|
||||
|
||||
// Enable TTS temporarily if it was disabled
|
||||
if (!wasEnabled && ttsPlayer.toggle) {
|
||||
ttsPlayer.toggle();
|
||||
}
|
||||
|
||||
// Speak the text
|
||||
ttsPlayer.speak(text, (result) => {
|
||||
console.log("Debug: TTS completed with result:", result);
|
||||
|
||||
// Restore previous enabled state
|
||||
if (!wasEnabled && ttsPlayer.toggle) {
|
||||
ttsPlayer.toggle();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force all modules to reconnect
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
forceReconnect() {
|
||||
console.log("Debug: Forcing module reconnection");
|
||||
|
||||
// Get all required modules properly through dependency system
|
||||
const uiController = this.getModule('ui-controller');
|
||||
const socketClient = this.getModule('socket-client');
|
||||
const gameLoop = this.getModule('game-loop');
|
||||
const textBuffer = this.getModule('text-buffer');
|
||||
const ttsHandler = this.getModule('tts-player');
|
||||
|
||||
// Check if all modules are available
|
||||
if (!uiController || !socketClient || !gameLoop || !textBuffer || !ttsHandler) {
|
||||
console.error("Debug: One or more required modules not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// UI Controller
|
||||
if (uiController.textBuffer === null) {
|
||||
uiController.textBuffer = textBuffer;
|
||||
console.log("Debug: Reconnected UI Controller to Text Buffer");
|
||||
|
||||
// Reinitialize text buffer
|
||||
if (uiController.initializeTextBuffer) {
|
||||
uiController.initializeTextBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
if (uiController.ttsHandler === null) {
|
||||
uiController.ttsHandler = ttsHandler;
|
||||
console.log("Debug: Reconnected UI Controller to TTS Player");
|
||||
}
|
||||
|
||||
// Socket Client
|
||||
if (socketClient.textBuffer === null) {
|
||||
socketClient.textBuffer = textBuffer;
|
||||
console.log("Debug: Reconnected Socket Client to Text Buffer");
|
||||
}
|
||||
|
||||
// Game Loop
|
||||
if (gameLoop.uiController === null) {
|
||||
gameLoop.uiController = uiController;
|
||||
console.log("Debug: Reconnected Game Loop to UI Controller");
|
||||
}
|
||||
|
||||
if (gameLoop.socketClient === null) {
|
||||
gameLoop.socketClient = socketClient;
|
||||
console.log("Debug: Reconnected Game Loop to Socket Client");
|
||||
}
|
||||
|
||||
if (gameLoop.textBuffer === null) {
|
||||
gameLoop.textBuffer = textBuffer;
|
||||
console.log("Debug: Reconnected Game Loop to Text Buffer");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Register the module with the module registry
|
||||
if (window.moduleRegistry) {
|
||||
try {
|
||||
const debugUtilsModule = new DebugUtilsModule();
|
||||
window.moduleRegistry.register(debugUtilsModule);
|
||||
console.log('Debug Utilities Module registered successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to register Debug Utilities Module:', err);
|
||||
}
|
||||
} else {
|
||||
console.error('Module registry not available when attempting to register Debug Utilities Module');
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* Debug Utilities for AI Interactive Fiction
|
||||
* Provides debugging and testing tools
|
||||
*/
|
||||
|
||||
class DebugUtils {
|
||||
/**
|
||||
* Test the text processing pipeline with sample text
|
||||
* @param {string} text - Test text to process
|
||||
*/
|
||||
static testTextPipeline(text = "This is a test sentence. Let's see if it displays correctly!") {
|
||||
console.log("Debug: Testing text pipeline with:", text);
|
||||
|
||||
// Find the text buffer
|
||||
const textBuffer = window.TextBuffer || window.moduleRegistry?.getModule('text-buffer');
|
||||
if (textBuffer) {
|
||||
textBuffer.addText(text);
|
||||
console.log("Debug: Text added to buffer");
|
||||
return true;
|
||||
} else {
|
||||
console.error("Debug: TextBuffer not found");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the socket connection
|
||||
*/
|
||||
static testSocketConnection() {
|
||||
console.log("Debug: Testing socket connection");
|
||||
|
||||
// Find the socket client
|
||||
const socketClient = window.SocketClient || window.moduleRegistry?.getModule('socket-client');
|
||||
if (socketClient) {
|
||||
if (socketClient.isConnected) {
|
||||
console.log("Debug: Socket is connected");
|
||||
return true;
|
||||
} else {
|
||||
console.log("Debug: Socket is not connected, attempting connection");
|
||||
socketClient.connect();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
console.error("Debug: SocketClient not found");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the TTS system
|
||||
* @param {string} text - Test text to speak
|
||||
*/
|
||||
static testTTS(text = "This is a test of the text to speech system.") {
|
||||
console.log("Debug: Testing TTS with:", text);
|
||||
|
||||
// Find the TTS player
|
||||
const ttsPlayer = window.TTSPlayer || window.moduleRegistry?.getModule('tts');
|
||||
if (ttsPlayer) {
|
||||
const wasEnabled = ttsPlayer.isEnabled();
|
||||
|
||||
// Enable TTS temporarily if it was disabled
|
||||
if (!wasEnabled && ttsPlayer.toggle) {
|
||||
ttsPlayer.toggle();
|
||||
}
|
||||
|
||||
// Speak the text
|
||||
ttsPlayer.speak(text, (result) => {
|
||||
console.log("Debug: TTS completed with result:", result);
|
||||
|
||||
// Restore previous enabled state
|
||||
if (!wasEnabled && ttsPlayer.toggle) {
|
||||
ttsPlayer.toggle();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.error("Debug: TTSPlayer not found");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force all modules to reconnect
|
||||
*/
|
||||
static forceReconnect() {
|
||||
console.log("Debug: Forcing module reconnection");
|
||||
|
||||
// Get all modules
|
||||
const registry = window.moduleRegistry;
|
||||
if (!registry) {
|
||||
console.error("Debug: Module registry not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
const modules = registry.getAllModules();
|
||||
|
||||
// UI Controller
|
||||
const uiController = modules['ui-controller'];
|
||||
if (uiController) {
|
||||
if (uiController.textBuffer === null) {
|
||||
uiController.textBuffer = modules['text-buffer'];
|
||||
console.log("Debug: Reconnected UI Controller to Text Buffer");
|
||||
|
||||
// Reinitialize text buffer
|
||||
if (uiController.initializeTextBuffer) {
|
||||
uiController.initializeTextBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
if (uiController.ttsHandler === null) {
|
||||
uiController.ttsHandler = modules['tts'];
|
||||
console.log("Debug: Reconnected UI Controller to TTS Player");
|
||||
}
|
||||
}
|
||||
|
||||
// Socket Client
|
||||
const socketClient = modules['socket-client'];
|
||||
if (socketClient) {
|
||||
if (socketClient.textBuffer === null) {
|
||||
socketClient.textBuffer = modules['text-buffer'];
|
||||
console.log("Debug: Reconnected Socket Client to Text Buffer");
|
||||
}
|
||||
}
|
||||
|
||||
// Game Loop
|
||||
const gameLoop = modules['game-loop'];
|
||||
if (gameLoop) {
|
||||
if (gameLoop.uiController === null) {
|
||||
gameLoop.uiController = modules['ui-controller'];
|
||||
console.log("Debug: Reconnected Game Loop to UI Controller");
|
||||
}
|
||||
|
||||
if (gameLoop.socketClient === null) {
|
||||
gameLoop.socketClient = modules['socket-client'];
|
||||
console.log("Debug: Reconnected Game Loop to Socket Client");
|
||||
}
|
||||
|
||||
if (gameLoop.textBuffer === null) {
|
||||
gameLoop.textBuffer = modules['text-buffer'];
|
||||
console.log("Debug: Reconnected Game Loop to Text Buffer");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Export as global for easy console access
|
||||
window.DebugUtils = DebugUtils;
|
||||
|
||||
export { DebugUtils };
|
||||
@@ -1,332 +0,0 @@
|
||||
/**
|
||||
* ElevenLabs TTS Handler
|
||||
* Provides TTS via ElevenLabs API
|
||||
*/
|
||||
import { ApiTTSHandlerBase } from './api-tts-handler-base.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
export class ElevenLabsTTSHandler extends ApiTTSHandlerBase {
|
||||
constructor() {
|
||||
super('elevenlabs', 'ElevenLabs TTS');
|
||||
|
||||
// Voice options specific to ElevenLabs
|
||||
this.voiceOptions = {
|
||||
voice: 'pNInz6obpgDQGcFmaJgB', // Default voice ID for ElevenLabs
|
||||
model: 'eleven_multilingual_v2', // Use the multilingual model
|
||||
speed: 1.0
|
||||
};
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'speak',
|
||||
'speakPreloaded',
|
||||
'preloadSpeech',
|
||||
'stop',
|
||||
'isAvailable',
|
||||
'getId',
|
||||
'getVoices',
|
||||
'setVoiceOptions',
|
||||
'getModule',
|
||||
'setupVoiceFromPreferences',
|
||||
'loadVoices',
|
||||
'selectVoiceForLocale',
|
||||
'selectDefaultVoice',
|
||||
'generateSpeechAudio',
|
||||
'getDefaultApiBaseUrl'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the ElevenLabs TTS handler
|
||||
* @param {Function} progressCallback - Callback for progress updates
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize(progressCallback = null) {
|
||||
try {
|
||||
if (progressCallback) {
|
||||
progressCallback(10, 'Initializing ElevenLabs TTS');
|
||||
}
|
||||
|
||||
// Call parent initialize method
|
||||
const initSuccess = await super.initialize(progressCallback);
|
||||
|
||||
if (!initSuccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(40, 'ElevenLabs TTS dependencies loaded');
|
||||
}
|
||||
|
||||
// Set default voices in case API call fails
|
||||
this.voices = [
|
||||
{ id: 'pNInz6obpgDQGcFmaJgB', name: 'Rachel', language: 'en' },
|
||||
{ id: '21m00Tcm4TlvDq8ikWAM', name: 'Adam', language: 'en' },
|
||||
{ id: 'AZnzlk1XvdvUeBnXmlld', name: 'Antoni', language: 'en' },
|
||||
{ id: 'EXAVITQu4vr4xnSDxMaL', name: 'Bella', language: 'en' },
|
||||
{ id: 'ErXwobaYiN019PkySvjV', name: 'Daniel', language: 'en' }
|
||||
];
|
||||
|
||||
// Load voice preferences
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
// Load model preference
|
||||
const model = persistenceManager.getPreference('tts', 'elevenlabs_model', 'eleven_multilingual_v2');
|
||||
if (model) {
|
||||
this.voiceOptions.model = model;
|
||||
}
|
||||
|
||||
// Load voice preference
|
||||
const voice = persistenceManager.getPreference('tts', 'elevenlabs_voice');
|
||||
if (voice) {
|
||||
this.voiceOptions.voice = voice;
|
||||
}
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(60, 'ElevenLabs TTS preferences loaded');
|
||||
}
|
||||
|
||||
// Only attempt to load voices from API if we have an API key
|
||||
if (this.apiKey) {
|
||||
try {
|
||||
await this.loadVoices();
|
||||
console.log(`ElevenLabs TTS: Loaded ${this.voices.length} voices from API`);
|
||||
} catch (error) {
|
||||
console.warn('ElevenLabs TTS: Could not load voices from API, using defaults');
|
||||
// Don't fail initialization, we already have default voices
|
||||
}
|
||||
} else {
|
||||
console.log('ElevenLabs TTS: No API key provided, using default voices');
|
||||
// Mark as available but not fully functional
|
||||
this.available = true;
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(80, `ElevenLabs TTS loaded ${this.voices.length} voices`);
|
||||
}
|
||||
|
||||
// Set voice based on locale
|
||||
const localization = this.getModule('localization');
|
||||
if (localization) {
|
||||
const locale = localization.getLocale();
|
||||
console.log(`ElevenLabs TTS: Setting voice for locale: ${locale}`);
|
||||
this.selectVoiceForLocale(locale);
|
||||
} else {
|
||||
this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
// Mark as ready even if we're using default voices
|
||||
this.isReady = true;
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(100, 'ElevenLabs TTS initialized');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('ElevenLabs TTS: Initialization error:', error);
|
||||
if (progressCallback) {
|
||||
progressCallback(100, `ElevenLabs TTS initialization failed - ${error.message}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default API base URL for ElevenLabs
|
||||
* @returns {string} - Default API base URL
|
||||
*/
|
||||
getDefaultApiBaseUrl() {
|
||||
return 'https://api.elevenlabs.io/v1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available voices from ElevenLabs API
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async loadVoices() {
|
||||
if (!this.apiKey) {
|
||||
console.log('ElevenLabs TTS: No API key provided, skipping voice loading');
|
||||
// Return true to indicate initialization was successful, even without voices
|
||||
// This allows the handler to appear in the dropdown for configuration
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/voices`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'xi-api-key': this.apiKey
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.voices && Array.isArray(data.voices)) {
|
||||
this.voices = data.voices.map(voice => ({
|
||||
id: voice.voice_id,
|
||||
name: voice.name,
|
||||
language: voice.labels?.language || 'unknown'
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('ElevenLabs TTS: Error loading voices:', error);
|
||||
return true; // Still return true to allow the handler to be configured
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a voice for the given locale
|
||||
* @param {string} locale - Locale code
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectVoiceForLocale(locale) {
|
||||
if (!this.voices || this.voices.length === 0) {
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
// Extract language code from locale (e.g., 'en-US' -> 'en')
|
||||
const langCode = locale.split('-')[0].toLowerCase();
|
||||
|
||||
// Find a voice that matches the language code
|
||||
const matchingVoice = this.voices.find(voice => {
|
||||
if (voice.language && voice.language !== 'unknown') {
|
||||
return voice.language.toLowerCase() === langCode;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (matchingVoice) {
|
||||
this.voiceOptions.voice = matchingVoice.id;
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no match, use default
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a default voice
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectDefaultVoice() {
|
||||
// If we have voices, use the first one
|
||||
if (this.voices && this.voices.length > 0) {
|
||||
this.voiceOptions.voice = this.voices[0].id;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Use hardcoded default voice ID
|
||||
this.voiceOptions.voice = 'pNInz6obpgDQGcFmaJgB';
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech audio data using ElevenLabs API
|
||||
* @param {string} text - Text to generate speech for
|
||||
* @returns {Promise<Object>} - Audio data (Blob)
|
||||
*/
|
||||
async generateSpeechAudio(text) {
|
||||
// Don't attempt to call the API if no API key is set or text is empty
|
||||
if (!text || !this.apiKey || this.apiKey.trim() === '') {
|
||||
console.log('ElevenLabs TTS: No API key provided or empty text, skipping API call');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create request payload
|
||||
const payload = {
|
||||
text: text,
|
||||
model_id: this.voiceOptions.model || 'eleven_multilingual_v2',
|
||||
voice_settings: {
|
||||
stability: 0.5,
|
||||
similarity_boost: 0.75,
|
||||
style: 0.0,
|
||||
use_speaker_boost: true,
|
||||
speed: this.voiceOptions.speed || 1.0
|
||||
}
|
||||
};
|
||||
|
||||
// Make API request
|
||||
const response = await fetch(`${this.apiBaseUrl}/text-to-speech/${this.voiceOptions.voice}?optimize_streaming_latency=0`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'xi-api-key': this.apiKey,
|
||||
'Accept': 'audio/wav'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Get audio blob from response
|
||||
const audioBlob = await response.blob();
|
||||
|
||||
// Ensure it's treated as WAV
|
||||
return new Blob([audioBlob], { type: 'audio/wav' });
|
||||
} catch (error) {
|
||||
console.error('ElevenLabs TTS: Error generating speech:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Promise<Array>} - Resolves with array of voice objects
|
||||
*/
|
||||
async getVoices() {
|
||||
if (!this.available) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If voices are already loaded, return them
|
||||
if (this.voices && this.voices.length > 0) {
|
||||
return this.voices;
|
||||
}
|
||||
|
||||
// Otherwise try to load voices
|
||||
try {
|
||||
await this.loadVoices();
|
||||
return this.voices || [];
|
||||
} catch (error) {
|
||||
console.error('ElevenLabs TTS: Error getting voices:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
// Call parent method for common options
|
||||
super.setVoiceOptions(options);
|
||||
|
||||
// Handle ElevenLabs-specific options
|
||||
if (options.model) {
|
||||
this.voiceOptions.model = options.model;
|
||||
|
||||
// Save the model preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'elevenlabs_model', options.model);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const ElevenLabsTTS = new ElevenLabsTTSHandler();
|
||||
@@ -6,7 +6,7 @@ import { ApiTTSModuleBase } from './api-tts-module-base.js';
|
||||
|
||||
export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
||||
constructor() {
|
||||
super('elevenlabs', 'ElevenLabs TTS');
|
||||
super('elevenlabs-tts', 'ElevenLabs TTS');
|
||||
|
||||
// Voice options specific to ElevenLabs
|
||||
this.voiceOptions = {
|
||||
@@ -112,35 +112,35 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
||||
const response = await fetch(`${this.apiBaseUrl}/voices`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'xi-api-key': apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
'Accept': 'application/json',
|
||||
'xi-api-key': apiKey
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`ElevenLabs TTS: API error: ${response.status} ${response.statusText}`);
|
||||
return true; // Use defaults, but don't fail initialization
|
||||
console.error(`ElevenLabs TTS: API error ${response.status} ${response.statusText}`);
|
||||
return true; // Continue with default voices
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.voices && Array.isArray(data.voices)) {
|
||||
// Transform API response to our internal format
|
||||
// Map API voices to our format
|
||||
this.voices = data.voices.map(voice => ({
|
||||
id: voice.voice_id,
|
||||
name: voice.name,
|
||||
language: 'en', // ElevenLabs doesn't provide language info
|
||||
preview: voice.preview_url
|
||||
language: voice.language || 'en',
|
||||
gender: 'unknown',
|
||||
preview_url: voice.preview_url
|
||||
}));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true; // Continue with default voices
|
||||
} catch (error) {
|
||||
console.error('ElevenLabs TTS: Error loading voices:', error);
|
||||
return true; // Continue with default voices
|
||||
}
|
||||
|
||||
// If API call failed, we still return true since we have default voices
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,12 +149,18 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectVoiceForLocale(locale) {
|
||||
if (!this.voices || this.voices.length === 0) {
|
||||
return this.selectDefaultVoice();
|
||||
// Extract language code from locale (e.g., 'en-US' -> 'en')
|
||||
const langCode = locale.split('-')[0].toLowerCase();
|
||||
|
||||
// For English locales, select 'Rachel' if available
|
||||
if (langCode === 'en') {
|
||||
const defaultVoice = this.voices.find(v => v.id === 'pNInz6obpgDQGcFmaJgB');
|
||||
if (defaultVoice) {
|
||||
this.voiceOptions.voice = defaultVoice.id;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ElevenLabs doesn't provide language info for voices
|
||||
// Simply use the first voice as default
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
@@ -254,17 +260,6 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
||||
}
|
||||
}
|
||||
|
||||
// Register the module with the module registry
|
||||
// Module registry MUST be accessed via window, not direct import
|
||||
if (window.moduleRegistry) {
|
||||
try {
|
||||
// Create instance first, then register it
|
||||
const elevenLabsTTSModule = new ElevenLabsTTSModule();
|
||||
window.moduleRegistry.register(elevenLabsTTSModule);
|
||||
console.log('ElevenLabs TTS Module registered successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to register ElevenLabs TTS Module:', err);
|
||||
}
|
||||
} else {
|
||||
console.error('Module registry not available when attempting to register ElevenLabs TTS Module');
|
||||
}
|
||||
const elevenLabsTTSModule = new ElevenLabsTTSModule();
|
||||
|
||||
export { elevenLabsTTSModule };
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Manages the main game logic and connects various modules
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class GameLoopModule extends BaseModule {
|
||||
constructor() {
|
||||
@@ -62,24 +61,24 @@ class GameLoopModule extends BaseModule {
|
||||
|
||||
setupSocketEventListeners() {
|
||||
// Get the socket client module using parent's getModule method
|
||||
this.socketClient = this.getModule('socket-client');
|
||||
const socketClient = this.getModule('socket-client');
|
||||
|
||||
if (!this.socketClient) {
|
||||
if (!socketClient) {
|
||||
console.error("Socket client module not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect UI controller to socket client for command handling
|
||||
this.uiController = this.getModule('ui-controller');
|
||||
const uiController = this.getModule('ui-controller');
|
||||
|
||||
if (this.uiController) {
|
||||
this.uiController.socketClient = this.socketClient;
|
||||
if (uiController) {
|
||||
uiController.socketClient = socketClient;
|
||||
} else {
|
||||
console.warn("GameLoop: UI Controller not ready for Socket Client assignment.");
|
||||
}
|
||||
|
||||
// Listen for socket connection event
|
||||
this.socketClient.on('connect', () => {
|
||||
socketClient.on('connect', () => {
|
||||
console.log("GameLoop: Socket connected event received.");
|
||||
|
||||
// Request a new game start when we connect
|
||||
@@ -93,19 +92,19 @@ class GameLoopModule extends BaseModule {
|
||||
});
|
||||
|
||||
// Listen for game state updates
|
||||
this.socketClient.on('gameStateUpdate', (data) => {
|
||||
socketClient.on('gameStateUpdate', (data) => {
|
||||
console.log("GameLoop: Game state update received", data);
|
||||
this.updateGameState(data);
|
||||
});
|
||||
|
||||
// Listen for narrative responses
|
||||
this.socketClient.on('narrativeResponse', (data) => {
|
||||
socketClient.on('narrativeResponse', (data) => {
|
||||
console.log("GameLoop: Narrative response received", data);
|
||||
// Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline
|
||||
});
|
||||
|
||||
// Listen for game introduction
|
||||
this.socketClient.on('gameIntroduction', (data) => {
|
||||
socketClient.on('gameIntroduction', (data) => {
|
||||
console.log("GameLoop: Received gameIntroduction");
|
||||
this.gameState.started = true;
|
||||
this.updateUIState();
|
||||
@@ -113,7 +112,7 @@ class GameLoopModule extends BaseModule {
|
||||
});
|
||||
|
||||
// Connect to the socket server
|
||||
this.socketClient.connect().then(success => {
|
||||
socketClient.connect().then(success => {
|
||||
if (success) {
|
||||
console.log("GameLoop: Socket connection established successfully.");
|
||||
} else {
|
||||
@@ -146,19 +145,21 @@ class GameLoopModule extends BaseModule {
|
||||
* Update UI with current game state
|
||||
*/
|
||||
updateUIState() {
|
||||
if (!this.uiController) return;
|
||||
const uiController = this.getModule('ui-controller');
|
||||
if (!uiController) return;
|
||||
|
||||
// Update UI components based on game state
|
||||
this.uiController.updateButtonStates(this.gameState);
|
||||
uiController.updateButtonStates(this.gameState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to start a new game
|
||||
*/
|
||||
requestStartGame() {
|
||||
if (!this.socketClient) return;
|
||||
const socketClient = this.getModule('socket-client');
|
||||
if (!socketClient) return;
|
||||
|
||||
this.socketClient.requestStartGame();
|
||||
socketClient.requestStartGame();
|
||||
this.gameState.started = true;
|
||||
}
|
||||
|
||||
@@ -166,18 +167,20 @@ class GameLoopModule extends BaseModule {
|
||||
* Request to save the current game
|
||||
*/
|
||||
requestSaveGame() {
|
||||
if (!this.socketClient) return;
|
||||
const socketClient = this.getModule('socket-client');
|
||||
if (!socketClient) return;
|
||||
|
||||
this.socketClient.requestSaveGame();
|
||||
socketClient.requestSaveGame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to load a saved game
|
||||
*/
|
||||
requestLoadGame() {
|
||||
if (!this.socketClient) return;
|
||||
const socketClient = this.getModule('socket-client');
|
||||
if (!socketClient) return;
|
||||
|
||||
this.socketClient.requestLoadGame();
|
||||
socketClient.requestLoadGame();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,11 +204,5 @@ class GameLoopModule extends BaseModule {
|
||||
// Create the singleton instance
|
||||
const GameLoop = new GameLoopModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(GameLoop);
|
||||
|
||||
// Export the module
|
||||
export { GameLoop };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.GameLoop = GameLoop;
|
||||
@@ -1,789 +0,0 @@
|
||||
/**
|
||||
* Kokoro TTS Handler
|
||||
* Handles text-to-speech using the Kokoro library
|
||||
*/
|
||||
|
||||
import { TTSHandler } from './tts-handler.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
export class KokoroHandler extends TTSHandler {
|
||||
/**
|
||||
* Constructor
|
||||
* @param {Object} options - Options for the handler
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super(options);
|
||||
|
||||
// Set default options
|
||||
this.options = {
|
||||
rate: 1.0,
|
||||
volume: 1.0,
|
||||
...options
|
||||
};
|
||||
|
||||
// Initialize properties
|
||||
this.id = 'kokoro';
|
||||
this.name = 'Kokoro TTS Handler';
|
||||
this.available = false;
|
||||
this.loading = false;
|
||||
this.iframe = null;
|
||||
this.currentAudio = null;
|
||||
this.currentVoice = null;
|
||||
this.pendingGenerations = new Map();
|
||||
this.generationCounter = 0;
|
||||
|
||||
// Default voices (will be replaced by dynamically fetched voices)
|
||||
this.voices = [];
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['localization', 'persistence-manager'];
|
||||
|
||||
// Bind methods
|
||||
this.initialize = this.initialize.bind(this);
|
||||
this.speak = this.speak.bind(this);
|
||||
this.stop = this.stop.bind(this);
|
||||
this.getVoices = this.getVoices.bind(this);
|
||||
this.setVoice = this.setVoice.bind(this);
|
||||
this.generateSpeech = this.generateSpeech.bind(this);
|
||||
this.preprocessText = this.preprocessText.bind(this);
|
||||
this.speakPreloaded = this.speakPreloaded.bind(this);
|
||||
this.preloadSpeech = this.preloadSpeech.bind(this);
|
||||
this.pause = this.pause.bind(this);
|
||||
this.resume = this.resume.bind(this);
|
||||
this.setOptions = this.setOptions.bind(this);
|
||||
this.setupVoiceFromPreferences = this.setupVoiceFromPreferences.bind(this);
|
||||
this.getId = this.getId.bind(this);
|
||||
this.handleIframeMessage = this.handleIframeMessage.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the handler
|
||||
* @returns {string} - Handler ID
|
||||
*/
|
||||
getId() {
|
||||
return 'kokoro';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a module from the registry
|
||||
* @param {string} id - Module ID
|
||||
* @returns {Object} - Module instance
|
||||
*/
|
||||
getModule(id) {
|
||||
return moduleRegistry.getModule(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the handler
|
||||
* @param {Function} progressCallback - Callback for progress updates
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize(progressCallback) {
|
||||
try {
|
||||
console.log('Kokoro TTS: Initializing...');
|
||||
|
||||
// Check if already initialized
|
||||
if (this.available && this.isReady) {
|
||||
console.log('Kokoro TTS: Already initialized and ready');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ensure we have at least default voices ready
|
||||
if (!this.voices || this.voices.length === 0) {
|
||||
console.log('Kokoro TTS: No voices set, initializing with defaults');
|
||||
this.voices = this.getDefaultVoices();
|
||||
}
|
||||
|
||||
// Set loading flag
|
||||
this.loading = true;
|
||||
this.isReady = false; // Explicitly set to false during initialization
|
||||
|
||||
// Create iframe if not already created
|
||||
if (!this.iframe) {
|
||||
console.log('Kokoro TTS: Creating iframe');
|
||||
// Create iframe
|
||||
this.iframe = document.createElement('iframe');
|
||||
this.iframe.style.display = 'none';
|
||||
this.iframe.src = '/kokoro-loader.html';
|
||||
document.body.appendChild(this.iframe);
|
||||
|
||||
// Add message listener - IMPORTANT: Use an arrow function to preserve 'this'
|
||||
window.addEventListener('message', (event) => this.handleIframeMessage(event));
|
||||
}
|
||||
|
||||
// Set up event handler for configuration changes
|
||||
document.addEventListener('tts:configure', (event) => {
|
||||
if (event.detail) {
|
||||
if (typeof event.detail.rate === 'number') {
|
||||
this.options.rate = event.detail.rate;
|
||||
console.log(`Kokoro TTS: Rate updated to ${this.options.rate}`);
|
||||
}
|
||||
if (typeof event.detail.volume === 'number') {
|
||||
this.options.volume = event.detail.volume;
|
||||
console.log(`Kokoro TTS: Volume updated to ${this.options.volume}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for Kokoro to load
|
||||
return new Promise((resolve) => {
|
||||
// Set a timeout to prevent hanging indefinitely
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('Kokoro TTS: Initialization timed out');
|
||||
this.loading = false;
|
||||
this.isReady = false;
|
||||
this.available = false;
|
||||
resolve(false);
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
// Handle progress updates
|
||||
const handleProgress = (progress, message) => {
|
||||
console.log(`Kokoro TTS: Progress ${progress * 100}% - ${message}`);
|
||||
if (progressCallback) {
|
||||
progressCallback(progress, message);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle message events
|
||||
const messageHandler = (event) => {
|
||||
if (event.source !== this.iframe.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data;
|
||||
|
||||
if (data.type === 'kokoro-progress') {
|
||||
handleProgress(data.progress, data.message);
|
||||
} else if (data.type === 'kokoro-ready') {
|
||||
console.log('Kokoro TTS: Received ready message from iframe', data);
|
||||
// Remove the message listener
|
||||
window.removeEventListener('message', messageHandler);
|
||||
|
||||
// Clear the timeout
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Set availability based on success
|
||||
this.available = data.success;
|
||||
this.loading = false;
|
||||
this.isReady = data.success; // Set isReady flag based on success
|
||||
|
||||
// Store voices if provided
|
||||
if (data.success && data.voices && Array.isArray(data.voices)) {
|
||||
console.log(`Kokoro TTS: Received ${data.voices.length} voices from Kokoro iframe during initialization`);
|
||||
this.voices = data.voices;
|
||||
} else {
|
||||
console.warn('Kokoro TTS: No voices received during initialization or invalid voices data');
|
||||
if (data.success) {
|
||||
// Even though we already set the default voices, check and update if needed
|
||||
if (!this.voices || this.voices.length === 0) {
|
||||
this.voices = this.getDefaultVoices();
|
||||
console.log('Kokoro TTS: Using default voices as fallback');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up voice from preferences
|
||||
if (data.success) {
|
||||
this.setupVoiceFromPreferences().then(() => {
|
||||
console.log('Kokoro TTS: Voice set up from preferences during initialization');
|
||||
}).catch(error => {
|
||||
console.error('Kokoro TTS: Error setting up voice from preferences during initialization:',
|
||||
error ? (error.message || error) : 'Unknown error');
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve with success status
|
||||
resolve(data.success);
|
||||
}
|
||||
};
|
||||
|
||||
// Add the message handler
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
// Initial progress update
|
||||
handleProgress(0.1, 'Starting Kokoro initialization');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Kokoro TTS: Error during initialization:', error);
|
||||
this.loading = false;
|
||||
this.isReady = false;
|
||||
this.available = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages from the iframe
|
||||
* @param {MessageEvent} event - Message event
|
||||
*/
|
||||
handleIframeMessage(event) {
|
||||
// Only process messages from our iframe
|
||||
if (!this.iframe || event.source !== this.iframe.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data;
|
||||
console.log('Kokoro TTS: Received message from iframe:', data.type);
|
||||
|
||||
switch (data.type) {
|
||||
case 'kokoro-log':
|
||||
console.log(`Kokoro Loader: ${data.message}`);
|
||||
break;
|
||||
|
||||
case 'kokoro-ready':
|
||||
console.log('Kokoro TTS: Received ready message from iframe. Success:', data.success, 'Voices:', data.voices ? data.voices.length : 0);
|
||||
|
||||
// Store availability
|
||||
this.loading = false;
|
||||
this.available = data.success;
|
||||
this.isReady = data.success; // Important to set this for the base handler
|
||||
|
||||
// Store voices
|
||||
if (data.success && data.voices && Array.isArray(data.voices)) {
|
||||
console.log(`Kokoro TTS: Storing ${data.voices.length} voices from iframe`);
|
||||
this.voices = data.voices;
|
||||
} else if (data.success) {
|
||||
// If success but no voices, use defaults
|
||||
console.warn('Kokoro TTS: No voices received from iframe, using defaults');
|
||||
this.voices = this.getDefaultVoices();
|
||||
}
|
||||
|
||||
// Set up voice from preferences if ready
|
||||
if (this.available) {
|
||||
this.setupVoiceFromPreferences().then(() => {
|
||||
console.log('Kokoro TTS: Voice set up from preferences');
|
||||
});
|
||||
}
|
||||
|
||||
// Dispatch ready event
|
||||
this.dispatchEvent('tts:ready', { success: data.success });
|
||||
break;
|
||||
|
||||
case 'kokoro-generated':
|
||||
// Handle generated speech
|
||||
if (data.id && this.pendingGenerations.has(data.id)) {
|
||||
const { resolve, reject } = this.pendingGenerations.get(data.id);
|
||||
this.pendingGenerations.delete(data.id);
|
||||
|
||||
if (data.success && data.result) {
|
||||
// Create an audio element from the result
|
||||
try {
|
||||
// Create a blob from the buffer
|
||||
const blob = new Blob([data.result.buffer], { type: 'audio/wav' });
|
||||
|
||||
// Create audio element
|
||||
const audio = new Audio(URL.createObjectURL(blob));
|
||||
|
||||
// Create a play function
|
||||
const play = () => {
|
||||
audio.play().catch(error => {
|
||||
console.error('Error playing Kokoro audio:', error);
|
||||
});
|
||||
};
|
||||
|
||||
resolve({ audio, play, blob });
|
||||
} catch (error) {
|
||||
console.error('Error processing Kokoro audio:', error);
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
console.error('Kokoro TTS: Invalid speech generation result');
|
||||
reject(new Error(data.error || 'Unknown error generating speech'));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'kokoro-progress':
|
||||
// Progress updates are handled during initialization
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the voice from preferences
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setupVoiceFromPreferences() {
|
||||
try {
|
||||
console.log('Kokoro TTS: Setting up voice from preferences, available voices:', this.voices ? this.voices.length : 0);
|
||||
|
||||
// If no voices are available yet, use default voice
|
||||
if (!this.voices || this.voices.length === 0) {
|
||||
console.warn('Kokoro TTS: No voices available yet, using default voice');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get persistence manager
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (!persistenceManager) {
|
||||
console.warn('Kokoro TTS: Persistence manager not available');
|
||||
this.currentVoice = this.voices[0]; // Default to first voice
|
||||
return;
|
||||
}
|
||||
|
||||
// Get localization
|
||||
const localization = this.getModule('localization');
|
||||
if (!localization) {
|
||||
console.warn('Kokoro TTS: Localization not available');
|
||||
this.currentVoice = this.voices[0]; // Default to first voice
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current locale
|
||||
let currentLocale = 'en-us'; // Default locale
|
||||
if (localization && typeof localization.getLocale === 'function') {
|
||||
currentLocale = localization.getLocale();
|
||||
console.log('Kokoro TTS: Current locale from localization:', currentLocale);
|
||||
} else {
|
||||
console.warn('Kokoro TTS: getLocale method not available, using default locale');
|
||||
}
|
||||
|
||||
// Get voice preference
|
||||
const voiceId = persistenceManager.getPreference('tts-voice-kokoro');
|
||||
console.log('Kokoro TTS: Preferred voice ID:', voiceId);
|
||||
|
||||
// Find voice
|
||||
if (voiceId) {
|
||||
const voice = this.voices.find(v => v.id === voiceId);
|
||||
if (voice) {
|
||||
console.log('Kokoro TTS: Found preferred voice:', voice.id, voice.name);
|
||||
this.currentVoice = voice;
|
||||
return;
|
||||
} else {
|
||||
console.warn('Kokoro TTS: Preferred voice not found:', voiceId);
|
||||
}
|
||||
}
|
||||
|
||||
// Find voice for current locale
|
||||
if (currentLocale) {
|
||||
// Standardize locale format (compare lowercase and handle hyphens/underscores)
|
||||
const normalizedLocale = currentLocale.toLowerCase().replace('_', '-');
|
||||
const localePrefix = normalizedLocale.split('-')[0]; // Get language prefix (en, de, etc.)
|
||||
|
||||
// First try exact locale match
|
||||
let localeVoice = this.voices.find(v => v.lang && v.lang.toLowerCase().replace('_', '-') === normalizedLocale);
|
||||
|
||||
// If no exact match, try prefix match (en-US with en-GB for example)
|
||||
if (!localeVoice) {
|
||||
localeVoice = this.voices.find(v => {
|
||||
if (!v.lang) return false;
|
||||
const voiceLocale = v.lang.toLowerCase().replace('_', '-');
|
||||
return voiceLocale.startsWith(localePrefix + '-');
|
||||
});
|
||||
}
|
||||
|
||||
if (localeVoice) {
|
||||
console.log('Kokoro TTS: Found locale voice:', localeVoice.id, localeVoice.name, 'for locale:', normalizedLocale);
|
||||
this.currentVoice = localeVoice;
|
||||
return;
|
||||
} else {
|
||||
console.warn('Kokoro TTS: No voice found for locale:', normalizedLocale);
|
||||
}
|
||||
}
|
||||
|
||||
// Default to first voice if available
|
||||
if (this.voices.length > 0) {
|
||||
console.log('Kokoro TTS: Using first available voice:', this.voices[0].id, this.voices[0].name);
|
||||
this.currentVoice = this.voices[0];
|
||||
} else {
|
||||
console.warn('Kokoro TTS: No voices available after all checks');
|
||||
}
|
||||
} catch (error) {
|
||||
// Log detailed error information
|
||||
console.error('Kokoro TTS: Error setting up voice from preferences:', error ? error.message || error : 'Unknown error');
|
||||
|
||||
// Default to first voice if available
|
||||
if (this.voices && this.voices.length > 0) {
|
||||
console.log('Kokoro TTS: Falling back to first voice after error');
|
||||
this.currentVoice = this.voices[0];
|
||||
} else {
|
||||
console.warn('Kokoro TTS: No voices available to fall back to after error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice for TTS
|
||||
* @param {Object} voice - Voice to set
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
setVoice(voice) {
|
||||
if (!voice || !voice.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find voice
|
||||
const foundVoice = this.voices.find(v => v.id === voice.id);
|
||||
if (!foundVoice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set voice
|
||||
this.currentVoice = foundVoice;
|
||||
|
||||
// Save preference
|
||||
try {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts-voice-kokoro', foundVoice.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Kokoro TTS: Error saving voice preference:', error);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set options for TTS
|
||||
* @param {Object} options - Options to set
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
setOptions(options) {
|
||||
if (!options) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update options
|
||||
this.options = {
|
||||
...this.options,
|
||||
...options
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Array} - Array of voice objects
|
||||
*/
|
||||
getVoices() {
|
||||
if (!this.voices || this.voices.length === 0) {
|
||||
return this.getDefaultVoices();
|
||||
}
|
||||
return this.voices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess text for TTS
|
||||
* @param {string} text - Text to preprocess
|
||||
* @returns {string} - Preprocessed text
|
||||
*/
|
||||
preprocessText(text) {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove HTML tags
|
||||
let processed = text.replace(/<[^>]*>/g, '');
|
||||
|
||||
// Replace special characters
|
||||
processed = processed.replace(/ /g, ' ');
|
||||
processed = processed.replace(/&/g, '&');
|
||||
processed = processed.replace(/</g, '<');
|
||||
processed = processed.replace(/>/g, '>');
|
||||
processed = processed.replace(/"/g, '"');
|
||||
processed = processed.replace(/'/g, "'");
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload speech for later playback
|
||||
* @param {string} text - Text to preload
|
||||
* @returns {Promise<Object>} - Resolves with preloaded audio data
|
||||
*/
|
||||
async preloadSpeech(text) {
|
||||
if (!this.available) {
|
||||
console.warn('Kokoro TTS: Not available');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// No longer check the local cache as we're using TTSFactory's centralized cache
|
||||
// Generate speech directly
|
||||
const result = await this.generateSpeech(text);
|
||||
|
||||
// Return result for centralized caching in TTSFactory
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Kokoro TTS: Error preloading speech:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using preloaded audio
|
||||
* @param {Object} preloadData - Preloaded audio data
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
speakPreloaded(preloadData, callback = null) {
|
||||
if (!this.available) {
|
||||
console.warn('Kokoro TTS: Not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
// Create audio element if not already created
|
||||
const audio = preloadData.audio;
|
||||
|
||||
// Set up event handlers
|
||||
audio.onended = () => {
|
||||
this.currentAudio = null;
|
||||
if (callback) callback();
|
||||
};
|
||||
|
||||
audio.onerror = (error) => {
|
||||
console.error('Kokoro TTS: Audio playback error:', error);
|
||||
this.currentAudio = null;
|
||||
if (callback) callback(error);
|
||||
};
|
||||
|
||||
// Set volume
|
||||
audio.volume = this.options.volume;
|
||||
|
||||
// Store current audio
|
||||
this.currentAudio = audio;
|
||||
|
||||
// Play audio
|
||||
if (preloadData.play) {
|
||||
preloadData.play();
|
||||
} else {
|
||||
audio.play().catch(error => {
|
||||
console.error('Kokoro TTS: Error playing audio:', error);
|
||||
this.currentAudio = null;
|
||||
if (callback) callback(error);
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Kokoro TTS: Error speaking preloaded audio:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Object} options - Speech options
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async speak(text, options = {}) {
|
||||
if (!this.available) {
|
||||
console.warn('Kokoro TTS: Not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
console.log('Kokoro TTS: Generating speech for:', text);
|
||||
|
||||
// Generate speech
|
||||
const result = await this.generateSpeech(text);
|
||||
|
||||
if (!result || !result.audio) {
|
||||
console.error('Kokoro TTS: Invalid speech generation result');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set up event handlers
|
||||
result.audio.onended = () => {
|
||||
console.log('Kokoro TTS: Audio playback ended');
|
||||
this.currentAudio = null;
|
||||
// Dispatch event for completion
|
||||
window.dispatchEvent(new CustomEvent('tts:speak-completed'));
|
||||
};
|
||||
|
||||
result.audio.onerror = (error) => {
|
||||
console.error('Kokoro TTS: Audio playback error:', error);
|
||||
this.currentAudio = null;
|
||||
// Dispatch event for error
|
||||
window.dispatchEvent(new CustomEvent('tts:speak-error', {
|
||||
detail: { error: error }
|
||||
}));
|
||||
};
|
||||
|
||||
// Set volume
|
||||
result.audio.volume = this.options.volume;
|
||||
|
||||
// Store current audio
|
||||
this.currentAudio = result.audio;
|
||||
|
||||
console.log('Kokoro TTS: Attempting to play audio');
|
||||
|
||||
// Play audio with better error handling
|
||||
try {
|
||||
if (result.play && typeof result.play === 'function') {
|
||||
await result.play();
|
||||
} else {
|
||||
await result.audio.play();
|
||||
}
|
||||
console.log('Kokoro TTS: Audio playback started successfully');
|
||||
return true;
|
||||
} catch (playError) {
|
||||
console.error('Error playing Kokoro audio:', playError);
|
||||
this.currentAudio = null;
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Kokoro TTS: Error speaking:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech using the iframe
|
||||
* @param {string} text - Text to generate speech for
|
||||
* @returns {Promise<Object>} - Resolves with audio data
|
||||
*/
|
||||
async generateSpeech(text) {
|
||||
if (!this.iframe || !this.iframe.contentWindow) {
|
||||
throw new Error('Kokoro iframe not initialized');
|
||||
}
|
||||
|
||||
// Preprocess text
|
||||
const processedText = this.preprocessText(text);
|
||||
|
||||
// Ensure we have a valid voice
|
||||
let voiceId = 'af_heart'; // Default fallback
|
||||
if (this.currentVoice && this.currentVoice.id) {
|
||||
voiceId = this.currentVoice.id;
|
||||
} else if (this.voices && this.voices.length > 0) {
|
||||
// Default to first voice if none selected
|
||||
this.currentVoice = this.voices[0];
|
||||
voiceId = this.currentVoice.id;
|
||||
console.log(`Kokoro TTS: No voice set, defaulting to ${voiceId}`);
|
||||
}
|
||||
|
||||
console.log(`Kokoro TTS: Generating speech with voice ${voiceId}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Generate unique ID for this request
|
||||
const id = `gen-${++this.generationCounter}`;
|
||||
|
||||
// Store the pending generation
|
||||
this.pendingGenerations.set(id, { resolve, reject });
|
||||
|
||||
// Send the generation request to the iframe
|
||||
this.iframe.contentWindow.postMessage({
|
||||
type: 'kokoro-generate',
|
||||
id: id,
|
||||
text: processedText,
|
||||
voice: voiceId,
|
||||
speed: this.options.rate
|
||||
}, '*');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop current speech
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
stop() {
|
||||
if (this.currentAudio) {
|
||||
try {
|
||||
this.currentAudio.pause();
|
||||
this.currentAudio.currentTime = 0;
|
||||
this.currentAudio = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Kokoro TTS: Error stopping speech:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause current speech
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
pause() {
|
||||
if (this.currentAudio) {
|
||||
try {
|
||||
this.currentAudio.pause();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Kokoro TTS: Error pausing speech:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume current speech
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
resume() {
|
||||
if (this.currentAudio) {
|
||||
try {
|
||||
this.currentAudio.play();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Kokoro TTS: Error resuming speech:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default voices for current locale
|
||||
* @returns {Array} Default voices
|
||||
*/
|
||||
getDefaultVoices() {
|
||||
// Check if localization module is available
|
||||
const localization = this.getModule('localization');
|
||||
let locale = 'en-us'; // Default fallback
|
||||
|
||||
if (localization) {
|
||||
locale = localization.getLocale();
|
||||
console.log(`Kokoro TTS: Getting default voices for locale: ${locale}`);
|
||||
} else {
|
||||
console.log('Kokoro TTS: Localization module not available, using default locale: en-us');
|
||||
}
|
||||
|
||||
// Use the actual voices defined in the Kokoro loader
|
||||
return [
|
||||
// American Female voices
|
||||
{ id: 'af_heart', name: 'Heart', lang: 'en-US', gender: 'female' },
|
||||
{ id: 'af_daisy', name: 'Daisy', lang: 'en-US', gender: 'female' },
|
||||
{ id: 'af_soft', name: 'Soft', lang: 'en-US', gender: 'female' },
|
||||
{ id: 'af_glados', name: 'GLaDOS', lang: 'en-US', gender: 'female' },
|
||||
{ id: 'af_southern_belle', name: 'Southern Belle', lang: 'en-US', gender: 'female' },
|
||||
{ id: 'af_dramatic', name: 'Dramatic', lang: 'en-US', gender: 'female' },
|
||||
{ id: 'af_valley_girl', name: 'Valley Girl', lang: 'en-US', gender: 'female' },
|
||||
{ id: 'af_british', name: 'British', lang: 'en-US', gender: 'female' },
|
||||
{ id: 'af_russian', name: 'Russian', lang: 'en-US', gender: 'female' },
|
||||
{ id: 'af_german', name: 'German', lang: 'en-US', gender: 'female' },
|
||||
{ id: 'af_cheeky_cute', name: 'Cheeky Cute', lang: 'en-US', gender: 'female' },
|
||||
|
||||
// American Male voices
|
||||
{ id: 'am_bruce', name: 'Bruce', lang: 'en-US', gender: 'male' },
|
||||
{ id: 'am_announcer', name: 'Announcer', lang: 'en-US', gender: 'male' },
|
||||
{ id: 'am_radio_host', name: 'Radio Host', lang: 'en-US', gender: 'male' },
|
||||
|
||||
// British Female voices
|
||||
{ id: 'bf_charlotte', name: 'Charlotte', lang: 'en-GB', gender: 'female' },
|
||||
{ id: 'bf_elizabeth', name: 'Elizabeth', lang: 'en-GB', gender: 'female' },
|
||||
{ id: 'bf_lily', name: 'Lily', lang: 'en-GB', gender: 'female' },
|
||||
{ id: 'bf_olivia', name: 'Olivia', lang: 'en-GB', gender: 'female' },
|
||||
{ id: 'bf_victoria', name: 'Victoria', lang: 'en-GB', gender: 'female' },
|
||||
|
||||
// British Male voices
|
||||
{ id: 'bm_william', name: 'William', lang: 'en-GB', gender: 'male' },
|
||||
{ id: 'bm_arthur', name: 'Arthur', lang: 'en-GB', gender: 'male' },
|
||||
{ id: 'bm_george', name: 'George', lang: 'en-GB', gender: 'male' },
|
||||
{ id: 'bm_harry', name: 'Harry', lang: 'en-GB', gender: 'male' },
|
||||
{ id: 'bm_jack', name: 'Jack', lang: 'en-GB', gender: 'male' }
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import { TTSHandlerModule } from './tts-handler-module.js';
|
||||
|
||||
export class KokoroTTSModule extends TTSHandlerModule {
|
||||
constructor() {
|
||||
super('kokoro', 'Kokoro TTS');
|
||||
super('kokoro-tts', 'Kokoro TTS');
|
||||
|
||||
// Declare proper dependencies according to architecture principles
|
||||
this.dependencies = ['persistence-manager', 'localization'];
|
||||
|
||||
// State
|
||||
this.iframe = null;
|
||||
@@ -641,17 +644,6 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
||||
}
|
||||
}
|
||||
|
||||
// Register the module with the module registry
|
||||
// Module registry MUST be accessed via window, not direct import
|
||||
if (window.moduleRegistry) {
|
||||
try {
|
||||
// Create instance first, then register it
|
||||
const kokoroTTSModule = new KokoroTTSModule();
|
||||
window.moduleRegistry.register(kokoroTTSModule);
|
||||
console.log('Kokoro TTS Module registered successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to register Kokoro TTS Module:', err);
|
||||
}
|
||||
} else {
|
||||
console.error('Module registry not available when attempting to register Kokoro TTS Module');
|
||||
}
|
||||
const kokoroTTSModule = new KokoroTTSModule();
|
||||
|
||||
export { kokoroTTSModule };
|
||||
@@ -3,18 +3,13 @@
|
||||
* Renders calculated paragraph layouts into the DOM with proper animations
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class LayoutRendererModule extends BaseModule {
|
||||
constructor() {
|
||||
super('layout-renderer', 'Layout Renderer');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['animation-queue'];
|
||||
|
||||
// Module references
|
||||
this.animationQueue = null;
|
||||
this.ttsPlayer = null;
|
||||
this.dependencies = ['animation-queue', 'tts-player'];
|
||||
|
||||
// Configuration
|
||||
this.updateConfig({
|
||||
@@ -40,22 +35,13 @@ class LayoutRendererModule extends BaseModule {
|
||||
try {
|
||||
this.reportProgress(10, "Initializing Layout Renderer");
|
||||
|
||||
// Get animation queue from module registry
|
||||
this.animationQueue = this.getModule('animation-queue');
|
||||
if (!this.animationQueue) {
|
||||
// Check for animation queue dependency
|
||||
const animationQueue = this.getModule('animation-queue');
|
||||
if (!animationQueue) {
|
||||
console.warn("Layout Renderer: Animation Queue module not found in registry");
|
||||
return false;
|
||||
}
|
||||
|
||||
// We'll try to get the TTS module, but it's not a hard dependency
|
||||
// We'll check for it again at runtime when needed
|
||||
setTimeout(() => {
|
||||
// Try to get TTS module after a delay to allow it to initialize
|
||||
this.ttsPlayer = this.getModule('tts-player');
|
||||
if (!this.ttsPlayer) {
|
||||
console.log("Layout Renderer: TTS Player module not found yet, will try again when needed");
|
||||
}
|
||||
}, 500);
|
||||
|
||||
this.reportProgress(100, "Layout Renderer ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -85,6 +71,8 @@ class LayoutRendererModule extends BaseModule {
|
||||
* @returns {HTMLElement} - The created paragraph element
|
||||
*/
|
||||
renderParagraph(layout, options = {}) {
|
||||
const animationQueue = this.getModule('animation-queue');
|
||||
|
||||
const {
|
||||
container = document.getElementById('paragraphs'),
|
||||
id = `p-${Date.now()}`,
|
||||
@@ -113,94 +101,64 @@ class LayoutRendererModule extends BaseModule {
|
||||
|
||||
// Calculate paragraph height based on number of lines
|
||||
const numLines = layout.breaks.length - 1;
|
||||
paragraphElement.style.height = `${lineHeight * numLines}px`;
|
||||
const paragraphHeight = numLines * lineHeight;
|
||||
paragraphElement.style.height = `${paragraphHeight}em`;
|
||||
|
||||
// Apply custom styles
|
||||
Object.assign(paragraphElement.style, style);
|
||||
// Apply custom style properties
|
||||
for (const prop in style) {
|
||||
paragraphElement.style[prop] = style[prop];
|
||||
}
|
||||
|
||||
// Create a fragment to build the paragraph
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// Track total delay for animations
|
||||
// Populate with words
|
||||
const wordElements = [];
|
||||
let lineIndex = 0;
|
||||
let totalDelay = 0;
|
||||
let wordElements = [];
|
||||
|
||||
// Process each line in the layout
|
||||
for (let i = 1; i < layout.breaks.length; i++) {
|
||||
// Track the current x position within the line
|
||||
let xPosition = 0;
|
||||
// Calculate each word's position based on layout data
|
||||
for (let i = 0; i < layout.nodes.length; i++) {
|
||||
const wordNode = layout.nodes[i];
|
||||
|
||||
// Process nodes in this line
|
||||
for (let j = layout.breaks[i-1].position; j < layout.breaks[i].position; j++) {
|
||||
const node = layout.nodes[j];
|
||||
// Get the current line index from breaks array
|
||||
while (lineIndex < layout.breaks.length - 1 && i >= layout.breaks[lineIndex + 1]) {
|
||||
lineIndex++;
|
||||
}
|
||||
|
||||
// Create the word element
|
||||
const wordElement = this.renderWord(wordNode.text, animateWords);
|
||||
wordElements.push(wordElement);
|
||||
|
||||
// Position the word absolutely within paragraph
|
||||
if (wordNode.x !== undefined && wordNode.y !== undefined) {
|
||||
// Use calculated position
|
||||
wordElement.style.position = 'absolute';
|
||||
wordElement.style.left = `${wordNode.x}px`;
|
||||
wordElement.style.top = `${lineIndex * lineHeight}em`;
|
||||
} else {
|
||||
// Fallback for missing positioning data
|
||||
wordElement.style.position = 'relative';
|
||||
wordElement.style.marginRight = '0.25em';
|
||||
}
|
||||
|
||||
// Add to paragraph
|
||||
paragraphElement.appendChild(wordElement);
|
||||
|
||||
// Handle whitespace after the word
|
||||
if (wordNode.spaceAfter) {
|
||||
const spaceElement = document.createElement('span');
|
||||
spaceElement.className = 'space';
|
||||
spaceElement.innerHTML = ' ';
|
||||
|
||||
// Handle different node types
|
||||
switch (node.type) {
|
||||
case 'box':
|
||||
// This is a word
|
||||
if (node.value && node.value.trim() !== '') {
|
||||
const wordElement = this.renderWord(node.value, animateWords);
|
||||
|
||||
// Position the word within the line
|
||||
wordElement.style.position = 'absolute';
|
||||
wordElement.style.left = `${xPosition * 100 / containerWidth}%`;
|
||||
wordElement.style.top = `${(i - 1) * lineHeight}px`;
|
||||
|
||||
// Update x position for next word
|
||||
xPosition += node.width;
|
||||
|
||||
paragraphElement.appendChild(wordElement);
|
||||
wordElements.push(wordElement);
|
||||
}
|
||||
break;
|
||||
case 'glue':
|
||||
// This is a space - calculate its width based on the ratio
|
||||
const ratio = layout.breaks[i].ratio;
|
||||
let spaceWidth = node.width;
|
||||
|
||||
if (ratio > 0) {
|
||||
// Stretch space
|
||||
spaceWidth += ratio * node.stretch;
|
||||
} else if (ratio < 0) {
|
||||
// Shrink space
|
||||
spaceWidth += ratio * node.shrink;
|
||||
}
|
||||
|
||||
xPosition += spaceWidth;
|
||||
break;
|
||||
case 'penalty':
|
||||
// This is a hyphen or line break opportunity
|
||||
if (node.flagged && node.penalty < Infinity && j === layout.breaks[i].position) {
|
||||
const hyphenElement = document.createElement('span');
|
||||
hyphenElement.className = 'hyphen-marker';
|
||||
hyphenElement.textContent = '-';
|
||||
hyphenElement.style.position = 'absolute';
|
||||
hyphenElement.style.left = `${xPosition * 100 / containerWidth}%`;
|
||||
hyphenElement.style.top = `${(i - 1) * lineHeight}px`;
|
||||
|
||||
paragraphElement.appendChild(hyphenElement);
|
||||
wordElements.push(hyphenElement);
|
||||
}
|
||||
break;
|
||||
case 'tag':
|
||||
// This is a preserved tag
|
||||
if (typeof node.value === 'string') {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = node.value;
|
||||
while (tempDiv.firstChild) {
|
||||
const tagElement = tempDiv.firstChild;
|
||||
tagElement.style.position = 'absolute';
|
||||
tagElement.style.left = `${xPosition * 100 / containerWidth}%`;
|
||||
tagElement.style.top = `${(i - 1) * lineHeight}px`;
|
||||
|
||||
paragraphElement.appendChild(tagElement);
|
||||
|
||||
// Estimate width for positioning next element
|
||||
xPosition += 20; // Approximate width of tag
|
||||
}
|
||||
}
|
||||
break;
|
||||
if (wordNode.x !== undefined) {
|
||||
// Position space after word
|
||||
spaceElement.style.position = 'absolute';
|
||||
const wordWidth = wordElement.offsetWidth || wordNode.width || wordNode.text.length * 8;
|
||||
spaceElement.style.left = `${wordNode.x + wordWidth}px`;
|
||||
spaceElement.style.top = `${lineIndex * lineHeight}em`;
|
||||
} else {
|
||||
spaceElement.style.position = 'relative';
|
||||
}
|
||||
|
||||
paragraphElement.appendChild(spaceElement);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +166,7 @@ class LayoutRendererModule extends BaseModule {
|
||||
container.appendChild(paragraphElement);
|
||||
|
||||
// Schedule animations for words if enabled
|
||||
if (animateWords && this.animationQueue) {
|
||||
if (animateWords && animationQueue) {
|
||||
// Schedule animations for each word with a faster timing
|
||||
const baseDelay = 0; // Starting delay
|
||||
const wordDelay = 20; // Delay between words in ms (reduced from 40)
|
||||
@@ -221,24 +179,27 @@ class LayoutRendererModule extends BaseModule {
|
||||
});
|
||||
|
||||
// Schedule TTS if enabled - start it earlier in the animation sequence
|
||||
if (tts && this.ttsPlayer) {
|
||||
// Get the full text for TTS
|
||||
const fullText = layout.originalText || layout.processedText || paragraphElement.textContent;
|
||||
|
||||
// Schedule TTS with the animation queue - start after just a few words appear
|
||||
this.animationQueue.schedule(() => {
|
||||
this.ttsPlayer.speak(fullText, (result) => {
|
||||
if (!result || !result.success) {
|
||||
console.warn('TTS playback issue:', result ? result.reason : 'unknown');
|
||||
}
|
||||
});
|
||||
}, Math.min(100, wordDelay * 3)); // Start TTS earlier
|
||||
if (tts) {
|
||||
const ttsPlayer = this.getModule('tts-player');
|
||||
if (ttsPlayer) {
|
||||
// Get the full text for TTS
|
||||
const fullText = layout.originalText || layout.processedText || paragraphElement.textContent;
|
||||
|
||||
// Schedule TTS with the animation queue - start after just a few words appear
|
||||
animationQueue.schedule(() => {
|
||||
ttsPlayer.speak(fullText, (result) => {
|
||||
if (!result || !result.success) {
|
||||
console.warn('TTS playback issue:', result ? result.reason : 'unknown');
|
||||
}
|
||||
});
|
||||
}, Math.min(100, wordDelay * 3)); // Start TTS earlier
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule completion callback
|
||||
if (onComplete && typeof onComplete === 'function') {
|
||||
const completionDelay = totalDelay + 200; // Reduced completion delay
|
||||
this.animationQueue.schedule(onComplete, completionDelay);
|
||||
animationQueue.schedule(onComplete, completionDelay);
|
||||
}
|
||||
} else if (onComplete && typeof onComplete === 'function') {
|
||||
// If not animating, call onComplete immediately
|
||||
@@ -286,11 +247,12 @@ class LayoutRendererModule extends BaseModule {
|
||||
* @param {number} speed - Animation speed factor
|
||||
*/
|
||||
scheduleWordAnimation(wordElement, delay, speed) {
|
||||
if (!this.animationQueue) return;
|
||||
const animationQueue = this.getModule('animation-queue');
|
||||
if (!animationQueue) return;
|
||||
|
||||
const actualDelay = delay * speed;
|
||||
|
||||
this.animationQueue.schedule(() => {
|
||||
animationQueue.schedule(() => {
|
||||
wordElement.style.opacity = '1';
|
||||
wordElement.style.transform = 'translateY(0)';
|
||||
wordElement.style.transition = `opacity 0.2s ease-out, transform 0.3s ease-out`;
|
||||
@@ -301,11 +263,5 @@ class LayoutRendererModule extends BaseModule {
|
||||
// Create the singleton instance
|
||||
const LayoutRenderer = new LayoutRendererModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(LayoutRenderer);
|
||||
|
||||
// Export the module
|
||||
export { LayoutRenderer };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.LayoutRenderer = LayoutRenderer;
|
||||
+503
-88
@@ -17,6 +17,7 @@ console.log('Module registry initialized and assigned to window.moduleRegistry')
|
||||
const ModuleState = {
|
||||
PENDING: 'PENDING',
|
||||
LOADING: 'LOADING',
|
||||
FETCHING: 'FETCHING', // Added new state for fetching resources
|
||||
WAITING: 'WAITING',
|
||||
INITIALIZING: 'INITIALIZING',
|
||||
FINISHED: 'FINISHED',
|
||||
@@ -37,6 +38,7 @@ const ModuleLoader = (function() {
|
||||
let moduleWeights = {};
|
||||
let createdModules = new Set(); // Track which modules we've created UI elements for
|
||||
let gameLoopModule = null; // Add variable to hold game loop instance
|
||||
let moduleTimings = {}; // Track timing data for modules
|
||||
|
||||
/**
|
||||
* Initialize the loader
|
||||
@@ -80,14 +82,15 @@ const ModuleLoader = (function() {
|
||||
* Setup event listeners for module communication
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// Listen for module progress events
|
||||
document.addEventListener('module:progress', handleModuleProgress);
|
||||
|
||||
// Listen for module state change events
|
||||
document.addEventListener('module:stateChange', handleModuleStateChange);
|
||||
|
||||
// Listen for module status message events
|
||||
document.addEventListener('module:message', handleModuleMessage);
|
||||
|
||||
// Listen for module progress events
|
||||
document.addEventListener('module:progress', handleModuleProgress);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,35 +106,35 @@ const ModuleLoader = (function() {
|
||||
];
|
||||
|
||||
// Define modules with their weights
|
||||
const modulesToLoad = [
|
||||
let modulesToLoad = [
|
||||
// Core functionality modules
|
||||
{ id: 'persistence-manager', script: '/js/persistence-manager.js', weight: 40 },
|
||||
{ id: 'localization', script: '/js/localization.js', weight: 40 },
|
||||
{ id: 'text-processor', script: '/js/text-processor.js', weight: 40 },
|
||||
{ id: 'paragraph-layout', script: '/js/paragraph-layout.js', weight: 40 },
|
||||
{ id: 'layout-renderer', script: '/js/layout-renderer.js', weight: 45 }, // Add Layout Renderer module
|
||||
{ id: 'animation-queue', script: '/js/animation-queue.js', weight: 50 },
|
||||
{ id: 'persistence-manager', script: '/js/persistence-manager-module.js', weight: 12 },
|
||||
{ id: 'localization', script: '/js/localization-module.js', weight: 12 },
|
||||
{ id: 'text-processor', script: '/js/text-processor-module.js', weight: 15 },
|
||||
{ id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 },
|
||||
{ id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module
|
||||
{ id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 },
|
||||
|
||||
// Audio and TTS modules
|
||||
{ id: 'audio-manager', script: '/js/audio-manager.js', weight: 60 },
|
||||
{ id: 'kokoro', script: '/js/kokoro-tts-module.js', weight: 65 },
|
||||
{ id: 'browser', script: '/js/browser-tts-module.js', weight: 65 },
|
||||
{ id: 'elevenlabs', script: '/js/elevenlabs-tts-module.js', weight: 65 },
|
||||
{ id: 'openai', script: '/js/openai-tts-module.js', weight: 65 },
|
||||
{ id: 'tts-factory', script: '/js/tts-factory.js', weight: 70 }, // TTSFactory must be loaded before TTSPlayer
|
||||
{ id: 'tts', script: '/js/tts-player.js', weight: 75 },
|
||||
{ id: 'audio-manager', script: '/js/audio-manager-module.js', weight: 12 },
|
||||
{ id: 'kokoro', script: '/js/kokoro-tts-module.js', weight: 50 },
|
||||
{ id: 'browser', script: '/js/browser-tts-module.js', weight: 12 },
|
||||
{ id: 'elevenlabs', script: '/js/elevenlabs-tts-module.js', weight: 12 },
|
||||
{ id: 'openai', script: '/js/openai-tts-module.js', weight: 12 },
|
||||
{ id: 'tts-factory', script: '/js/tts-factory-module.js', weight: 13 }, // TTSFactory must be loaded before TTSPlayer
|
||||
{ id: 'tts-player', script: '/js/tts-player-module.js', weight: 13 },
|
||||
|
||||
// UI and interaction modules
|
||||
{ id: 'text-buffer', script: '/js/text-buffer.js', weight: 50 },
|
||||
{ id: 'ui-effects', script: '/js/ui-effects.js', weight: 50 }, // Add UI Effects module
|
||||
{ id: 'ui-input-handler', script: '/js/ui-input-handler.js', weight: 50 }, // Add UI Input Handler module
|
||||
{ id: 'ui-display-handler', script: '/js/ui-display-handler.js', weight: 60 }, // Add UI Display Handler module
|
||||
{ id: 'ui-controller', script: '/js/ui-controller.js', weight: 100 },
|
||||
{ id: 'options-ui', script: '/js/options-ui.js', weight: 40 },
|
||||
{ id: 'socket-client', script: '/js/socket-client.js', weight: 60 },
|
||||
{ id: 'text-buffer', script: '/js/text-buffer-module.js', weight: 12 },
|
||||
{ id: 'ui-effects', script: '/js/ui-effects-module.js', weight: 12 }, // Add UI Effects module
|
||||
{ id: 'ui-input-handler', script: '/js/ui-input-handler-module.js', weight: 27 }, // Add UI Input Handler module
|
||||
{ id: 'ui-display-handler', script: '/js/ui-display-handler-module.js', weight: 27 }, // Add UI Display Handler module
|
||||
{ id: 'ui-controller', script: '/js/ui-controller-module.js', weight: 27 },
|
||||
{ id: 'options-ui', script: '/js/options-ui-module.js', weight: 13 },
|
||||
{ id: 'socket-client', script: '/js/socket-client-module.js', weight: 17 },
|
||||
|
||||
// Main game module - should be last to load
|
||||
{ id: 'game-loop', script: '/js/game-loop.js', weight: 25 }
|
||||
{ id: 'game-loop', script: '/js/game-loop-module.js', weight: 27 }
|
||||
];
|
||||
|
||||
// Store module weights for progress calculation
|
||||
@@ -141,7 +144,7 @@ const ModuleLoader = (function() {
|
||||
|
||||
// Create a module list entry for each module
|
||||
modulesToLoad.forEach(module => {
|
||||
createModuleListItem(module.id, getModuleNameFromId(module.id));
|
||||
createModuleItem(module.id, getModuleNameFromId(module.id));
|
||||
});
|
||||
|
||||
// Load dependencies first
|
||||
@@ -150,7 +153,271 @@ const ModuleLoader = (function() {
|
||||
|
||||
// Load each module script
|
||||
const loadPromises = modulesToLoad.map(module => loadScript(module.script));
|
||||
return Promise.all(loadPromises);
|
||||
const loadResult = await Promise.all(loadPromises);
|
||||
|
||||
// Wait briefly for modules to register
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Analyze dependencies and detect circular references
|
||||
analyzeModuleDependencies();
|
||||
|
||||
return loadResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze module dependencies to detect circular references and print detailed diagnostics
|
||||
*/
|
||||
function analyzeModuleDependencies() {
|
||||
const registry = window.moduleRegistry;
|
||||
if (!registry || !registry.modules) {
|
||||
console.error("Module Registry not available for dependency analysis");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build dependency graph
|
||||
const graph = {};
|
||||
|
||||
// Initialize the graph with all modules
|
||||
Object.keys(registry.modules).forEach(moduleId => {
|
||||
graph[moduleId] = [];
|
||||
});
|
||||
|
||||
// Add dependencies to graph
|
||||
Object.entries(registry.modules).forEach(([moduleId, module]) => {
|
||||
if (module.dependencies && Array.isArray(module.dependencies)) {
|
||||
module.dependencies.forEach(depId => {
|
||||
// Check if dependency exists
|
||||
if (!registry.modules[depId]) {
|
||||
console.warn(`Module ${moduleId} depends on missing module ${depId}`);
|
||||
} else {
|
||||
graph[moduleId].push(depId);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Detect circular dependencies using DFS
|
||||
const detectCycles = () => {
|
||||
const visited = {};
|
||||
const recStack = {};
|
||||
const cycles = [];
|
||||
const pathStack = [];
|
||||
|
||||
const dfs = (node, path = []) => {
|
||||
// Node is already in recursion stack - we found a cycle
|
||||
if (recStack[node]) {
|
||||
const cycleStart = path.indexOf(node);
|
||||
if (cycleStart !== -1) {
|
||||
const cycle = path.slice(cycleStart).concat(node);
|
||||
cycles.push(cycle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If already visited and not in recursion, no cycle through this node
|
||||
if (visited[node]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark node as visited and add to recursion stack
|
||||
visited[node] = true;
|
||||
recStack[node] = true;
|
||||
pathStack.push(node);
|
||||
|
||||
// Visit all neighbors
|
||||
const hasCycle = graph[node].some(neighbor => {
|
||||
return dfs(neighbor, [...pathStack]);
|
||||
});
|
||||
|
||||
// Remove from recursion stack and path
|
||||
recStack[node] = false;
|
||||
pathStack.pop();
|
||||
|
||||
return hasCycle;
|
||||
};
|
||||
|
||||
// Start DFS from each node
|
||||
Object.keys(graph).forEach(node => {
|
||||
if (!visited[node]) {
|
||||
dfs(node);
|
||||
}
|
||||
});
|
||||
|
||||
return cycles;
|
||||
};
|
||||
|
||||
// Find all circular dependencies
|
||||
const cycles = detectCycles();
|
||||
|
||||
// Display detailed information about circular dependencies
|
||||
if (cycles.length > 0) {
|
||||
console.group("%cCircular Dependencies Detected", "color: red; font-weight: bold");
|
||||
cycles.forEach((cycle, index) => {
|
||||
console.log(`%cCircular Dependency Chain ${index + 1}:`, "font-weight: bold");
|
||||
|
||||
// Print the cycle with dependency details
|
||||
cycle.forEach((moduleId, i) => {
|
||||
const module = registry.modules[moduleId] || { name: 'Unknown' };
|
||||
const nextModuleId = cycle[(i + 1) % cycle.length];
|
||||
const nextModule = registry.modules[nextModuleId] || { name: 'Unknown' };
|
||||
console.log(
|
||||
`%c${moduleId}%c (${module.name}) depends on %c${nextModuleId}%c (${nextModule.name})`,
|
||||
"color: blue; font-weight: bold",
|
||||
"color: black",
|
||||
"color: blue; font-weight: bold",
|
||||
"color: black"
|
||||
);
|
||||
|
||||
// Print the actual dependencies declared by this module
|
||||
if (module.dependencies && Array.isArray(module.dependencies)) {
|
||||
console.log(` Dependencies declared by ${moduleId}:`, module.dependencies);
|
||||
}
|
||||
});
|
||||
|
||||
// Suggest potential solutions
|
||||
console.log('%cPossible solutions:', 'font-weight: bold');
|
||||
console.log('1. Remove one of the dependencies from the chain');
|
||||
console.log('2. Use dynamic dependency resolution instead of static dependencies');
|
||||
console.log('3. Create an interface module that both modules depend on');
|
||||
console.log('4. Refactor module responsibilities to eliminate circular needs');
|
||||
});
|
||||
console.groupEnd();
|
||||
} else {
|
||||
console.log("%cNo circular dependencies detected", "color: green; font-weight: bold");
|
||||
}
|
||||
|
||||
// Calculate an optimized loading order using topological sort
|
||||
const calculateOptimalLoadOrder = () => {
|
||||
const result = [];
|
||||
const visited = {};
|
||||
const temp = {}; // Temporary marks for detecting cycles
|
||||
|
||||
const visit = (node) => {
|
||||
// Node is already in result, skip
|
||||
if (visited[node]) return;
|
||||
|
||||
// If temp is true, we have a cycle
|
||||
if (temp[node]) {
|
||||
console.warn(`Skipping cycle involving ${node} during topological sort`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark node as being processed
|
||||
temp[node] = true;
|
||||
|
||||
// Process all dependencies first
|
||||
if (graph[node]) {
|
||||
graph[node].forEach(dep => visit(dep));
|
||||
}
|
||||
|
||||
// Mark as visited and add to result
|
||||
temp[node] = false;
|
||||
visited[node] = true;
|
||||
result.push(node);
|
||||
};
|
||||
|
||||
// Visit all nodes
|
||||
Object.keys(graph).forEach(node => {
|
||||
if (!visited[node]) {
|
||||
visit(node);
|
||||
}
|
||||
});
|
||||
|
||||
return result.reverse(); // Reverse to get correct order
|
||||
};
|
||||
|
||||
const optimalOrder = calculateOptimalLoadOrder();
|
||||
|
||||
// Print the optimal loading order
|
||||
console.group("%cOptimal Module Loading Order", "color: green; font-weight: bold");
|
||||
optimalOrder.forEach((moduleId, index) => {
|
||||
const module = registry.modules[moduleId] || { name: 'Unknown' };
|
||||
console.log(`${index + 1}. %c${moduleId}%c (${module.name})`,
|
||||
"font-weight: bold", "font-weight: normal");
|
||||
});
|
||||
console.groupEnd();
|
||||
|
||||
// Compare with actual loading order and suggest improvements
|
||||
console.log("%cRecommended changes to module loading order in loader.js:", "font-weight: bold");
|
||||
if (optimalOrder.length > 0) {
|
||||
console.log("const modulesToLoad = [");
|
||||
optimalOrder.forEach((moduleId, index) => {
|
||||
// Generate a reasonable path based on the moduleId
|
||||
const scriptPath = `/js/${moduleId}.js`;
|
||||
console.log(` { id: '${moduleId}', script: '${scriptPath}', weight: ${index * 5 + 5} },`);
|
||||
});
|
||||
console.log("];");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort modules by their dependencies to create an optimal loading order
|
||||
* This function can be used before initialization to ensure modules are loaded in the correct order
|
||||
* @param {Array} modules - Array of module objects with id and dependencies
|
||||
* @returns {Array} - Sorted array of modules
|
||||
*/
|
||||
function sortModulesByDependencies(modules) {
|
||||
// Build a dependency graph
|
||||
const graph = {};
|
||||
|
||||
// Initialize the graph with all modules
|
||||
modules.forEach(module => {
|
||||
graph[module.id] = { module, dependencies: [] };
|
||||
});
|
||||
|
||||
// Add dependencies to the graph
|
||||
// We need to do this in a second pass because some modules might reference others that come later in the array
|
||||
modules.forEach(module => {
|
||||
if (module.dependencies && Array.isArray(module.dependencies)) {
|
||||
module.dependencies.forEach(depId => {
|
||||
if (graph[depId]) {
|
||||
graph[module.id].dependencies.push(depId);
|
||||
} else {
|
||||
console.warn(`Module ${module.id} depends on unknown module ${depId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Perform a topological sort
|
||||
const result = [];
|
||||
const visited = {};
|
||||
const temp = {}; // For cycle detection
|
||||
|
||||
function visit(nodeId) {
|
||||
// Node is already in result, skip
|
||||
if (visited[nodeId]) return;
|
||||
|
||||
// If temp is true, we have a cycle
|
||||
if (temp[nodeId]) {
|
||||
console.warn(`Circular dependency detected involving ${nodeId}. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark node as being processed
|
||||
temp[nodeId] = true;
|
||||
|
||||
// Process all dependencies first
|
||||
if (graph[nodeId] && graph[nodeId].dependencies) {
|
||||
graph[nodeId].dependencies.forEach(depId => {
|
||||
visit(depId);
|
||||
});
|
||||
}
|
||||
|
||||
// Mark as visited and add to result
|
||||
temp[nodeId] = false;
|
||||
visited[nodeId] = true;
|
||||
result.push(graph[nodeId].module);
|
||||
}
|
||||
|
||||
// Visit all nodes
|
||||
Object.keys(graph).forEach(nodeId => {
|
||||
if (!visited[nodeId]) {
|
||||
visit(nodeId);
|
||||
}
|
||||
});
|
||||
|
||||
return result.reverse(); // Reverse for correct dependency order
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,12 +426,78 @@ const ModuleLoader = (function() {
|
||||
* @returns {Promise} - Resolves when script is loaded
|
||||
*/
|
||||
function loadScript(src) {
|
||||
// Extract module ID from src path
|
||||
const moduleId = src.split('/').pop().replace('.js', '');
|
||||
|
||||
// Update state to LOADING if this is a module
|
||||
if (moduleId && moduleWeights[moduleId]) {
|
||||
// Ensure module item exists in UI
|
||||
const moduleItem = document.getElementById(`module-${moduleId}`);
|
||||
if (!moduleItem) {
|
||||
createModuleItem(moduleId, getModuleNameFromId(moduleId));
|
||||
}
|
||||
|
||||
// Set initial progress to 0%
|
||||
handleModuleProgress({
|
||||
detail: { moduleId, progress: 0 }
|
||||
});
|
||||
|
||||
// Set state to loading
|
||||
updateModuleState(moduleId, ModuleState.LOADING);
|
||||
|
||||
// Record start time for this module (for timing data)
|
||||
if (!moduleTimings[moduleId]) {
|
||||
moduleTimings[moduleId] = {};
|
||||
}
|
||||
moduleTimings[moduleId].startTime = performance.now();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'module';
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
||||
|
||||
// Monitor loading progress using a fake progress indicator (0-10%)
|
||||
if (moduleId && moduleWeights[moduleId]) {
|
||||
let loadProgress = 0;
|
||||
const progressInterval = setInterval(() => {
|
||||
loadProgress = Math.min(loadProgress + 1, 9); // Max 9% until actual load completes
|
||||
handleModuleProgress({
|
||||
detail: { moduleId, progress: loadProgress }
|
||||
});
|
||||
}, 100);
|
||||
|
||||
script.onload = () => {
|
||||
clearInterval(progressInterval);
|
||||
// Final progress at 10% when script is loaded
|
||||
handleModuleProgress({
|
||||
detail: { moduleId, progress: 10 }
|
||||
});
|
||||
|
||||
// Record script load complete time
|
||||
if (moduleTimings[moduleId]) {
|
||||
moduleTimings[moduleId].scriptLoadTime = performance.now();
|
||||
}
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = (error) => {
|
||||
clearInterval(progressInterval);
|
||||
updateModuleState(moduleId, ModuleState.ERROR);
|
||||
|
||||
// Record error time
|
||||
if (moduleTimings[moduleId]) {
|
||||
moduleTimings[moduleId].errorTime = performance.now();
|
||||
}
|
||||
|
||||
reject(new Error(`Failed to load script: ${src}`));
|
||||
};
|
||||
} else {
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
||||
}
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
@@ -292,27 +625,50 @@ const ModuleLoader = (function() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a module list item in the UI
|
||||
* Create a module list item
|
||||
* @param {string} id - Module ID
|
||||
* @param {string} name - Module display name
|
||||
* @param {string} name - Module name
|
||||
* @returns {HTMLLIElement} List item element
|
||||
*/
|
||||
function createModuleListItem(id, name) {
|
||||
if (!modulesList) return;
|
||||
|
||||
// Check if we've already created this module item
|
||||
if (createdModules.has(id)) return;
|
||||
|
||||
// Mark this module as created
|
||||
function createModuleItem(id, name) {
|
||||
if (!modulesList || createdModules.has(id)) return null;
|
||||
createdModules.add(id);
|
||||
|
||||
const moduleItem = document.createElement('li');
|
||||
moduleItem.className = 'module-item';
|
||||
moduleItem.id = `module-${id}`;
|
||||
moduleItem.innerHTML = `
|
||||
<span class="module-name">${name}</span>
|
||||
<span class="module-status status-pending">Pending</span>
|
||||
`;
|
||||
modulesList.appendChild(moduleItem);
|
||||
// Create elements dynamically
|
||||
const li = document.createElement('li');
|
||||
li.id = `module-${id}`;
|
||||
li.className = 'module-item';
|
||||
|
||||
// Set initial progress to 0 using CSS variable
|
||||
li.style.setProperty('--progress-width', '0%');
|
||||
|
||||
// Create module name element
|
||||
const moduleName = document.createElement('div');
|
||||
moduleName.className = 'module-name';
|
||||
moduleName.textContent = name;
|
||||
|
||||
// Create module status element
|
||||
const moduleStatus = document.createElement('div');
|
||||
moduleStatus.className = 'module-status status-pending';
|
||||
moduleStatus.textContent = 'Pending';
|
||||
|
||||
// Create module status details element
|
||||
const moduleDetails = document.createElement('div');
|
||||
moduleDetails.className = 'module-status-detail';
|
||||
moduleDetails.textContent = '';
|
||||
|
||||
// Append all elements to the list item
|
||||
li.appendChild(moduleName);
|
||||
li.appendChild(moduleStatus);
|
||||
li.appendChild(moduleDetails);
|
||||
|
||||
// Force a reflow to ensure animation works
|
||||
void li.offsetWidth;
|
||||
|
||||
// Add to modules list
|
||||
modulesList.appendChild(li);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,7 +676,17 @@ const ModuleLoader = (function() {
|
||||
*/
|
||||
function handleModuleProgress(event) {
|
||||
const { moduleId, progress } = event.detail;
|
||||
updateModuleProgress(moduleId, progress);
|
||||
|
||||
// Get the module element
|
||||
const moduleItem = document.querySelector(`#module-${moduleId}`);
|
||||
if (moduleItem) {
|
||||
// Update module item's before pseudo-element width using CSS variable
|
||||
moduleItem.style.setProperty('--progress-width', `${progress}%`);
|
||||
|
||||
// Also set a data attribute for browsers that don't support CSS variables
|
||||
moduleItem.setAttribute('data-progress', progress);
|
||||
}
|
||||
|
||||
updateOverallProgress();
|
||||
}
|
||||
|
||||
@@ -329,9 +695,37 @@ const ModuleLoader = (function() {
|
||||
*/
|
||||
function handleModuleStateChange(event) {
|
||||
const { moduleId, state } = event.detail;
|
||||
// Update UI with the new state
|
||||
updateModuleState(moduleId, state);
|
||||
|
||||
// If module is finished, update overall completion
|
||||
if (state === ModuleState.FINISHED) {
|
||||
// This triggers only when ALL modules are complete, so modules would be removed too quickly
|
||||
// if (areAllModulesComplete()) {
|
||||
// hideLoadingOverlay();
|
||||
// }
|
||||
const moduleItem = document.getElementById(`module-${moduleId}`);
|
||||
if (moduleItem) {
|
||||
// Ensure module-finished class is added with a small delay to avoid race conditions
|
||||
setTimeout(() => {
|
||||
moduleItem.classList.add('module-finished');
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
updateOverallProgress();
|
||||
|
||||
// Record timing data
|
||||
if (moduleTimings[moduleId]) {
|
||||
moduleTimings[moduleId][state] = performance.now();
|
||||
|
||||
// If the module is finished or has an error, calculate total time
|
||||
if (state === ModuleState.FINISHED || state === ModuleState.ERROR) {
|
||||
const startTime = moduleTimings[moduleId].startTime || 0;
|
||||
moduleTimings[moduleId].totalTime = performance.now() - startTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all modules are finished after each state change
|
||||
checkAllFinished();
|
||||
}
|
||||
@@ -349,33 +743,45 @@ const ModuleLoader = (function() {
|
||||
*/
|
||||
function checkAllFinished() {
|
||||
const modules = moduleRegistry.getAllModules();
|
||||
const allFinished = Object.values(modules).every(module => {
|
||||
|
||||
// Add detailed logging of all module states
|
||||
console.log('Module states:', Object.entries(modules).map(([id, module]) => {
|
||||
return `${id}: ${module.getState()}`;
|
||||
}));
|
||||
|
||||
// First determine which modules are pending
|
||||
const pendingModules = Object.values(modules).filter(module => {
|
||||
const state = module.getState();
|
||||
return state === ModuleState.FINISHED || state === ModuleState.ERROR;
|
||||
return state !== ModuleState.FINISHED && state !== ModuleState.ERROR;
|
||||
});
|
||||
|
||||
// Log pending modules (if any)
|
||||
if (pendingModules.length > 0) {
|
||||
console.log('Modules still pending:', pendingModules.map(m => `${m.id} (${m.getState()})`));
|
||||
} else {
|
||||
console.log('No modules pending - all modules are in FINISHED or ERROR state');
|
||||
}
|
||||
|
||||
// Determine if all modules are finished based on pendingModules
|
||||
const allFinished = pendingModules.length === 0;
|
||||
|
||||
if (allFinished && !isLoadingComplete) {
|
||||
console.log('All modules finished loading. Proceeding to finalization...');
|
||||
finalizeLoading();
|
||||
} else if (!allFinished) {
|
||||
// Log which modules are not finished yet
|
||||
const pendingModules = Object.values(modules).filter(module => {
|
||||
const state = module.getState();
|
||||
return state !== ModuleState.FINISHED && state !== ModuleState.ERROR;
|
||||
});
|
||||
|
||||
if (pendingModules.length > 0) {
|
||||
console.log('Modules still pending:', pendingModules.map(m => `${m.id} (${m.getState()})`))
|
||||
}
|
||||
} else if (allFinished && isLoadingComplete) {
|
||||
console.log('All modules are finished but isLoadingComplete is already true');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Finalize the loading process
|
||||
*/
|
||||
function finalizeLoading() {
|
||||
console.log('Loading completed. Finalizing...');
|
||||
try {
|
||||
// Display timing data
|
||||
displayModuleTimings();
|
||||
|
||||
completeFinalization();
|
||||
} catch (error) {
|
||||
console.error('Error during finalization:', error);
|
||||
@@ -409,6 +815,36 @@ const ModuleLoader = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display module timing data to help with weight optimization
|
||||
*/
|
||||
function displayModuleTimings() {
|
||||
console.group('Module Loading Performance Data');
|
||||
console.log('This data can be used to optimize module weights:');
|
||||
|
||||
// Format timing data as tuples [moduleId, totalTime, weight]
|
||||
const timingData = Object.entries(moduleTimings)
|
||||
.filter(([moduleId, timing]) => timing.totalTime !== undefined)
|
||||
.map(([moduleId, timing]) => {
|
||||
return [moduleId, Math.round(timing.totalTime), moduleWeights[moduleId] || 1];
|
||||
})
|
||||
.sort((a, b) => b[1] - a[1]); // Sort by total time (descending)
|
||||
|
||||
// Create a table for easy reading
|
||||
console.table(timingData.map(([moduleId, time, weight]) => {
|
||||
return {
|
||||
moduleId,
|
||||
'totalTime (ms)': time,
|
||||
'current weight': weight,
|
||||
'suggested weight': Math.max(1, Math.round(time / 50)) // Simple heuristic based on time
|
||||
};
|
||||
}));
|
||||
|
||||
console.log('Raw timing data:');
|
||||
console.table(moduleTimings);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the loading overlay with a fade out animation
|
||||
* Then completely remove it from the DOM
|
||||
@@ -445,16 +881,6 @@ const ModuleLoader = (function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback in case the transition event doesn't fire
|
||||
setTimeout(() => {
|
||||
if (loadingOverlay && loadingOverlay.parentNode) {
|
||||
console.log('Module Loader: Removing overlay from DOM (fallback)');
|
||||
loadingOverlay.parentNode.removeChild(loadingOverlay);
|
||||
loadingOverlay = null;
|
||||
}
|
||||
// Execute callback in fallback as well
|
||||
if (callback) callback();
|
||||
}, 1000); // Wait longer than the transition duration
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,11 +903,12 @@ const ModuleLoader = (function() {
|
||||
'status-waiting',
|
||||
'status-initializing',
|
||||
'status-finished',
|
||||
'status-error'
|
||||
'status-error',
|
||||
'status-fetching'
|
||||
);
|
||||
|
||||
// Add appropriate class and text
|
||||
let statusText = '';
|
||||
// Set the new status
|
||||
let statusText = 'Unknown';
|
||||
switch (state) {
|
||||
case ModuleState.PENDING:
|
||||
statusElement.classList.add('status-pending');
|
||||
@@ -491,6 +918,10 @@ const ModuleLoader = (function() {
|
||||
statusElement.classList.add('status-loading');
|
||||
statusText = 'Loading';
|
||||
break;
|
||||
case ModuleState.FETCHING:
|
||||
statusElement.classList.add('status-fetching');
|
||||
statusText = 'Fetching';
|
||||
break;
|
||||
case ModuleState.WAITING:
|
||||
statusElement.classList.add('status-waiting');
|
||||
statusText = 'Waiting';
|
||||
@@ -508,25 +939,9 @@ const ModuleLoader = (function() {
|
||||
statusText = 'Error';
|
||||
break;
|
||||
}
|
||||
|
||||
statusElement.textContent = statusText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the progress of a module
|
||||
* @param {string} id - Module ID
|
||||
* @param {number} progress - Progress percentage (0-100)
|
||||
*/
|
||||
function updateModuleProgress(id, progress) {
|
||||
// Module states are now managed by the module itself
|
||||
|
||||
// Update any additional UI elements for module progress if needed
|
||||
const moduleItem = document.getElementById(`module-${id}`);
|
||||
if (moduleItem) {
|
||||
// Update progress display if needed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status text of a module in the UI
|
||||
* @param {string} id - Module ID
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Handles translations and locale settings
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class LocalizationModule extends BaseModule {
|
||||
/**
|
||||
@@ -106,16 +105,16 @@ class LocalizationModule extends BaseModule {
|
||||
const translations = await response.json();
|
||||
this.translations[normalizedLocale] = translations;
|
||||
} else {
|
||||
// Don't try to load language part without region (e.g., "en") - we only support full locales
|
||||
// Fallback to en-us if the requested locale isn't found
|
||||
if (normalizedLocale !== 'en-us') {
|
||||
console.warn(`Translations for ${normalizedLocale} not found, falling back to en-us`);
|
||||
await this.loadTranslations('en-us');
|
||||
this.translations[normalizedLocale] = this.translations['en-us'];
|
||||
} else {
|
||||
// If en-us is not found, create an empty translation set
|
||||
console.warn('English translations not found, using empty set');
|
||||
this.translations[normalizedLocale] = {};
|
||||
// If exact locale not found, try to load just the language part
|
||||
const langPart = normalizedLocale.split('-')[0];
|
||||
if (langPart !== normalizedLocale) {
|
||||
const langResponse = await fetch(`/locales/${langPart}.json`);
|
||||
if (langResponse.ok) {
|
||||
const translations = await langResponse.json();
|
||||
this.translations[normalizedLocale] = translations;
|
||||
} else {
|
||||
console.warn(`No translations found for ${locale} or ${langPart}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -251,8 +250,5 @@ class LocalizationModule extends BaseModule {
|
||||
// Create the singleton instance
|
||||
const Localization = new LocalizationModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(Localization);
|
||||
|
||||
// Export the module
|
||||
export { Localization };
|
||||
@@ -1,240 +0,0 @@
|
||||
/**
|
||||
* OpenAI TTS Handler
|
||||
* Provides TTS via OpenAI API
|
||||
*/
|
||||
import { ApiTTSHandlerBase } from './api-tts-handler-base.js';
|
||||
|
||||
export class OpenAITTSHandler extends ApiTTSHandlerBase {
|
||||
constructor() {
|
||||
super('openai', 'OpenAI TTS');
|
||||
|
||||
// Voice options specific to OpenAI
|
||||
this.voiceOptions = {
|
||||
voice: 'alloy', // Default voice for OpenAI
|
||||
model: 'tts-1', // Standard model
|
||||
speed: 1.0,
|
||||
response_format: 'mp3' // OpenAI supports mp3, opus, aac, and flac (not wav)
|
||||
};
|
||||
|
||||
// Predefined voices
|
||||
this.voices = [
|
||||
{ id: 'alloy', name: 'Alloy', language: 'en' },
|
||||
{ id: 'echo', name: 'Echo', language: 'en' },
|
||||
{ id: 'fable', name: 'Fable', language: 'en' },
|
||||
{ id: 'onyx', name: 'Onyx', language: 'en' },
|
||||
{ id: 'nova', name: 'Nova', language: 'en' },
|
||||
{ id: 'shimmer', name: 'Shimmer', language: 'en' }
|
||||
];
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'speak',
|
||||
'speakPreloaded',
|
||||
'preloadSpeech',
|
||||
'stop',
|
||||
'isAvailable',
|
||||
'getId',
|
||||
'getVoices',
|
||||
'setVoiceOptions',
|
||||
'getModule',
|
||||
'setupVoiceFromPreferences',
|
||||
'loadVoices',
|
||||
'selectVoiceForLocale',
|
||||
'selectDefaultVoice',
|
||||
'generateSpeechAudio',
|
||||
'getDefaultApiBaseUrl'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the OpenAI TTS handler
|
||||
* @param {Function} progressCallback - Callback for progress updates
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize(progressCallback = null) {
|
||||
try {
|
||||
// Call parent initialize method
|
||||
const initSuccess = await super.initialize(progressCallback);
|
||||
|
||||
if (!initSuccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load voice preferences
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
// Load model preference
|
||||
const model = persistenceManager.getPreference('tts', 'openai_model', 'tts-1');
|
||||
if (model) {
|
||||
this.voiceOptions.model = model;
|
||||
}
|
||||
|
||||
// Load format preference
|
||||
const format = persistenceManager.getPreference('tts', 'openai_format', 'mp3');
|
||||
if (format) {
|
||||
this.voiceOptions.response_format = format;
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI TTS should be considered available if the API key is set
|
||||
// This will be checked by the parent class already
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('OpenAI TTS: Initialization error:', error);
|
||||
if (progressCallback) {
|
||||
progressCallback(100, `OpenAI TTS initialization failed - ${error.message}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default API base URL for OpenAI
|
||||
* @returns {string} - Default API base URL
|
||||
*/
|
||||
getDefaultApiBaseUrl() {
|
||||
return 'https://api.openai.com/v1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available voices from OpenAI API
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async loadVoices() {
|
||||
// OpenAI has a fixed set of voices, no need to fetch them
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a voice for the given locale
|
||||
* @param {string} locale - Locale code
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectVoiceForLocale(locale) {
|
||||
// Extract language code from locale (e.g., 'en-US' -> 'en')
|
||||
const langCode = locale.split('-')[0].toLowerCase();
|
||||
|
||||
// All OpenAI voices are English-based, so if the locale is English, we might want to pick a specific voice
|
||||
// Otherwise, just use the default voice
|
||||
if (langCode === 'en') {
|
||||
this.voiceOptions.voice = 'nova'; // A bit more natural-sounding for general use
|
||||
return true;
|
||||
}
|
||||
|
||||
// For non-English locales, still use a default voice (OpenAI voices can handle multiple languages)
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a default voice
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectDefaultVoice() {
|
||||
this.voiceOptions.voice = 'alloy';
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech audio data using OpenAI API
|
||||
* @param {string} text - Text to generate speech for
|
||||
* @returns {Promise<Object>} - Audio data (Blob)
|
||||
*/
|
||||
async generateSpeechAudio(text) {
|
||||
if (!text || !this.apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Log the actual values being used - don't truncate or mask for debugging
|
||||
console.log('OpenAI TTS: Generating speech with:');
|
||||
console.log('- API Key:', this.apiKey);
|
||||
console.log('- API URL:', this.apiBaseUrl);
|
||||
|
||||
// Create request payload
|
||||
const payload = {
|
||||
model: this.voiceOptions.model || 'tts-1',
|
||||
input: text,
|
||||
voice: this.voiceOptions.voice || 'alloy',
|
||||
response_format: this.voiceOptions.response_format || 'mp3',
|
||||
speed: this.voiceOptions.speed || 1.0
|
||||
};
|
||||
|
||||
// Make API request
|
||||
const response = await fetch(`${this.apiBaseUrl}/audio/speech`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`API error: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
|
||||
// Get audio blob from response
|
||||
const audioBlob = await response.blob();
|
||||
|
||||
// Note: OpenAI doesn't support WAV format directly, so we're using the format specified in voiceOptions
|
||||
// The audio element should still be able to play mp3/opus/aac properly
|
||||
return new Blob([audioBlob], { type: `audio/${this.voiceOptions.response_format}` });
|
||||
} catch (error) {
|
||||
console.error('OpenAI TTS: Error generating speech:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Promise<Array>} - Resolves with array of voice objects
|
||||
*/
|
||||
async getVoices() {
|
||||
if (!this.available) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// OpenAI has a fixed set of voices
|
||||
return this.voices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
// Call parent method for common options
|
||||
super.setVoiceOptions(options);
|
||||
|
||||
// Handle OpenAI-specific options
|
||||
if (options.model) {
|
||||
this.voiceOptions.model = options.model;
|
||||
|
||||
// Save the model preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'openai_model', options.model);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.response_format) {
|
||||
// Ensure valid format: mp3, opus, aac, or flac
|
||||
const validFormats = ['mp3', 'opus', 'aac', 'flac'];
|
||||
if (validFormats.includes(options.response_format)) {
|
||||
this.voiceOptions.response_format = options.response_format;
|
||||
|
||||
// Save the format preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'openai_format', options.response_format);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const OpenAITTS = new OpenAITTSHandler();
|
||||
@@ -6,7 +6,7 @@ import { ApiTTSModuleBase } from './api-tts-module-base.js';
|
||||
|
||||
export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
constructor() {
|
||||
super('openai', 'OpenAI TTS');
|
||||
super('openai-tts', 'OpenAI TTS');
|
||||
|
||||
// Voice options specific to OpenAI
|
||||
this.voiceOptions = {
|
||||
@@ -115,10 +115,7 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
const langCode = locale.split('-')[0].toLowerCase();
|
||||
|
||||
// All OpenAI voices are English-based
|
||||
// For English locales, we could customize the voice selection
|
||||
// For non-English locales, we'll just use the default
|
||||
|
||||
// In this simple implementation, we'll just use the default voice
|
||||
// Return default voice
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
@@ -127,21 +124,26 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectDefaultVoice() {
|
||||
this.voiceOptions.voice = 'alloy';
|
||||
this.voiceOptions.voice = 'alloy'; // Default voice
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Array} - Array of voice objects
|
||||
*/
|
||||
getAvailableVoices() {
|
||||
return this.voices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech audio data using OpenAI API
|
||||
* @param {string} text - Text to generate speech for
|
||||
* @returns {Promise<Object>} - Audio data object
|
||||
*/
|
||||
async generateSpeechAudio(text) {
|
||||
if (!text || !this.apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
reason: 'missing_api_key_or_text'
|
||||
};
|
||||
if (!this.isReady || !this.apiKey) {
|
||||
return { success: false, reason: 'not_ready' };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -239,17 +241,6 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
}
|
||||
}
|
||||
|
||||
// Register the module with the module registry
|
||||
// Module registry MUST be accessed via window, not direct import
|
||||
if (window.moduleRegistry) {
|
||||
try {
|
||||
// Create instance first, then register it
|
||||
const openAITTSModule = new OpenAITTSModule();
|
||||
window.moduleRegistry.register(openAITTSModule);
|
||||
console.log('OpenAI TTS Module registered successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to register OpenAI TTS Module:', err);
|
||||
}
|
||||
} else {
|
||||
console.error('Module registry not available when attempting to register OpenAI TTS Module');
|
||||
}
|
||||
const openAITTSModule = new OpenAITTSModule();
|
||||
|
||||
export { openAITTSModule };
|
||||
@@ -0,0 +1,974 @@
|
||||
/**
|
||||
* Options UI Module
|
||||
* Provides the options UI for the game
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { createUIElement, populateDropdown, registerHandler, createPreferenceBinding } from './ui-helper.js';
|
||||
|
||||
class OptionsUIModule extends BaseModule {
|
||||
/**
|
||||
* Create a new options UI module
|
||||
*/
|
||||
constructor() {
|
||||
super('options-ui', 'Options UI');
|
||||
|
||||
// Set up dependencies
|
||||
this.dependencies = [
|
||||
'persistence-manager',
|
||||
'localization',
|
||||
'tts-factory',
|
||||
'audio-manager'
|
||||
];
|
||||
|
||||
// Modal element
|
||||
this.modal = null;
|
||||
|
||||
// UI elements
|
||||
this.elements = {};
|
||||
|
||||
// Settings that require reload
|
||||
this.reloadRequired = false;
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'show',
|
||||
'hide',
|
||||
'createModal',
|
||||
'populateTtsSystems',
|
||||
'populateVoices',
|
||||
'populateLanguages',
|
||||
'loadPreferences',
|
||||
'applySettings',
|
||||
'handleTtsSystemChanged',
|
||||
'showReloadNotice',
|
||||
'toggle',
|
||||
'setupEventListeners',
|
||||
'saveCurrentSettings',
|
||||
'setupApiUrlFields',
|
||||
'setupInitialState',
|
||||
'dispatchApiChangeEvent',
|
||||
'getPreference',
|
||||
'updatePreference'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an API change event
|
||||
* @param {string} eventType - Event type (e.g. 'api:key:change')
|
||||
* @param {string} provider - Provider name (e.g. 'elevenlabs')
|
||||
* @param {string} valueType - Value type (e.g. 'key', 'url')
|
||||
* @param {string} value - Value to dispatch
|
||||
*/
|
||||
dispatchApiChangeEvent(eventType, provider, valueType, value) {
|
||||
document.dispatchEvent(new CustomEvent(eventType, {
|
||||
detail: { provider, [valueType]: value }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a preference from the persistence manager
|
||||
* @param {string} category - Preference category
|
||||
* @param {string} key - Preference key
|
||||
* @param {*} defaultValue - Default value if preference doesn't exist
|
||||
* @returns {*} - Preference value
|
||||
*/
|
||||
getPreference(category, key, defaultValue) {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
return persistenceManager.getPreference(category, key, defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a preference in the persistence manager
|
||||
* @param {string} category - Preference category
|
||||
* @param {string} key - Preference key
|
||||
* @param {*} value - Value to set
|
||||
*/
|
||||
updatePreference(category, key, value) {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
persistenceManager.updatePreference(category, key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Options UI module
|
||||
* @returns {Promise<boolean>} - Promise resolves with initialization success
|
||||
*/
|
||||
async initialize() {
|
||||
console.log('Options UI: Initializing');
|
||||
|
||||
// Create DOM elements
|
||||
this.createModal();
|
||||
|
||||
// Set up event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Set up API URL fields
|
||||
this.setupApiUrlFields();
|
||||
|
||||
// Set up initial state
|
||||
await this.setupInitialState();
|
||||
|
||||
// Set up immediate save listeners
|
||||
this.setupImmediateSaveListeners();
|
||||
|
||||
this.reportProgress(100, 'Options UI initialized');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the options modal
|
||||
*/
|
||||
createModal() {
|
||||
if (this.modal) return;
|
||||
|
||||
const body = document.body;
|
||||
|
||||
// Create modal container
|
||||
this.modal = createUIElement('div', { className: 'options-modal', id: 'options-modal' }, null, body);
|
||||
|
||||
// Create modal content
|
||||
const modalContent = createUIElement('div', { className: 'options-content' }, null, this.modal);
|
||||
|
||||
// Create header
|
||||
const header = createUIElement('div', { className: 'options-header' }, null, modalContent);
|
||||
createUIElement('h2', {}, 'Options', header);
|
||||
this.elements.closeButton = createUIElement('button', { className: 'options-close', 'aria-label': 'Close' }, '×', header);
|
||||
|
||||
// Create settings container
|
||||
const settings = createUIElement('div', { className: 'options-settings' }, null, modalContent);
|
||||
|
||||
// TTS Settings
|
||||
const ttsSection = createUIElement('div', { className: 'options-section' }, null, settings);
|
||||
createUIElement('h3', {}, 'Text-to-Speech', ttsSection);
|
||||
|
||||
// TTS Toggle
|
||||
const ttsSpeechToggleContainer = createUIElement('div', { className: 'options-row' }, null, ttsSection);
|
||||
createUIElement('label', {}, 'Enable Text-to-Speech:', ttsSpeechToggleContainer);
|
||||
this.elements.ttsEnabled = createUIElement('input', { type: 'checkbox', id: 'tts-enabled' }, null, ttsSpeechToggleContainer);
|
||||
|
||||
// TTS System
|
||||
const ttsSystemContainer = createUIElement('div', { className: 'options-row' }, null, ttsSection);
|
||||
createUIElement('label', {}, 'TTS System:', ttsSystemContainer);
|
||||
this.elements.ttsSystem = createUIElement('select', { id: 'tts-system' }, null, ttsSystemContainer);
|
||||
|
||||
// TTS Voice
|
||||
const ttsVoiceContainer = createUIElement('div', { className: 'options-row' }, null, ttsSection);
|
||||
createUIElement('label', {}, 'Voice:', ttsVoiceContainer);
|
||||
this.elements.ttsVoice = createUIElement('select', { id: 'tts-voice' }, null, ttsVoiceContainer);
|
||||
|
||||
// TTS Speed
|
||||
const speedContainer = createUIElement('div', { className: 'options-row' }, null, ttsSection);
|
||||
createUIElement('label', {}, 'TTS Speed:', speedContainer);
|
||||
this.elements.ttsSpeed = createUIElement('input', {
|
||||
type: 'range',
|
||||
id: 'tts-speed',
|
||||
min: '0',
|
||||
max: '100'
|
||||
}, null, speedContainer);
|
||||
|
||||
// Create API settings for each provider
|
||||
const apiSettings = this.createApiSettings(ttsSection);
|
||||
|
||||
// Audio Settings Section
|
||||
const audioSection = createUIElement('div', { className: 'options-section' }, null, settings);
|
||||
createUIElement('h3', {}, 'Audio', audioSection);
|
||||
|
||||
// Master Volume
|
||||
const masterVolumeContainer = createUIElement('div', { className: 'options-row' }, null, audioSection);
|
||||
createUIElement('label', {}, 'Master Volume:', masterVolumeContainer);
|
||||
this.elements.masterVolume = createUIElement('input', {
|
||||
type: 'range',
|
||||
id: 'master-volume',
|
||||
min: '0',
|
||||
max: '100'
|
||||
}, null, masterVolumeContainer);
|
||||
|
||||
// Music Volume
|
||||
const musicVolumeContainer = createUIElement('div', { className: 'options-row' }, null, audioSection);
|
||||
createUIElement('label', {}, 'Music Volume:', musicVolumeContainer);
|
||||
this.elements.musicVolume = createUIElement('input', {
|
||||
type: 'range',
|
||||
id: 'music-volume',
|
||||
min: '0',
|
||||
max: '100'
|
||||
}, null, musicVolumeContainer);
|
||||
|
||||
// SFX Volume
|
||||
const sfxVolumeContainer = createUIElement('div', { className: 'options-row' }, null, audioSection);
|
||||
createUIElement('label', {}, 'Sound Effects Volume:', sfxVolumeContainer);
|
||||
this.elements.sfxVolume = createUIElement('input', {
|
||||
type: 'range',
|
||||
id: 'sfx-volume',
|
||||
min: '0',
|
||||
max: '100'
|
||||
}, null, sfxVolumeContainer);
|
||||
|
||||
// Ambience Volume
|
||||
const ambienceVolumeContainer = createUIElement('div', { className: 'options-row' }, null, audioSection);
|
||||
createUIElement('label', {}, 'Ambience Volume:', ambienceVolumeContainer);
|
||||
this.elements.ambienceVolume = createUIElement('input', {
|
||||
type: 'range',
|
||||
id: 'ambience-volume',
|
||||
min: '0',
|
||||
max: '100'
|
||||
}, null, ambienceVolumeContainer);
|
||||
|
||||
// Language Section
|
||||
const languageSection = createUIElement('div', { className: 'options-section' }, null, settings);
|
||||
createUIElement('h3', {}, 'Language Settings', languageSection);
|
||||
|
||||
// Language selection
|
||||
const languageContainer = createUIElement('div', { className: 'options-row' }, null, languageSection);
|
||||
createUIElement('label', {}, 'Language:', languageContainer);
|
||||
this.elements.language = createUIElement('select', { id: 'app-language' }, null, languageContainer);
|
||||
|
||||
// Initialize with display: none
|
||||
this.modal.style.display = 'none';
|
||||
|
||||
// Add event handlers
|
||||
this.elements.closeButton.addEventListener('click', () => {
|
||||
this.saveCurrentSettings();
|
||||
this.hide();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API settings for TTS providers
|
||||
* @param {HTMLElement} parentSection - Parent section for API settings
|
||||
* @returns {Object} - Object with API settings elements
|
||||
*/
|
||||
createApiSettings(parentSection) {
|
||||
// ElevenLabs settings
|
||||
// API Key
|
||||
const elevenLabsApiKeyContainer = createUIElement('div', {
|
||||
className: 'options-row elevenlabs-setting',
|
||||
'data-provider': 'elevenlabs'
|
||||
}, null, parentSection);
|
||||
|
||||
createUIElement('label', {}, 'ElevenLabs API Key:', elevenLabsApiKeyContainer);
|
||||
this.elements.elevenLabsApiKey = createUIElement('input', {
|
||||
type: 'password',
|
||||
placeholder: 'Enter your ElevenLabs API key'
|
||||
}, null, elevenLabsApiKeyContainer);
|
||||
|
||||
// API URL
|
||||
const elevenLabsApiUrlContainer = createUIElement('div', {
|
||||
className: 'options-row elevenlabs-setting',
|
||||
'data-provider': 'elevenlabs'
|
||||
}, null, parentSection);
|
||||
|
||||
createUIElement('label', {}, 'ElevenLabs API URL:', elevenLabsApiUrlContainer);
|
||||
this.elements.elevenLabsApiUrl = createUIElement('input', {
|
||||
type: 'text',
|
||||
placeholder: 'https://api.elevenlabs.io/v1'
|
||||
}, null, elevenLabsApiUrlContainer);
|
||||
|
||||
// OpenAI settings
|
||||
// API Key
|
||||
const openaiApiKeyContainer = createUIElement('div', {
|
||||
className: 'options-row openai-setting',
|
||||
'data-provider': 'openai'
|
||||
}, null, parentSection);
|
||||
|
||||
createUIElement('label', {}, 'OpenAI API Key:', openaiApiKeyContainer);
|
||||
this.elements.openaiApiKey = createUIElement('input', {
|
||||
type: 'password',
|
||||
placeholder: 'Enter your OpenAI API key'
|
||||
}, null, openaiApiKeyContainer);
|
||||
|
||||
// API URL
|
||||
const openaiApiUrlContainer = createUIElement('div', {
|
||||
className: 'options-row openai-setting',
|
||||
'data-provider': 'openai'
|
||||
}, null, parentSection);
|
||||
|
||||
createUIElement('label', {}, 'OpenAI API URL:', openaiApiUrlContainer);
|
||||
this.elements.openaiApiUrl = createUIElement('input', {
|
||||
type: 'text',
|
||||
placeholder: 'https://api.openai.com/v1'
|
||||
}, null, openaiApiUrlContainer);
|
||||
|
||||
// Initially hide API settings
|
||||
const apiSettings = document.querySelectorAll('.elevenlabs-setting, .openai-setting');
|
||||
apiSettings.forEach(setting => {
|
||||
setting.style.display = 'none';
|
||||
});
|
||||
|
||||
return { elevenLabsApiKeyContainer, elevenLabsApiUrlContainer, openaiApiKeyContainer, openaiApiUrlContainer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for UI elements
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// TTS System change event
|
||||
if (this.elements.ttsSystem) {
|
||||
this.elements.ttsSystem.addEventListener('change', this.handleTtsSystemChanged);
|
||||
}
|
||||
|
||||
// TTS Enable toggle event
|
||||
if (this.elements.ttsEnabled) {
|
||||
this.elements.ttsEnabled.addEventListener('change', (event) => {
|
||||
const enabled = event.target.checked;
|
||||
console.log('Options UI: TTS enabled changed to', enabled);
|
||||
|
||||
// Save setting
|
||||
this.updatePreference('tts', 'enabled', enabled);
|
||||
|
||||
// Update TTS Factory
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
ttsFactory.configure({ enabled });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Voice change event
|
||||
if (this.elements.ttsVoice) {
|
||||
this.elements.ttsVoice.addEventListener('change', (event) => {
|
||||
const voice = event.target.value;
|
||||
console.log('Options UI: TTS voice changed to', voice);
|
||||
|
||||
// Save setting
|
||||
this.updatePreference('tts', 'voice', voice);
|
||||
|
||||
// Update TTS Factory
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
ttsFactory.configure({ voice });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TTS Speed change event
|
||||
if (this.elements.ttsSpeed) {
|
||||
this.elements.ttsSpeed.addEventListener('input', (event) => {
|
||||
const speed = parseInt(event.target.value) / 100;
|
||||
console.log('Options UI: TTS speed changed to', speed);
|
||||
|
||||
// Save setting
|
||||
this.updatePreference('tts', 'speed', speed);
|
||||
|
||||
// Update TTS Factory
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
ttsFactory.configure({ speed });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Language change event
|
||||
if (this.elements.language) {
|
||||
this.elements.language.addEventListener('change', (event) => {
|
||||
const locale = event.target.value;
|
||||
console.log('Options UI: Language changed to', locale);
|
||||
|
||||
// Save settings
|
||||
this.updatePreference('app', 'locale', locale);
|
||||
this.updatePreference('tts', 'language', locale);
|
||||
|
||||
// Update Localization module
|
||||
const localization = this.getModule('localization');
|
||||
if (localization) {
|
||||
localization.setLocale(locale);
|
||||
}
|
||||
|
||||
// Show reload notice
|
||||
this.showReloadNotice();
|
||||
});
|
||||
}
|
||||
|
||||
// Audio Settings
|
||||
// Master Volume
|
||||
if (this.elements.masterVolume) {
|
||||
this.elements.masterVolume.addEventListener('input', (event) => {
|
||||
const volume = parseInt(event.target.value) / 100;
|
||||
console.log('Options UI: Master volume changed to', volume);
|
||||
|
||||
// Save setting
|
||||
this.updatePreference('audio', 'masterVolume', volume);
|
||||
|
||||
// Update Audio Manager
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
if (audioManager) {
|
||||
audioManager.setMasterVolume(volume);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Music Volume
|
||||
if (this.elements.musicVolume) {
|
||||
this.elements.musicVolume.addEventListener('input', (event) => {
|
||||
const volume = parseInt(event.target.value) / 100;
|
||||
console.log('Options UI: Music volume changed to', volume);
|
||||
|
||||
// Save setting
|
||||
this.updatePreference('audio', 'musicVolume', volume);
|
||||
|
||||
// Update Audio Manager
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
if (audioManager) {
|
||||
audioManager.setMusicVolume(volume);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// SFX Volume
|
||||
if (this.elements.sfxVolume) {
|
||||
this.elements.sfxVolume.addEventListener('input', (event) => {
|
||||
const volume = parseInt(event.target.value) / 100;
|
||||
console.log('Options UI: SFX volume changed to', volume);
|
||||
|
||||
// Save setting
|
||||
this.updatePreference('audio', 'sfxVolume', volume);
|
||||
|
||||
// Update Audio Manager
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
if (audioManager) {
|
||||
audioManager.setSfxVolume(volume);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ambience Volume
|
||||
if (this.elements.ambienceVolume) {
|
||||
this.elements.ambienceVolume.addEventListener('input', (event) => {
|
||||
const volume = parseInt(event.target.value) / 100;
|
||||
console.log('Options UI: Ambience volume changed to', volume);
|
||||
|
||||
// Save setting
|
||||
this.updatePreference('audio', 'ambienceVolume', volume);
|
||||
|
||||
// Update Audio Manager
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
if (audioManager) {
|
||||
audioManager.setAmbienceVolume(volume);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle TTS system change
|
||||
* @param {Event} event - Change event
|
||||
*/
|
||||
async handleTtsSystemChanged(event) {
|
||||
const selectedSystem = event.target.value;
|
||||
console.log('Options UI: TTS system changed to', selectedSystem);
|
||||
|
||||
// Update API settings visibility
|
||||
this.updateApiSettingsVisibility(selectedSystem);
|
||||
|
||||
// Save setting
|
||||
this.updatePreference('tts', 'preferred_handler', selectedSystem);
|
||||
|
||||
// Notify TTSFactory of handler change
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
await ttsFactory.setActiveHandler(selectedSystem);
|
||||
|
||||
// Now that the handler has changed, update voices for the selected system
|
||||
await this.populateVoices();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update API settings visibility based on selected TTS system
|
||||
* @param {string} selectedSystem - Selected TTS system
|
||||
*/
|
||||
updateApiSettingsVisibility(selectedSystem) {
|
||||
const elevenLabsSettings = document.querySelectorAll('.elevenlabs-setting');
|
||||
const openaiSettings = document.querySelectorAll('.openai-setting');
|
||||
|
||||
elevenLabsSettings.forEach(setting => {
|
||||
setting.style.display = selectedSystem === 'elevenlabs' ? 'flex' : 'none';
|
||||
});
|
||||
|
||||
openaiSettings.forEach(setting => {
|
||||
setting.style.display = selectedSystem === 'openai' ? 'flex' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the options UI
|
||||
*/
|
||||
show() {
|
||||
if (this.modal) {
|
||||
this.modal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the options UI
|
||||
*/
|
||||
hide() {
|
||||
if (this.modal) {
|
||||
this.modal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the options UI visibility
|
||||
*/
|
||||
toggle() {
|
||||
if (this.modal) {
|
||||
if (this.modal.style.display === 'flex') {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the TTS systems dropdown
|
||||
*/
|
||||
async populateTtsSystems() {
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (!ttsFactory || !this.elements.ttsSystem) return;
|
||||
|
||||
// Get available TTS systems
|
||||
const handlers = ttsFactory.getAvailableHandlers();
|
||||
console.log('Options UI: Available TTS handlers:', handlers);
|
||||
|
||||
// Format for display
|
||||
const systems = handlers.map(handler => ({
|
||||
id: handler.id,
|
||||
name: this.getTtsSystemName(handler.id)
|
||||
}));
|
||||
|
||||
// Populate dropdown
|
||||
populateDropdown(
|
||||
this.elements.ttsSystem,
|
||||
systems,
|
||||
'id',
|
||||
'name',
|
||||
this.getPreference('tts', 'preferred_handler', 'none')
|
||||
);
|
||||
|
||||
// Update API settings visibility
|
||||
this.updateApiSettingsVisibility(this.elements.ttsSystem.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a friendly name for a TTS system
|
||||
* @param {string} id - TTS system ID
|
||||
* @returns {string} - Friendly name
|
||||
*/
|
||||
getTtsSystemName(id) {
|
||||
const names = {
|
||||
'none': 'None',
|
||||
'browser': 'Browser',
|
||||
'kokoro': 'Kokoro',
|
||||
'elevenlabs': 'ElevenLabs',
|
||||
'openai': 'OpenAI'
|
||||
};
|
||||
|
||||
return names[id] || id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the voices dropdown
|
||||
*/
|
||||
async populateVoices() {
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (!ttsFactory || !this.elements.ttsVoice) return;
|
||||
|
||||
// Get voices for current TTS system
|
||||
const voices = await ttsFactory.getVoices() || [];
|
||||
console.log('Options UI: TTS voices:', voices);
|
||||
|
||||
// Populate dropdown
|
||||
populateDropdown(
|
||||
this.elements.ttsVoice,
|
||||
voices,
|
||||
'id',
|
||||
'name',
|
||||
this.getPreference('tts', 'voice', '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the languages dropdown
|
||||
*/
|
||||
async populateLanguages() {
|
||||
const localization = this.getModule('localization');
|
||||
if (!localization || !this.elements.language) return;
|
||||
|
||||
// Get available languages
|
||||
const languages = localization.getAvailableLocales() || [];
|
||||
console.log('Options UI: Available languages:', languages);
|
||||
|
||||
// Format languages with their names
|
||||
const languageOptions = languages.map(code => ({
|
||||
code,
|
||||
name: localization.getLanguageName(code)
|
||||
}));
|
||||
|
||||
// Populate dropdown
|
||||
populateDropdown(
|
||||
this.elements.language,
|
||||
languageOptions,
|
||||
'code',
|
||||
'name',
|
||||
this.getPreference('app', 'locale', 'en-us')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user preferences from the persistence manager
|
||||
*/
|
||||
loadPreferences() {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (!persistenceManager) return;
|
||||
|
||||
console.log('Options UI: Loading preferences');
|
||||
|
||||
// TTS Settings
|
||||
// TTS Enable
|
||||
if (this.elements.ttsEnabled) {
|
||||
this.elements.ttsEnabled.checked = this.getPreference('tts', 'enabled', true);
|
||||
}
|
||||
|
||||
// TTS System
|
||||
if (this.elements.ttsSystem) {
|
||||
const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none');
|
||||
if (this.elements.ttsSystem.querySelector(`option[value="${preferredHandler}"]`)) {
|
||||
this.elements.ttsSystem.value = preferredHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// TTS Speed
|
||||
if (this.elements.ttsSpeed) {
|
||||
const speed = this.getPreference('tts', 'speed', 1);
|
||||
this.elements.ttsSpeed.value = Math.round(speed * 100);
|
||||
}
|
||||
|
||||
// Audio Settings
|
||||
// Master Volume
|
||||
if (this.elements.masterVolume) {
|
||||
const masterVolume = this.getPreference('audio', 'masterVolume', 1);
|
||||
this.elements.masterVolume.value = Math.round(masterVolume * 100);
|
||||
}
|
||||
|
||||
// Music Volume
|
||||
if (this.elements.musicVolume) {
|
||||
const musicVolume = this.getPreference('audio', 'musicVolume', 1);
|
||||
this.elements.musicVolume.value = Math.round(musicVolume * 100);
|
||||
}
|
||||
|
||||
// SFX Volume
|
||||
if (this.elements.sfxVolume) {
|
||||
const sfxVolume = this.getPreference('audio', 'sfxVolume', 1);
|
||||
this.elements.sfxVolume.value = Math.round(sfxVolume * 100);
|
||||
}
|
||||
|
||||
// Ambience Volume
|
||||
if (this.elements.ambienceVolume) {
|
||||
const ambienceVolume = this.getPreference('audio', 'ambienceVolume', 1);
|
||||
this.elements.ambienceVolume.value = Math.round(ambienceVolume * 100);
|
||||
}
|
||||
|
||||
// Language
|
||||
if (this.elements.language) {
|
||||
const locale = this.getPreference('app', 'locale', 'en');
|
||||
if (this.elements.language.querySelector(`option[value="${locale}"]`)) {
|
||||
this.elements.language.value = locale;
|
||||
}
|
||||
}
|
||||
|
||||
// Update API settings visibility
|
||||
if (this.elements.ttsSystem) {
|
||||
this.updateApiSettingsVisibility(this.elements.ttsSystem.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up two-way binding for TTS Enabled
|
||||
* @param {HTMLElement} element - UI element
|
||||
* @param {Object} persistenceManager - Persistence Manager module
|
||||
* @param {string} category - Preference category
|
||||
* @param {string} key - Preference key
|
||||
* @param {*} defaultValue - Default value if preference doesn't exist
|
||||
* @param {Function} [transform] - Optional transform function
|
||||
*/
|
||||
setupTtsEnabledBinding(element, persistenceManager, category, key, defaultValue, transform) {
|
||||
createPreferenceBinding(
|
||||
element,
|
||||
persistenceManager,
|
||||
category,
|
||||
key,
|
||||
defaultValue,
|
||||
transform
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up two-way binding for TTS Voice
|
||||
* @param {HTMLElement} element - UI element
|
||||
* @param {Object} persistenceManager - Persistence Manager module
|
||||
* @param {string} category - Preference category
|
||||
* @param {string} key - Preference key
|
||||
* @param {*} defaultValue - Default value if preference doesn't exist
|
||||
* @param {Function} [transform] - Optional transform function
|
||||
*/
|
||||
setupTtsVoiceBinding(element, persistenceManager, category, key, defaultValue, transform) {
|
||||
createPreferenceBinding(
|
||||
element,
|
||||
persistenceManager,
|
||||
category,
|
||||
key,
|
||||
defaultValue,
|
||||
transform
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up two-way binding for App Language
|
||||
* @param {HTMLElement} element - UI element
|
||||
* @param {Object} persistenceManager - Persistence Manager module
|
||||
* @param {string} category - Preference category
|
||||
* @param {string} key - Preference key
|
||||
* @param {*} defaultValue - Default value if preference doesn't exist
|
||||
* @param {Function} [transform] - Optional transform function
|
||||
*/
|
||||
setupLanguageBinding(element, persistenceManager, category, key, defaultValue, transform) {
|
||||
createPreferenceBinding(
|
||||
element,
|
||||
persistenceManager,
|
||||
category,
|
||||
key,
|
||||
defaultValue,
|
||||
transform
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up two-way binding for API settings
|
||||
* @param {Object} persistenceManager - Persistence Manager module
|
||||
*/
|
||||
setupApiPreferenceBindings(persistenceManager) {
|
||||
// ElevenLabs API Key
|
||||
createPreferenceBinding(
|
||||
this.elements.elevenLabsApiKey,
|
||||
persistenceManager,
|
||||
'tts',
|
||||
'elevenlabs_api_key',
|
||||
null,
|
||||
(value) => {
|
||||
this.dispatchApiChangeEvent('api:key:change', 'elevenlabs', 'key', value);
|
||||
return value;
|
||||
}
|
||||
);
|
||||
|
||||
// ElevenLabs API URL
|
||||
createPreferenceBinding(
|
||||
this.elements.elevenLabsApiUrl,
|
||||
persistenceManager,
|
||||
'tts',
|
||||
'elevenlabs_api_url',
|
||||
null,
|
||||
(value) => {
|
||||
this.dispatchApiChangeEvent('api:url:change', 'elevenlabs', 'url', value);
|
||||
return value;
|
||||
}
|
||||
);
|
||||
|
||||
// OpenAI API Key
|
||||
createPreferenceBinding(
|
||||
this.elements.openaiApiKey,
|
||||
persistenceManager,
|
||||
'tts',
|
||||
'openai_api_key',
|
||||
null,
|
||||
(value) => {
|
||||
this.dispatchApiChangeEvent('api:key:change', 'openai', 'key', value);
|
||||
return value;
|
||||
}
|
||||
);
|
||||
|
||||
// OpenAI API URL
|
||||
createPreferenceBinding(
|
||||
this.elements.openaiApiUrl,
|
||||
persistenceManager,
|
||||
'tts',
|
||||
'openai_api_url',
|
||||
null,
|
||||
(value) => {
|
||||
this.dispatchApiChangeEvent('api:url:change', 'openai', 'url', value);
|
||||
return value;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current settings
|
||||
*/
|
||||
saveCurrentSettings() {
|
||||
// With two-way binding, settings are saved automatically as they change
|
||||
console.log('Options UI: Settings saved');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply settings
|
||||
*/
|
||||
applySettings() {
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
// Apply TTS settings
|
||||
const enabled = this.getPreference('tts', 'enabled', false);
|
||||
const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none');
|
||||
|
||||
ttsFactory.configure({ enabled });
|
||||
ttsFactory.setActiveHandler(preferredHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a reload notice
|
||||
* @param {string} message - Message to show
|
||||
*/
|
||||
showReloadNotice(message) {
|
||||
console.log('Options UI: Reload required -', message);
|
||||
this.reloadRequired = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up listeners for settings that should save immediately
|
||||
*/
|
||||
setupImmediateSaveListeners() {
|
||||
// Settings are saved immediately with two-way binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI text based on current language
|
||||
*/
|
||||
updateUIText() {
|
||||
// Update UI text based on current language
|
||||
const localization = this.getModule('localization');
|
||||
if (!localization) return;
|
||||
|
||||
// Update modal title
|
||||
const modalTitle = this.modal.querySelector('h2');
|
||||
if (modalTitle) {
|
||||
modalTitle.textContent = localization.translate('options.title', 'Options');
|
||||
}
|
||||
|
||||
// Update section titles
|
||||
const ttsSectionTitle = this.modal.querySelector('.options-section h3:first-child');
|
||||
if (ttsSectionTitle) {
|
||||
ttsSectionTitle.textContent = localization.translate('options.tts.title', 'Text-to-Speech');
|
||||
}
|
||||
|
||||
const langSectionTitle = this.modal.querySelector('.options-section:nth-child(2) h3');
|
||||
if (langSectionTitle) {
|
||||
langSectionTitle.textContent = localization.translate('options.language.title', 'Language Settings');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up API URL fields with default values
|
||||
*/
|
||||
setupApiUrlFields() {
|
||||
// Set up ElevenLabs API URL
|
||||
if (this.elements.elevenLabsApiUrl) {
|
||||
const savedUrl = this.getPreference('tts', 'elevenlabs_api_url');
|
||||
const defaultUrl = 'https://api.elevenlabs.io/v1';
|
||||
|
||||
// If no saved URL, set the default
|
||||
if (!savedUrl) {
|
||||
console.log('Options UI: Setting default ElevenLabs API URL:', defaultUrl);
|
||||
this.updatePreference('tts', 'elevenlabs_api_url', defaultUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up OpenAI API URL
|
||||
if (this.elements.openaiApiUrl) {
|
||||
const savedUrl = this.getPreference('tts', 'openai_api_url');
|
||||
const defaultUrl = 'https://api.openai.com/v1';
|
||||
|
||||
// If no saved URL, set the default
|
||||
if (!savedUrl) {
|
||||
console.log('Options UI: Setting default OpenAI API URL:', defaultUrl);
|
||||
this.updatePreference('tts', 'openai_api_url', defaultUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure API keys are initialized if not already set
|
||||
if (!this.getPreference('tts', 'elevenlabs_api_key')) {
|
||||
this.updatePreference('tts', 'elevenlabs_api_key', '');
|
||||
}
|
||||
|
||||
if (!this.getPreference('tts', 'openai_api_key')) {
|
||||
this.updatePreference('tts', 'openai_api_key', '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the initial state of the Options UI
|
||||
* @returns {Promise<boolean>} - Promise resolves when setup is complete
|
||||
*/
|
||||
async setupInitialState() {
|
||||
try {
|
||||
console.log('Options UI: Setting up initial state');
|
||||
|
||||
// Add event listener for toggling options UI
|
||||
document.addEventListener('ui:options:toggle', () => this.toggle());
|
||||
|
||||
// Set up key bindings
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.modal && this.modal.style.display === 'flex') {
|
||||
this.saveCurrentSettings();
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Populate TTS systems
|
||||
await this.populateTtsSystems();
|
||||
|
||||
// Populate languages
|
||||
await this.populateLanguages();
|
||||
|
||||
// Populate voices based on current TTS system
|
||||
await this.populateVoices();
|
||||
|
||||
// Load current preferences
|
||||
this.loadPreferences();
|
||||
|
||||
// Register for TTS events to update voices when they change
|
||||
document.addEventListener('tts:voices:updated', () => {
|
||||
console.log('Options UI: Received tts:voices:updated event, updating voice dropdown');
|
||||
this.populateVoices();
|
||||
});
|
||||
|
||||
// Set up language change listener
|
||||
document.addEventListener('locale:changed', async () => {
|
||||
this.updateUIText();
|
||||
await this.populateLanguages();
|
||||
});
|
||||
|
||||
// Register event listeners for TTS availability and voiceId changes
|
||||
document.addEventListener('tts:engine:change', async (event) => {
|
||||
console.log('Options UI: Received TTS engine change event:', event.detail);
|
||||
await this.populateVoices();
|
||||
this.updateApiSettingsVisibility(this.elements.ttsSystem.value);
|
||||
});
|
||||
|
||||
console.log('Options UI: Initial state setup complete');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Options UI: Error setting up initial state', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const OptionsUI = new OptionsUIModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(OptionsUI);
|
||||
|
||||
// Export the module
|
||||
export { OptionsUI };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@
|
||||
* and connects it to the text rendering pipeline.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class ParagraphLayoutModule extends BaseModule {
|
||||
constructor() {
|
||||
@@ -44,9 +43,9 @@ class ParagraphLayoutModule extends BaseModule {
|
||||
this.reportProgress(20, "Initializing paragraph layout");
|
||||
|
||||
// Get text processor using parent's getModule method
|
||||
this.textProcessor = this.getModule('text-processor');
|
||||
const textProcessor = this.getModule('text-processor');
|
||||
|
||||
if (!this.textProcessor) {
|
||||
if (!textProcessor) {
|
||||
console.warn("Paragraph Layout: Text Processor not found, will use fallback processing");
|
||||
}
|
||||
|
||||
@@ -119,12 +118,10 @@ class ParagraphLayoutModule extends BaseModule {
|
||||
}
|
||||
});
|
||||
|
||||
// Use parent's addEventListener for automatic cleanup
|
||||
this.addEventListener(document, 'ui:typography:hyphenation', (event) => {
|
||||
// Listen for config changes
|
||||
this.addEventListener(document, 'ui:hyphenation:toggle', (event) => {
|
||||
if (event.detail && typeof event.detail.enabled === 'boolean') {
|
||||
// Use parent's updateConfig method
|
||||
this.updateConfig({ hyphenationEnabled: event.detail.enabled });
|
||||
console.log(`Paragraph Layout: Hyphenation ${this.config.hyphenationEnabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -135,23 +132,20 @@ class ParagraphLayoutModule extends BaseModule {
|
||||
* @param {string} fontFamily - Font family
|
||||
*/
|
||||
updateFont(fontSize, fontFamily) {
|
||||
if (!this.textMeasureCtx) return;
|
||||
if (!this.textMeasureCtx) {
|
||||
console.warn("Text measurement context not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the font settings
|
||||
this.config.defaultFontSize = fontSize;
|
||||
this.config.defaultFontFamily = fontFamily;
|
||||
// Update config if values are provided
|
||||
if (fontSize) this.updateConfig({ defaultFontSize: fontSize });
|
||||
if (fontFamily) this.updateConfig({ defaultFontFamily: fontFamily });
|
||||
|
||||
// Set the font on the canvas context
|
||||
const fontString = `${fontSize} ${fontFamily}`;
|
||||
this.textMeasureCtx.font = fontString;
|
||||
// Set font on measurement context
|
||||
this.textMeasureCtx.font = `${fontSize} ${fontFamily}`;
|
||||
|
||||
if (this.config.debugMode) {
|
||||
console.log(`Paragraph Layout: Font updated to ${fontString}`);
|
||||
|
||||
// Test measurement
|
||||
const testText = "The quick brown fox jumps over the lazy dog";
|
||||
const width = this.measureText(testText);
|
||||
console.log(`Paragraph Layout: Test text width: ${width}px`);
|
||||
console.log(`Font updated: ${fontSize} ${fontFamily}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,13 +155,11 @@ class ParagraphLayoutModule extends BaseModule {
|
||||
* @returns {number} - Text width in pixels
|
||||
*/
|
||||
measureText(text) {
|
||||
if (!this.textMeasureCtx) {
|
||||
this.initializeTextMeasurement();
|
||||
}
|
||||
|
||||
if (!this.textMeasureCtx) return 0;
|
||||
if (!text) return 0;
|
||||
|
||||
return this.textMeasureCtx.measureText(text).width;
|
||||
const metrics = this.textMeasureCtx.measureText(text);
|
||||
return metrics.width;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,28 +170,25 @@ class ParagraphLayoutModule extends BaseModule {
|
||||
processTextForLayout(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Remove extra whitespace
|
||||
text = text.trim().replace(/\s+/g, ' ');
|
||||
let processedText = text;
|
||||
const textProcessor = this.getModule('text-processor');
|
||||
|
||||
try {
|
||||
// Apply text processor transformations if available
|
||||
if (this.textProcessor) {
|
||||
// Apply smartypants for typography improvements
|
||||
if (this.textProcessor.applySmartypants) {
|
||||
text = this.textProcessor.applySmartypants(text);
|
||||
}
|
||||
|
||||
// Apply hyphenation if enabled
|
||||
if (this.config.hyphenationEnabled && this.textProcessor.hyphenateText) {
|
||||
text = this.textProcessor.hyphenateText(text);
|
||||
}
|
||||
// Apply text processing if available
|
||||
if (textProcessor) {
|
||||
// Apply smartypants (typographic punctuation) if available
|
||||
if (typeof textProcessor.applySmartypants === 'function') {
|
||||
processedText = textProcessor.applySmartypants(processedText);
|
||||
}
|
||||
|
||||
return text;
|
||||
} catch (error) {
|
||||
console.error("Error processing text for layout:", error);
|
||||
return text;
|
||||
// Apply hyphenation if enabled and available
|
||||
if (this.config.hyphenationEnabled && typeof textProcessor.hyphenateText === 'function') {
|
||||
processedText = textProcessor.hyphenateText(processedText);
|
||||
}
|
||||
} else if (this.config.debugMode) {
|
||||
console.log("Text processor not available, skipping text processing");
|
||||
}
|
||||
|
||||
return processedText;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,7 +291,9 @@ class ParagraphLayoutModule extends BaseModule {
|
||||
const ParagraphLayout = new ParagraphLayoutModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(ParagraphLayout);
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(ParagraphLayout);
|
||||
}
|
||||
|
||||
// Export the module
|
||||
export { ParagraphLayout };
|
||||
@@ -3,7 +3,6 @@
|
||||
* Handles saving and loading game state and user preferences
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class PersistenceManagerModule extends BaseModule {
|
||||
/**
|
||||
@@ -147,6 +146,8 @@ class PersistenceManagerModule extends BaseModule {
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
savePreferences() {
|
||||
if (!this.preferences) return false;
|
||||
|
||||
try {
|
||||
localStorage.setItem(this.keys.preferences, JSON.stringify(this.preferences));
|
||||
|
||||
@@ -174,20 +175,17 @@ class PersistenceManagerModule extends BaseModule {
|
||||
// Parse stored preferences
|
||||
const storedPrefs = JSON.parse(prefsJson);
|
||||
|
||||
// Merge with default preferences to ensure all keys exist
|
||||
// Merge with defaults to ensure all keys exist
|
||||
this.preferences = this.mergeWithDefaults(storedPrefs, this.defaultPreferences);
|
||||
} else {
|
||||
// Use default preferences if none found
|
||||
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
|
||||
|
||||
// Use defaults if no stored preferences found
|
||||
this.preferences = {...this.defaultPreferences};
|
||||
}
|
||||
|
||||
return this.preferences;
|
||||
} catch (error) {
|
||||
console.error("Error loading preferences:", error);
|
||||
|
||||
// Fall back to default preferences
|
||||
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
|
||||
this.preferences = {...this.defaultPreferences};
|
||||
return this.preferences;
|
||||
}
|
||||
}
|
||||
@@ -199,38 +197,31 @@ class PersistenceManagerModule extends BaseModule {
|
||||
* @returns {Object} - Merged preferences
|
||||
*/
|
||||
mergeWithDefaults(stored, defaults) {
|
||||
const result = {};
|
||||
// Base case: if stored is not an object or is null, return defaults
|
||||
if (typeof stored !== 'object' || stored === null) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
// For each category in defaults
|
||||
for (const category in defaults) {
|
||||
result[category] = {};
|
||||
|
||||
// Copy all settings from defaults for this category
|
||||
for (const setting in defaults[category]) {
|
||||
// Use stored value if it exists, otherwise use default
|
||||
result[category][setting] = (stored[category] && stored[category][setting] !== undefined)
|
||||
? stored[category][setting]
|
||||
: defaults[category][setting];
|
||||
}
|
||||
|
||||
// Copy any additional settings from stored that aren't in defaults
|
||||
if (stored[category]) {
|
||||
for (const setting in stored[category]) {
|
||||
if (result[category][setting] === undefined) {
|
||||
result[category][setting] = stored[category][setting];
|
||||
}
|
||||
// Create a new object to avoid modifying the input objects
|
||||
const merged = {};
|
||||
|
||||
// Add all keys from defaults, overriding with stored values where they exist
|
||||
for (const key in defaults) {
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, key)) {
|
||||
// If the default value is an object and not null, recurse
|
||||
if (typeof defaults[key] === 'object' && defaults[key] !== null) {
|
||||
merged[key] = this.mergeWithDefaults(
|
||||
Object.prototype.hasOwnProperty.call(stored, key) ? stored[key] : {},
|
||||
defaults[key]
|
||||
);
|
||||
} else {
|
||||
// Otherwise, use stored value if it exists, otherwise use default
|
||||
merged[key] = Object.prototype.hasOwnProperty.call(stored, key) ? stored[key] : defaults[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy any additional categories from stored that aren't in defaults
|
||||
for (const category in stored) {
|
||||
if (result[category] === undefined) {
|
||||
result[category] = stored[category];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,26 +232,22 @@ class PersistenceManagerModule extends BaseModule {
|
||||
* @returns {*} - Preference value
|
||||
*/
|
||||
getPreference(category, setting, defaultValue = null) {
|
||||
if (!category || !setting) return defaultValue;
|
||||
|
||||
// Ensure preferences are loaded
|
||||
if (!this.preferences) {
|
||||
this.loadPreferences();
|
||||
}
|
||||
|
||||
if (this.preferences[category] && this.preferences[category][setting] !== undefined) {
|
||||
return this.preferences[category][setting];
|
||||
}
|
||||
// Check if category exists
|
||||
if (!this.preferences[category]) return defaultValue;
|
||||
|
||||
// If default value provided, use it
|
||||
if (defaultValue !== null) {
|
||||
// Check if setting exists in category
|
||||
if (!Object.prototype.hasOwnProperty.call(this.preferences[category], setting)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Otherwise check default preferences
|
||||
if (this.defaultPreferences[category] && this.defaultPreferences[category][setting] !== undefined) {
|
||||
return this.defaultPreferences[category][setting];
|
||||
}
|
||||
|
||||
// If all else fails, return null
|
||||
return null;
|
||||
return this.preferences[category][setting];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,6 +258,9 @@ class PersistenceManagerModule extends BaseModule {
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
updatePreference(category, setting, value) {
|
||||
if (!category || !setting) return false;
|
||||
|
||||
// Ensure preferences are loaded
|
||||
if (!this.preferences) {
|
||||
this.loadPreferences();
|
||||
}
|
||||
@@ -281,23 +271,20 @@ class PersistenceManagerModule extends BaseModule {
|
||||
}
|
||||
|
||||
// Update preference
|
||||
const oldValue = this.preferences[category][setting];
|
||||
this.preferences[category][setting] = value;
|
||||
|
||||
// Save preferences
|
||||
this.savePreferences();
|
||||
const success = this.savePreferences();
|
||||
|
||||
// Dispatch event if value changed
|
||||
if (oldValue !== value) {
|
||||
this.dispatchEvent('preference-changed', {
|
||||
category,
|
||||
setting,
|
||||
value,
|
||||
oldValue
|
||||
});
|
||||
}
|
||||
// Dispatch event
|
||||
this.dispatchEvent('preference-updated', {
|
||||
category,
|
||||
setting,
|
||||
value,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return true;
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,18 +293,18 @@ class PersistenceManagerModule extends BaseModule {
|
||||
*/
|
||||
resetPreferences() {
|
||||
try {
|
||||
// Clone default preferences
|
||||
// Create a deep clone of default preferences
|
||||
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
|
||||
|
||||
// Save preferences
|
||||
this.savePreferences();
|
||||
const success = this.savePreferences();
|
||||
|
||||
// Dispatch event
|
||||
this.dispatchEvent('preferences-reset', {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return true;
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error("Error resetting preferences:", error);
|
||||
return false;
|
||||
@@ -346,6 +333,14 @@ class PersistenceManagerModule extends BaseModule {
|
||||
|
||||
if (slotsJson) {
|
||||
this.saveSlots = JSON.parse(slotsJson);
|
||||
|
||||
// Validate each save slot
|
||||
for (const id in this.saveSlots) {
|
||||
const slot = this.saveSlots[id];
|
||||
if (!slot.id || !slot.name || !slot.timestamp || !slot.state) {
|
||||
delete this.saveSlots[id];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.saveSlots = {};
|
||||
}
|
||||
@@ -496,8 +491,5 @@ class PersistenceManagerModule extends BaseModule {
|
||||
// Create the singleton instance
|
||||
const PersistenceManager = new PersistenceManagerModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(PersistenceManager);
|
||||
|
||||
// Export the module
|
||||
export { PersistenceManager };
|
||||
@@ -3,7 +3,6 @@
|
||||
* Handles WebSocket communication for receiving text fragments and game state
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class SocketClientModule extends BaseModule {
|
||||
constructor() {
|
||||
@@ -190,8 +189,8 @@ class SocketClientModule extends BaseModule {
|
||||
this.textBuffer.addText(text);
|
||||
} else {
|
||||
console.error('Socket Client: Text buffer not available');
|
||||
// Attempt to get text buffer again
|
||||
this.textBuffer = moduleRegistry.getModule('text-buffer');
|
||||
// Attempt to get text buffer again using parent's getModule method
|
||||
this.textBuffer = this.getModule('text-buffer');
|
||||
if (this.textBuffer) {
|
||||
this.textBuffer.addText(text);
|
||||
} else {
|
||||
@@ -400,11 +399,5 @@ class SocketClientModule extends BaseModule {
|
||||
// Create the singleton instance
|
||||
const SocketClient = new SocketClientModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(SocketClient);
|
||||
|
||||
// Export the module
|
||||
export { SocketClient };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.SocketClient = SocketClient;
|
||||
@@ -3,7 +3,6 @@
|
||||
* Manages text processing and sentence detection for the UI
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class TextBufferModule extends BaseModule {
|
||||
constructor() {
|
||||
@@ -113,60 +112,42 @@ class TextBufferModule extends BaseModule {
|
||||
this.buffer += text;
|
||||
|
||||
// If we have a trailing newline as a complete sentence, add a period
|
||||
if (this.buffer.endsWith('\n') && !this.buffer.endsWith('.\n')) {
|
||||
const lastChar = this.buffer.charAt(this.buffer.length - 2);
|
||||
if (lastChar !== '.' && lastChar !== '!' && lastChar !== '?') {
|
||||
this.buffer = this.buffer.slice(0, -1) + '.\n';
|
||||
}
|
||||
}
|
||||
this.buffer = this.buffer.replace(/\n$/g, '.\n');
|
||||
|
||||
// Process any complete sentences
|
||||
// Process sentences
|
||||
this.processSentences();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Process complete sentences in the buffer
|
||||
*/
|
||||
processSentences() {
|
||||
// Prevent concurrent processing
|
||||
if (this.processingLock) return;
|
||||
// If already processing, don't start another processing cycle
|
||||
if (this.processingLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processingLock = true;
|
||||
|
||||
try {
|
||||
// Check for sentence endings (including newlines as sentence endings)
|
||||
const sentenceEndings = [/[.!?]\s+/g, /[.!?]$/m, /\n/g];
|
||||
// If the buffer is empty, release the lock and check queue
|
||||
if (this.buffer.length === 0) {
|
||||
this.processingLock = false;
|
||||
|
||||
let foundSentence = false;
|
||||
|
||||
for (const pattern of sentenceEndings) {
|
||||
if (this.buffer.match(pattern)) {
|
||||
foundSentence = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundSentence) {
|
||||
// No complete sentences yet
|
||||
this.processingLock = false;
|
||||
// If no more text to process, end processing
|
||||
if (this.processingQueue.length === 0) {
|
||||
this.isProcessingActive = false;
|
||||
|
||||
// Use parent's dispatchEvent method
|
||||
super.dispatchEvent('buffer:waiting', {
|
||||
remainingText: this.buffer,
|
||||
queueLength: this.processingQueue.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the next complete sentence
|
||||
this.processNextSentence();
|
||||
} catch (error) {
|
||||
console.error("Error processing sentences:", error);
|
||||
this.processingLock = false;
|
||||
this.isProcessingActive = false;
|
||||
// Process the next text fragment
|
||||
this.processNextFromQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the next sentence
|
||||
this.processNextSentence();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Process the next sentence in the buffer
|
||||
*/
|
||||
@@ -197,7 +178,7 @@ class TextBufferModule extends BaseModule {
|
||||
// Remove the processed sentence from buffer
|
||||
this.buffer = this.buffer.substring(endIndex);
|
||||
|
||||
console.log(`TextBuffer: Processing sentence: "${sentence.trim()}"`);
|
||||
console.log(`TextBuffer: Processing sentence: "${sentence.trim()}"`);
|
||||
|
||||
// Use parent's dispatchEvent method
|
||||
super.dispatchEvent('buffer:sentence', {
|
||||
@@ -284,11 +265,5 @@ class TextBufferModule extends BaseModule {
|
||||
// Create the singleton instance
|
||||
const TextBuffer = new TextBufferModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(TextBuffer);
|
||||
|
||||
// Export the module
|
||||
export { TextBuffer };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.TextBuffer = TextBuffer;
|
||||
@@ -3,7 +3,6 @@
|
||||
* Handles text formatting and typography enhancements like smart quotes and hyphenation
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import Hyphenopoly from './hyphenopoly.module.js';
|
||||
|
||||
class TextProcessorModule extends BaseModule {
|
||||
@@ -15,6 +14,9 @@ class TextProcessorModule extends BaseModule {
|
||||
this.hyphenatorReady = false;
|
||||
this.locale = 'en-us';
|
||||
|
||||
// Add localization as a dependency
|
||||
this.dependencies = ['localization'];
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
'loadSmartyPantsScript',
|
||||
@@ -25,9 +27,6 @@ class TextProcessorModule extends BaseModule {
|
||||
'setLocale',
|
||||
'handleLocaleChanged'
|
||||
]);
|
||||
|
||||
// Add localization as a dependency
|
||||
this.dependencies = ['localization'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,11 +319,5 @@ class TextProcessorModule extends BaseModule {
|
||||
// Create the singleton instance
|
||||
const TextProcessor = new TextProcessorModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(TextProcessor);
|
||||
|
||||
// Export the module
|
||||
export { TextProcessor };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.TextProcessor = TextProcessor;
|
||||
@@ -11,7 +11,14 @@ class TTSFactoryModule extends BaseModule {
|
||||
constructor() {
|
||||
super('tts-factory', 'TTS Factory');
|
||||
|
||||
this.dependencies = ['persistence-manager', 'localization'];
|
||||
this.dependencies = [
|
||||
'persistence-manager',
|
||||
'localization',
|
||||
'browser-tts', // Browser TTS handler
|
||||
'kokoro-tts', // Kokoro TTS handler
|
||||
'elevenlabs-tts',// ElevenLabs TTS handler
|
||||
'openai-tts' // OpenAI TTS handler
|
||||
];
|
||||
this.handlers = {};
|
||||
this.initStatus = {};
|
||||
this.activeHandler = null;
|
||||
@@ -122,7 +129,7 @@ class TTSFactoryModule extends BaseModule {
|
||||
|
||||
// Load preferences
|
||||
this.reportProgress(40, 'Loading TTS preferences');
|
||||
await this.loadPreferences();
|
||||
const preferences = await this.loadPreferences();
|
||||
|
||||
// Check for TTS handlers
|
||||
this.reportProgress(60, 'Finding TTS handlers');
|
||||
@@ -131,15 +138,27 @@ class TTSFactoryModule extends BaseModule {
|
||||
// Set default status
|
||||
this.ttsAvailable = false;
|
||||
|
||||
// Set up event handlers - do this before initializing handlers
|
||||
// so we can listen for events during initialization
|
||||
this.setupEvents();
|
||||
|
||||
// Initialize preferred or fallback handler
|
||||
this.reportProgress(80, 'Initializing TTS handler');
|
||||
await this.initializeHandlerSystem();
|
||||
|
||||
// Set up event handlers
|
||||
this.setupEvents();
|
||||
// Apply configuration from preferences
|
||||
if (preferences) {
|
||||
console.log('TTS Factory: Applying saved configuration');
|
||||
// Apply speed setting
|
||||
this.configure({ speed: preferences.speed });
|
||||
|
||||
// Update TTS availability based on active handler
|
||||
this.updateTTSAvailability();
|
||||
}
|
||||
|
||||
// Debug: Log all registered modules
|
||||
// Debug: Log all registered modules and handlers
|
||||
this.debugLogAllRegisteredModules();
|
||||
this.debugTTSHandlers();
|
||||
|
||||
this.reportProgress(100, 'TTS Factory initialized');
|
||||
console.log(`TTS Factory: Initialization complete, TTS available: ${this.ttsAvailable}`);
|
||||
@@ -284,69 +303,39 @@ class TTSFactoryModule extends BaseModule {
|
||||
getAvailableHandlers() {
|
||||
const availableHandlers = [];
|
||||
|
||||
// The key handlers we want to ALWAYS include in the dropdown for API configuration
|
||||
const apiHandlerIds = ['elevenlabs', 'openai'];
|
||||
// Always include a 'none' option
|
||||
availableHandlers.push({
|
||||
id: 'none',
|
||||
handler: null,
|
||||
displayName: 'None'
|
||||
});
|
||||
|
||||
// First, add all API-based handlers to make sure they're always available in the UI
|
||||
// even if they're not registered or initialized
|
||||
for (const id of apiHandlerIds) {
|
||||
// If the handler is registered in our handlers object, use it
|
||||
if (this.handlers[id]) {
|
||||
console.log(`TTS Factory: Adding API handler ${id} to available handlers list`);
|
||||
availableHandlers.push({
|
||||
id: id,
|
||||
handler: this.handlers[id]
|
||||
});
|
||||
} else {
|
||||
// If the handler isn't registered yet, still include it in the list
|
||||
// This ensures API handlers always show up in the UI for configuration
|
||||
console.log(`TTS Factory: Adding placeholder for API handler ${id} to available handlers list`);
|
||||
availableHandlers.push({
|
||||
id: id,
|
||||
handler: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add Kokoro handler - it's not an API handler but we want it to always appear
|
||||
if (this.handlers['kokoro']) {
|
||||
console.log('TTS Factory: Adding Kokoro handler to available handlers list');
|
||||
// Always add all registered handlers to the dropdown, regardless of ready state
|
||||
for (const id in this.handlers) {
|
||||
const handler = this.handlers[id];
|
||||
availableHandlers.push({
|
||||
id: 'kokoro',
|
||||
handler: this.handlers['kokoro']
|
||||
id: id,
|
||||
handler: handler,
|
||||
isReady: handler.isReady === true
|
||||
});
|
||||
}
|
||||
|
||||
// Then add any other non-API handlers that are initialized/ready
|
||||
for (const id in this.handlers) {
|
||||
// Skip handlers we've already added
|
||||
if (apiHandlerIds.includes(id) || id === 'kokoro') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const handler = this.handlers[id];
|
||||
|
||||
// Only include non-API handlers if they're properly initialized
|
||||
const isAvailable = this.initStatus[id] === true || handler.isReady === true;
|
||||
|
||||
if (handler && isAvailable) {
|
||||
console.log(`TTS Factory: Adding non-API handler ${id} to available handlers list`);
|
||||
// Check if this handler is already in the list
|
||||
if (!availableHandlers.some(h => h.id === id)) {
|
||||
availableHandlers.push({
|
||||
id: id,
|
||||
handler: handler
|
||||
});
|
||||
}
|
||||
// Add placeholder entries for important API handlers that might not be registered yet
|
||||
const apiHandlerIds = ['elevenlabs', 'openai'];
|
||||
for (const id of apiHandlerIds) {
|
||||
// Only add if not already in the list
|
||||
if (!this.handlers[id] && !availableHandlers.some(h => h.id === id)) {
|
||||
console.log(`TTS Factory: Adding placeholder for API handler ${id} to available handlers list`);
|
||||
availableHandlers.push({
|
||||
id: id,
|
||||
handler: null,
|
||||
isReady: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (availableHandlers.length === 0) {
|
||||
console.warn('TTS Factory: No available handlers found - something is wrong!');
|
||||
} else {
|
||||
console.log(`TTS Factory: Found ${availableHandlers.length} available handlers:`,
|
||||
availableHandlers.map(h => h.id).join(', '));
|
||||
}
|
||||
console.log(`TTS Factory: Returning ${availableHandlers.length} handlers for UI (including 'none'):`,
|
||||
availableHandlers.map(h => h.id).join(', '));
|
||||
|
||||
return availableHandlers;
|
||||
}
|
||||
@@ -369,14 +358,59 @@ class TTSFactoryModule extends BaseModule {
|
||||
async loadPreferences() {
|
||||
// Get the persistence manager for preferences
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
// Load speed preference
|
||||
const savedSpeed = persistenceManager.getPreference('tts', 'speed');
|
||||
if (typeof savedSpeed === 'number') {
|
||||
this.speed = savedSpeed;
|
||||
console.log(`TTS Factory: Loaded speed preference: ${this.speed}`);
|
||||
if (!persistenceManager) {
|
||||
console.warn('TTS Factory: No persistence manager available, using default settings');
|
||||
return;
|
||||
}
|
||||
|
||||
// Default settings for first run
|
||||
const defaults = {
|
||||
'speed': 0.5, // Default speech rate (0-1 range)
|
||||
'preferred_handler': 'kokoro', // Default to Kokoro TTS
|
||||
'enabled': false, // TTS disabled by default
|
||||
'voice': '', // Empty default - will be selected based on handler
|
||||
'language': 'en-US', // Default language
|
||||
'volume': 1.0, // Default volume
|
||||
'elevenlabs_api_key': '', // Empty API key by default
|
||||
'elevenlabs_api_url': 'https://api.elevenlabs.io/v1', // Default ElevenLabs API URL
|
||||
'openai_api_key': '', // Empty API key by default
|
||||
'openai_api_url': 'https://api.openai.com/v1' // Default OpenAI API URL
|
||||
};
|
||||
|
||||
// Ensure all defaults are set in persistence if they don't exist
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
if (persistenceManager.getPreference('tts', key) === undefined) {
|
||||
console.log(`TTS Factory: Setting default for '${key}': ${value}`);
|
||||
persistenceManager.updatePreference('tts', key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Load speech rate preference
|
||||
const savedSpeed = persistenceManager.getPreference('tts', 'speed');
|
||||
if (typeof savedSpeed === 'number') {
|
||||
this.speed = savedSpeed;
|
||||
console.log(`TTS Factory: Loaded speed preference: ${this.speed}`);
|
||||
} else {
|
||||
this.speed = defaults.speed;
|
||||
console.log(`TTS Factory: Using default speed: ${this.speed}`);
|
||||
}
|
||||
|
||||
// Load other preferences we need for initialization
|
||||
const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler');
|
||||
console.log(`TTS Factory: Loaded preferred handler: ${preferredHandler || 'none'}`);
|
||||
|
||||
// We'll handle the preferred handler in initializeHandlerSystem()
|
||||
|
||||
// Check if TTS is enabled
|
||||
const ttsEnabled = persistenceManager.getPreference('tts', 'enabled');
|
||||
console.log(`TTS Factory: TTS enabled: ${ttsEnabled}`);
|
||||
|
||||
// Return the loaded preferences for convenience
|
||||
return {
|
||||
preferredHandler: preferredHandler || defaults.preferred_handler,
|
||||
enabled: ttsEnabled !== undefined ? ttsEnabled : defaults.enabled,
|
||||
speed: this.speed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,7 +441,7 @@ class TTSFactoryModule extends BaseModule {
|
||||
for (const [index, handler] of handlers.entries()) {
|
||||
try {
|
||||
console.log(`TTS Factory: Attempting to get module '${handler.id}'`);
|
||||
const module = moduleRegistry.getModule(handler.id);
|
||||
const module = this.getModule(handler.id);
|
||||
|
||||
if (module) {
|
||||
console.log(`TTS Factory: Successfully got module '${handler.id}'`, module);
|
||||
@@ -436,22 +470,35 @@ class TTSFactoryModule extends BaseModule {
|
||||
console.log(`TTS Factory: Preferred handler from settings: ${preferredHandler || 'none'}`);
|
||||
}
|
||||
|
||||
// Try to initialize and set the preferred handler
|
||||
if (preferredHandler && this.handlers[preferredHandler]) {
|
||||
console.log(`TTS Factory: Attempting to initialize preferred handler: ${preferredHandler}`);
|
||||
|
||||
// Try to initialize the preferred handler
|
||||
const success = await this.initializeHandler(preferredHandler);
|
||||
|
||||
if (success) {
|
||||
console.log(`TTS Factory: Preferred handler ${preferredHandler} initialized successfully`);
|
||||
return await this.setActiveHandler(preferredHandler);
|
||||
// Special case for 'none' preference
|
||||
if (preferredHandler === 'none') {
|
||||
console.log('TTS Factory: User has disabled TTS (none selected)');
|
||||
this.activeHandler = null;
|
||||
this.updateTTSAvailability();
|
||||
return true;
|
||||
}
|
||||
|
||||
// If user has a preferred handler, attempt to set it even if not initialized
|
||||
if (preferredHandler) {
|
||||
// Check if handler exists
|
||||
if (this.handlers[preferredHandler]) {
|
||||
console.log(`TTS Factory: Attempting to initialize preferred handler: ${preferredHandler}`);
|
||||
|
||||
// Try to initialize the preferred handler
|
||||
const success = await this.initializeHandler(preferredHandler);
|
||||
|
||||
// Set as active regardless of initialization result
|
||||
// TTS will be considered disabled if handler exists but isn't ready
|
||||
console.log(`TTS Factory: Setting preferred handler ${preferredHandler} as active (init success: ${success})`);
|
||||
await this.setActiveHandler(preferredHandler);
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`TTS Factory: Preferred handler ${preferredHandler} initialization failed, trying fallbacks`);
|
||||
console.log(`TTS Factory: Preferred handler ${preferredHandler} not registered yet, will be set when available`);
|
||||
// We can't set it as active yet since it doesn't exist, but we've stored the preference
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't initialize the preferred handler, try fallbacks
|
||||
// If we don't have a preferred handler or it's not registered, try fallbacks
|
||||
return this.attemptFallbackHandler();
|
||||
}
|
||||
|
||||
@@ -526,13 +573,15 @@ class TTSFactoryModule extends BaseModule {
|
||||
*/
|
||||
registerHandler(id, handler) {
|
||||
if (!handler) {
|
||||
console.warn(`TTS Factory: Cannot register null handler for id ${id}`);
|
||||
console.error(`TTS Factory: Cannot register null handler for ${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`TTS Factory: Registering handler ${id}`);
|
||||
this.handlers[id] = handler;
|
||||
this.initStatus[id] = false;
|
||||
|
||||
// Note: Handlers now declare their own dependencies and access them via getModule()
|
||||
// They no longer need dependencies to be provided by the factory
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -541,14 +590,49 @@ class TTSFactoryModule extends BaseModule {
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
async setActiveHandler(id) {
|
||||
// Make sure the handler exists and is initialized
|
||||
if (!this.handlers[id] || !this.initStatus[id]) {
|
||||
console.error(`TTS Factory: Cannot set active handler to ${id} - not available`);
|
||||
// Special case for 'none' option
|
||||
if (id === 'none') {
|
||||
console.log('TTS Factory: Disabling TTS (none selected)');
|
||||
this.activeHandler = null;
|
||||
|
||||
// Save the preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'preferred_handler', 'none');
|
||||
}
|
||||
|
||||
// Dispatch event
|
||||
document.dispatchEvent(new CustomEvent('tts:handler:changed', {
|
||||
detail: { handler: 'none', available: false }
|
||||
}));
|
||||
|
||||
this.updateTTSAvailability();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the handler exists
|
||||
if (!this.handlers[id]) {
|
||||
console.warn(`TTS Factory: Handler ${id} not registered - still setting as preferred`);
|
||||
// We'll still set the preference but won't set as active until it's registered
|
||||
|
||||
// Save the preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'preferred_handler', id);
|
||||
}
|
||||
|
||||
// We should not set this.activeHandler since the handler doesn't exist
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`TTS Factory: Setting active handler to ${id}`);
|
||||
|
||||
// Check if the handler is ready (just for logging)
|
||||
const isReady = this.handlers[id].isReady === true;
|
||||
if (!isReady) {
|
||||
console.warn(`TTS Factory: Handler ${id} is not ready - TTS will be considered disabled until ready`);
|
||||
}
|
||||
|
||||
// Stop any current speech
|
||||
if (this.activeHandler && this.handlers[this.activeHandler]) {
|
||||
this.handlers[this.activeHandler].stop();
|
||||
@@ -565,10 +649,13 @@ class TTSFactoryModule extends BaseModule {
|
||||
|
||||
// Dispatch event
|
||||
const event = new CustomEvent('tts:handler:changed', {
|
||||
detail: { handler: id }
|
||||
detail: { handler: id, available: isReady }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// Update overall TTS availability
|
||||
this.updateTTSAvailability();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -591,8 +678,8 @@ class TTSFactoryModule extends BaseModule {
|
||||
*/
|
||||
speak(text, options = {}) {
|
||||
// Check if we have an active handler
|
||||
if (!this.activeHandler || !this.ttsAvailable) {
|
||||
console.warn('TTS Factory: No active handler or TTS not available');
|
||||
if (!this.activeHandler) {
|
||||
console.warn('TTS Factory: No active handler set');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -603,6 +690,12 @@ class TTSFactoryModule extends BaseModule {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the handler is ready
|
||||
if (!handler.isReady) {
|
||||
console.warn(`TTS Factory: Active handler ${this.activeHandler} is not ready`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Apply speed option if specified
|
||||
const effectiveOptions = { ...options };
|
||||
@@ -804,35 +897,28 @@ class TTSFactoryModule extends BaseModule {
|
||||
* Update overall TTS availability
|
||||
*/
|
||||
updateTTSAvailability() {
|
||||
// TTS is considered available if at least one handler is initialized
|
||||
const wasAvailable = this.ttsAvailable;
|
||||
|
||||
// Check if any handler is available (initialized and ready)
|
||||
let anyHandlerAvailable = false;
|
||||
for (const id in this.handlers) {
|
||||
const handler = this.handlers[id];
|
||||
if (handler && this.initStatus[id] === true && handler.isReady === true) {
|
||||
anyHandlerAvailable = true;
|
||||
break;
|
||||
}
|
||||
// TTS is considered available only if the active handler exists and is ready
|
||||
let ttsAvailable = false;
|
||||
|
||||
if (this.activeHandler && this.handlers[this.activeHandler]) {
|
||||
// Check if the active handler is ready
|
||||
ttsAvailable = this.handlers[this.activeHandler].isReady === true;
|
||||
}
|
||||
|
||||
this.ttsAvailable = anyHandlerAvailable;
|
||||
this.ttsAvailable = ttsAvailable;
|
||||
|
||||
console.log('TTS Factory: Availability updated:', this.ttsAvailable);
|
||||
console.log('TTS Factory: Handler status:', JSON.stringify(this.initStatus));
|
||||
|
||||
// Handler details for debugging
|
||||
for (const id in this.handlers) {
|
||||
const handler = this.handlers[id];
|
||||
console.log(`TTS Factory: Handler ${id} status: initStatus=${this.initStatus[id]}, isReady=${handler ? handler.isReady : 'handler undefined'}`);
|
||||
}
|
||||
console.log(`TTS Factory: Availability updated: ${this.ttsAvailable} (active handler: ${this.activeHandler || 'none'})`);
|
||||
|
||||
// Only dispatch event if availability changed
|
||||
if (wasAvailable !== this.ttsAvailable) {
|
||||
// Notify the UI about TTS availability
|
||||
const event = new CustomEvent('tts:availability', {
|
||||
detail: { available: this.ttsAvailable }
|
||||
detail: {
|
||||
available: this.ttsAvailable,
|
||||
activeHandler: this.activeHandler
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
@@ -1616,11 +1702,5 @@ class TTSFactoryModule extends BaseModule {
|
||||
// Create module instance
|
||||
const TTSFactory = new TTSFactoryModule();
|
||||
|
||||
// Import the moduleRegistry for initial registration
|
||||
// Note: This is the only place where direct import is appropriate, as we need to
|
||||
// register the module before it can use the dependency system
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
moduleRegistry.register(TTSFactory);
|
||||
|
||||
// Export the module
|
||||
export { TTSFactory };
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* TTS Handler Base Class
|
||||
* Abstract base class defining the interface for all TTS handlers
|
||||
*/
|
||||
export class TTSHandler {
|
||||
constructor() {
|
||||
this.voiceOptions = {};
|
||||
this.isReady = false;
|
||||
|
||||
// Set up event dispatcher
|
||||
this.eventTarget = document.createElement('div');
|
||||
|
||||
// Module state tracking - conform to BaseModule interface
|
||||
this.state = 'PENDING';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handler ID
|
||||
* @returns {string} - Handler identifier
|
||||
*/
|
||||
getId() {
|
||||
throw new Error('getId() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the TTS handler
|
||||
* @param {Function} progressCallback - Optional progress callback
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize(progressCallback = null) {
|
||||
throw new Error('initialize() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this TTS handler is available
|
||||
* @returns {boolean} - True if handler is ready to use
|
||||
*/
|
||||
isAvailable() {
|
||||
return this.isReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if voice is currently speaking
|
||||
* @returns {boolean} - True if speaking
|
||||
*/
|
||||
isSpeaking() {
|
||||
return false; // Default implementation
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using this handler
|
||||
* @param {string} text - The text to speak
|
||||
* @param {Function} callback - Optional callback when speech completes
|
||||
*/
|
||||
speak(text, callback = null) {
|
||||
throw new Error('speak() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop speech
|
||||
*/
|
||||
stop() {
|
||||
throw new Error('stop() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
// Default implementation merges options
|
||||
this.voiceOptions = { ...this.voiceOptions, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Promise<Array>} - Resolves with array of voice objects
|
||||
*/
|
||||
async getVoices() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current module state
|
||||
* @returns {string} - Current state
|
||||
*/
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the module state
|
||||
* @param {string} newState - The new state
|
||||
*/
|
||||
changeState(newState) {
|
||||
this.state = newState;
|
||||
|
||||
// Dispatch state change event
|
||||
this.dispatchEvent('state:changed', {
|
||||
state: newState
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a custom event
|
||||
* @param {string} eventName - Name of the event
|
||||
* @param {Object} detail - Event details
|
||||
*/
|
||||
dispatchEvent(eventName, detail = {}) {
|
||||
const event = new CustomEvent(eventName, {
|
||||
detail: { handlerId: this.getId(), ...detail },
|
||||
bubbles: true
|
||||
});
|
||||
this.eventTarget.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event listener
|
||||
* @param {string} eventName - Name of the event
|
||||
* @param {Function} callback - Event handler function
|
||||
*/
|
||||
addEventListener(eventName, callback) {
|
||||
this.eventTarget.addEventListener(eventName, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listener
|
||||
* @param {string} eventName - Name of the event
|
||||
* @param {Function} callback - Event handler function
|
||||
*/
|
||||
removeEventListener(eventName, callback) {
|
||||
this.eventTarget.removeEventListener(eventName, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind methods to this instance
|
||||
* @param {Array<string>} methodNames - Array of method names to bind
|
||||
*/
|
||||
bindMethods(methodNames) {
|
||||
if (!Array.isArray(methodNames)) return;
|
||||
|
||||
methodNames.forEach(methodName => {
|
||||
if (typeof this[methodName] === 'function') {
|
||||
this[methodName] = this[methodName].bind(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a unique identifier for the current voice configuration
|
||||
* Used for caching purposes
|
||||
* @returns {string} - Unique identifier for current voice
|
||||
*/
|
||||
getCurrentVoiceIdentifier() {
|
||||
// Default implementation uses voice ID and rate/speed
|
||||
const voiceId = this.voiceOptions.voice || 'default';
|
||||
const rate = this.voiceOptions.rate || this.voiceOptions.speed || 1.0;
|
||||
|
||||
// Return a string that uniquely identifies this voice configuration
|
||||
return `${voiceId}_${rate}`;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
* Manages TTS functionality and interacts with available TTS handlers
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class TTSPlayerModule extends BaseModule {
|
||||
constructor() {
|
||||
@@ -75,9 +74,10 @@ class TTSPlayerModule extends BaseModule {
|
||||
if (!available) {
|
||||
this.enabled = false;
|
||||
// Notify UI that TTS is disabled
|
||||
document.dispatchEvent(new CustomEvent('tts:stateChange', {
|
||||
detail: { enabled: false, available: false }
|
||||
}));
|
||||
super.dispatchEvent('tts:stateChange', {
|
||||
enabled: false,
|
||||
available: false
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -86,9 +86,10 @@ class TTSPlayerModule extends BaseModule {
|
||||
this.addEventListener(document, 'tts:toggle', () => {
|
||||
this.toggle();
|
||||
// Dispatch state change event for UI to update
|
||||
document.dispatchEvent(new CustomEvent('tts:stateChange', {
|
||||
detail: { enabled: this.enabled, available: ttsFactory.ttsAvailable }
|
||||
}));
|
||||
super.dispatchEvent('tts:stateChange', {
|
||||
enabled: this.enabled,
|
||||
available: ttsFactory.ttsAvailable
|
||||
});
|
||||
});
|
||||
|
||||
// Also listen for ui:tts:toggle events (from the main UI)
|
||||
@@ -101,28 +102,23 @@ class TTSPlayerModule extends BaseModule {
|
||||
}
|
||||
|
||||
// Dispatch state change event for UI to update
|
||||
document.dispatchEvent(new CustomEvent('tts:stateChange', {
|
||||
detail: { enabled: this.enabled, available: ttsFactory.ttsAvailable }
|
||||
}));
|
||||
super.dispatchEvent('tts:stateChange', {
|
||||
enabled: this.enabled,
|
||||
available: ttsFactory.ttsAvailable
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for sentence ready events to preload TTS
|
||||
this.addEventListener(document, 'buffer:sentence', (event) => {
|
||||
if (event.detail && event.detail.sentence && this.enabled) {
|
||||
// Add to preload queue
|
||||
this.preloadSpeech(event.detail.sentence);
|
||||
}
|
||||
});
|
||||
|
||||
// Dispatch initial state to UI
|
||||
document.dispatchEvent(new CustomEvent('tts:stateChange', {
|
||||
detail: { enabled: this.enabled, available: ttsFactory.ttsAvailable }
|
||||
}));
|
||||
// Request available TTS voices
|
||||
this.reportProgress(60, "Checking for available TTS voices");
|
||||
const voices = await ttsFactory.getVoices();
|
||||
console.log(`TTS Player: ${voices.length} voices available`);
|
||||
|
||||
this.reportProgress(100, "TTS Player ready");
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error initializing TTS Player:", error);
|
||||
this.reportProgress(100, "TTS Player initialization failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -134,15 +130,15 @@ class TTSPlayerModule extends BaseModule {
|
||||
preloadSpeech(text) {
|
||||
if (!text || !this.enabled) return;
|
||||
|
||||
// Don't preload if already in cache
|
||||
if (this.preloadedAudio.has(text)) return;
|
||||
// Skip if already preloaded or in queue
|
||||
if (this.preloadedAudio.has(text) || this.preloadQueue.includes(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to preload queue
|
||||
this.preloadQueue.push(text);
|
||||
console.log(`TTS Player: Added to preload queue: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Start processing the queue if not already processing
|
||||
if (!this.isPreloading) {
|
||||
// Start preloading if not already preloading and no active speech
|
||||
if (!this.isPreloading && !this.currentSpeech) {
|
||||
this.processPreloadQueue();
|
||||
}
|
||||
}
|
||||
@@ -150,44 +146,46 @@ class TTSPlayerModule extends BaseModule {
|
||||
/**
|
||||
* Process the preload queue
|
||||
*/
|
||||
async processPreloadQueue() {
|
||||
if (this.preloadQueue.length === 0 || this.isPreloading) return;
|
||||
processPreloadQueue() {
|
||||
if (this.preloadQueue.length === 0 || this.isPreloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPreloading = true;
|
||||
const text = this.preloadQueue.shift();
|
||||
|
||||
try {
|
||||
// Get TTSFactory from module registry
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (!ttsFactory) {
|
||||
console.error("TTS Player: TTSFactory module not found in registry");
|
||||
this.isPreloading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only preload if we're not currently speaking or the text is different from current speech
|
||||
if (!this.isSpeaking() || (this.currentSpeech && this.currentSpeech !== text)) {
|
||||
console.log(`TTS Player: Preloading speech for: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Use the preload method of the TTS factory
|
||||
const preloadData = await ttsFactory.preloadSpeech(text);
|
||||
if (preloadData) {
|
||||
this.preloadedAudio.set(text, preloadData);
|
||||
console.log(`TTS Player: Successfully preloaded speech for: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
} else {
|
||||
console.warn(`TTS Player: Failed to preload speech for: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("TTS Player: Error preloading speech:", error);
|
||||
} finally {
|
||||
// Skip if already preloaded
|
||||
if (this.preloadedAudio.has(text)) {
|
||||
this.isPreloading = false;
|
||||
this.processPreloadQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Use TTS Factory to generate audio
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
console.log(`Preloading TTS for: "${text.substring(0, 30)}${text.length > 30 ? '...' : ''}"`);
|
||||
|
||||
// Process next in queue if available
|
||||
if (this.preloadQueue.length > 0) {
|
||||
// Use requestAnimationFrame to prevent blocking
|
||||
requestAnimationFrame(() => this.processPreloadQueue());
|
||||
}
|
||||
ttsFactory.generateSpeech(text)
|
||||
.then(audioData => {
|
||||
if (audioData && audioData.success) {
|
||||
this.preloadedAudio.set(text, audioData);
|
||||
console.log(`TTS preloaded successfully for: "${text.substring(0, 30)}${text.length > 30 ? '...' : ''}"`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error preloading TTS:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.isPreloading = false;
|
||||
|
||||
// Continue processing queue
|
||||
if (this.preloadQueue.length > 0) {
|
||||
this.processPreloadQueue();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.isPreloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,28 +196,32 @@ class TTSPlayerModule extends BaseModule {
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
speak(text, callback = null) {
|
||||
// Check if TTS is enabled
|
||||
if (!this.enabled) {
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'tts_disabled' }), 0);
|
||||
setTimeout(() => callback({ success: false, reason: 'disabled' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get TTSFactory from module registry
|
||||
if (this.currentSpeech) {
|
||||
// Stop current speech if any
|
||||
this.stop();
|
||||
}
|
||||
|
||||
this.currentSpeech = text;
|
||||
this.pendingCallback = callback;
|
||||
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
this.pendingCallback = callback;
|
||||
this.currentSpeech = text;
|
||||
|
||||
// Check if this text was preloaded
|
||||
const preloadedData = this.preloadedAudio.get(text);
|
||||
if (preloadedData) {
|
||||
console.log("TTS Player: Using preloaded speech");
|
||||
// Check if we have this preloaded
|
||||
if (this.preloadedAudio.has(text)) {
|
||||
const preloadedAudio = this.preloadedAudio.get(text);
|
||||
this.preloadedAudio.delete(text); // Remove from cache after use
|
||||
|
||||
// Use the preloaded speech data
|
||||
ttsFactory.speakPreloaded(preloadedData, (result) => {
|
||||
console.log(`Using preloaded TTS for: "${text.substring(0, 30)}${text.length > 30 ? '...' : ''}"`);
|
||||
|
||||
// Play the preloaded audio
|
||||
ttsFactory.playAudio(preloadedAudio, (result) => {
|
||||
// Store the completed result
|
||||
this.currentSpeech = null;
|
||||
|
||||
@@ -358,8 +360,5 @@ class TTSPlayerModule extends BaseModule {
|
||||
// Create the singleton instance
|
||||
const TTSPlayer = new TTSPlayerModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(TTSPlayer);
|
||||
|
||||
// Export the module
|
||||
export { TTSPlayer };
|
||||
@@ -1,8 +1,7 @@
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import { ModuleEvent } from './base-module.js';
|
||||
|
||||
class UIController extends BaseModule {
|
||||
class UIControllerModule extends BaseModule {
|
||||
constructor() {
|
||||
super('ui-controller', 'UI Controller');
|
||||
|
||||
@@ -450,7 +449,7 @@ class UIController extends BaseModule {
|
||||
break;
|
||||
case 'menu':
|
||||
// Toggle options menu
|
||||
const optionsUI = moduleRegistry.getModule('options-ui');
|
||||
const optionsUI = this.getModule('options-ui');
|
||||
if (optionsUI) {
|
||||
optionsUI.toggle();
|
||||
}
|
||||
@@ -560,13 +559,7 @@ class UIController extends BaseModule {
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const uiController = new UIController();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(uiController);
|
||||
const uiController = new UIControllerModule();
|
||||
|
||||
// Export the module
|
||||
export { uiController as UIController };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.UIController = uiController;
|
||||
@@ -3,9 +3,8 @@
|
||||
* Manages the display of text and UI elements
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class UIDisplayHandler extends BaseModule {
|
||||
class UIDisplayHandlerModule extends BaseModule {
|
||||
constructor() {
|
||||
super('ui-display-handler', 'UI Display Handler');
|
||||
|
||||
@@ -570,14 +569,7 @@ class UIDisplayHandler extends BaseModule {
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const uiDisplayHandler = new UIDisplayHandler();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(uiDisplayHandler);
|
||||
const uiDisplayHandler = new UIDisplayHandlerModule();
|
||||
|
||||
// Export the module
|
||||
export { uiDisplayHandler as UIDisplayHandler };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
console.log('UIDisplayHandler: Registering with window');
|
||||
window.UIDisplayHandler = uiDisplayHandler;
|
||||
@@ -1,12 +1,11 @@
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class UIEffects extends BaseModule {
|
||||
class UIEffectsModule extends BaseModule {
|
||||
constructor() {
|
||||
super('ui-effects', 'UI Effects');
|
||||
|
||||
// No external dependencies
|
||||
this.dependencies = [];
|
||||
this.dependencies = ['ui-display-handler'];
|
||||
|
||||
// Effects state
|
||||
this.activeEffects = new Map();
|
||||
@@ -291,7 +290,7 @@ class UIEffects extends BaseModule {
|
||||
|
||||
applyTextEmphasis(text, options = {}) {
|
||||
// Use existing display handler to show emphasized text
|
||||
const displayHandler = moduleRegistry.getModule('ui-display-handler');
|
||||
const displayHandler = this.getModule('ui-display-handler');
|
||||
if (!displayHandler) return null;
|
||||
|
||||
const style = {
|
||||
@@ -328,14 +327,7 @@ class UIEffects extends BaseModule {
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const uiEffects = new UIEffects();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(uiEffects);
|
||||
const uiEffects = new UIEffectsModule();
|
||||
|
||||
// Export the module
|
||||
export { uiEffects as UIEffects };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
console.log('UIEffects: Registering with window');
|
||||
window.UIEffects = uiEffects;
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* UI Helper
|
||||
* Provides utility functions for UI components
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create and append a UI element
|
||||
* @param {string} type - Element type (div, input, button, etc.)
|
||||
* @param {Object} attributes - Attributes to set on the element
|
||||
* @param {string} [text] - Text content for the element
|
||||
* @param {HTMLElement} parent - Parent element
|
||||
* @returns {HTMLElement} - Created element
|
||||
*/
|
||||
export function createUIElement(type, attributes = {}, text = '', parent = null) {
|
||||
const element = document.createElement(type);
|
||||
|
||||
// Set attributes
|
||||
for (const [key, value] of Object.entries(attributes || {})) {
|
||||
if (key === 'className') {
|
||||
element.className = value;
|
||||
} else {
|
||||
element.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Set text content if provided
|
||||
if (text) {
|
||||
element.textContent = text;
|
||||
}
|
||||
|
||||
// Append to parent if provided
|
||||
if (parent) {
|
||||
parent.appendChild(element);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate a dropdown with options
|
||||
* @param {HTMLSelectElement} dropdown - Dropdown element
|
||||
* @param {Array} items - Array of items
|
||||
* @param {string} valueKey - Key for item value
|
||||
* @param {string} textKey - Key for item text
|
||||
* @param {string} [selectedValue] - Value to select
|
||||
*/
|
||||
export function populateDropdown(dropdown, items, valueKey, textKey, selectedValue) {
|
||||
// Clear existing options
|
||||
dropdown.innerHTML = '';
|
||||
|
||||
// Add options
|
||||
items.forEach(item => {
|
||||
const option = document.createElement('option');
|
||||
option.value = item[valueKey];
|
||||
option.textContent = item[textKey];
|
||||
dropdown.appendChild(option);
|
||||
});
|
||||
|
||||
// Set selected value if provided
|
||||
if (selectedValue !== undefined && selectedValue !== null) {
|
||||
dropdown.value = selectedValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an event handler for a UI element
|
||||
* @param {HTMLElement} element - UI element
|
||||
* @param {string} eventType - Event type (change, input, click, etc.)
|
||||
* @param {Function} handler - Event handler function
|
||||
*/
|
||||
export function registerHandler(element, eventType, handler) {
|
||||
if (element) {
|
||||
element.addEventListener(eventType, handler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a preference binding between a UI element and persistence manager
|
||||
* @param {HTMLElement} element - UI element
|
||||
* @param {Object} persistenceManager - Persistence manager instance
|
||||
* @param {string} category - Preference category
|
||||
* @param {string} key - Preference key
|
||||
* @param {Function} [updateUIFunc] - Function to update UI when preference changes
|
||||
* @param {Function} [valueTransform] - Function to transform value before saving
|
||||
*/
|
||||
export function createPreferenceBinding(element, persistenceManager, category, key, updateUIFunc, valueTransform) {
|
||||
if (!element || !persistenceManager) return;
|
||||
|
||||
// Get initial value from persistence
|
||||
const value = persistenceManager.getPreference(category, key);
|
||||
|
||||
// Update UI initially if value exists and update function provided
|
||||
if (value !== null && value !== undefined && updateUIFunc) {
|
||||
updateUIFunc(element, value);
|
||||
} else {
|
||||
// Default UI update based on element type
|
||||
if (element.type === 'checkbox') {
|
||||
element.checked = !!value;
|
||||
} else if (element.tagName === 'SELECT' || element.type === 'text' || element.type === 'password') {
|
||||
element.value = value !== null && value !== undefined ? value : '';
|
||||
} else if (element.type === 'range') {
|
||||
element.value = value !== null && value !== undefined ? value : 50;
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
const eventType = element.type === 'checkbox' ? 'change' : 'input';
|
||||
element.addEventListener(eventType, () => {
|
||||
let newValue;
|
||||
|
||||
if (element.type === 'checkbox') {
|
||||
newValue = element.checked;
|
||||
} else if (element.type === 'range' || element.type === 'number') {
|
||||
newValue = parseFloat(element.value);
|
||||
} else {
|
||||
newValue = element.value;
|
||||
}
|
||||
|
||||
// Apply transform if provided
|
||||
if (valueTransform) {
|
||||
newValue = valueTransform(newValue, element);
|
||||
}
|
||||
|
||||
// Save to persistence
|
||||
persistenceManager.updatePreference(category, key, newValue);
|
||||
|
||||
// Dispatch event for other modules
|
||||
document.dispatchEvent(new CustomEvent('preference:changed', {
|
||||
detail: {
|
||||
category,
|
||||
key,
|
||||
value: newValue
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up common modal behaviors (show/hide/close)
|
||||
* @param {HTMLElement} modal - Modal element
|
||||
* @param {HTMLElement} [closeButton] - Close button element
|
||||
* @returns {Object} - Modal control functions
|
||||
*/
|
||||
export function setupModalControls(modal, closeButton) {
|
||||
if (!modal) return {};
|
||||
|
||||
const show = () => {
|
||||
modal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
modal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
};
|
||||
|
||||
// Set up close button if provided
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', hide);
|
||||
}
|
||||
|
||||
// Set up ESC key to close
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modal.style.display === 'flex') {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
return { show, hide, toggle: () => modal.style.display === 'flex' ? hide() : show() };
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class UIInputHandler extends BaseModule {
|
||||
class UIInputHandlerModule extends BaseModule {
|
||||
constructor() {
|
||||
super('ui-input-handler', 'UI Input Handler');
|
||||
|
||||
@@ -386,14 +385,7 @@ class UIInputHandler extends BaseModule {
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const uiInputHandler = new UIInputHandler();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(uiInputHandler);
|
||||
const uiInputHandler = new UIInputHandlerModule();
|
||||
|
||||
// Export the module
|
||||
export { uiInputHandler as UIInputHandler };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
console.log('UIInputHandler: Registering with window');
|
||||
window.UIInputHandler = uiInputHandler;
|
||||
Reference in New Issue
Block a user