feat: Integrate Kokoro TTS with WebGPU and fallback

This commit is contained in:
2025-04-01 10:34:24 +00:00
parent 113e3b995d
commit 1882acac8c
111 changed files with 9143 additions and 4447 deletions
+19
View File
@@ -0,0 +1,19 @@
FROM node:18
# Install basic development tools
RUN apt update && apt install -y less git procps
# Install Kokoro JS dependencies if needed
RUN apt install -y build-essential python3
# Ensure default `node` user has access to `sudo`
ARG USERNAME=node
RUN apt-get install -y sudo \
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
&& chmod 0440 /etc/sudoers.d/$USERNAME
# Set the default user
USER node
# Set working directory
WORKDIR /workspace
+25
View File
@@ -0,0 +1,25 @@
{
"name": "Node.js Development",
"build": {
"dockerfile": "Dockerfile"
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"settings": {
"terminal.integrated.defaultProfile.linux": "bash",
"terminal.integrated.profiles.linux": {
"bash": {
"path": "/bin/bash"
}
}
}
}
},
"forwardPorts": [3001],
"postCreateCommand": "npm install",
"remoteUser": "node"
}
+1 -1
View File
@@ -3,7 +3,7 @@ OPENROUTER_API_KEY=sk-or-v1-69865e0b635ef9bb4a2edc7c520fe056fd94b791c3d5f65009a2
OPENROUTER_MODEL=anthropic/claude-3-opus-20240229
# Application Configuration
PORT=3000
PORT=3001
NODE_ENV=development
# Game Configuration
-3
View File
@@ -2,9 +2,6 @@
"folders": [
{
"path": "."
},
{
"path": "../ink.js"
}
]
}
-46
View File
@@ -1,46 +0,0 @@
/**
* Script to copy required assets from ink.js project to AI Interactive Fiction
*/
const fs = require('fs');
const path = require('path');
// Define asset directories
const sourceDir = 'e:/Georg/vhosts/ink.js';
const targetDir = 'e:/Georg/vhosts/ai.interactive.fiction/public';
// Assets to copy
const assets = [
{ src: 'book-3057904.png', dest: 'images/book-3057904.png' },
{ src: 'brown-wooden-flooring.jpg', dest: 'images/brown-wooden-flooring.jpg' },
{ src: 'EBGaramond12-Regular.otf', dest: 'fonts/EBGaramond12-Regular.otf' },
{ src: 'EBGaramond12-Italic.otf', dest: 'fonts/EBGaramond12-Italic.otf' }
];
// Create necessary directories
const directories = ['images', 'fonts', 'js', 'css'].map(dir => path.join(targetDir, dir));
directories.forEach(dir => {
if (!fs.existsSync(dir)) {
console.log(`Creating directory: ${dir}`);
fs.mkdirSync(dir, { recursive: true });
}
});
// Copy each asset
assets.forEach(asset => {
const source = path.join(sourceDir, asset.src);
const destination = path.join(targetDir, asset.dest);
try {
if (fs.existsSync(source)) {
fs.copyFileSync(source, destination);
console.log(`Successfully copied ${source} to ${destination}`);
} else {
console.error(`Source file does not exist: ${source}`);
}
} catch (error) {
console.error(`Error copying ${source}:`, error.message);
}
});
console.log('Asset copying completed.');
+1 -1
View File
@@ -59,7 +59,7 @@ exports.server = server;
const io = new socket_io_1.Server(server);
exports.io = io;
// Get port from environment variables or use default
const DEFAULT_PORT = 3000;
const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
// Serve static files from the public directory
+1 -2
View File
@@ -13,8 +13,7 @@
"build": "tsc",
"test": "jest",
"lint": "eslint --ext .ts src/",
"lint:fix": "eslint --ext .ts src/ --fix",
"copy-assets": "node copy-assets.js"
"lint:fix": "eslint --ext .ts src/ --fix"
},
"keywords": [],
"author": "",
+336 -183
View File
@@ -1,70 +1,127 @@
/* AI Interactive Fiction - Web UI Styles */
/* Variables */
:root {
--text-color: #222;
--background-color: #f8f4e8;
--book-shadow: rgba(0, 0, 0, 0.3);
--highlight-color: #783422;
--control-color: #555;
--light-color: rgba(255, 240, 210, 0.6);
--viewport-aspect-ratio: 1.6;
--book-width: 1000px;
--book-height: 620px;
--input-bg: rgba(255, 255, 255, 0.6);
--img-aspect-ratio: 1.613;
--aspect-ratio: min(var(--viewport-aspect-ratio), var(--img-aspect-ratio));
font-size: calc(var(--book-height)/(34 * 1.5));
}
/* Font faces */
@font-face {
font-family: "EB Garamond";
src: url("../fonts/EBGaramond12-Regular.otf") format("opentype");
font-weight: normal;
/* @font-face {
font-family: "Quattrocento";
font-style: normal;
src: url("Quattrocento-Regular.ttf");
}
@font-face {
font-family: "Quattrocento";
font-weight: bold;
src: url("Quattrocento-Bold.ttf");
}
@font-face {
font-family: "Open Sans";
font-style: normal;
src: url("OpenSans-VariableFont_wdth,wght.ttf");
}
@font-face {
font-family: "Open Sans";
font-style: italic;
src: url("OpenSans-Italic-VariableFont_wdth,wght.ttf");
} */
@font-face {
font-family: "EB Garamond";
font-style: normal;
src: url("../fonts/EBGaramond12-Regular.otf");
}
@font-face {
font-family: "EB Garamond";
font-style: italic;
src: url("../fonts/EBGaramond12-Italic.otf");
}
/* @font-face {
font-family: "EB Garamond";
font-style: normal;
font-weight: normal;
src: url("../fonts/EBGaramond-Regular.otf") format("opentype");
}
@font-face {
font-family: "EB Garamond";
src: url("../fonts/EBGaramond12-Italic.otf") format("opentype");
font-style:italic;
font-weight: normal;
font-style: italic;
src: url("../fonts/EBGaramond-Italic.otf") format("opentype");
} */
/* @font-face {
font-family: "EB Garamond";
font-style: normal;
font-weight: bold;
src: url("../fonts/EBGaramond-Bold.otf") format("opentype");
}
/* Global styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
@font-face {
font-family: "EB Garamond";
font-style: italic;
font-weight: bold;
src: url("../fonts/EBGaramond-BoldItalic.otf") format("opentype");
} */
html {
font-family: 'EB Garamond', sans-serif;
/* font-kerning: normal; */
/* font-variant-ligatures: common-ligatures historical-ligatures; */
/* font-variant-numeric: oldstyle-nums; */
font-feature-settings: 'kern' on, 'liga' on, 'onum' on, 'dlig' on, 'clig' on, 'calt' on, 'hlig' off, 'swsh' on, 'locl' off;
-webkit-font-smoothing: antialiased;
}
sup {
font-feature-settings: "sup" on;
/* vertical-align: inherit; */
font-size: inherit;
}
/* sub {
font-feature-settings: "subs" on;
} */
double {
font-size: 2rem;
vertical-align: -10%;
opacity: 0.8;
}
double.separator {
display: block;
margin: 2rem auto 3rem auto;
}
html, body {
height: 100%;
font-family: "EB Garamond", serif;
color: var(--text-color);
background-color: #222;
background-image: url(../images/brown-wooden-flooring.jpg);
background-size: cover;
background-position: center;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
body {
overflow: hidden;
background-image: url('../images/brown-wooden-flooring.jpg');
background-position: center center;
background-size: cover;
background-repeat: no-repeat;
}
body.switched {
transition: color 0.6s, background-color 0.6s;
}
:root {
font-size: calc(var(--book-height)/(34 * 1.5));
--img-aspect-ratio: 1.613;
--aspect-ratio: min(var(--viewport-aspect-ratio), var(--img-aspect-ratio));
}
h1 {
font-size: 2rem;
margin-bottom: 0.8rem;
text-align: center;
text-transform: uppercase;
text-transform:uppercase;
font-weight: normal;
}
@@ -74,6 +131,11 @@ h2 {
font-weight: normal;
}
h2.chapter-heading {
font-style: italic;
margin: 2rem auto 3rem auto;
}
h3 {
font-size: 1.2rem;
text-align: center;
@@ -85,20 +147,60 @@ h3 {
padding-bottom: 3rem;
}
/*
Built-in class:
# author: Name
*/
.byline {
font-feature-settings: "smcp";
}
.subtitle {
font-feature-settings: "scmp" off;
}
.separator {
text-align: center;
}
p, #ruler, #indent {
/*
Enables <iframe> support work on itch.io when using mobile iOS
*/
.outerContainer {
position: absolute;
display: block;
display: none;
margin: 0;
padding: 0;
-webkit-overflow-scrolling: touch;
overflow: scroll;
overflow-x: hidden;
height: 100%;
width: 100%;
top: 0;
left: 0;
margin-top: 24px;
background-size: cover;
background-repeat: no-repeat;
}
@media screen and (max-width: 980px) {
.outerContainer {
margin-top: 44px;
background-size: cover;
background-repeat: no-repeat;
}
}
p, #ruler,#indent {
font-size: 1.2rem;
line-height: 1.2;
color: rgba(0,0,0,0.9);
margin-block-end: 0;
margin-block-start: 0;
/* text-indent: 1.5em; */
/* text-align: justify; */
/* hyphens: manual; */
}
a {
@@ -118,6 +220,32 @@ strong {
font-weight: bold;
}
img {
display: block;
margin: 0 auto;
max-width: 100%;
}
.drop-cap {
font-size: 280%;
line-height: 1;
margin: 0;
padding: 0;
text-transform: uppercase;
}
.drop-quote {
font-size: 180%;
line-height: 1;
margin: 0;
padding: 0;
}
cap {
font-feature-settings: "smcp";
}
.container .hide {
opacity: 0.0;
}
@@ -131,37 +259,70 @@ strong {
transition: opacity 0.5s;
}
#command_input {
#choices {
display: grid;
grid-template-columns: repeat(3, 1fr);
width: calc(var(--book-width) * 0.39)px;
}
#choices *:first-child {
grid-column: 1 / -1;
}
#choices ol.categorized {
list-style-type: lower-alpha;
}
/*
Class applied to all choices
(Will always appear inside <p> element by default.)
*/
li.choice {
text-align: center;
line-height: 1.7rem;
}
ol.choice {
position: relative;
/* list-style-position: inside; To make sure the number is inside the li */
counter-reset: item; /* Set a counter */
padding-inline-start: 3rem;
}
/* ol.choice li::before {
position: absolute;
bottom: 1rem;
left: 3rem;
right: 3rem;
display: flex;
top: 0;
left: 0;
content: "............................................................................";
overflow: hidden;
white-space: nowrap;
text-indent: 1.5em;
} */
/*
Class applied to first choice
*/
:not(.choice)+.choice {
padding-top: 1rem;
}
#player_input {
flex-grow: 1;
font-family: inherit;
/*
Class applied to choice links
*/
.choice a {
font-size: 1.2rem;
padding: 0.5rem;
border: 1px solid rgba(0,0,0,0.3);
border-radius: 0.25rem;
background: rgba(255,255,255,0.9);
}
#submit_command {
margin-left: 0.5rem;
font-family: inherit;
font-size: 1.2rem;
padding: 0.5rem 1rem;
border: 1px solid rgba(0,0,0,0.3);
border-radius: 0.25rem;
background: rgba(255,255,255,0.9);
cursor: pointer;
}
#submit_command:hover {
background: rgba(255,255,255,1);
/*
Built-in class:
The End # CLASS: end
*/
.end {
text-align: center;
font-weight: bold;
color: black;
padding-top: 2rem;
padding-bottom: 2rem;
}
#controls {
@@ -170,8 +331,7 @@ strong {
position: absolute;
right: 0;
left: 0;
top: 1rem;
padding-top: 1rem;
top: -0.6rem;
user-select: none;
transition: color 0.6s, background 0.6s;
}
@@ -203,6 +363,7 @@ strong {
border-radius: 0.25rem;
background-color: rgba(0,0,0,0.9);
border: none;
/* slider progress trick */
box-shadow: -407px 0 0 400px rgba(0,0,0,0.3);
}
@@ -222,24 +383,21 @@ strong {
width: var(--book-width);
height: var(--book-height);
background-image: url('../images/book-3057904.png');
background-size: contain;
background-size: contain; /* Changed from cover to contain */
background-position: center;
background-repeat: no-repeat;
background-repeat: no-repeat; /* Prevents repeating the image when aspect ratio doesn't match */
perspective: 500px;
perspective-origin: 50% 50%;
max-width: 90vw;
max-height: 90vh;
margin: 0 auto;
transform-origin: center center;
}
#page_left, #page_right {
position: absolute;
top: 5%;
top: 5%; /* Adjust these values as necessary */
bottom: 10%;
width: 39%;
box-sizing: border-box;
padding: 0 3rem 1rem 1rem;
/* border: 1px dotted rgba(200,200,200,1); */
overflow: visible;
overflow-y: scroll;
opacity: 0.95;
@@ -248,50 +406,46 @@ strong {
#story {
overflow-x: visible;
margin-bottom: 3rem;
}
/* #story p span {
font-feature-settings: 'kern' on, 'liga' on, 'onum' on, 'clig' on, 'hlig' on;
} */
#page_left {
left: 11.5%;
}
#page_right {
/* background-color: rgba(200,200,200,0.5); */
right: 7%;
height: calc(28 * 1.2 * 1.2rem);
padding-bottom: 4rem;
padding-bottom: 0;
/* transform: translateX(-1%) translateY(2%) rotateX(0deg) rotateY(-1deg) rotateZ(0deg); */
}
.user-input {
font-style: italic;
color: #555;
margin-top: 1rem;
}
.narrative {
margin-top: 1rem;
}
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {
/* Firefox */
* {
scrollbar-width: auto;
scrollbar-color: #000000 rgba(255,255,255,0);
}
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: calc(1rem/4);
}
}
*::-webkit-scrollbar-track {
*::-webkit-scrollbar-track {
background: rgba(255,255,255,0.0);
}
}
*::-webkit-scrollbar-thumb {
*::-webkit-scrollbar-thumb {
background-color: #000000;
border-radius: calc(1rem/4/2);
border: none;
}
}
.fade-in {
animation: fadeIn ease 1s;
@@ -326,6 +480,27 @@ strong {
100% {opacity:1;}
}
#versions {
position: fixed;
top: 0px;
right: 12px;
font-size: 10px;
}
#preview {
position: absolute;
top: 0px;
left: 0px;
border: 1px none red;
background-color: #fff;
box-shadow: 2px 2px 2px rgba(0,0,0,0.3);
}
#preview p {
text-indent: 0;
text-align: left;
}
#ruler, #indent {
visibility: hidden;
position: absolute;
@@ -339,6 +514,24 @@ strong {
margin-block-end: 0;
}
#ruler i {
font-style: italic;
}
#indent {
text-indent: 1.5em;
}
#remark {
position: absolute;
left: 0;
right: 0;
bottom: 0;
text-align: center;
margin: 0 auto;
background-color: transparent;
}
#lighting {
position: absolute;
top: -35%;
@@ -348,8 +541,8 @@ strong {
animation: gradient-animation-shrink 1s 1;
background: radial-gradient(circle, rgba(255,240,182,0.1) 0%, rgba(255,237,165,0.2) 20%, rgba(0,0,0,0.9) 65%, rgba(0,0,0,0.9) 100%);
mix-blend-mode: color-burn;
pointer-events: none;
z-index: 999;
pointer-events: none; /* makes the element ignore mouse events, and pass them to elements underneath */
z-index: 999; /* should be high enough to be on top of other elements */
}
@keyframes gradient-animation-grow {
@@ -362,99 +555,59 @@ strong {
100% { width: 180%; height: 180%; left: -35%; top: -35%; }
}
.loading-indicator {
display: inline-block;
position: relative;
width: 1.2rem;
height: 1.2rem;
margin-left: 0.5rem;
}
.loading-indicator div {
box-sizing: border-box;
display: block;
position: absolute;
width: 1rem;
height: 1rem;
border: 0.2rem solid #000;
border-radius: 50%;
animation: loading-indicator 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #000 transparent transparent transparent;
}
.loading-indicator div:nth-child(1) {
animation-delay: -0.45s;
}
.loading-indicator div:nth-child(2) {
animation-delay: -0.3s;
}
.loading-indicator div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes loading-indicator {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
/* Command history */
#command_history {
max-height: 120px;
overflow-y: auto;
font-size: 16px;
margin-bottom: 15px;
border-top: 1px solid #d1c8b9;
padding-top: 10px;
scrollbar-width: thin;
scrollbar-color: #8b7765 rgba(255, 255, 255, 0.1);
}
/* Media queries for responsive design */
@media (max-width: 768px) {
:root {
font-size: calc(var(--book-height)/(40 * 1.5));
}
#book {
max-width: 95vw;
max-height: 95vh;
}
#page_left, #page_right {
width: 38%;
padding: 0 1rem 1rem 1rem;
}
#command_history::-webkit-scrollbar {
width: 6px;
}
/* Ensure responsive book sizing */
@media (max-width: 1200px) {
#book {
transform: scale(0.95);
transform-origin: center center;
}
#command_history::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
@media (max-width: 992px) {
#book {
transform: scale(0.85);
transform-origin: center center;
}
#command_history::-webkit-scrollbar-thumb {
background-color: #8b7765;
border-radius: 4px;
}
@media (max-width: 768px) {
#book {
transform: scale(0.75);
transform-origin: center center;
}
/* Input area */
#input_area {
display: flex;
margin-bottom: 15px;
}
@media (max-width: 576px) {
#book {
transform: scale(0.65);
transform-origin: center center;
}
#player_input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1c8b9;
background: rgba(255, 255, 255, 0.8);
font-family: 'EB Garamond', serif;
font-size: 16px;
outline: none;
border-radius: 4px 0 0 4px;
}
/* Additional responsive fix to ensure book remains centered */
@media (max-height: 700px) {
#book {
transform: scale(0.8);
transform-origin: center center;
}
#submit_command {
background-color: #8b7765;
border: 1px solid #8b7765;
color: white;
padding: 8px 12px;
cursor: pointer;
font-family: 'EB Garamond', serif;
border-radius: 0 4px 4px 0;
transition: background-color 0.2s;
}
@media (max-height: 600px) {
#book {
transform: scale(0.7);
transform-origin: center center;
}
#submit_command:hover {
background-color: #6d5d4d;
}
+31 -1
View File
@@ -59,7 +59,37 @@
<script>
var locale = "en";
</script>
<script src="js/ai-fiction.js"></script>
<!-- TTS implementation scripts - order matters! -->
<!-- 1. Kokoro TTS library - load as module -->
<script type="module">
try {
// Import KokoroTTS class from the module
const kokoroModule = await import('./js/kokoro-js.js');
// Expose the KokoroTTS class globally
window.KokoroTTS = kokoroModule.KokoroTTS;
console.log('KokoroTTS class loaded and exposed to window');
// Dispatch an event to signal that the class is ready
const event = new CustomEvent('kokoro-class-loaded');
window.dispatchEvent(event);
} catch (error) {
console.error('Failed to load KokoroTTS module:', error);
// Dispatch an event even on failure so handlers don't wait forever
const event = new CustomEvent('kokoro-class-load-failed');
window.dispatchEvent(event);
}
</script>
<!-- 2. TTS handlers (kokoro-handler needs to wait for KokoroTTS) -->
<script src="js/kokoro-handler.js"></script>
<script src="js/tts-handler.js"></script>
<!-- 3. TTS Factory for automatic selection -->
<script src="js/tts-factory.js"></script>
<!-- Main application script -->
<script src="js/ai-fiction.js"></script>
</body>
</html>
+931
View File
@@ -0,0 +1,931 @@
/**
* @license Hyphenopoly 5.2.0-beta.1 - client side hyphenation for webbrowsers
* ©2023 Mathias Nater, Güttingen (mathiasnater at gmail dot com)
* https://github.com/mnater/Hyphenopoly
*
* Released under the MIT license
* http://mnater.github.io/Hyphenopoly/LICENSE
*/
/* globals Hyphenopoly:readonly */
((w, o) => {
"use strict";
const SOFTHYPHEN = "\u00AD";
/**
* Event
*/
const event = ((H) => {
const knownEvents = new Map([
["afterElementHyphenation", []],
["beforeElementHyphenation", []],
["engineReady", []],
[
"error", [
(e) => {
if (e.runDefault) {
w.console.warn(e);
}
}
]
],
["hyphenopolyEnd", []],
["hyphenopolyStart", []]
]);
if (H.hev) {
const userEvents = new Map(o.entries(H.hev));
knownEvents.forEach((eventFuncs, eventName) => {
if (userEvents.has(eventName)) {
eventFuncs.unshift(userEvents.get(eventName));
}
});
}
return {
"fire": ((eventName, eventData) => {
eventData.runDefault = true;
eventData.preventDefault = () => {
eventData.runDefault = false;
};
knownEvents.get(eventName).forEach((eventFn) => {
eventFn(eventData);
});
})
};
})(Hyphenopoly);
/**
* Register copy event on element
* @param {Object} el The element
* @returns {undefined}
*/
function registerOnCopy(el) {
el.addEventListener(
"copy",
(e) => {
e.preventDefault();
const sel = w.getSelection();
const div = document.createElement("div");
div.appendChild(sel.getRangeAt(0).cloneContents());
e.clipboardData.setData("text/plain", sel.toString().replace(RegExp(SOFTHYPHEN, "g"), ""));
e.clipboardData.setData("text/html", div.innerHTML.replace(RegExp(SOFTHYPHEN, "g"), ""));
},
true
);
}
/**
* Convert settings from H.setup-Object to Map
* This is a IIFE to keep complexity low.
*/
((H) => {
/**
* Create a Map with a default Map behind the scenes. This mimics
* kind of a prototype chain of an object, but without the object-
* injection security risk.
*
* @param {Map} defaultsMap - A Map with default values
* @returns {Proxy} - A Proxy for the Map (dot-notation or get/set)
*/
function createMapWithDefaults(defaultsMap) {
const userMap = new Map();
/**
* The get-trap: get the value from userMap or else from defaults
* @param {Sring} key - The key to retrieve the value for
* @returns {*}
*/
function get(key) {
return (userMap.has(key))
? userMap.get(key)
: defaultsMap.get(key);
}
/**
* The set-trap: set the value to userMap and don't touch defaults
* @param {Sring} key - The key for the value
* @param {*} value - The value
* @returns {*}
*/
function set(key, value) {
userMap.set(key, value);
}
return new Proxy(defaultsMap, {
"get": (_target, prop) => {
if (prop === "set") {
return set;
}
if (prop === "get") {
return get;
}
return get(prop);
},
"ownKeys": () => {
return [
...new Set(
[...defaultsMap.keys(), ...userMap.keys()]
)
];
}
});
}
const settings = createMapWithDefaults(new Map([
["defaultLanguage", "en-us"],
[
"dontHyphenate", (() => {
const list = "abbr,acronym,audio,br,button,code,img,input,kbd,label,math,option,pre,samp,script,style,sub,sup,svg,textarea,var,video";
return createMapWithDefaults(
new Map(list.split(",").map((val) => {
return [val, true];
}))
);
})()
],
["dontHyphenateClass", "donthyphenate"],
["exceptions", new Map()],
["keepAlive", true],
["normalize", false],
["processShadows", false],
["safeCopy", true],
["substitute", new Map()],
["timeout", 1000]
]));
o.entries(H.s).forEach(([key, value]) => {
switch (key) {
case "selectors":
// Set settings.selectors to array of selectors
settings.set("selectors", o.keys(value));
/*
* For each selector add a property to settings with
* selector specific settings
*/
o.entries(value).forEach(([sel, selSettings]) => {
const selectorSettings = createMapWithDefaults(new Map([
["compound", "hyphen"],
["hyphen", SOFTHYPHEN],
["leftmin", 0],
["leftminPerLang", 0],
["minWordLength", 6],
["mixedCase", true],
["orphanControl", 1],
["rightmin", 0],
["rightminPerLang", 0]
]));
o.entries(selSettings).forEach(
([selSetting, setVal]) => {
if (typeof setVal === "object") {
selectorSettings.set(
selSetting,
new Map(o.entries(setVal))
);
} else {
selectorSettings.set(selSetting, setVal);
}
}
);
settings.set(sel, selectorSettings);
});
break;
case "dontHyphenate":
case "exceptions":
o.entries(value).forEach(([k, v]) => {
settings.get(key).set(k, v);
});
break;
case "substitute":
o.entries(value).forEach(([lang, subst]) => {
settings.substitute.set(
lang,
new Map(o.entries(subst))
);
});
break;
default:
settings.set(key, value);
}
});
H.c = settings;
})(Hyphenopoly);
((H) => {
const C = H.c;
let mainLanguage = null;
event.fire(
"hyphenopolyStart",
{
"msg": "hyphenopolyStart"
}
);
/**
* Factory for elements
* @returns {Object} elements-object
*/
function makeElementCollection() {
const list = new Map();
/*
* Counter counts the elements to be hyphenated.
* Needs to be an object (Pass by reference)
*/
const counter = [0];
/**
* Add element to elements
* @param {object} el The element
* @param {string} lang The language of the element
* @param {string} sel The selector of the element
* @returns {Object} An element-object
*/
function add(el, lang, sel) {
const elo = {
"element": el,
"selector": sel
};
if (!list.has(lang)) {
list.set(lang, []);
}
list.get(lang).push(elo);
counter[0] += 1;
return elo;
}
/**
* Removes elements from the list and updates the counter
* @param {string} lang - The lang of the elements to remove
*/
function rem(lang) {
let langCount = 0;
if (list.has(lang)) {
langCount = list.get(lang).length;
list.delete(lang);
counter[0] -= langCount;
if (counter[0] === 0) {
event.fire(
"hyphenopolyEnd",
{
"msg": "hyphenopolyEnd"
}
);
if (!C.keepAlive) {
window.Hyphenopoly = null;
}
}
}
}
return {
add,
counter,
list,
rem
};
}
/**
* Get language of element by searching its parents or fallback
* @param {Object} el The element
* @param {string} parentLang Lang of parent if available
* @param {boolean} fallback Will falback to mainlanguage
* @returns {string|null} The language or null
*/
function getLang(el, parentLang = "", fallback = true) {
// Find closest el with lang attr not empty
el = el.closest("[lang]:not([lang=''])");
if (el && el.lang) {
return el.lang.toLowerCase();
}
if (parentLang) {
return parentLang;
}
return (fallback)
? mainLanguage
: null;
}
/**
* Collect elements that have a selector defined in C.selectors
* and add them to elements.
* @param {Object} [parent = null] The start point element
* @param {string} [selector = null] The selector matching the parent
* @returns {Object} elements-object
*/
function collectElements(parent = null, selector = null) {
const elements = makeElementCollection();
const dontHyphenateSelector = (() => {
let s = "." + C.dontHyphenateClass;
o.getOwnPropertyNames(C.dontHyphenate).forEach((tag) => {
if (C.dontHyphenate.get(tag)) {
s += "," + tag;
}
});
return s;
})();
const matchingSelectors = C.selectors.join(",") + "," + dontHyphenateSelector;
/**
* Recursively walk all elements in el, lending lang and selName
* add them to elements if necessary.
* @param {Object} el The element to scan
* @param {string} pLang The language of the parent element
* @param {string} sel The selector of the parent element
* @param {boolean} isChild If el is a child element
* @returns {undefined}
*/
function processElements(el, pLang, sel, isChild = false) {
const eLang = getLang(el, pLang);
const langDef = H.cf.langs.get(eLang);
if (langDef === "H9Y") {
elements.add(el, eLang, sel);
if (!isChild && C.safeCopy) {
registerOnCopy(el);
}
} else if (!langDef && eLang !== "zxx") {
event.fire(
"error",
Error(`Element with '${eLang}' found, but '${eLang}.wasm' not loaded. Check language tags!`)
);
}
el.childNodes.forEach((n) => {
if (n.nodeType === 1 && !n.matches(matchingSelectors)) {
processElements(n, eLang, sel, true);
}
});
}
/**
* Searches the DOM for each sel
* @param {object} root The DOM root
* @returns {undefined}
*/
function getElems(root) {
C.selectors.forEach((sel) => {
root.querySelectorAll(sel).forEach((n) => {
processElements(n, getLang(n), sel, false);
});
});
}
if (parent === null) {
if (C.processShadows) {
w.document.querySelectorAll("*").forEach((m) => {
if (m.shadowRoot) {
getElems(m.shadowRoot);
}
});
}
getElems(w.document);
} else {
processElements(parent, getLang(parent), selector);
}
return elements;
}
const wordHyphenatorPool = new Map();
/**
* Factory for hyphenatorFunctions for a specific language and selector
* @param {Object} lo Language-Object
* @param {string} lang The language
* @param {string} sel The selector
* @returns {function} The hyphenate function
*/
function createWordHyphenator(lo, lang, sel) {
const poolKey = lang + "-" + sel;
if (wordHyphenatorPool.has(poolKey)) {
return wordHyphenatorPool.get(poolKey);
}
const selSettings = C.get(sel);
lo.cache.set(sel, new Map());
/**
* HyphenateFunction for non-compound words
* @param {string} word The word
* @returns {string} The hyphenated word
*/
function hyphenateNormal(word) {
if (word.length > 61) {
event.fire(
"error",
Error("Found word longer than 61 characters")
);
} else if (!lo.reNotAlphabet.test(word)) {
return lo.hyphenate(
word,
selSettings.hyphen.charCodeAt(0),
selSettings.leftminPerLang.get(lang),
selSettings.rightminPerLang.get(lang)
);
}
return word;
}
/**
* HyphenateFunction for compound words
* @param {string} word The word
* @returns {string} The hyphenated compound word
*/
function hyphenateCompound(word) {
const zeroWidthSpace = "\u200B";
let parts = null;
let wordHyphenator = null;
if (selSettings.compound === "auto" ||
selSettings.compound === "all") {
wordHyphenator = createWordHyphenator(lo, lang, sel);
parts = word.split("-").map((p) => {
if (p.length >= selSettings.minWordLength) {
return wordHyphenator(p);
}
return p;
});
if (selSettings.compound === "auto") {
word = parts.join("-");
} else {
word = parts.join("-" + zeroWidthSpace);
}
} else {
word = word.replace("-", "-" + zeroWidthSpace);
}
return word;
}
/**
* Checks if a string is mixed case
* @param {string} s The string
* @returns {boolean} true if s is mixed case
*/
function isMixedCase(s) {
return [...s].map((c) => {
return (c === c.toLowerCase());
}).some((v, i, a) => {
return (v !== a[0]);
});
}
/**
* HyphenateFunction for words (compound or not)
* @param {string} word The word
* @returns {string} The hyphenated word
*/
function hyphenator(word) {
let hw = lo.cache.get(sel).get(word);
if (!hw) {
if (lo.exc.has(word)) {
hw = lo.exc.get(word).replace(
/-/g,
selSettings.hyphen
);
} else if (!selSettings.mixedCase && isMixedCase(word)) {
hw = word;
} else if (word.indexOf("-") === -1) {
hw = hyphenateNormal(word);
} else {
hw = hyphenateCompound(word);
}
lo.cache.get(sel).set(word, hw);
}
return hw;
}
wordHyphenatorPool.set(poolKey, hyphenator);
return hyphenator;
}
const orphanControllerPool = new Map();
/**
* Factory for function that handles orphans
* @param {string} sel The selector
* @returns {function} The function created
*/
function createOrphanController(sel) {
if (orphanControllerPool.has(sel)) {
return orphanControllerPool.get(sel);
}
const selSettings = C.get(sel);
/**
* Function template
* @param {string} ignore unused result of replace
* @param {string} leadingWhiteSpace The leading whiteSpace
* @param {string} lastWord The last word
* @param {string} trailingWhiteSpace The trailing whiteSpace
* @returns {string} Treated end of text
*/
function controlOrphans(
ignore,
leadingWhiteSpace,
lastWord,
trailingWhiteSpace
) {
if (selSettings.orphanControl === 3 && leadingWhiteSpace === " ") {
// \u00A0 = no-break space (nbsp)
leadingWhiteSpace = "\u00A0";
}
return leadingWhiteSpace + lastWord.replace(RegExp(selSettings.hyphen, "g"), "") + trailingWhiteSpace;
}
orphanControllerPool.set(sel, controlOrphans);
return controlOrphans;
}
const wordRegExpPool = new Map();
/**
* Hyphenate an entitiy (text string or Element-Object)
* @param {string} lang - the language of the string
* @param {string} sel - the selectorName of settings
* @param {string} entity - the entity to be hyphenated
* @returns {string | null} hyphenated str according to setting of sel
*/
function hyphenate(lang, sel, entity) {
const lo = H.languages.get(lang);
const selSettings = C.get(sel);
const minWordLength = selSettings.minWordLength;
const regExpWord = (() => {
const key = lang + minWordLength;
if (wordRegExpPool.has(key)) {
return wordRegExpPool.get(key);
}
/*
* Transpiled RegExp of
* /[${alphabet}\p{Mn}Subset\p{Letter}\00AD-]
* {${minwordlength},}/gui
*/
const reWord = RegExp(
`[${lo.alphabet}a-z\u0300-\u036F\u0483-\u0487\u00DF-\u00F6\u00F8-\u00FE\u0101\u0103\u0105\u0107\u0109\u010D\u010F\u0111\u0113\u0117\u0119\u011B\u011D\u011F\u0123\u0125\u012B\u012F\u0131\u0135\u0137\u013C\u013E\u0142\u0144\u0146\u0148\u014D\u0151\u0153\u0155\u0159\u015B\u015D\u015F\u0161\u0165\u016B\u016D\u016F\u0171\u0173\u017A\u017C\u017E\u017F\u01CE\u01D0\u01D2\u01D4\u01D6\u01D8\u01DA\u01DC\u0219\u021B\u02BC\u0390\u03AC-\u03CE\u03D0\u03E3\u03E5\u03E7\u03E9\u03EB\u03ED\u03EF\u03F2\u0430-\u044F\u0451-\u045C\u045E\u045F\u0491\u04AF\u04E9\u0561-\u0585\u0587\u0905-\u090C\u090F\u0910\u0913-\u0928\u092A-\u0930\u0932\u0933\u0935-\u0939\u093D\u0960\u0961\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A85-\u0A8B\u0A8F\u0A90\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AE0\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B60\u0B61\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB5\u0BB7-\u0BB9\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D60\u0D61\u0D7A-\u0D7F\u0E01-\u0E2E\u0E30\u0E32\u0E33\u0E40-\u0E45\u10D0-\u10F0\u1200-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u1E0D\u1E37\u1E41\u1E43\u1E45\u1E47\u1E6D\u1F00-\u1F07\u1F10-\u1F15\u1F20-\u1F27\u1F30-\u1F37\u1F40-\u1F45\u1F50-\u1F57\u1F60-\u1F67\u1F70-\u1F7D\u1F80-\u1F87\u1F90-\u1F97\u1FA0-\u1FA7\u1FB2-\u1FB4\u1FB6\u1FB7\u1FC2-\u1FC4\u1FC6\u1FC7\u1FD2\u1FD3\u1FD6\u1FD7\u1FE2-\u1FE7\u1FF2-\u1FF4\u1FF6\u1FF7\u2C81\u2C83\u2C85\u2C87\u2C89\u2C8D\u2C8F\u2C91\u2C93\u2C95\u2C97\u2C99\u2C9B\u2C9D\u2C9F\u2CA1\u2CA3\u2CA5\u2CA7\u2CA9\u2CAB\u2CAD\u2CAF\u2CB1\u2CC9\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\u00AD\u200B-\u200D-]{${minWordLength},}`, "gui"
);
wordRegExpPool.set(key, reWord);
return reWord;
})();
/**
* Hyphenate text according to setting in sel
* @param {string} text - the strint to be hyphenated
* @returns {string} hyphenated string according to setting of sel
*/
function hyphenateText(text) {
if (C.normalize) {
text = text.normalize("NFC");
}
let tn = text.replace(
regExpWord,
createWordHyphenator(lo, lang, sel)
);
if (selSettings.orphanControl !== 1) {
tn = tn.replace(
/(\u0020*)(\S+)(\s*)$/,
createOrphanController(sel)
);
}
return tn;
}
/**
* Hyphenate element according to setting in sel
* @param {object} el - the HTMLElement to be hyphenated
* @returns {undefined}
*/
function hyphenateElement(el) {
event.fire(
"beforeElementHyphenation",
{
el,
lang
}
);
el.childNodes.forEach((n) => {
if (
n.nodeType === 3 &&
(/\S/).test(n.data) &&
n.data.length >= minWordLength
) {
n.data = hyphenateText(n.data);
}
});
H.res.els.counter[0] -= 1;
event.fire(
"afterElementHyphenation",
{
el,
lang
}
);
}
let r = null;
if (typeof entity === "string") {
r = hyphenateText(entity);
} else if (entity instanceof HTMLElement) {
hyphenateElement(entity);
}
return r;
}
/**
* Creates a language-specific string hyphenator
* @param {String} lang - The language this hyphenator hyphenates
*/
function createStringHyphenator(lang) {
return ((entity, sel = ".hyphenate") => {
if (typeof entity !== "string") {
event.fire(
"error",
Error("This use of hyphenators is deprecated. See https://mnater.github.io/Hyphenopoly/Hyphenators.html")
);
}
return hyphenate(lang, sel, entity);
});
}
/**
* Creates a polyglot HTML hyphenator
*/
function createDOMHyphenator() {
return ((entity, sel = ".hyphenate") => {
collectElements(entity, sel).list.forEach((els, l) => {
els.forEach((elo) => {
hyphenate(l, elo.selector, elo.element);
});
});
return null;
});
}
H.unhyphenate = () => {
H.res.els.list.forEach((els) => {
els.forEach((elo) => {
const n = elo.element.firstChild;
n.data = n.data.replace(RegExp(C[elo.selector].hyphen, "g"), "");
});
});
return Promise.resolve(H.res.els);
};
/**
* Hyphenate all elements with a given language
* @param {string} lang The language
* @param {Array} elArr Array of elements
* @returns {undefined}
*/
function hyphenateLangElements(lang, elements) {
const elArr = elements.list.get(lang);
if (elArr) {
elArr.forEach((elo) => {
hyphenate(lang, elo.selector, elo.element);
});
} else {
event.fire(
"error",
Error(`Engine for language '${lang}' loaded, but no elements found.`)
);
}
if (elements.counter[0] === 0) {
w.clearTimeout(H.timeOutHandler);
H.hide(0, null);
event.fire(
"hyphenopolyEnd",
{
"msg": "hyphenopolyEnd"
}
);
if (!C.keepAlive) {
window.Hyphenopoly = null;
}
}
}
/**
* Convert the exceptions from user input to Map
* @param {string} lang - The language for which the Map is created
* @return {Map}
*/
function createExceptionMap(lang) {
let exc = "";
if (C.exceptions.has(lang)) {
exc = C.exceptions.get(lang);
}
if (C.exceptions.has("global")) {
if (exc === "") {
exc = C.exceptions.get("global");
} else {
exc += ", " + C.exceptions.get("global");
}
}
if (exc === "") {
return new Map();
}
return new Map(exc.split(", ").map((e) => {
return [e.replace(/-/g, ""), e];
}));
}
/**
* Setup lo
* @param {string} lang The language
* @param {function} hyphenateFunction The hyphenateFunction
* @param {string} alphabet List of used characters
* @param {number} leftmin leftmin
* @param {number} rightmin rightmin
* @returns {undefined}
*/
function prepareLanguagesObj(
lang,
hyphenateFunction,
alphabet,
patternLeftmin,
patternRightmin
) {
C.selectors.forEach((sel) => {
const selSettings = C.get(sel);
if (selSettings.leftminPerLang === 0) {
selSettings.set("leftminPerLang", new Map());
}
if (selSettings.rightminPerLang === 0) {
selSettings.set("rightminPerLang", new Map());
}
selSettings.leftminPerLang.set(lang, Math.max(
patternLeftmin,
selSettings.leftmin,
Number(selSettings.leftminPerLang.get(lang)) || 0
));
selSettings.rightminPerLang.set(lang, Math.max(
patternRightmin,
selSettings.rightmin,
Number(selSettings.rightminPerLang.get(lang)) || 0
));
});
if (!H.languages) {
H.languages = new Map();
}
alphabet = alphabet.replace(/\\*-/g, "\\-");
H.languages.set(lang, {
alphabet,
"cache": new Map(),
"exc": createExceptionMap(lang),
"hyphenate": hyphenateFunction,
"ready": true,
"reNotAlphabet": RegExp(`[^${alphabet}]`, "i")
});
H.hy6ors.get(lang).resolve(createStringHyphenator(lang));
event.fire(
"engineReady",
{
lang
}
);
if (H.res.els) {
hyphenateLangElements(lang, H.res.els);
}
}
const decode = (() => {
const utf16ledecoder = new TextDecoder("utf-16le");
return ((ui16) => {
return utf16ledecoder.decode(ui16);
});
})();
/**
* Setup env for hyphenateFunction
* @param {ArrayBuffer} buf Memory buffer
* @param {function} hyphenateFunc hyphenateFunction
* @returns {function} hyphenateFunction with closured environment
*/
function encloseHyphenateFunction(buf, hyphenateFunc) {
const wordStore = new Uint16Array(buf, 0, 64);
return ((word, hyphencc, leftmin, rightmin) => {
wordStore.set([
...[...word].map((c) => {
return c.charCodeAt(0);
}),
0
]);
const len = hyphenateFunc(leftmin, rightmin, hyphencc);
if (len > 0) {
word = decode(
new Uint16Array(buf, 0, len)
);
}
return word;
});
}
/**
* Instantiate Wasm Engine
* @param {string} lang The language
* @returns {undefined}
*/
function instantiateWasmEngine(heProm, lang) {
const wa = window.WebAssembly;
/**
* Register character substitutions in the .wasm-hyphenEngine
* @param {number} alphalen - The length of the alphabet
* @param {object} exp - Export-object of the hyphenEngine
*/
function registerSubstitutions(alphalen, exp) {
if (C.substitute.has(lang)) {
const subst = C.substitute.get(lang);
subst.forEach((substituer, substituted) => {
const substitutedU = substituted.toUpperCase();
const substitutedUcc = (substitutedU === substituted)
? 0
: substitutedU.charCodeAt(0);
alphalen = exp.subst(
substituted.charCodeAt(0),
substitutedUcc,
substituer.charCodeAt(0)
);
});
}
return alphalen;
}
/**
* Instantiate the hyphenEngine
* @param {object} res - The fetched ressource
*/
function handleWasm(res) {
const exp = res.instance.exports;
// eslint-disable-next-line multiline-ternary
let alphalen = (wa.Global) ? exp.lct.value : exp.lct;
alphalen = registerSubstitutions(alphalen, exp);
heProm.l.forEach((l) => {
prepareLanguagesObj(
l,
encloseHyphenateFunction(
exp.mem.buffer,
exp.hyphenate
),
decode(new Uint16Array(exp.mem.buffer, 1408, alphalen)),
/* eslint-disable multiline-ternary */
(wa.Global) ? exp.lmi.value : exp.lmi,
(wa.Global) ? exp.rmi.value : exp.rmi
/* eslint-enable multiline-ternary */
);
});
}
heProm.w.then((response) => {
if (response.ok) {
if (
wa.instantiateStreaming &&
(response.headers.get("Content-Type") === "application/wasm")
) {
return wa.instantiateStreaming(response);
}
return response.arrayBuffer().then((ab) => {
return wa.instantiate(ab);
});
}
return Promise.reject(Error(`File ${lang}.wasm can't be loaded from ${H.paths.patterndir}`));
}).then(handleWasm, (e) => {
event.fire("error", e);
H.res.els.rem(lang);
});
}
H.main = () => {
H.res.DOM.then(() => {
mainLanguage = getLang(w.document.documentElement, "", false);
if (!mainLanguage && C.defaultLanguage !== "") {
mainLanguage = C.defaultLanguage;
}
const elements = collectElements();
H.res.els = elements;
elements.list.forEach((ignore, lang) => {
if (H.languages &&
H.languages.has(lang) &&
H.languages.get(lang).ready
) {
hyphenateLangElements(lang, elements);
}
});
});
H.res.he.forEach(instantiateWasmEngine);
Promise.all(
// Make sure all lang specific hyphenators and DOM are ready
[...H.hy6ors.entries()].
reduce((accumulator, value) => {
if (value[0] !== "HTML") {
return accumulator.concat(value[1]);
}
return accumulator;
}, []).
concat(H.res.DOM)
).then(() => {
H.hy6ors.get("HTML").resolve(createDOMHyphenator());
}, (e) => {
event.fire("error", e);
});
};
H.main();
})(Hyphenopoly);
})(window, Object);
+347
View File
@@ -0,0 +1,347 @@
/**
* @license Hyphenopoly_Loader 5.2.0-beta.1 - client side hyphenation
* ©2023 Mathias Nater, Güttingen (mathiasnater at gmail dot com)
* https://github.com/mnater/Hyphenopoly
*
* Released under the MIT license
* http://mnater.github.io/Hyphenopoly/LICENSE
*/
/* globals Hyphenopoly:readonly */
window.Hyphenopoly = {};
((w, d, H, o) => {
"use strict";
/**
* Shortcut for new Map
* @param {any} init - initialiser for new Map
* @returns {Map}
*/
const mp = (init) => {
return new Map(init);
};
const scriptName = "Hyphenopoly_Loader.js";
const thisScript = d.currentScript.src;
const store = sessionStorage;
let mainScriptLoaded = false;
/**
* The main function runs the feature test and loads Hyphenopoly if
* necessary.
*/
const main = (() => {
const shortcuts = {
"ac": "appendChild",
"ce": "createElement",
"ct": "createTextNode"
};
/**
* Create deferred Promise
*
* From http://lea.verou.me/2016/12/resolve-promises-externally-with-
* this-one-weird-trick/
* @return {promise}
*/
const defProm = () => {
let res = null;
let rej = null;
const promise = new Promise((resolve, reject) => {
res = resolve;
rej = reject;
});
promise.resolve = res;
promise.reject = rej;
return promise;
};
H.ac = new AbortController();
const fetchOptions = {
"credentials": H.s.CORScredentials,
"signal": H.ac.signal
};
let stylesNode = null;
/**
* Define function H.hide.
* This function hides (state = 1) or unhides (state = 0)
* the whole document (mode == 0) or
* each selected element (mode == 1) or
* text of each selected element (mode == 2) or
* nothing (mode == -1)
* @param {integer} state - State
* @param {integer} mode - Mode
*/
H.hide = (state, mode) => {
if (state) {
let vis = "{visibility:hidden!important}";
stylesNode = d[shortcuts.ce]("style");
let myStyle = "";
if (mode === 0) {
myStyle = "html" + vis;
} else if (mode !== -1) {
if (mode === 2) {
vis = "{color:transparent!important}";
}
o.keys(H.s.selectors).forEach((sel) => {
myStyle += sel + vis;
});
}
stylesNode[shortcuts.ac](d[shortcuts.ct](myStyle));
d.head[shortcuts.ac](stylesNode);
} else if (stylesNode) {
stylesNode.remove();
}
};
const tester = (() => {
let fakeBody = null;
return {
/**
* Append fakeBody with tests to document
* @returns {Object|null} The body element or null, if no tests
*/
"ap": () => {
if (fakeBody) {
d.documentElement[shortcuts.ac](fakeBody);
return fakeBody;
}
return null;
},
/**
* Remove fakeBody
* @returns {undefined}
*/
"cl": () => {
if (fakeBody) {
fakeBody.remove();
}
},
/**
* Create and append div with CSS-hyphenated word
* @param {string} lang Language
* @returns {undefined}
*/
"cr": (lang) => {
if (H.cf.langs.has(lang)) {
return;
}
fakeBody = fakeBody || d[shortcuts.ce]("body");
const testDiv = d[shortcuts.ce]("div");
const ha = "hyphens:auto";
testDiv.lang = lang;
testDiv.style.cssText = `visibility:hidden;-webkit-${ha};-ms-${ha};${ha};width:48px;font-size:12px;line-height:12px;border:none;padding:0;word-wrap:normal`;
testDiv[shortcuts.ac](
d[shortcuts.ct](H.lrq.get(lang).wo.toLowerCase())
);
fakeBody[shortcuts.ac](testDiv);
}
};
})();
/**
* Checks if hyphens (ev.prefixed) is set to auto for the element.
* @param {Object} elm - the element
* @returns {Boolean} result of the check
*/
const checkCSSHyphensSupport = (elmStyle) => {
const h = elmStyle.hyphens ||
elmStyle.webkitHyphens ||
elmStyle.msHyphens;
return (h === "auto");
};
H.res = {
"he": mp()
};
/**
* Load hyphenEngines to H.res.he
*
* Make sure each .wasm is loaded exactly once, even for fallbacks
* Store a list of languages to by hyphenated with each .wasm
* @param {string} lang The language
* @returns {undefined}
*/
const loadhyphenEngine = (lang) => {
const fn = H.lrq.get(lang).fn;
H.cf.pf = true;
H.cf.langs.set(lang, "H9Y");
if (H.res.he.has(fn)) {
H.res.he.get(fn).l.push(lang);
} else {
H.res.he.set(
fn,
{
"l": [lang],
"w": w.fetch(H.paths.patterndir + fn + ".wasm", fetchOptions)
}
);
}
};
H.lrq.forEach((value, lang) => {
if (value.wo === "FORCEHYPHENOPOLY" || H.cf.langs.get(lang) === "H9Y") {
loadhyphenEngine(lang);
} else {
tester.cr(lang);
}
});
const testContainer = tester.ap();
if (testContainer) {
testContainer.querySelectorAll("div").forEach((n) => {
if (checkCSSHyphensSupport(n.style) && n.offsetHeight > 12) {
H.cf.langs.set(n.lang, "CSS");
} else {
loadhyphenEngine(n.lang);
}
});
tester.cl();
}
const hev = H.hev;
if (H.cf.pf) {
H.res.DOM = new Promise((res) => {
if (d.readyState === "loading") {
d.addEventListener(
"DOMContentLoaded",
res,
{
"once": true,
"passive": true
}
);
} else {
res();
}
});
H.hide(1, H.s.hide);
H.timeOutHandler = w.setTimeout(() => {
H.hide(0, null);
// eslint-disable-next-line no-bitwise
if (H.s.timeout & 1) {
H.ac.abort();
}
// eslint-disable-next-line no-console
console.info(scriptName + " timed out.");
}, H.s.timeout);
if (mainScriptLoaded) {
H.main();
} else {
// Load main script
fetch(H.paths.maindir + "Hyphenopoly.js", fetchOptions).
then((response) => {
if (response.ok) {
response.blob().then((blb) => {
const script = d[shortcuts.ce]("script");
script.src = URL.createObjectURL(blb);
d.head[shortcuts.ac](script);
mainScriptLoaded = true;
URL.revokeObjectURL(script.src);
});
}
});
}
H.hy6ors = mp();
H.cf.langs.forEach((langDef, lang) => {
if (langDef === "H9Y") {
H.hy6ors.set(lang, defProm());
}
});
H.hy6ors.set("HTML", defProm());
H.hyphenators = new Proxy(H.hy6ors, {
"get": (target, key) => {
return target.get(key);
},
"set": () => {
// Inhibit setting of hyphenators
return true;
}
});
(() => {
if (hev && hev.polyfill) {
hev.polyfill();
}
})();
} else {
(() => {
if (hev && hev.tearDown) {
hev.tearDown();
}
w.Hyphenopoly = null;
})();
}
(() => {
if (H.cft) {
store.setItem(scriptName, JSON.stringify(
{
"langs": [...H.cf.langs.entries()],
"pf": H.cf.pf
}
));
}
})();
});
H.config = (c) => {
/**
* Sets default properties for an Object
* @param {object} obj - The object to set defaults to
* @param {object} defaults - The defaults to set
* @returns {object}
*/
const setDefaults = (obj, defaults) => {
if (obj) {
o.entries(defaults).forEach(([k, v]) => {
// eslint-disable-next-line security/detect-object-injection
obj[k] = obj[k] || v;
});
return obj;
}
return defaults;
};
H.cft = Boolean(c.cacheFeatureTests);
if (H.cft && store.getItem(scriptName)) {
H.cf = JSON.parse(store.getItem(scriptName));
H.cf.langs = mp(H.cf.langs);
} else {
H.cf = {
"langs": mp(),
"pf": false
};
}
const maindir = thisScript.slice(0, (thisScript.lastIndexOf("/") + 1));
const patterndir = maindir + "patterns/";
H.paths = setDefaults(c.paths, {
maindir,
patterndir
});
H.s = setDefaults(c.setup, {
"CORScredentials": "include",
"hide": "all",
"selectors": {".hyphenate": {}},
"timeout": 1000
});
// Change mode string to mode int
H.s.hide = ["all", "element", "text"].indexOf(H.s.hide);
if (c.handleEvent) {
H.hev = c.handleEvent;
}
const fallbacks = mp(o.entries(c.fallbacks || {}));
H.lrq = mp();
o.entries(c.require).forEach(([lang, wo]) => {
H.lrq.set(lang.toLowerCase(), {
"fn": fallbacks.get(lang) || lang,
wo
});
});
main();
};
})(window, document, Hyphenopoly, Object);
+89 -13
View File
@@ -33,29 +33,79 @@ class AIFiction {
this.typingSpeed = 30; // Default value, will be adjusted by slider
this.typingTimeout = null;
// Check for kokoro-js being loaded (Now handled by factory)
// this.checkForKokoroJs(); // No longer needed here
// Bind event handlers
this.bindEvents();
// Initialize socket communication
this.initializeSocket();
// Initialize UI
// Initialize UI (TTS part will be updated by event)
this.initializeUI();
// Listen for TTS readiness
this.listenForTTSReady();
}
/**
* Initialize the UI
* Check if kokoro-js is loaded
*/
checkForKokoroJs() {
try {
// With our TTS factory in place, we don't need to manually check for kokoro
// as the factory will handle loading and fallback automatically
console.log("TTS Factory will handle initialization of speech systems");
} catch (e) {
console.warn("Error checking for TTS systems:", e);
}
}
/**
* Initialize the UI (Initial state, TTS updated later)
*/
initializeUI() {
this.updateTypingSpeed();
this.updateSpeechButton();
// Start with speech button disabled, will be enabled by tts-ready event
this.speechButton.setAttribute('disabled', 'disabled');
this.speechButton.setAttribute('title', 'Initializing Text-to-Speech...');
this.updateSpeechButton(false);
// Disable buttons initially
// Disable other buttons initially
this.rewindButton.setAttribute('disabled', 'disabled');
this.loadButton.setAttribute('disabled', 'disabled');
// Start the game
// Start the game (if socket is ready)
if (this.socket && this.socket.connected) {
this.startGame();
} else {
console.log("Waiting for socket connection to start game...");
}
}
/**
* Listen for the tts-ready event from the factory
*/
listenForTTSReady() {
window.addEventListener('tts-ready', (event) => {
console.log('Received tts-ready event:', event.detail);
const { available, type, handler } = event.detail;
if (available) {
console.log(`TTS System active: ${type}`);
this.speechButton.removeAttribute('disabled');
const ttsName = type === 'kokoro' ? 'Kokoro TTS' : 'Browser TTS';
this.speechButton.setAttribute('title', `Text-to-Speech (${ttsName})`);
// Ensure the button style reflects the initial state (off)
this.updateSpeechButton(window.ttsHandler ? window.ttsHandler.isEnabled() : false);
} else {
console.warn("No TTS system available after initialization.");
this.speechButton.setAttribute('disabled', 'disabled');
this.speechButton.setAttribute('title', 'Text-to-Speech not available');
this.updateSpeechButton(false);
}
});
}
/**
@@ -74,19 +124,40 @@ class AIFiction {
// Toggle speech
this.speechButton.addEventListener('click', () => {
if (ttsHandler && typeof ttsHandler.isEnabled === 'function') {
const enabled = ttsHandler.toggle();
// Check if the handler is available (it should be if button is enabled)
if (window.ttsHandler) {
// Ensure AudioContext is resumed on user interaction if using Kokoro
if (window.ttsFactory && window.ttsFactory.usingKokoro && window.ttsHandler.audioContext && window.ttsHandler.audioContext.state === 'suspended') {
window.ttsHandler.audioContext.resume().catch(err => console.error('Error resuming AudioContext on click:', err));
}
// Set user activation flag for the handler
window.ttsHandler.hasUserActivation = true;
const enabled = window.ttsHandler.toggle();
this.updateSpeechButton(enabled);
if (enabled) {
// Speak the last narrative if speech was just enabled
const lastNarrative = this.storyContainer.lastElementChild;
if (lastNarrative && lastNarrative.classList.contains('narrative')) {
ttsHandler.speak(lastNarrative.textContent);
console.log("Speaking last narrative on toggle");
// Use a slight delay to ensure audio context is resumed
setTimeout(() => window.ttsHandler.speak(lastNarrative.textContent), 50);
}
// Update the tooltip with active TTS system info
if (window.ttsFactory) {
const ttsInfo = window.ttsFactory.getActiveTTSInfo();
this.speechButton.setAttribute('title', `Text-to-Speech (${ttsInfo.name})`);
}
} else {
console.log('TTS not ready yet');
// If disabling, ensure speech stops
window.ttsHandler.stop();
}
} else {
console.log('TTS handler not available when speech button clicked.');
// Optionally show an alert or keep button disabled
}
});
@@ -281,8 +352,10 @@ class AIFiction {
element.className = 'narrative hide';
this.storyContainer.appendChild(element);
// Apply SmartyPants transformations for better typography
const processedText = SmartyPants.smartypantsu ? SmartyPants.smartypantsu(text, 1) : text;
// Apply SmartyPants transformations for better typography if available
const processedText = window.SmartyPants && typeof window.SmartyPants.smartypantsu === 'function'
? window.SmartyPants.smartypantsu(text, 1)
: text;
// Clear any existing typing timeouts
if (this.typingTimeout) {
@@ -293,8 +366,9 @@ class AIFiction {
this.typeText(element, processedText, 0);
// Read text aloud if speech is enabled
if (ttsHandler && ttsHandler.isEnabled()) {
ttsHandler.speak(text);
if (window.ttsHandler && window.ttsHandler.isEnabled()) {
console.log("Speaking narrative text with TTS");
window.ttsHandler.speak(text);
}
}
@@ -411,9 +485,11 @@ class AIFiction {
if (enabled) {
this.speechButton.style.fontWeight = 'bold';
this.speechButton.style.color = '#000';
this.speechButton.style.backgroundColor = '#eee';
} else {
this.speechButton.style.fontWeight = 'normal';
this.speechButton.style.color = '#333';
this.speechButton.style.backgroundColor = '';
}
}
+56
View File
@@ -0,0 +1,56 @@
function kap(text, measureText, measure, hyphenation) {
console.log("Typesetting hyphenated text:", text, measure);
if (!hyphenation) {
text = text.replace(/\|/g, '');
}
let hyphenWidth = measureText('-');
let spaceWidth = measureText('\u00A0');
let nodes = [];
text.split(/([.,:;!?] |\s|\||<.*?>)/u).forEach(function (fragment) {
let fragmentWidth = measureText(fragment);
if (fragment === ' ') {
let stretch = (spaceWidth * 3) / 6;
let shrink = (spaceWidth * 3) / 9;
nodes.push(linebreak.glue(spaceWidth, stretch, shrink));
} else if (fragment === '|') {
// nodes.push(linebreak.penalty(hyphenWidth, 100, 1));
nodes.push(linebreak.penalty(hyphenWidth * 0.25, 100, 1));
} else if (fragment.match(/(<.*?>)/u)) {
nodes.push(linebreak.tag(fragmentWidth, fragment));
} else if (fragment.match(/[.,:;!?] /u)) {
let punctuation = fragment.match(/([.,:;!?])( )/u);
let punctuationSymbolWidth = measureText(punctuation[1]) * 0.25;
let punctuationWidth = measureText(punctuation[1]) * 0.75 + spaceWidth;
nodes.push(linebreak.box(punctuationSymbolWidth, punctuation[1]));
let stretch = (punctuationWidth * 3) / 6;
let shrink = (punctuationWidth * 3) / 9;
nodes.push(linebreak.glue(punctuationWidth, stretch, shrink));
} else if (fragment.match(/(\s+)/u)) {
} else {
nodes.push(linebreak.box(fragmentWidth, fragment));
}
});
nodes.push(linebreak.glue(0, linebreak.infinity, 0));
nodes.push(linebreak.penalty(0, -linebreak.infinity, 1));
let demerits = {
line: 10,
flagged: 100,
fitness: 3000
};
let breaks = linebreak(nodes, measure, { tolerance: 3, demerits });
if (!breaks.length) {
breaks = linebreak(nodes, measure, { tolerance: 10, demerits });
}
return { nodes, breaks };
}
+597
View File
@@ -0,0 +1,597 @@
/**
* Kokoro Text-to-Speech Handler for AI Interactive Fiction
* Uses the kokoro-js library for high-quality TTS
*/
class KokoroHandler {
constructor() {
this.enabled = false;
this.speaking = false;
this.paused = false;
this.audio = null;
this.currentSpeed = 1.0; // Note: KokoroTTS might not support speed changes directly
this.audioQueue = [];
this.isProcessingQueue = false;
this.kokoroReady = false;
this.kokoroInstance = null; // Store the KokoroTTS instance
this.hasUserActivation = false;
this.initializationPromise = null;
this.audioContext = null; // For playing the generated audio
this.currentVoice = "af_heart"; // Default voice from README
this.currentAudioSource = null; // To keep track of the playing audio source
// Start initialization process
this.initializeKokoro();
}
/**
* Initialize Kokoro TTS by waiting for the class and then instantiating
*/
async initializeKokoro() {
if (this.initializationPromise) {
return this.initializationPromise;
}
this.initializationPromise = new Promise(async (resolve) => {
try {
// Wait for the KokoroTTS class to be loaded
if (typeof window.KokoroTTS === 'undefined') {
console.log('Kokoro TTS class not found, waiting for it to load...');
let loadTimeoutId = null; // Variable to hold the timeout ID
const loadHandler = async () => {
clearTimeout(loadTimeoutId); // <<< Clear the timeout
window.removeEventListener('kokoro-class-loaded', loadHandler);
window.removeEventListener('kokoro-class-load-failed', failHandler);
console.log('KokoroTTS class loaded event received.');
const success = await this._initKokoroInstance();
resolve(success);
};
const failHandler = () => {
clearTimeout(loadTimeoutId); // <<< Clear the timeout
window.removeEventListener('kokoro-class-loaded', loadHandler);
window.removeEventListener('kokoro-class-load-failed', failHandler);
console.error('KokoroTTS class failed to load.');
resolve(false);
};
window.addEventListener('kokoro-class-loaded', loadHandler);
window.addEventListener('kokoro-class-load-failed', failHandler);
// Timeout if the event never fires
loadTimeoutId = setTimeout(() => { // <<< Store the timeout ID
// Check if still waiting (listener might have run but instance not ready yet)
if (!this.kokoroInstance && !this.kokoroReady) {
window.removeEventListener('kokoro-class-loaded', loadHandler);
window.removeEventListener('kokoro-class-load-failed', failHandler);
console.error('Timed out waiting for KokoroTTS class load event.');
resolve(false);
}
}, 15000); // Increased timeout
return;
}
// If we get here, KokoroTTS class is already available
console.log('KokoroTTS class found directly.');
const success = await this._initKokoroInstance();
resolve(success);
} catch (error) {
console.error('Error during KokoroHandler initialization:', error);
resolve(false);
}
});
return this.initializationPromise;
}
/**
* Internal method to create and initialize the KokoroTTS instance
* @private
*/
async _initKokoroInstance() {
if (this.kokoroInstance || this.kokoroReady) return true; // Already initialized or initializing
try {
console.log('Initializing KokoroTTS instance (GPU Only Attempt)...');
const model_id = "onnx-community/Kokoro-82M-v1.0-ONNX";
// --- Check for WebGPU Support ---
const device = await this.getBestDevice();
if (device !== 'webgpu') {
console.warn('WebGPU not available or supported. Kokoro TTS (GPU) cannot be initialized.');
// Explicitly set ready to false and return false to signal failure
this.kokoroReady = false;
return false;
}
// --- End WebGPU Check ---
// Use fp32 for WebGPU as recommended
const dtype = 'fp32';
console.log(`Attempting KokoroTTS init with device: ${device}, dtype: ${dtype}`);
console.log(`Calling KokoroTTS.from_pretrained('${model_id}', { dtype: '${dtype}', device: '${device}' })...`);
// --- Add Timeout Wrapper for from_pretrained ---
const fromPretrainedPromise = window.KokoroTTS.from_pretrained(model_id, {
dtype: dtype,
device: device, // Always 'webgpu' if we reach here
});
const pretrainedTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('KokoroTTS.from_pretrained (WebGPU) timed out after 55 seconds')), 55000) // 55 seconds timeout
);
try {
this.kokoroInstance = await Promise.race([
fromPretrainedPromise,
pretrainedTimeoutPromise
]);
} catch (timeoutError) {
console.error(timeoutError.message); // Log the specific timeout error
throw timeoutError; // Re-throw to be caught by the outer catch block
}
// --- End Timeout Wrapper ---
console.log('KokoroTTS.from_pretrained call completed.');
if (!this.kokoroInstance) {
console.error('KokoroTTS.from_pretrained returned a falsy value.');
throw new Error('KokoroTTS.from_pretrained returned null or undefined.');
}
// Defer AudioContext creation until first use
this.kokoroReady = true;
console.log('Kokoro TTS (WebGPU) instance created successfully (AudioContext deferred).');
return true;
} catch (error) {
console.error('Error during KokoroTTS (WebGPU) initialization:', error);
if (error.message) {
console.error('Error message:', error.message);
}
if (error.stack) {
console.error('Error stack:', error.stack);
}
this.kokoroInstance = null;
this.kokoroReady = false;
return false; // Ensure failure is explicitly returned
}
}
/**
* Determine the best device (webgpu or wasm)
* Checks for WebGPU support.
* @private
*/
async getBestDevice() {
if (navigator.gpu) {
try {
// Request an adapter. If this succeeds, WebGPU is likely available.
const adapter = await navigator.gpu.requestAdapter();
if (adapter) {
console.log('WebGPU supported, selecting webgpu device.');
return 'webgpu';
}
console.warn('WebGPU adapter request returned null.');
} catch (e) {
console.warn('WebGPU adapter request failed:', e);
}
}
console.log('WebGPU not supported or available, cannot use GPU for Kokoro.');
return 'wasm'; // Return wasm indicating GPU is not the best/available option
}
/**
* List available voices (delegates to KokoroTTS instance)
*/
async listVoices() {
if (!this.kokoroReady || !this.kokoroInstance) {
console.warn('Kokoro not ready, cannot list voices.');
return [];
}
try {
// The README uses tts.list_voices(), assuming it's a method on the instance
if (typeof this.kokoroInstance.list_voices === 'function') {
return await this.kokoroInstance.list_voices();
} else {
console.warn('list_voices method not found on KokoroTTS instance. Returning default.');
// Fallback based on README examples
return [{ name: 'af_heart', description: 'Default American Female' }];
}
} catch (error) {
console.error('Error listing Kokoro voices:', error);
return [];
}
}
/**
* Set the voice to use
* @param {string} voiceName - Name of the voice (e.g., 'af_heart')
*/
setVoice(voiceName) {
this.currentVoice = voiceName;
console.log(`Kokoro voice set to: ${voiceName}`);
}
/**
* Toggle TTS functionality on/off
* @returns {boolean} New state of TTS (enabled/disabled)
*/
toggle() {
// Set user activation flag when toggle is called
this.hasUserActivation = true;
// --- Create AudioContext on first activation ---
if (!this.audioContext) {
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
console.log('AudioContext created on user activation.');
// Resume if context starts suspended
if (this.audioContext.state === 'suspended') {
this.audioContext.resume().catch(err => console.error('Error resuming initial AudioContext:', err));
}
} catch (e) {
console.error('Failed to create AudioContext:', e);
// If AudioContext fails, Kokoro cannot play audio
this.kokoroReady = false;
return false;
}
}
// --- End AudioContext Creation ---
if (!this.kokoroReady) {
console.warn('Kokoro TTS not ready yet');
// Optionally, trigger re-initialization or inform user
return false;
}
this.enabled = !this.enabled;
console.log("Kokoro TTS toggled:", this.enabled ? "ON" : "OFF");
// Stop any ongoing speech when disabling
if (!this.enabled && (this.speaking || this.isProcessingQueue)) {
this.stop();
}
return this.enabled;
}
/**
* Set the speech rate/speed
* @param {number} speed - Speed multiplier (0.1 to 2.0)
*/
setSpeed(speed) {
this.currentSpeed = Math.max(0.5, Math.min(2.0, speed));
}
/**
* Process text for better speech synthesis
* @param {string} text - Text to process
* @returns {string} - Processed text
*/
processTextForSpeech(text) {
if (!text) return "";
// Remove markdown/formatting that would sound strange when read
text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); // Bold
text = text.replace(/\*([^*]+)\*/g, '$1'); // Italic
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // Links
// Clean up any HTML tags
text = text.replace(/<[^>]+>/g, '');
return text;
}
/**
* Split text into digestible chunks for better TTS handling
* @param {string} text - Text to split
* @returns {string[]} - Array of text chunks
*/
splitTextIntoChunks(text) {
if (!text) return [];
// Split by sentence terminators, keeping the terminator with the chunk
const sentenceRegex = /[^.!?]+[.!?]+/g;
const sentences = text.match(sentenceRegex) || [text];
// Group sentences into chunks for better performance
const chunks = [];
let currentChunk = '';
for (const sentence of sentences) {
// If adding this sentence would make the chunk too long, start a new chunk
if (currentChunk.length + sentence.length > 500) {
if (currentChunk) chunks.push(currentChunk);
currentChunk = sentence;
} else {
currentChunk += sentence;
}
}
// Add the last chunk if it's not empty
if (currentChunk) chunks.push(currentChunk);
return chunks;
}
/**
* Process the speech queue using KokoroTTS
* @private
*/
async processQueue() {
// Ensure AudioContext is ready before processing
if (!this.audioContext) {
console.warn('AudioContext not available, cannot process Kokoro queue.');
this.isProcessingQueue = false;
this.speaking = false;
return;
}
// Ensure AudioContext is running
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume().catch(err => console.error('Error resuming AudioContext for queue:', err));
}
if (this.isProcessingQueue || this.audioQueue.length === 0 || !this.kokoroReady || !this.kokoroInstance) {
if (this.audioQueue.length === 0) {
this.speaking = false; // Ensure speaking flag is reset when queue is empty
}
// Reset processing flag if we exit early
if (this.isProcessingQueue && this.audioQueue.length === 0) {
this.isProcessingQueue = false;
}
return;
}
this.isProcessingQueue = true;
this.speaking = true; // Set speaking true when processing starts
try {
const textChunk = this.audioQueue.shift();
if (!textChunk) {
this.isProcessingQueue = false;
this.speaking = false;
return;
}
console.log(`Kokoro generating chunk (${this.audioQueue.length} remaining):`, textChunk.substring(0, 30) + "...");
try {
// Use Kokoro instance to generate audio
const audioResult = await this.kokoroInstance.generate(textChunk, {
voice: this.currentVoice,
});
// --- Updated Check: Expect Float32Array ---
if (!audioResult || !audioResult.audio || !(audioResult.audio instanceof Float32Array) || !audioResult.sampling_rate) {
console.error('Invalid audio data or sampling rate received from KokoroTTS.generate', audioResult);
throw new Error('Invalid audio data or sampling rate received from KokoroTTS.generate');
}
// --- End Updated Check ---
const rawAudioSamples = audioResult.audio;
const samplingRate = audioResult.sampling_rate;
console.log(`Received raw audio samples (${rawAudioSamples.length}), sample rate: ${samplingRate}`);
// Decode and play the raw audio samples
await this.playRawAudio(rawAudioSamples, samplingRate);
} catch (error) {
console.error("Error generating or playing Kokoro speech:", error);
} finally {
// Always continue processing the queue
this.isProcessingQueue = false;
// Check if queue is now empty to reset speaking flag
if (this.audioQueue.length === 0) {
this.speaking = false;
console.log("Kokoro queue finished.");
}
// Use setTimeout to avoid potential stack overflow on rapid processing
setTimeout(() => this.processQueue(), 0);
}
} catch (error) {
console.error("Error in Kokoro processQueue:", error);
this.isProcessingQueue = false;
this.speaking = false; // Reset speaking flag on error
}
}
/**
* Play raw Float32Array audio samples using Web Audio API
* @param {Float32Array} samples - The raw audio samples
* @param {number} sampleRate - The sample rate of the audio
* @private
*/
async playRawAudio(samples, sampleRate) {
if (!this.audioContext) {
console.error('AudioContext not initialized.');
return;
}
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume().catch(err => console.error('Error resuming AudioContext for playback:', err));
}
try {
// Create an AudioBuffer
const audioBuffer = this.audioContext.createBuffer(
1, // Number of channels (assuming mono)
samples.length, // Length of the buffer
sampleRate // Sample rate
);
// Copy the samples to the AudioBuffer
// NOTE: If audio is stereo, this needs adjustment
audioBuffer.copyToChannel(samples, 0);
// Create a source node
const source = this.audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.audioContext.destination);
// Store the current source to allow stopping
this.currentAudioSource = source;
console.log(`Playing audio buffer (${(samples.length / sampleRate).toFixed(2)}s)`);
return new Promise((resolve) => {
source.onended = () => {
// Check if this source was the one we intended to stop
if (this.currentAudioSource === source) {
this.currentAudioSource = null;
}
console.log('Audio playback finished.');
resolve();
};
source.start(0); // Start playback immediately
});
} catch (error) {
console.error('Error creating or playing raw audio buffer:', error);
this.currentAudioSource = null; // Clear source on error
}
}
/**
* Speak the provided text using KokoroTTS
* @param {string} text - Text to be spoken
* @param {function} onEndCallback - Callback when all speech ends
*/
speak(text, onEndCallback = null) {
if (!this.enabled || !text) {
if (onEndCallback) onEndCallback();
return;
}
// If kokoro is not ready yet, wait for initialization
if (!this.kokoroReady) {
console.warn("Kokoro TTS not ready yet, waiting for initialization...");
this.initializationPromise.then(success => {
if (success && this.enabled) {
this._speakInternal(text, onEndCallback);
} else {
console.error("Kokoro failed to initialize, cannot speak.");
if (onEndCallback) onEndCallback();
}
});
return;
}
this._speakInternal(text, onEndCallback);
}
/**
* Internal method to handle speech after initialization checks
* @private
*/
_speakInternal(text, onEndCallback) {
// Ensure AudioContext is resumed after user interaction
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume().catch(err => console.error('Error resuming AudioContext:', err));
}
// Don't attempt to speak without user activation
if (!this.hasUserActivation) {
console.warn("Not attempting to speak because there hasn't been user interaction yet");
if (onEndCallback) onEndCallback();
return;
}
try {
const processedText = this.processTextForSpeech(text);
console.log("Kokoro TTS attempting to speak:", processedText.substring(0, 50) + "...");
// Stop any existing speech
this.stop();
// Split into manageable chunks (consider if Kokoro handles long text well)
const chunks = this.splitTextIntoChunks(processedText);
this.audioQueue = chunks;
// Start processing the queue
if (this.audioQueue.length > 0 && !this.isProcessingQueue) {
this.processQueue();
}
// Set up a completion callback
if (onEndCallback) {
const checkCompletion = () => {
if (!this.isSpeaking()) { // Check if speaking is false
onEndCallback();
} else {
setTimeout(checkCompletion, 150); // Check again shortly
}
};
// Start checking slightly after processing begins
setTimeout(checkCompletion, 100);
}
} catch (error) {
console.error("Error in Kokoro speak:", error);
if (onEndCallback) onEndCallback();
}
}
/**
* Pause the current speech (Note: May not be perfectly resumable with AudioBufferSourceNode)
*/
pause() {
if (!this.speaking || !this.audioContext) return;
// Suspending AudioContext is a way to pause, but resuming might not be seamless
this.audioContext.suspend().catch(err => console.error('Error suspending AudioContext:', err));
this.paused = true;
console.log('Kokoro audio paused (via AudioContext suspend)');
}
/**
* Resume paused speech
*/
resume() {
if (!this.paused || !this.audioContext) return;
this.audioContext.resume().catch(err => console.error('Error resuming AudioContext:', err));
this.paused = false;
console.log('Kokoro audio resumed (via AudioContext resume)');
}
/**
* Stop the current speech
*/
stop() {
// Stop any currently playing audio source
if (this.currentAudioSource) {
try {
this.currentAudioSource.stop();
} catch (e) {
// Ignore errors if source already stopped
}
this.currentAudioSource = null;
}
// Clear the queue and reset flags
this.audioQueue = [];
this.isProcessingQueue = false;
this.speaking = false;
this.paused = false;
console.log('Kokoro speech stopped and queue cleared.');
}
/**
* Check if TTS is currently active/enabled
*/
isEnabled() {
return this.enabled && this.kokoroReady;
}
/**
* Check if speech is currently in progress
*/
isSpeaking() {
// Consider both the processing flag and if an audio source is active
return this.speaking || this.isProcessingQueue || !!this.currentAudioSource;
}
}
// Don't create a global instance here - the factory will do this
// const ttsHandler = new KokoroHandler();
File diff suppressed because one or more lines are too long
+334
View File
@@ -0,0 +1,334 @@
var linebreak = function (nodes, lines, settings = {
demerits: {
line: 10,
flagged: 100,
fitness: 3000
},
tolerance: 2
}) {
const options = settings;
activeNodes = new LinkedList(),
sum = {
width: 0,
stretch: 0,
shrink: 0
},
lineLengths = lines,
breaks = [],
tmp = {
data: {
demerits: Infinity
}
};
function breakpoint(position, demerits, ratio, line, fitnessClass, totals, previous) {
return {
position: position,
demerits: demerits,
ratio: ratio,
line: line,
fitnessClass: fitnessClass,
totals: totals || {
width: 0,
stretch: 0,
shrink: 0
},
previous: previous
};
}
function computeCost(start, end, active, currentLine) {
var width = sum.width - active.totals.width,
stretch = 0,
shrink = 0,
// If the current line index is within the list of linelengths, use it, otherwise use
// the last line length of the list.
lineLength = currentLine < lineLengths.length ? lineLengths[currentLine - 1] : lineLengths[lineLengths.length - 1];
if (nodes[end].type === 'penalty') {
width += nodes[end].width;
}
if (width < lineLength) {
// Calculate the stretch ratio
stretch = sum.stretch - active.totals.stretch;
if (stretch > 0) {
return (lineLength - width) / stretch;
} else {
return linebreak.infinity;
}
} else if (width > lineLength) {
// Calculate the shrink ratio
shrink = sum.shrink - active.totals.shrink;
if (shrink > 0) {
return (lineLength - width) / shrink;
} else {
return linebreak.infinity;
}
} else {
// perfect match
return 0;
}
}
// Add width, stretch and shrink values from the current
// break point up to the next box or forced penalty.
function computeSum(breakPointIndex) {
var result = {
width: sum.width,
stretch: sum.stretch,
shrink: sum.shrink
},
i = 0;
for (i = breakPointIndex; i < nodes.length; i += 1) {
if (nodes[i].type === 'glue') {
result.width += nodes[i].width;
result.stretch += nodes[i].stretch;
result.shrink += nodes[i].shrink;
} else if (nodes[i].type === 'box' || (nodes[i].type === 'penalty' && nodes[i].penalty === -linebreak.infinity && i > breakPointIndex)) {
break;
}
}
return result;
}
let graphNodes = [];
let graphEdges = [];
// The main loop of the algorithm
function mainLoop(node, index, nodes) {
var active = activeNodes.first,
next = null,
ratio = 0,
demerits = 0,
candidates = [],
badness,
currentLine = 0,
tmpSum,
currentClass = 0,
fitnessClass,
candidate,
newNode;
// The inner loop iterates through all the active nodes with line < currentLine and then
// breaks out to insert the new active node candidates before looking at the next active
// nodes for the next lines. The result of this is that the active node list is always
// sorted by line number.
while (active !== null) {
candidates = [{
demerits: Infinity
}, {
demerits: Infinity
}, {
demerits: Infinity
}, {
demerits: Infinity
}];
// Iterate through the linked list of active nodes to find new potential active nodes
// and deactivate current active nodes.
while (active !== null) {
next = active.next;
currentLine = active.data.line + 1;
ratio = computeCost(active.data.position, index, active.data, currentLine);
// Deactive nodes when the distance between the current active node and the
// current node becomes too large (i.e. it exceeds the stretch limit and the stretch
// ratio becomes negative) or when the current node is a forced break (i.e. the end
// of the paragraph when we want to remove all active nodes, but possibly have a final
// candidate active node---if the paragraph can be set using the given tolerance value.)
if (ratio < -1 || (node.type === 'penalty' && node.penalty === -linebreak.infinity)) {
activeNodes.remove(active);
}
// If the ratio is within the valid range of -1 <= ratio <= tolerance calculate the
// total demerits and record a candidate active node.
if (-1 <= ratio && ratio <= options.tolerance) {
badness = 100 * Math.pow(Math.abs(ratio), 3);
// Positive penalty
if (node.type === 'penalty' && node.penalty >= 0) {
demerits = Math.pow(options.demerits.line + badness, 2) + Math.pow(node.penalty, 2);
// Negative penalty but not a forced break
} else if (node.type === 'penalty' && node.penalty !== -linebreak.infinity) {
demerits = Math.pow(options.demerits.line + badness, 2) - Math.pow(node.penalty, 2);
// All other cases
} else {
demerits = Math.pow(options.demerits.line + badness, 2);
}
if (node.type === 'penalty' && nodes[active.data.position].type === 'penalty') {
demerits += options.demerits.flagged * node.flagged * nodes[active.data.position].flagged;
}
// Calculate the fitness class for this candidate active node.
if (ratio < -0.5) {
currentClass = 0;
} else if (ratio <= 0.5) {
currentClass = 1;
} else if (ratio <= 1) {
currentClass = 2;
} else {
currentClass = 3;
}
// Add a fitness penalty to the demerits if the fitness classes of two adjacent lines
// differ too much.
if (Math.abs(currentClass - active.data.fitnessClass) > 1) {
demerits += options.demerits.fitness;
}
// Add the total demerits of the active node to get the total demerits of this candidate node.
demerits += active.data.demerits;
// Only store the best candidate for each fitness class
if (demerits < candidates[currentClass].demerits) {
candidates[currentClass] = {
active: active,
demerits: demerits,
ratio: ratio
};
}
}
active = next;
// Stop iterating through active nodes to insert new candidate active nodes in the active list
// before moving on to the active nodes for the next line.
// TODO: The Knuth and Plass paper suggests a conditional for currentLine < j0. This means paragraphs
// with identical line lengths will not be sorted by line number. Find out if that is a desirable outcome.
// For now I left this out, as it only adds minimal overhead to the algorithm and keeping the active node
// list sorted has a higher priority.
if (active !== null && active.data.line >= currentLine) {
break;
}
}
tmpSum = computeSum(index);
for (fitnessClass = 0; fitnessClass < candidates.length; fitnessClass += 1) {
candidate = candidates[fitnessClass];
if (candidate.demerits < Infinity) {
newNode = new Node(breakpoint(index, candidate.demerits, candidate.ratio,
candidate.active.data.line + 1, fitnessClass, tmpSum, candidate.active));
graphNodes.push({
id: index
});
graphEdges.push({
from: index,
to: candidate.active.data.position,
label: candidate.ratio.toFixed(2)
});
if (active !== null) {
activeNodes.insertBefore(active, newNode);
} else {
activeNodes.push(newNode);
}
}
}
}
}
// Add an active node for the start of the paragraph.
activeNodes.push(new Node(breakpoint(0, 0, 0, 0, 0, undefined, null)));
graphNodes.push({
id: 0
});
nodes.forEach(function (node, index, nodes) {
if (node.type === 'box') {
sum.width += node.width;
} else if (node.type === 'glue') {
if (index > 0 && nodes[index - 1].type === 'box') {
mainLoop(node, index, nodes);
}
sum.width += node.width;
sum.stretch += node.stretch;
sum.shrink += node.shrink;
} else if (node.type === 'penalty' && node.penalty !== linebreak.infinity) {
mainLoop(node, index, nodes);
}
});
if (activeNodes.size !== 0) {
// Find the best active node (the one with the least total demerits.)
activeNodes.forEach(function (node) {
if (node.data.demerits < tmp.data.demerits) {
tmp = node;
}
});
graphNodes.forEach(function (n) {
let label = nodes[n.id].value;
if (nodes[n.id].type === 'glue') {
label = nodes[n.id - 1].value;
} else if (nodes[n.id].type === 'penalty') {
label = nodes[n.id - 1].value;
} else {
label = nodes[n.id].value;
}
n.label = label;
});
while (tmp !== null) {
breaks.push({
position: tmp.data.position,
ratio: tmp.data.ratio
});
tmp = tmp.data.previous;
}
return breaks.reverse();
} else {
console.warn('Overfull paragraph.');
}
return [];
};
linebreak.infinity = 10000;
linebreak.glue = function (width, stretch, shrink) {
return {
type: 'glue',
width: width,
stretch: stretch,
shrink: shrink
};
};
linebreak.box = function (width, value) {
return {
type: 'box',
width: width,
value: value
};
};
linebreak.tag = function (width, value) {
return {
type: 'tag',
width: width,
value: value
}
}
linebreak.penalty = function (width, penalty, flagged) {
return {
type: 'penalty',
width: width,
penalty: penalty,
flagged: flagged
};
};
+187
View File
@@ -0,0 +1,187 @@
class LinkedList {
constructor() {
this.head = null;
this.tail = null;
this.listSize = 0;
}
get size() {
return this.listSize;
}
isLinked(node) {
return !((node && node.prev === null && node.next === null && this.tail !== node && this.head !== node) || this.isEmpty());
}
isEmpty() {
return this.listSize === 0;
}
get first() {
return this.head;
}
get last() {
return this.last;
}
toString() {
return this.toArray().toString();
}
toArray() {
var node = this.head,
result = [];
while (node !== null) {
result.push(node);
node = node.next;
}
return result;
}
// Note that modifying the list during
// iteration is not safe.
forEach(fun) {
var node = this.head;
while (node !== null) {
fun(node);
node = node.next;
}
}
contains(n) {
var node = this.head;
if (!this.isLinked(n)) {
return false;
}
while (node !== null) {
if (node === n) {
return true;
}
node = node.next;
}
return false;
}
at(i) {
var node = this.head, index = 0;
if (i >= this.listLength || i < 0) {
return null;
}
while (node !== null) {
if (i === index) {
return node;
}
node = node.next;
index += 1;
}
return null;
}
insertAfter(node, newNode) {
if (!this.isLinked(node)) {
return this;
}
newNode.prev = node;
newNode.next = node.next;
if (node.next === null) {
this.tail = newNode;
} else {
node.next.prev = newNode;
}
node.next = newNode;
this.listSize += 1;
return this;
}
insertBefore(node, newNode) {
if (!this.isLinked(node)) {
return this;
}
newNode.prev = node.prev;
newNode.next = node;
if (node.prev === null) {
this.head = newNode;
} else {
node.prev.next = newNode;
}
node.prev = newNode;
this.listSize += 1;
return this;
}
push(node) {
if (this.head === null) {
this.unshift(node);
} else {
this.insertAfter(this.tail, node);
}
return this;
}
unshift(node) {
if (this.head === null) {
this.head = node;
this.tail = node;
node.prev = null;
node.next = null;
this.listSize += 1;
} else {
this.insertBefore(this.head, node);
}
return this;
}
remove(node) {
if (!this.isLinked(node)) {
return this;
}
if (node.prev === null) {
this.head = node.next;
} else {
node.prev.next = node.next;
}
if (node.next === null) {
this.tail = node.prev;
} else {
node.next.prev = node.prev;
}
this.listSize -= 1;
return this;
}
pop() {
var node = this.tail;
this.tail.prev.next = null;
this.tail = this.tail.prev;
this.listSize -= 1;
node.prev = null;
node.next = null;
return node;
}
shift() {
var node = this.head;
this.head.next.prev = null;
this.head = this.head.next;
this.listSize -= 1;
node.prev = null;
node.next = null;
return node;
}
}
class Node {
constructor(data) {
this.prev = null;
this.next = null;
this.data = data;
}
toString() {
return this.data.toString();
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+804 -56
View File
@@ -1,66 +1,814 @@
/* eslint-env browser, amd, node */
/**
* SmartyPants - Smart typography for web content
* Converts straight quotes to curly quotes, dashes to em-dashes, etc.
* Based on the original SmartyPants by John Gruber
*
* @file Translates plain ASCII punctuation characters into "smart" typographic punctuation
* @desc See the readme for details, installation instructions, and * license information.
* @version 0.0.4
* @author othree
*
* Copyright (c) 2003-2004 John Gruber
* Copyright (c) 2016 Kao, Wei-Ko(othree)
*
* @see {@link https://www.npmjs.com/package/smartypants|smartypants.js}
*/
const SmartyPants = (function() {
// Regular expressions for matching
const quotes = {
double: {
opening: /(\s|^)"(\w)/g,
closing: /(\w)"/g,
openingNested: /(\s|^)'(\w)/g,
closingNested: /(\w)'/g
},
single: {
opening: /(\s|^)'(\w)/g,
closing: /(\w)'/g
/*! smartypants.js 0.0.6 | (c) Kao, Wei-Ko(othree) | github.com/othree/smartypants.js/blob/master/LICENSE */
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define('SmartyPants', ['exports'], function (exports) {
factory((root.SmartyPants = exports));
});
} else if (typeof exports === 'object' && typeof exports.nodeName !== 'string') {
// CommonJS
factory(exports);
} else {
// Browser globals
factory((root.SmartyPants = {}));
}
};
const dashes = {
emDash: /--/g,
enDash: / - /g
};
const ellipses = /\.\.\./g;
}(this, function (exports) {
"use strict";
exports.__esModule = true;
exports.smartypantsu = exports.smartellipses = exports.smartdashes = exports.smartquotes = exports.smartypants = void 0;
var tags_to_skip = /<(\/?)(?:pre|code|kbd|script|math)[^>]*>/i;
/**
* Process text with SmartyPants transformations
* @param text text to be parsed
* @param attr value of the smart_quotes="" attribute
*/
function process(text) {
if (!text) return text;
let result = text;
// Transform double quotes
result = result.replace(quotes.double.opening, '$1"$2');
result = result.replace(quotes.double.closing, '$1"');
// Transform single quotes
result = result.replace(quotes.single.opening, '$1\u2018$2');
result = result.replace(quotes.single.closing, '$1\u2019');
// Transform apostrophes (same as closing single quotes)
result = result.replace(/(\w)'(\w)/g, '$1\u2019$2');
// Transform dashes
result = result.replace(dashes.emDash, '—');
result = result.replace(dashes.enDash, ' ');
// Transform ellipses
result = result.replace(ellipses, '…');
return result;
var SmartyPants = function (text, attr) {
if (text === void 0) { text = ''; }
if (attr === void 0) { attr = '1'; }
var do_quotes;
var do_backticks;
var do_dashes;
var do_ellipses;
var do_stupefy;
var convert_quot = 0;
if (typeof attr === 'number') {
attr = attr.toString();
}
return {
process: process
else {
attr = attr.replace(/\s/g, '');
}
/**
* Parse attributes:
* 0 : do nothing
* 1 : set all
* 2 : set all, using old school en- and em- dash shortcuts
* 3 : set all, using inverted old school en and em- dash shortcuts
*
* q : quotes
* b : backtick quotes (``double'' only)
* B : backtick quotes (``double'' and `single')
* d : dashes
* D : old school dashes
* i : inverted old school dashes
* e : ellipses
* w : convert &quot; entities to " for Dreamweaver users
*/
if (attr === '0') {
// Do nothing
return text;
}
else if (attr === '1') {
// Do everything, turn all options on.
do_quotes = 1;
do_backticks = 1;
do_dashes = 1;
do_ellipses = 1;
}
else if (attr === '2') {
// Do everything, turn all options on, use old school dash shorthand.
do_quotes = 1;
do_backticks = 1;
do_dashes = 2;
do_ellipses = 1;
}
else if (attr === '3') {
// Do everything, turn all options on, use inverted old school dash shorthand.
do_quotes = 1;
do_backticks = 1;
do_dashes = 3;
do_ellipses = 1;
}
else if (attr === '-1') {
// Special "stupefy" mode.
do_stupefy = 1;
}
else {
for (var i = 0; i < attr.length; i++) {
var c = attr[i];
if (c === 'q') {
do_quotes = 1;
}
if (c === 'b') {
do_backticks = 1;
}
if (c === 'B') {
do_backticks = 2;
}
if (c === 'd') {
do_dashes = 1;
}
if (c === 'D') {
do_dashes = 2;
}
if (c === 'i') {
do_dashes = 3;
}
if (c === 'e') {
do_ellipses = 1;
}
if (c === 'w') {
convert_quot = 1;
}
}
}
var tokens = _tokenize(text);
var result = '';
/**
* Keep track of when we're inside <pre> or <code> tags.
*/
var in_pre = 0;
/**
* This is a cheat, used to get some context
* for one-character tokens that consist of
* just a quote char. What we do is remember
* the last character of the previous text
* token, to use as context to curl single-
* character quote tokens correctly.
*/
var prev_token_last_char = '';
for (var i = 0; i < tokens.length; i++) {
var cur_token = tokens[i];
if (cur_token[0] === 'tag') {
result = result + cur_token[1];
var matched = tags_to_skip.exec(cur_token[1]);
if (matched) {
if (matched[1] === '/') {
in_pre = 0;
}
else {
in_pre = 1;
}
}
}
else {
var t = cur_token[1];
var last_char = t.substring(t.length - 1, t.length); // Remember last char of this token before processing.
if (!in_pre) {
t = ProcessEscapes(t);
if (convert_quot) {
t = t.replace(/$quot;/g, '"');
}
if (do_dashes) {
if (do_dashes === 1) {
t = EducateDashes(t);
}
if (do_dashes === 2) {
t = EducateDashesOldSchool(t);
}
if (do_dashes === 3) {
t = EducateDashesOldSchoolInverted(t);
}
}
if (do_ellipses) {
t = EducateEllipses(t);
}
// Note: backticks need to be processed before quotes.
if (do_backticks) {
t = EducateBackticks(t);
if (do_backticks === 2) {
t = EducateSingleBackticks(t);
}
}
if (do_quotes) {
if (t === '\'') {
// Special case: single-character ' token
if (/\S/.test(prev_token_last_char)) {
t = '&#8217;';
}
else {
t = '&#8216;';
}
}
else if (t === '"') {
// Special case: single-character " token
if (/\S/.test(prev_token_last_char)) {
t = '&#8221;';
}
else {
t = '&#8220;';
}
}
else {
// Normal case:
t = EducateQuotes(t);
}
}
if (do_stupefy) {
t = StupefyEntities(t);
}
}
prev_token_last_char = last_char;
result = result + t;
}
}
return result;
};
})();
exports.smartypants = SmartyPants;
var SmartQuotes = function (text, attr) {
/**
* should we educate ``backticks'' -style quotes?
*/
// var do_backticks:number;
if (text === void 0) { text = ''; }
if (attr === void 0) { attr = '1'; }
if (typeof attr === 'number') {
attr = attr.toString();
}
else {
attr = attr.replace(/\s/g, '');
}
if (attr === '0') {
// Do nothing
return text;
// } else if (attr === '2') {
// // smarten ``backticks'' -style quotes
// do_backticks = 1;
// } else {
// do_backticks = 0;
}
/**
* Special case to handle quotes at the very end of $text when preceded by
* an HTML tag. Add a space to give the quote education algorithm a bit of
* context, so that it can guess correctly that it's a closing quote:
*/
var add_extra_space = 0;
if (/>['"]$/.test(text)) {
add_extra_space = 1; // Remember, so we can trim the extra space later.
text = text + ' ';
}
var tokens = _tokenize(text);
var result = '';
/**
* Keep track of when we're inside <pre> or <code> tags.
*/
var in_pre = 0;
/**
* This is a cheat, used to get some context
* for one-character tokens that consist of
* just a quote char. What we do is remember
* the last character of the previous text
* token, to use as context to curl single-
* character quote tokens correctly.
*/
var prev_token_last_char = '';
for (var i = 0; i < tokens.length; i++) {
var cur_token = tokens[i];
if (cur_token[0] === 'tag') {
result = result + cur_token[1];
var matched = tags_to_skip.exec(cur_token[1]);
if (matched) {
if (matched[1] === '/') {
in_pre = 0;
}
else {
in_pre = 1;
}
}
}
else {
var t = cur_token[1];
var last_char = t.substring(t.length - 1, t.length); // Remember last char of this token before processing.
if (!in_pre) {
t = ProcessEscapes(t);
if (t === '\'') {
// Special case: single-character ' token
if (/\S/.test(prev_token_last_char)) {
t = '&#8217;';
}
else {
t = '&#8216;';
}
}
else if (t === '"') {
// Special case: single-character " token
if (/\S/.test(prev_token_last_char)) {
t = '&#8221;';
}
else {
t = '&#8220;';
}
}
else {
// Normal case:
t = EducateQuotes(t);
}
}
prev_token_last_char = last_char;
result = result + t;
}
}
if (add_extra_space) {
result = result.replace(/ $/, '');
}
return result;
};
exports.smartquotes = SmartQuotes;
var SmartDashes = function (text, attr) {
if (text === void 0) { text = ''; }
if (attr === void 0) { attr = '1'; }
// reference to the subroutine to use for dash education, default to EducateDashes:
var dash_sub_ref = EducateDashes;
if (typeof attr === 'number') {
attr = attr.toString();
}
else {
attr = attr.replace(/\s/g, '');
}
if (attr === '0') {
// Do nothing
return text;
}
else if (attr === '2') {
// use old smart dash shortcuts, "--" for en, "---" for em
dash_sub_ref = EducateDashesOldSchool;
}
else if (attr === '3') {
// inverse of 2, "--" for em, "---" for en
dash_sub_ref = EducateDashesOldSchoolInverted;
}
var tokens = _tokenize(text);
var result = '';
/**
* Keep track of when we're inside <pre> or <code> tags.
*/
var in_pre = 0;
for (var i = 0; i < tokens.length; i++) {
var cur_token = tokens[i];
if (cur_token[0] === 'tag') {
result = result + cur_token[1];
var matched = tags_to_skip.exec(cur_token[1]);
if (matched) {
if (matched[1] === '/') {
in_pre = 0;
}
else {
in_pre = 1;
}
}
}
else {
var t = cur_token[1];
if (!in_pre) {
t = ProcessEscapes(t);
t = dash_sub_ref(t);
}
result = result + t;
}
}
return result;
};
exports.smartdashes = SmartDashes;
var SmartEllipses = function (text, attr) {
if (text === void 0) { text = ''; }
if (attr === void 0) { attr = '1'; }
if (typeof attr === 'number') {
attr = attr.toString();
}
else {
attr = attr.replace(/\s/g, '');
}
if (attr === '0') {
// Do nothing
return text;
}
var tokens = _tokenize(text);
var result = '';
/**
* Keep track of when we're inside <pre> or <code> tags.
*/
var in_pre = 0;
for (var i = 0; i < tokens.length; i++) {
var cur_token = tokens[i];
if (cur_token[0] === 'tag') {
result = result + cur_token[1];
var matched = tags_to_skip.exec(cur_token[1]);
if (matched) {
if (matched[1] === '/') {
in_pre = 0;
}
else {
in_pre = 1;
}
}
}
else {
var t = cur_token[1];
if (!in_pre) {
t = ProcessEscapes(t);
t = EducateEllipses(t);
}
result = result + t;
}
}
return result;
};
exports.smartellipses = SmartEllipses;
/**
* @param {string} str String
* @return {string} The string, with "educated" curly quote HTML entities.
*
* Example input: "Isn't this fun?"
* Example output: &#8220;Isn&#8217;t this fun?&#8221;
*/
var EducateQuotes = function (str) {
/**
* Make our own "punctuation" character class, because the POSIX-style
* [:PUNCT:] is only available in Perl 5.6 or later:
*
* JavaScript don't have punctuation class neither.
*/
var punct_class = '[!"#\$\%\'()*+,-./:;<=>?\@\[\\\]\^_`{|}~]'; // eslint-disable-line no-useless-escape
/**
* Special case if the very first character is a quote
* followed by punctuation at a non-word-break. Close the quotes by brute force:
*/
str = str.replace(new RegExp("^'(?=" + punct_class + "\\B)"), '&#8217;'); // eslint-disable-line no-useless-escape
str = str.replace(new RegExp("^\"(?=" + punct_class + "\\B)"), '&#8221;'); // eslint-disable-line no-useless-escape
/**
* Special case for double sets of quotes, e.g.:
* <p>He said, "'Quoted' words in a larger quote."</p>
*/
str = str.replace(/"'(?=\w)/, '&#8220;&#8216;');
str = str.replace(/'"(?=\w)/, '&#8216;&#8220;');
/**
* Special case for decade abbreviations (the '80s):
*/
str = str.replace(/'(?=\d\d)/, '&#8217;');
var close_class = '[^\\ \\t\\r\\n\\[\\{\\(\\-]'; // eslint-disable-line no-useless-escape
var not_close_class = '[\\ \\t\\r\\n\\[\\{\\(\\-]'; // eslint-disable-line no-useless-escape
var dec_dashes = '&#8211;|&#8212;';
/**
* Get most opening single quotes:
* s {
* (
* \s | # a whitespace char, or
* &nbsp; | # a non-breaking space entity, or
* -- | # dashes, or
* &[mn]dash; | # named dash entities
* $dec_dashes | # or decimal entities
* &\#x201[34]; # or hex
* )
* ' # the quote
* (?=\w) # followed by a word character
* } {$1&#8216;}xg;
*/
str = str.replace(new RegExp("(\\s|&nbsp;|--|&[mn]dash;|" + dec_dashes + "|&#x201[34])'(?=\\w)", 'g'), '\$1&#8216;'); // eslint-disable-line no-useless-escape
/**
* Single closing quotes:
* s {
* ($close_class)?
* '
* (?(1)| # If $1 captured, then do nothing;
* (?=\s | s\b) # otherwise, positive lookahead for a whitespace
* ) # char or an 's' at a word ending position. This
* # is a special case to handle something like:
* # "<i>Custer</i>'s Last Stand."
* } {$1&#8217;}xgi;
*/
str = str.replace(new RegExp("(" + close_class + ")'", 'g'), '\$1&#8217;'); // eslint-disable-line no-useless-escape
str = str.replace(new RegExp("(" + not_close_class + "?)'(?=\\s|s\\b)", 'g'), '\$1&#8217;'); // eslint-disable-line no-useless-escape
/**
* Any remaining single quotes should be opening ones:
*/
str = str.replace(/'/g, '&#8216;');
/**
* Get most opening double quotes:
* s {
* (
* \s | # a whitespace char, or
* &nbsp; | # a non-breaking space entity, or
* -- | # dashes, or
* &[mn]dash; | # named dash entities
* $dec_dashes | # or decimal entities
* &\#x201[34]; # or hex
* )
* " # the quote
* (?=\w) # followed by a word character
* } {$1&#8220;}xg;
*/
str = str.replace(new RegExp("(\\s|&nbsp;|--|&[mn]dash;|" + dec_dashes + "|&#x201[34])\"(?=\\w)", 'g'), '\$1&#8220;'); // eslint-disable-line no-useless-escape
/**
* Double closing quotes:
* s {
* ($close_class)?
* "
* (?(1)|(?=\s)) # If $1 captured, then do nothing;
* # if not, then make sure the next char is whitespace.
* } {$1&#8221;}xg;
*/
str = str.replace(new RegExp("(" + close_class + ")\"", 'g'), '\$1&#8221;'); // eslint-disable-line no-useless-escape
str = str.replace(new RegExp("(" + not_close_class + "?)\"(?=\\s)", 'g'), '\$1&#8221;'); // eslint-disable-line no-useless-escape
/**
* Any remaining quotes should be opening ones.
*/
str = str.replace(/"/g, '&#8220;');
return str;
};
/**
* @param {string} str String
* @return {string} The string, with ``backticks'' -style double quotes
* translated into HTML curly quote entities.
*
* Example input: ``Isn't this fun?''
* Example output: &#8220;Isn't this fun?&#8221;
*/
var EducateBackticks = function (str) {
str = str.replace(/``/g, '&#8220;');
str = str.replace(/''/g, '&#8221;');
return str;
};
/**
* @param {string} str String
* @return {string} The string, with `backticks' -style single quotes
* translated into HTML curly quote entities.
*
* Example input: `Isn't this fun?'
* Example output: &#8216;Isn&#8217;t this fun?&#8217;
*/
var EducateSingleBackticks = function (str) {
str = str.replace(/`/g, '&#8216;');
str = str.replace(/'/g, '&#8217;');
return str;
};
/**
* @param {string} str String
* @return {string} The string, with each instance of "--" translated to
* an em-dash HTML entity.
*/
var EducateDashes = function (str) {
str = str.replace(/--/g, '&#8212;');
return str;
};
/**
* @param {string} str String
* @return {string} The string, with each instance of "--" translated to
* an en-dash HTML entity, and each "---" translated to
* an em-dash HTML entity.
*/
var EducateDashesOldSchool = function (str) {
str = str.replace(/---/g, '&#8212;');
str = str.replace(/--/g, '&#8211;');
return str;
};
/**
* @param {string} str String
* @return {string} The string, with each instance of "--" translated to
* an em-dash HTML entity, and each "---" translated to
* an en-dash HTML entity. Two reasons why: First, unlike the
* en- and em-dash syntax supported by
* EducateDashesOldSchool(), it's compatible with existing
* entries written before SmartyPants 1.1, back when "--" was
* only used for em-dashes. Second, em-dashes are more
* common than en-dashes, and so it sort of makes sense that
* the shortcut should be shorter to type. (Thanks to Aaron
* Swartz for the idea.)
*/
var EducateDashesOldSchoolInverted = function (str) {
str = str.replace(/---/g, '&#8211;');
str = str.replace(/--/g, '&#8212;');
return str;
};
/**
* @param {string} str String
* @return {string} The string, with each instance of "..." translated to
* an ellipsis HTML entity. Also converts the case where
* there are spaces between the dots.
*
* Example input: Huh...?
* Example output: Huh&#8230;?
*/
var EducateEllipses = function (str) {
str = str.replace(/\.\.\./g, '&#8230;');
str = str.replace(/\. \. \./g, '&#8230;');
return str;
};
/**
* @param {string} str String
* @return {string} The string, with each SmartyPants HTML entity translated to
* its ASCII counterpart.
*
* Example input: &#8220;Hello &#8212; world.&#8221;
* Example output: "Hello -- world."
*/
var StupefyEntities = function (str) {
str = str.replace(/&#8211;/g, '-'); // en-dash
str = str.replace(/&#8212;/g, '--'); // em-dash
str = str.replace(/&#8216;/g, '\''); // open single quote
str = str.replace(/&#8217;/g, '\''); // close single quote
str = str.replace(/&#8220;/g, '"'); // open double quote
str = str.replace(/&#8221;/g, '"'); // close double quote
str = str.replace(/&#8230;/g, '...'); // ellipsis
return str;
};
/**
* @param {string} str String
* @return {string} The string, with each SmartyPants HTML entity translated to
* UTF-8 characters.
*
* Example input: “Hello &#8217; world.”
* Example output: "Hello — world."
*/
var EducateEntities = function (text, attr) {
if (attr === void 0) { attr = '1'; }
var do_quotes;
var do_backticks;
var do_dashes;
var do_ellipses;
// var do_stupefy:number;
if (typeof attr === 'number') {
attr = attr.toString();
}
else {
attr = attr.replace(/\s/g, '');
}
if (attr === '0') {
// Do nothing
return text;
}
else if (attr === '1') {
// Do everything, turn all options on.
do_quotes = 1;
do_backticks = 1;
do_dashes = 1;
do_ellipses = 1;
}
else if (attr === '2') {
// Do everything, turn all options on, use old school dash shorthand.
do_quotes = 1;
do_backticks = 1;
do_dashes = 3;
do_ellipses = 1;
}
else if (attr === '3') {
// Do everything, turn all options on, use inverted old school dash shorthand.
do_quotes = 1;
do_backticks = 1;
do_dashes = 3;
do_ellipses = 1;
// } else if (attr === '-1') {
// // Special "stupefy" mode.
// do_stupefy = 1;
}
else {
for (var i = 0; i < attr.length; i++) {
var c = attr[i];
if (c === 'q') {
do_quotes = 1;
}
if (c === 'b') {
do_backticks = 1;
}
if (c === 'B') {
do_backticks = 2;
}
if (c === 'd') {
do_dashes = 1;
}
if (c === 'D') {
do_dashes = 2;
}
if (c === 'i') {
do_dashes = 3;
}
if (c === 'e') {
do_ellipses = 1;
}
}
}
if (do_dashes) {
text = text.replace(/&#8211;/g, '\u2013'); // en-dash
text = text.replace(/&#8212;/g, '\u2014'); // em-dash
}
if (do_quotes || do_backticks) {
if(locale === 'de') {
text = text.replace(/&#8220;/g, '\u00bb'); // open double quote
text = text.replace(/&#8221;/g, '\u00ab'); // close double quote
} else {
text = text.replace(/&#8220;/g, '\u201c'); // open double quote
text = text.replace(/&#8221;/g, '\u201d'); // close double quote
}
text = text.replace(/&#8216;/g, '\u2018'); // open single quote
text = text.replace(/&#8217;/g, '\u2019'); // close single quote
}
if (do_ellipses) {
text = text.replace(/&#8230;/g, '\u2026'); // ellipsis
}
// Do markdown bold and italics
text = text.replace(/\*\*(.*)\*\*/gim, '<b>$1</b>') // bold text
text = text.replace(/\_(.*)\_/gim, '<i>$1</i>'); // italic text
return text;
};
/**
* @param {string} str String
* @return {string} The string, with each SmartyPants UTF-8 chars translated to
* its ASCII counterpart.
*
* Example input: &#8220;Hello &#8212; world.&#8221;
* Example output: "Hello -- world."
*/
var StupifyUTF8Char = function (str) {
str = str.replace(/\u2013/g, '-'); // en-dash
str = str.replace(/\u2014/g, '--'); // em-dash
str = str.replace(/\u2018/g, '\''); // open single quote
str = str.replace(/\u2019/g, '\''); // close single quote
str = str.replace(/\u201c/g, '"'); // open double quote
str = str.replace(/\u201d/g, '"'); // close double quote
str = str.replace(/\u2026/g, '...'); // ellipsis
return str;
};
/**
* @param {string} str String
* @return {string} string, with after processing the following backslash
* escape sequences. This is useful if you want to force a "dumb"
* quote or other character to appear.
*
* Escape Value
* ------ -----
* \\ &#92;
* \" &#34;
* \' &#39;
* \. &#46;
* \- &#45;
* \` &#96;
*
*/
var ProcessEscapes = function (str) {
str = str.replace(/\\\\/g, '&#92;');
str = str.replace(/\\"/g, '&#34;');
str = str.replace(/\\'/g, '&#39;');
str = str.replace(/\\\./g, '&#46;');
str = str.replace(/\\-/g, '&#45;');
str = str.replace(/\\`/g, '&#96;');
return str;
};
/**
* @param {string} str String containing HTML markup.
* @return {Array<token>} Reference to an array of the tokens comprising the input
* string. Each token is either a tag (possibly with nested,
* tags contained therein, such as <a href="<MTFoo>">, or a
* run of text between tags. Each element of the array is a
* two-element array; the first is either 'tag' or 'text';
* the second is the actual value.
*
* Based on the _tokenize() subroutine from Brad Choate's MTRegex plugin.
* <http://www.bradchoate.com/past/mtregex.php>
*/
var _tokenize = function (str) {
var pos = 0;
var len = str.length;
var tokens = [];
var match = /<!--[\s\S]*?-->|<\?.*?\?>|<[^>]*>/g;
var matched = null;
while (matched = match.exec(str)) { // eslint-disable-line no-cond-assign
if (pos < matched.index) {
var t_1 = ['text', str.substring(pos, matched.index)];
tokens.push(t_1);
}
var t = ['tag', matched.toString()];
tokens.push(t);
pos = match.lastIndex;
}
if (pos < len) {
var t = ['text', str.substring(pos, len)];
tokens.push(t);
}
return tokens;
};
var smartypantsu = function (text, attr) {
if (text === void 0) { text = ''; }
if (attr === void 0) { attr = '1'; }
var str = SmartyPants(text, attr);
if (typeof attr === 'number') {
attr = attr.toString();
}
else {
attr = attr.replace(/\s/g, '');
}
if (attr === '-1') {
return StupifyUTF8Char(str);
}
else {
return EducateEntities(str, attr);
}
};
exports.smartypantsu = smartypantsu;
exports["default"] = SmartyPants;
// Make available in browser and Node.js environments
if (typeof module !== 'undefined' && module.exports) {
module.exports = SmartyPants;
}
}));
+189
View File
@@ -0,0 +1,189 @@
/**
* TTS Factory for AI Interactive Fiction
* Attempts to use Kokoro TTS first, then falls back to browser TTS if needed
*/
class TTSFactory {
constructor() {
this.activeTTSHandler = null;
this.kokoroHandler = null;
this.browserTTSHandler = null;
this.initializationAttempted = false;
this.usingKokoro = false;
this.initializationPromise = null; // Promise for the factory initialization
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.initialize());
} else {
// Use requestAnimationFrame to ensure scripts are parsed
requestAnimationFrame(() => this.initialize());
}
}
/**
* Initialize available TTS handlers
*/
async initialize() {
// Prevent multiple initializations
if (this.initializationAttempted) return this.initializationPromise;
this.initializationAttempted = true;
console.log('Initializing TTS Factory...');
this.initializationPromise = new Promise(async (resolve) => {
let kokoroInitialized = false;
// Try to initialize Kokoro first (preferred option)
try {
// Check if KokoroHandler class is defined (loaded via script tag)
if (typeof KokoroHandler !== 'undefined') {
console.log('Attempting to initialize Kokoro TTS...');
this.kokoroHandler = new KokoroHandler();
// --- Increase Timeout for Kokoro Initialization ---
// Wait for KokoroHandler's internal initialization promise
// Use Promise.race to add a longer timeout specifically for Kokoro init
const kokoroTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Kokoro initialization timed out in factory')), 60000) // 60 seconds timeout
);
try {
kokoroInitialized = await Promise.race([
this.kokoroHandler.initializationPromise,
kokoroTimeoutPromise
]);
} catch (timeoutError) {
console.error(timeoutError.message); // Log the timeout error
kokoroInitialized = false;
}
// --- End Increase Timeout ---
if (kokoroInitialized) {
console.log('Kokoro Handler reported successful initialization.');
} else {
console.warn('Kokoro Handler reported failed or timed out initialization.');
}
} else {
console.warn('KokoroHandler class not found when factory initialized.');
}
} catch (error) {
console.error('Error creating or initializing Kokoro Handler:', error);
kokoroInitialized = false; // Ensure it's marked as failed
}
// Initialize browser TTS as fallback (can happen in parallel)
try {
if (typeof TTSHandler !== 'undefined') {
console.log('Initializing browser TTS as fallback...');
this.browserTTSHandler = new TTSHandler();
} else {
console.warn('TTSHandler class not found when factory initialized.');
}
} catch (error) {
console.error('Error initializing browser TTS:', error);
}
// Decide which handler to use based on Kokoro's success
this.selectActiveHandler(kokoroInitialized);
resolve(); // Resolve the factory's promise
});
return this.initializationPromise;
}
// Removed waitForKokoroInitialization as KokoroHandler now manages its own promise
/**
* Select which TTS handler to use
* @param {boolean} kokoroInitialized - Whether Kokoro initialization succeeded
*/
selectActiveHandler(kokoroInitialized) {
// First choice: Kokoro if it's available and initialized successfully
if (kokoroInitialized && this.kokoroHandler && this.kokoroHandler.kokoroReady) {
console.log('Using Kokoro TTS as primary TTS system');
this.activeTTSHandler = this.kokoroHandler;
this.usingKokoro = true;
}
// Fallback to browser TTS if available
else if (this.browserTTSHandler) {
console.log('Falling back to browser TTS.');
this.activeTTSHandler = this.browserTTSHandler;
this.usingKokoro = false;
}
// No TTS available
else {
console.error('No TTS system available.');
this.activeTTSHandler = null;
this.usingKokoro = false;
}
// Expose the active handler as the global ttsHandler
window.ttsHandler = this.activeTTSHandler;
// Log the active TTS system
if (this.usingKokoro) {
console.log('TTS Factory initialized with Kokoro TTS');
} else if (this.activeTTSHandler) {
console.log('TTS Factory initialized with browser TTS');
} else {
console.log('TTS Factory initialized with no available TTS');
}
// Dispatch an event to notify the UI that TTS is ready (or not)
const ttsReadyEvent = new CustomEvent('tts-ready', {
detail: {
available: !!this.activeTTSHandler,
type: this.usingKokoro ? 'kokoro' : (this.activeTTSHandler ? 'browser' : 'none'),
handler: this.activeTTSHandler
}
});
window.dispatchEvent(ttsReadyEvent);
}
/**
* Get info about the active TTS system
*/
getActiveTTSInfo() {
if (!this.activeTTSHandler) {
return { available: false, type: 'none', name: 'None' };
}
return {
available: true,
type: this.usingKokoro ? 'kokoro' : 'browser',
name: this.usingKokoro ? 'Kokoro TTS' : 'Browser TTS'
};
}
/**
* Force switching to a specific TTS system
* @param {string} type - Either 'kokoro' or 'browser'
*/
switchTTS(type) {
if (type === 'kokoro' && this.kokoroHandler && this.kokoroHandler.kokoroReady) {
this.activeTTSHandler = this.kokoroHandler;
this.usingKokoro = true;
window.ttsHandler = this.activeTTSHandler;
console.log('Switched to Kokoro TTS');
// Dispatch event on switch
const ttsReadyEvent = new CustomEvent('tts-ready', { detail: { available: true, type: 'kokoro', handler: this.activeTTSHandler } });
window.dispatchEvent(ttsReadyEvent);
return true;
} else if (type === 'browser' && this.browserTTSHandler) {
this.activeTTSHandler = this.browserTTSHandler;
this.usingKokoro = false;
window.ttsHandler = this.activeTTSHandler;
console.log('Switched to browser TTS');
// Dispatch event on switch
const ttsReadyEvent = new CustomEvent('tts-ready', { detail: { available: true, type: 'browser', handler: this.activeTTSHandler } });
window.dispatchEvent(ttsReadyEvent);
return true;
}
console.error(`Failed to switch to ${type} TTS - not available`);
return false;
}
}
// Create the global TTS factory instance
window.ttsFactory = new TTSFactory();
+19
View File
@@ -18,6 +18,9 @@ class TTSHandler {
this.speakQueue = [];
this.isSpeakingFromQueue = false;
// Flag to track when we're deliberately stopping speech
this.intentionalStop = false;
// Initialize if speech synthesis is available
if ('speechSynthesis' in window) {
this.synth = window.speechSynthesis;
@@ -242,11 +245,16 @@ class TTSHandler {
};
utterance.onerror = (event) => {
// Don't treat interrupted errors as real errors when we're deliberately stopping
if (event.error === "interrupted" && this.intentionalStop) {
console.log("Speech intentionally interrupted");
} else {
console.error("Speech synthesis error:", event);
if (event.error === "not-allowed") {
this.permissionError = true;
this.enabled = false;
}
}
if (onEndCallback) onEndCallback();
this.processSpeakQueue();
@@ -368,12 +376,23 @@ class TTSHandler {
stop() {
if (!this.synth) return;
// Set flag to indicate this is an intentional stop before canceling
this.intentionalStop = true;
// Cancel any current speech synthesis
this.synth.cancel();
// Reset state
this.speaking = false;
this.paused = false;
this.utterance = null;
this.speakQueue = [];
this.isSpeakingFromQueue = false;
// Reset the intentional stop flag after a short delay
setTimeout(() => {
this.intentionalStop = false;
}, 100);
}
/**
+1034
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -20,7 +20,7 @@ const server = http.createServer(app);
const io = new SocketIOServer(server);
// Get port from environment variables or use default
const DEFAULT_PORT = 3000;
const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default