;
63 | }
--------------------------------------------------------------------------------
/reader/util/poorMansConsole.ts:
--------------------------------------------------------------------------------
1 | type SmallConsole = {
2 | debug: (...args: any[]) => void
3 | info: (...args: any[]) => void
4 | log: (...args: any[]) => void
5 | warn: (...args: any[]) => void
6 | error: (...args: any[]) => void
7 | }
8 | let pmcInstance: SmallConsole | null = null;
9 |
10 | export function createPoorMansConsole(debug: HTMLElement): SmallConsole {
11 | if (pmcInstance) { return pmcInstance; }
12 |
13 | function logToStupidPreBlock(color: string, ...args: any[]) {
14 | const dv = document.createElement("div");
15 | dv.style.color = color;
16 | dv.innerHTML = args.map((arg: any) => {
17 | if (typeof arg === "string") {
18 | return arg;
19 | }
20 | try {
21 | return JSON.stringify(arg);
22 | } catch (e) {
23 | return arg;
24 | }
25 | }).join(", ")
26 | debug.appendChild(dv);
27 | debug.scrollTo(0, debug.scrollHeight)
28 | }
29 |
30 | debug.addEventListener("click", () => {
31 | debug.style.display = "none";
32 | });
33 | document.getElementById("open-debug")?.addEventListener("click", () => {
34 | debug.style.display = "block";
35 | })
36 |
37 | pmcInstance = {
38 | debug: (...args: any[]) => {
39 | if (import.meta.env.VITE_LOG_LEVEL === "DEBUG") {
40 | console.debug(...args);
41 | logToStupidPreBlock("gray", ...args)
42 | }
43 | },
44 | log: (...args: any[]) => {
45 | if (["DEBUG", "INFO"].includes(import.meta.env.VITE_LOG_LEVEL)) {
46 | console.log(...args);
47 | logToStupidPreBlock("black", ...args)
48 | }
49 | },
50 | info: (...args: any[]) => {
51 | if (["DEBUG", "INFO"].includes(import.meta.env.VITE_LOG_LEVEL)) {
52 | console.log(...args);
53 | logToStupidPreBlock("black", ...args)
54 | }
55 | },
56 | warn: (...args: any[]) => {
57 | if ( ["DEBUG", "INFO", "WARN"].includes(import.meta.env.VITE_LOG_LEVEL)) {
58 | console.warn(...args);
59 | logToStupidPreBlock("darkorange", ...args)
60 | }
61 | },
62 | error: (...args: any[]) => {
63 | if (["DEBUG", "INFO", "WARN", "ERROR"].includes(import.meta.env.VITE_LOG_LEVEL)) {
64 | console.error(...args);
65 | logToStupidPreBlock("red", ...args)
66 | }
67 | },
68 | }
69 | return pmcInstance;
70 | }
--------------------------------------------------------------------------------
/reader/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | lean-reader
7 |
8 |
9 |
30 |
31 |
34 |
35 |
36 | Bezig met laden...
37 |
38 |
39 |
42 |
43 |
54 |
55 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/reader/readium-speech/utils/features.ts:
--------------------------------------------------------------------------------
1 | // This is heavily inspired by Easy Speech
2 |
3 | export interface WebSpeechFeatures {
4 | speechSynthesis: SpeechSynthesis | undefined;
5 | speechSynthesisUtterance: typeof SpeechSynthesisUtterance | undefined;
6 | speechSynthesisVoice: typeof SpeechSynthesisVoice | undefined;
7 | speechSynthesisEvent: typeof SpeechSynthesisEvent | undefined;
8 | speechSynthesisErrorEvent: typeof SpeechSynthesisErrorEvent | undefined;
9 | onvoiceschanged: boolean;
10 | speechSynthesisSpeaking: boolean;
11 | speechSynthesisPaused: boolean;
12 | onboundary: boolean;
13 | onend: boolean;
14 | onerror: boolean;
15 | onmark: boolean;
16 | onpause: boolean;
17 | onresume: boolean;
18 | onstart: boolean;
19 | [key: string]: any; // Allow dynamic property assignment
20 | }
21 |
22 | /**
23 | * Common prefixes for browsers that tend to implement their custom names for
24 | * certain parts of their API.
25 | */
26 | const prefixes = ["webKit", "moz", "ms", "o"];
27 |
28 | /**
29 | * Events that should be available on utterances
30 | */
31 | const utteranceEvents = [
32 | "boundary",
33 | "end",
34 | "error",
35 | "mark",
36 | "pause",
37 | "resume",
38 | "start"
39 | ];
40 |
41 | /**
42 | * Make the first character of a String uppercase
43 | */
44 | const capital = (s: string) => `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
45 |
46 | /**
47 | * Check if an object has a property
48 | */
49 | const hasProperty = (target: any = {}, prop: string): boolean => {
50 | return Object.hasOwnProperty.call(target, prop) || prop in target || !!target[prop];
51 | };
52 |
53 | /**
54 | * Returns, if a given name exists in global scope
55 | * @private
56 | * @param name
57 | * @return {boolean}
58 | */
59 | const inGlobalScope = (name: string) => typeof window !== "undefined" && name in window;
60 |
61 | /**
62 | * Find a feature in global scope by checking for various combinations and
63 | * variations of the base-name
64 | * @param {String} baseName name of the component to look for, must begin with
65 | * lowercase char
66 | * @return {Object|undefined} The component from global scope, if found
67 | */
68 | const detect = (baseName: string): object | undefined => {
69 | const capitalBaseName = capital(baseName);
70 | const baseNameWithPrefixes = prefixes.map(p => `${p}${capitalBaseName}`);
71 | const found = [baseName, capitalBaseName]
72 | .concat(baseNameWithPrefixes)
73 | .find(inGlobalScope);
74 |
75 | return found && typeof window !== "undefined" ? (window as any)[found] : undefined;
76 | };
77 |
78 | /**
79 | * Detects all possible occurrences of the main Web Speech API components
80 | * in the global scope using prefix detection.
81 | */
82 | export const detectFeatures = (): WebSpeechFeatures => {
83 | const features: WebSpeechFeatures = {} as WebSpeechFeatures;
84 |
85 | // Use prefix detection to find all speech synthesis features
86 | ;[
87 | "speechSynthesis",
88 | "speechSynthesisUtterance",
89 | "speechSynthesisVoice",
90 | "speechSynthesisEvent",
91 | "speechSynthesisErrorEvent"
92 | ].forEach(feature => {
93 | features[feature] = detect(feature);
94 | });
95 |
96 | // Check for event support
97 | features.onvoiceschanged = hasProperty(features.speechSynthesis, "onvoiceschanged");
98 | features.speechSynthesisSpeaking = hasProperty(features.speechSynthesis, "speaking");
99 | features.speechSynthesisPaused = hasProperty(features.speechSynthesis, "paused");
100 |
101 | const hasUtterance = features.speechSynthesisUtterance ? hasProperty(features.speechSynthesisUtterance, "prototype") : false;
102 |
103 | utteranceEvents.forEach(event => {
104 | const name = `on${event}`;
105 | features[name] = hasUtterance && features.speechSynthesisUtterance ? hasProperty(features.speechSynthesisUtterance.prototype, name) : false;
106 | });
107 |
108 | return features;
109 | };
--------------------------------------------------------------------------------
/dist/reader/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | lean-reader
7 |
8 |
9 |
10 |
11 |
12 |
13 |
34 |
35 |
38 |
39 |
40 | Bezig met laden...
41 |
42 |
43 |
46 |
47 |
58 |
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/dist/assets/style-4xmtn6Nt.css:
--------------------------------------------------------------------------------
1 | html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:"";content:none}table{border-collapse:collapse;border-spacing:0}@font-face{font-family:AGaramondPro;font-weight:400;src:url(/lean-reader/assets/AGaramondPro-Regular-B8Dtnlpi.woff)}:root{height:100%;--kb-cyan: #9cdbd9;--kb-red: #ef6079;--kb-gold: #cba052;--kb-beige: #ecdcc8;--kb-light-beige: #feeeda;--kb-gray3: #7a847f;--kb-gray4: #414644;--kb-gray: #e3e6e5;--kb-web-gray: #f3f5f6;--kb-link-active: #2B4B9A;--kb-link-visited: #723572;--kb-link-hover: #A5112B;--kb-link-inactive: #7A847F;--kb-link-black: #333333;--reader-header-inner-height: 24px;--reader-footer-inner-height: 16px;--reader-header-padding: 1em;--reader-footer-padding: 1em;--reader-header-height: calc(var(--reader-header-padding) * 2 + var(--reader-header-inner-height));--reader-footer-height: calc(var(--reader-footer-padding) * 2 + var(--reader-footer-inner-height))}body{min-height:100%;overflow:hidden;touch-action:pan-x pan-y;overscroll-behavior-x:none;overscroll-behavior-y:none;font-family:Arial,Helvetica,sans-serif}h1,h2,h3,h4{font-family:AGaramondPro,Arial,Helvetica,sans-serif;font-weight:700;font-style:normal}footer,.header-footer{background-color:var(--kb-light-beige);margin:0;position:fixed;width:calc(100% - 2em)}.header-footer{padding:var(--reader-header-padding);height:var(--reader-header-inner-height);display:flex}.header-footer h1{height:100%;font-size:1.5em;flex-grow:1}footer{padding:var(--reader-footer-padding);bottom:0;height:var(--reader-footer-inner-height);overflow:hidden}#debug{overflow-y:auto;position:fixed;left:0;width:calc(100% - 2em);padding:1em 1em 0;margin:0;bottom:0;height:250px;background-color:var(--kb-light-beige);display:none}#debug div{padding:4px 0;border-top:1px solid rgba(0,0,0,.1)}a{color:var(--kb-link-black)}a:hover{color:var(--kb-link-hover)}.header-footer a{text-decoration:none;font-size:1.5em}.header-footer button,.header-footer select,.header-footer a,.header-footer span{height:100%;display:inline-block;border-radius:0;border-width:1px;border-color:#000}.header-footer button{border-radius:4px}.header-footer button:active{outline:1px solid var(--kb-link-active);background-color:var(--kb-gray)}.header-footer button:hover{outline:1px solid var(--kb-link-active)}.header-footer button:hover>img{filter:invert(14%) sepia(25%) saturate(7340%) hue-rotate(219deg) brightness(86%) contrast(117%)}.header-footer a>img,.header-footer button>img{margin-top:2px;height:calc(100% - 4px)}.header-footer .centered{display:flex;width:100%;text-align:center;justify-content:center}.header-footer button#rate-percentage{padding:3px;border:none;border-top:1px solid;border-bottom:1px solid;background-color:var(--kb-web-gray);border-radius:0;cursor:default;-webkit-user-select:none;user-select:none;z-index:1}.header-footer #voice-select{max-width:calc(100% - 160px)}.header-footer #play-readaloud{margin-right:1em;margin-left:.25em}.header-footer #rate-slower,.header-footer #rate-faster{padding-inline-start:2px;padding-inline-end:2px}.header-footer #rate-slower{border-right:none;border-bottom-right-radius:0;border-top-right-radius:0}.header-footer #rate-faster{border-left:none;border-bottom-left-radius:0;border-top-left-radius:0}.header-footer input[type=radio]{display:none}.header-footer input[type=radio]+label{cursor:pointer;padding-top:6px;margin-left:1em}.header-footer input[type=radio]:checked+label{cursor:default;font-weight:700;text-decoration:underline}#wrapper{padding-top:var(--reader-header-height);height:calc(100vh - (var(--reader-footer-height) + var(--reader-header-height)) - 8px);height:calc(100dvh - (var(--reader-footer-height) + var(--reader-header-height)) - 8px);display:flex;width:100%;margin:0}#wrapper button{height:100%}main#listing{width:100%;height:calc(100vh - (var(--reader-footer-height) + var(--reader-header-height)) - 8px);height:calc(100dvh - (var(--reader-footer-height) + var(--reader-header-height)) - 8px);padding-top:var(--reader-header-height);overflow-y:auto}ul#boek-entry-list{padding:1em}ul#boek-entry-list li{margin:1em}#container{contain:content;width:100%;height:100%;flex-grow:1}.readium-navigator-iframe{width:100%;height:100%;border-width:0}#no-voices-found{font-size:.8em;display:none}
2 |
--------------------------------------------------------------------------------
/reader/core/textNodeHelper.ts:
--------------------------------------------------------------------------------
1 | import type { DocumentTextNodesChunk, RangedTextNode } from "./types";
2 |
3 | export const UNICODE_WORD_REGEX = /[\p{Letter}\p{Number}]+/ug
4 |
5 | function isDOMRectVisible(wnd : Window, rect : DOMRect): boolean {
6 | const viewport = {
7 | width: wnd.innerWidth || wnd.document.documentElement.clientWidth,
8 | height: wnd.innerHeight || wnd.document.documentElement.clientHeight
9 | };
10 | if (rect.bottom < 0 || rect.right < 0 ||
11 | rect.top > viewport.height || rect.left > viewport.width) {
12 | return false;
13 | }
14 | return true;
15 | }
16 |
17 | export function isTextNodeVisible(wnd : Window, textNode : Node): boolean {
18 | const range = new Range();
19 | range.setStart(textNode, 0);
20 | range.setEnd(textNode, textNode.textContent!.length)
21 | return isDOMRectVisible(wnd, range.getBoundingClientRect());
22 | }
23 |
24 | function getElementsWithOwnText(wnd : Window|null, currentElement? : Element, gathered? : HTMLElement[]): HTMLElement[] {
25 | currentElement = currentElement ?? wnd!.document.documentElement;
26 | gathered = gathered ?? [];
27 |
28 | for (let idx = 0; idx < currentElement.childNodes.length; idx++) {
29 | if (currentElement.childNodes[idx].nodeName.toLocaleLowerCase() === "head") { continue; }
30 | if (currentElement.childNodes[idx].nodeType === Node.TEXT_NODE && currentElement.childNodes[idx].textContent!.trim().length > 0) {
31 | if (gathered.indexOf(currentElement as HTMLElement) < 0) {
32 | gathered.push(currentElement as HTMLElement);
33 | }
34 | } else if (currentElement.childNodes[idx].nodeType === Node.ELEMENT_NODE) {
35 | getElementsWithOwnText(wnd, currentElement.childNodes[idx] as Element, gathered);
36 | }
37 | }
38 | return gathered;
39 | }
40 |
41 |
42 | function gatherTextNodes(currentElement : HTMLElement, gathered? : Node[] ): Node[] {
43 | gathered = gathered ?? []
44 |
45 | for (let idx = 0; idx < currentElement.childNodes.length; idx++) {
46 | if (currentElement.childNodes[idx].nodeType === Node.TEXT_NODE) {
47 | gathered.push(currentElement.childNodes[idx])
48 | } else if (currentElement.childNodes[idx].nodeType === Node.ELEMENT_NODE) {
49 | gatherTextNodes(currentElement.childNodes[idx] as HTMLElement, gathered);
50 | }
51 | }
52 | return gathered;
53 | }
54 |
55 |
56 | const injectTrailingSpace = (inStr : string) => inStr.replace(/([^\s])$/, "$1 ")
57 |
58 | export function gatherAndPrepareTextNodes(wnd : Window): DocumentTextNodesChunk[] {
59 | const elems = getElementsWithOwnText(wnd);
60 | // first purge out the elements that are already accounted for because they are a child
61 | // of one of the elements in the original list
62 | const elemsWithChildren = elems.filter((el) => el.childElementCount > 0);
63 | const purgedElems = elems.filter((el) => elemsWithChildren.indexOf(el.parentElement as HTMLElement) < 0)
64 |
65 | // For each of these root-elements distill all their text-nodes as one utterance:
66 | // utterance is a DocumentTextNodesChunk (often a block or a an element)
67 | return purgedElems
68 | .map((el) => gatherTextNodes(el))
69 | .map((chnk) => ({
70 | rangedTextNodes: chnk.reduce(
71 | (aggr, cur) => {
72 | const parentStartCharIndex = aggr.length > 0 ? (
73 | aggr[aggr.length - 1].parentStartCharIndex +
74 | injectTrailingSpace(aggr[aggr.length - 1].textNode.textContent!).length
75 | ) : 0;
76 | return aggr.concat({
77 | textNode: cur,
78 | parentStartCharIndex: parentStartCharIndex
79 | });
80 | }, [] as RangedTextNode[]
81 | ),
82 | utteranceStr: chnk.reduce((aggr, cur) => {
83 | return aggr + injectTrailingSpace(cur.textContent!)
84 | }, "")
85 | }))
86 | .filter((chnk) => chnk.utteranceStr.trim().length > 0)
87 | }
88 |
89 |
90 |
91 | function getWordCharPosFor(textNode : Node, testFn : (wordRect : DOMRect) => boolean) : number {
92 | if (!textNode.textContent || textNode.textContent.length === 0) { return -1; }
93 |
94 | const matches = textNode.textContent.matchAll(UNICODE_WORD_REGEX);
95 | let wordPositions = [0]
96 | for (const m of matches) {
97 | wordPositions.push(m.index)
98 | }
99 | wordPositions.push(textNode.textContent.length - 1)
100 |
101 | for (let wIdx = 0; wIdx < wordPositions.length - 1; wIdx++) {
102 | const range = new Range()
103 | const startPos = wordPositions[wIdx];
104 | const endPos = wordPositions[wIdx + 1]
105 | range.setStart(textNode, startPos);
106 | range.setEnd(textNode, endPos);
107 | range.getClientRects()
108 | for (let i = 0; i < range.getClientRects().length; i++) {
109 | const rect = range.getClientRects().item(i);
110 | if (rect && testFn(rect)) {
111 | return startPos;
112 | }
113 | }
114 | }
115 | return -1;
116 | }
117 |
118 |
119 | export const getWordCharPosAtXY = (x : number, y : number, textNode : Node) =>
120 | getWordCharPosFor(textNode, (wordRect) => x >= wordRect.x && x <= wordRect.x + wordRect.width && y >= wordRect.y && y <= wordRect.y + wordRect.height)
121 |
122 |
123 | export const getFirstVisibleWordCharPos = (wnd : Window, textNode : Node) =>
124 | getWordCharPosFor(textNode, (wordRect) => isDOMRectVisible(wnd, wordRect));
125 |
--------------------------------------------------------------------------------
/reader/css/style.css:
--------------------------------------------------------------------------------
1 | @import url("reset.css");
2 |
3 | @font-face {
4 | font-family: AGaramondPro;
5 | font-weight: 400;
6 | src: url(../assets/AGaramondPro-Regular.woff);
7 | }
8 |
9 | :root {
10 | height: 100%;
11 | --kb-cyan: #9cdbd9;
12 | --kb-red: #ef6079;
13 | --kb-gold: #cba052;
14 | --kb-beige: #ecdcc8;
15 | --kb-light-beige: #feeeda;
16 | --kb-gray3: #7a847f;
17 | --kb-gray4: #414644;
18 | --kb-gray: #e3e6e5;
19 | --kb-web-gray: #f3f5f6;
20 | --kb-link-active: #2B4B9A;
21 | --kb-link-visited: #723572;
22 | --kb-link-hover: #A5112B;
23 | --kb-link-inactive: #7A847F;
24 | --kb-link-black: #333333;
25 | --reader-header-inner-height: 24px;
26 | --reader-footer-inner-height: 16px;
27 | --reader-header-padding: 1em;
28 | --reader-footer-padding: 1em;
29 | --reader-header-height: calc(var(--reader-header-padding) * 2 + var(--reader-header-inner-height));
30 | --reader-footer-height: calc(var(--reader-footer-padding) * 2 + var(--reader-footer-inner-height));
31 | }
32 |
33 | body {
34 | min-height: 100%;
35 | overflow: hidden;
36 | touch-action: pan-x pan-y;
37 | overscroll-behavior-x: none;
38 | overscroll-behavior-y: none;
39 | font-family: Arial, Helvetica, sans-serif;
40 | }
41 |
42 | h1,
43 | h2,
44 | h3,
45 | h4 {
46 | font-family: AGaramondPro, Arial, Helvetica, sans-serif;
47 | font-weight: 700;
48 | /* bold */
49 | font-style: normal;
50 | }
51 |
52 | footer,
53 | .header-footer {
54 | background-color: var(--kb-light-beige);
55 | margin: 0;
56 | position: fixed;
57 | width: calc(100% - 2em);
58 | }
59 |
60 | .header-footer {
61 | padding: var(--reader-header-padding);
62 | height: var(--reader-header-inner-height);
63 | display: flex;
64 | }
65 |
66 | .header-footer h1 {
67 | height: 100%;
68 | font-size: 1.5em;
69 | flex-grow: 1;
70 | }
71 |
72 | footer {
73 | padding: var(--reader-footer-padding);
74 | bottom: 0;
75 | height: var(--reader-footer-inner-height);
76 | overflow: hidden;
77 | }
78 |
79 | #debug {
80 | overflow-y: auto;
81 | position: fixed;
82 | left: 0;
83 | width: calc(100% - 2em);
84 | padding: 1em 1em 0 1em;
85 | margin: 0;
86 | bottom: 0px;
87 | height: 250px;
88 | background-color: var(--kb-light-beige);
89 | display: none;
90 | }
91 |
92 |
93 | #debug div {
94 | padding: 4px 0;
95 | border-top: 1px solid rgba(0, 0, 0, 0.1);
96 | }
97 |
98 | a {
99 | color: var(--kb-link-black);
100 | }
101 |
102 | a:hover {
103 | color: var(--kb-link-hover)
104 | }
105 |
106 | .header-footer a {
107 | text-decoration: none;
108 | font-size: 1.5em;
109 | }
110 |
111 | .header-footer button,
112 | .header-footer select,
113 | .header-footer a,
114 | .header-footer span {
115 | height: 100%;
116 | display: inline-block;
117 | border-radius: 0;
118 | border-width: 1px;
119 | border-color: black;
120 | }
121 |
122 | .header-footer button {
123 | border-radius: 4px;
124 | }
125 |
126 | .header-footer button:active {
127 | outline: 1px solid var(--kb-link-active);
128 | background-color: var(--kb-gray);
129 | }
130 |
131 |
132 | .header-footer button:hover {
133 | outline: 1px solid var(--kb-link-active);
134 | }
135 |
136 | .header-footer button:hover>img {
137 | filter: invert(14%) sepia(25%) saturate(7340%) hue-rotate(219deg) brightness(86%) contrast(117%);
138 | }
139 |
140 |
141 | .header-footer a>img,
142 | .header-footer button>img {
143 | margin-top: 2px;
144 | height: calc(100% - 4px);
145 | }
146 |
147 | .header-footer .centered {
148 | display: flex;
149 | width: 100%;
150 | text-align: center;
151 | justify-content: center;
152 | }
153 |
154 | .header-footer button#rate-percentage {
155 | padding: 3px;
156 | border: none;
157 | border-top: 1px solid;
158 | border-bottom: 1px solid;
159 | background-color: var(--kb-web-gray);
160 | border-radius: 0;
161 | cursor: default;
162 | user-select: none;
163 | z-index: 1;
164 | }
165 |
166 | .header-footer #voice-select {
167 | max-width: calc(100% - 160px);
168 | }
169 |
170 | .header-footer #play-readaloud {
171 | margin-right: 1em;
172 | margin-left: 0.25em;
173 | }
174 |
175 | .header-footer #rate-slower,
176 | .header-footer #rate-faster {
177 | padding-inline-start: 2px;
178 | padding-inline-end: 2px;
179 | }
180 |
181 | .header-footer #rate-slower {
182 | border-right: none;
183 | border-bottom-right-radius: 0;
184 | border-top-right-radius: 0;
185 | }
186 |
187 | .header-footer #rate-faster {
188 | border-left: none;
189 | border-bottom-left-radius: 0;
190 | border-top-left-radius: 0;
191 | }
192 |
193 | .header-footer input[type='radio'] {
194 | display: none;
195 | }
196 |
197 | .header-footer input[type='radio'] + label {
198 | cursor: pointer;
199 | padding-top: 6px;
200 | margin-left: 1em;
201 | }
202 |
203 | .header-footer input[type='radio']:checked + label {
204 | cursor: default;
205 | font-weight: bold;
206 | text-decoration: underline;
207 | }
208 |
209 |
210 | #wrapper {
211 | padding-top: var(--reader-header-height);
212 | height: calc(100vh - (var(--reader-footer-height) + var(--reader-header-height)) - 8px);
213 | height: calc(100dvh - (var(--reader-footer-height) + var(--reader-header-height)) - 8px);
214 | display: flex;
215 | width: 100%;
216 | margin: 0;
217 | }
218 |
219 | #wrapper button {
220 | height: 100%;
221 | }
222 |
223 | main#listing {
224 | width: 100%;
225 | height: calc(100vh - (var(--reader-footer-height) + var(--reader-header-height)) - 8px);
226 | height: calc(100dvh - (var(--reader-footer-height) + var(--reader-header-height)) - 8px);
227 | padding-top: var(--reader-header-height);
228 | overflow-y: auto;
229 | }
230 |
231 | ul#boek-entry-list {
232 | padding: 1em;
233 | }
234 |
235 | ul#boek-entry-list li {
236 | margin: 1em;
237 | }
238 |
239 |
240 | #container {
241 | contain: content;
242 | width: 100%;
243 | height: 100%;
244 | flex-grow: 1;
245 | }
246 |
247 | .readium-navigator-iframe {
248 | width: 100%;
249 | height: 100%;
250 | border-width: 0;
251 | }
252 |
253 | #no-voices-found {
254 | font-size: 0.8em;
255 | display: none;
256 | }
--------------------------------------------------------------------------------
/reader/readium-speech/WebSpeech/TmpNavigator.ts:
--------------------------------------------------------------------------------
1 | import { type ReadiumSpeechPlaybackEngine } from "../engine";
2 | import { type ReadiumSpeechNavigator, type ReadiumSpeechPlaybackEvent, type ReadiumSpeechPlaybackState } from "../navigator";
3 | import { type ReadiumSpeechUtterance } from "../utterance";
4 | import { type ReadiumSpeechVoice } from "../voices";
5 | import { WebSpeechEngine } from "./webSpeechEngine";
6 |
7 | export class WebSpeechReadAloudNavigator implements ReadiumSpeechNavigator {
8 | private engine: ReadiumSpeechPlaybackEngine;
9 | private contentQueue: ReadiumSpeechUtterance[] = [];
10 | private eventListeners: Map void)[]> = new Map();
11 |
12 | // Navigator owns the state, not the engine
13 | private navigatorState: ReadiumSpeechPlaybackState = "idle";
14 |
15 | constructor(engine?: ReadiumSpeechPlaybackEngine) {
16 | this.engine = engine || new WebSpeechEngine();
17 | this.setupEngineListeners();
18 | this.initializeEngine();
19 | }
20 |
21 | private async initializeEngine(): Promise {
22 | if (this.engine instanceof WebSpeechEngine) {
23 | try {
24 | await this.engine.initialize({maxTimeout: 60000, interval: 10});
25 | } catch (error) {
26 | console.warn("Failed to initialize WebSpeechEngine:", error);
27 | }
28 | }
29 | }
30 |
31 | private setupEngineListeners(): void {
32 | // Bridge engine events to navigator state management
33 | this.engine.on("start", () => {
34 | this.setNavigatorState("playing");
35 | this.emitEvent({ type: "start" });
36 | });
37 |
38 | this.engine.on("end", () => {
39 | const currentIndex = this.engine.getCurrentUtteranceIndex();
40 | const totalCount = this.engine.getUtteranceCount();
41 |
42 | if (currentIndex < totalCount - 1) {
43 | // Navigator handles continuous playback
44 | this.engine.speak(currentIndex + 1);
45 | } else {
46 | // Reached end - set navigator to idle
47 | this.setNavigatorState("idle");
48 | }
49 |
50 | this.emitEvent({ type: "end" });
51 | });
52 |
53 | this.engine.on("pause", () => {
54 | this.setNavigatorState("paused");
55 | this.emitEvent({ type: "pause" });
56 | });
57 |
58 | this.engine.on("resume", () => {
59 | this.setNavigatorState("playing");
60 | this.emitEvent({ type: "resume" });
61 | });
62 |
63 | this.engine.on("error", (event) => {
64 | this.setNavigatorState("idle");
65 | // Only emit error for genuine errors, not interruptions during navigation
66 | if (event.detail.error !== "interrupted" && event.detail.error !== "canceled") {
67 | this.emitEvent(event);
68 | }
69 | });
70 |
71 | this.engine.on("ready", () => {
72 | if (this.contentQueue.length > 0) {
73 | this.setNavigatorState("ready");
74 | this.emitEvent({ type: "ready" });
75 | }
76 | });
77 |
78 | this.engine.on("boundary", (event) => {
79 | this.emitEvent(event);
80 | });
81 |
82 | this.engine.on("mark", (event) => {
83 | this.emitEvent(event);
84 | });
85 |
86 | this.engine.on("voiceschanged", () => {
87 | this.emitEvent({ type: "voiceschanged" });
88 | });
89 | }
90 |
91 | private setNavigatorState(state: ReadiumSpeechPlaybackState): void {
92 | this.navigatorState = state;
93 | }
94 |
95 | // Voice Management
96 | async getVoices(): Promise {
97 | return this.engine.getAvailableVoices();
98 | }
99 |
100 | async setVoice(voice: ReadiumSpeechVoice | string): Promise {
101 | this.engine.setVoice(voice);
102 | }
103 |
104 | getCurrentVoice(): ReadiumSpeechVoice | null {
105 | return this.engine.getCurrentVoice();
106 | }
107 |
108 | // Content Management
109 | loadContent(content: ReadiumSpeechUtterance | ReadiumSpeechUtterance[]): void {
110 | const contents = Array.isArray(content) ? content : [content];
111 | this.contentQueue = [...contents];
112 |
113 | // Load utterances first
114 | this.engine.loadUtterances(contents);
115 |
116 | // Then set navigator state to ready
117 | this.setNavigatorState("ready");
118 | this.emitContentChangeEvent({ content: contents });
119 | }
120 |
121 | getCurrentContent(): ReadiumSpeechUtterance | null {
122 | const index = this.getCurrentUtteranceIndex();
123 | return index < this.contentQueue.length ? this.contentQueue[index] : null;
124 | }
125 |
126 | getContentQueue(): ReadiumSpeechUtterance[] {
127 | return [...this.contentQueue];
128 | }
129 |
130 | getPlaybackRate(): number {
131 | return this.engine.getRate()
132 | }
133 |
134 | private getCurrentUtteranceIndex(): number {
135 | return this.engine.getCurrentUtteranceIndex();
136 | }
137 |
138 | // Playback Control - Navigator coordinates engine operations
139 | async play(): Promise {
140 | if (this.navigatorState === "paused") {
141 | // Resume from pause
142 | this.setNavigatorState("playing");
143 | this.engine.resume();
144 | } else if (this.navigatorState === "ready" || this.navigatorState === "idle") {
145 | // Start playing from beginning
146 | this.setNavigatorState("playing");
147 | this.engine.speak();
148 | } else if (this.navigatorState === "playing") {
149 | // Already playing, do nothing or restart
150 | return;
151 | }
152 | }
153 |
154 | pause(): void {
155 | if (this.navigatorState === "playing") {
156 | this.setNavigatorState("paused");
157 | this.engine.pause();
158 | }
159 | }
160 |
161 | stop(): void {
162 | this.setNavigatorState("idle");
163 | this.engine.stop(); // Reset engine index first
164 | this.emitEvent({ type: "stop" }); // Then emit event for UI update
165 | }
166 |
167 | async togglePlayPause(): Promise {
168 | if (this.navigatorState === "playing") {
169 | this.pause();
170 | } else {
171 | await this.play();
172 | }
173 | }
174 |
175 | // Navigation - Navigator coordinates with proper state management
176 | async next(): Promise {
177 | const currentIndex = this.getCurrentUtteranceIndex();
178 | const totalCount = this.engine.getUtteranceCount();
179 |
180 | if (currentIndex < totalCount - 1) {
181 | this.engine.speak(currentIndex + 1);
182 | return true;
183 | }
184 | return false;
185 | }
186 |
187 | async previous(): Promise {
188 | const currentIndex = this.getCurrentUtteranceIndex();
189 |
190 | if (currentIndex > 0) {
191 | this.engine.speak(currentIndex - 1);
192 | return true;
193 | }
194 | return false;
195 | }
196 |
197 | jumpTo(utteranceIndex: number): void {
198 | if (utteranceIndex >= 0 && utteranceIndex < this.contentQueue.length) {
199 | this.engine.speak(utteranceIndex);
200 | }
201 | }
202 |
203 | // Playback Parameters
204 | setRate(rate: number): void {
205 | this.engine.setRate(rate);
206 | }
207 |
208 | setPitch(pitch: number): void {
209 | this.engine.setPitch(pitch);
210 | }
211 |
212 | setVolume(volume: number): void {
213 | this.engine.setVolume(volume);
214 | }
215 |
216 | // State - Navigator is the single source of truth
217 | getState(): ReadiumSpeechPlaybackState {
218 | return this.navigatorState;
219 | }
220 |
221 | // Events
222 | on(event: ReadiumSpeechPlaybackEvent["type"] | "contentchange", listener: (event: ReadiumSpeechPlaybackEvent) => void): () => void {
223 | if (!this.eventListeners.has(event)) {
224 | this.eventListeners.set(event, []);
225 | }
226 | this.eventListeners.get(event)!.push(listener);
227 |
228 | return () => {
229 | const listeners = this.eventListeners.get(event);
230 | if (listeners) {
231 | const index = listeners.indexOf(listener);
232 | if (index > -1) {
233 | listeners.splice(index, 1);
234 | }
235 | }
236 | };
237 | }
238 |
239 | private emitEvent(event: ReadiumSpeechPlaybackEvent): void {
240 | const listeners = this.eventListeners.get(event.type);
241 | if (listeners) {
242 | listeners.forEach(callback => callback(event));
243 | }
244 | }
245 |
246 | private emitContentChangeEvent(event: { content: ReadiumSpeechUtterance[] }): void {
247 | const listeners = this.eventListeners.get("contentchange");
248 | if (listeners) {
249 | listeners.forEach(callback => callback({ type: "contentchange", detail: event } as unknown as ReadiumSpeechPlaybackEvent));
250 | }
251 | }
252 |
253 | async destroy(): Promise {
254 | this.eventListeners.clear();
255 | await this.engine.destroy();
256 | }
257 | }
--------------------------------------------------------------------------------
/reader/readium-speech/WebSpeech/webSpeechEngine.ts:
--------------------------------------------------------------------------------
1 | import type { ReadiumSpeechPlaybackEngine } from "../engine";
2 | import type { ReadiumSpeechPlaybackEvent, ReadiumSpeechPlaybackState } from "../navigator";
3 | import type { ReadiumSpeechUtterance } from "../utterance";
4 | import type { ReadiumSpeechVoice } from "../voices";
5 | import { getSpeechSynthesisVoices, parseSpeechSynthesisVoices, filterOnLanguage } from "../voices";
6 |
7 | import { detectFeatures, type WebSpeechFeatures } from "../utils/features";
8 | import { detectPlatformFeatures, type WebSpeechPlatformPatches } from "../utils/patches";
9 |
10 | import { stripHtml } from "string-strip-html";
11 |
12 | export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine {
13 | private speechSynthesis: SpeechSynthesis;
14 | private speechSynthesisUtterance: any;
15 | private currentVoice: ReadiumSpeechVoice | null = null;
16 | private currentUtterances: ReadiumSpeechUtterance[] = [];
17 | private currentUtteranceIndex: number = 0;
18 | private playbackState: ReadiumSpeechPlaybackState = "idle";
19 | private eventListeners: Map void)[]> = new Map();
20 |
21 | private voices: ReadiumSpeechVoice[] = [];
22 | private browserVoices: SpeechSynthesisVoice[] = [];
23 | private defaultVoice: ReadiumSpeechVoice | null = null;
24 |
25 | // Enhanced properties for cross-browser compatibility
26 | private resumeInfinityTimer?: number;
27 | private isPausedInternal: boolean = false;
28 | private isSpeakingInternal: boolean = false;
29 | private initialized: boolean = false;
30 | private maxLengthExceeded: "error" | "none" | "warn" = "warn";
31 | private utterancesBeingCancelled: boolean = false; // Flag to track if utterances are being cancelled
32 |
33 | // Playback parameters
34 | private rate: number = 1.0;
35 | private pitch: number = 1.0;
36 | private volume: number = 1.0;
37 |
38 | private features: WebSpeechFeatures;
39 | private patches: WebSpeechPlatformPatches;
40 |
41 | constructor() {
42 | // Use detected features instead of hardcoded window properties
43 | this.features = detectFeatures();
44 | this.patches = detectPlatformFeatures();
45 |
46 | if (!this.features.speechSynthesis || !this.features.speechSynthesisUtterance) {
47 | throw new Error("Web Speech API is not available in this environment");
48 | }
49 | this.speechSynthesis = this.features.speechSynthesis;
50 | this.speechSynthesisUtterance = this.features.speechSynthesisUtterance;
51 | }
52 | getRate(): number {
53 | return this.rate;
54 | }
55 |
56 | // From Easy Speech,
57 | // Check infinity pattern for long texts (except on problematic platforms)
58 | // Skip resume infinity for Microsoft Natural voices as they have different behavior
59 | private shouldUseResumeInfinity(): boolean {
60 | const selectedVoice = this.currentVoice;
61 | const isMsNatural = !!(selectedVoice?.name &&
62 | typeof selectedVoice.name === "string" &&
63 | selectedVoice.name.toLocaleLowerCase().includes("(natural)"));
64 | return this.patches.isAndroid !== true && !this.patches.isFirefox && !this.patches.isSafari && !isMsNatural;
65 | }
66 |
67 | // Creates a new SpeechSynthesisUtterance using detected constructor
68 | private createUtterance(text: string): SpeechSynthesisUtterance {
69 | return new this.speechSynthesisUtterance(text);
70 | }
71 |
72 | async initialize(options: {
73 | maxTimeout?: number;
74 | interval?: number;
75 | maxLengthExceeded?: "error" | "none" | "warn";
76 | } = {}): Promise {
77 | const { maxTimeout = 10000, interval = 10, maxLengthExceeded = "warn" } = options;
78 |
79 | if (this.initialized) {
80 | return false;
81 | }
82 |
83 | this.maxLengthExceeded = maxLengthExceeded;
84 |
85 | try {
86 | // Get and cache the browser's native voices
87 | this.browserVoices = await getSpeechSynthesisVoices(maxTimeout, interval);
88 | // Parse them into our internal format
89 | this.voices = parseSpeechSynthesisVoices(this.browserVoices);
90 |
91 | // Try to find voice matching user's language
92 | const langVoices = filterOnLanguage(this.voices);
93 | this.defaultVoice = langVoices.length > 0 ? langVoices[0] : this.voices[0] || null;
94 |
95 | this.initialized = true;
96 | return true;
97 | } catch (error) {
98 | console.error("Failed to initialize WebSpeechEngine:", error);
99 | this.initialized = false;
100 | return false;
101 | }
102 | }
103 |
104 | // Text length validation matching EasySpeech
105 | private validateText(text: string): void {
106 | const textBytes = new TextEncoder().encode(text);
107 | if (textBytes.length > 4096) {
108 | const message = "Text exceeds max length of 4096 bytes, which may not work with some voices.";
109 | switch (this.maxLengthExceeded) {
110 | case "none":
111 | break;
112 | case "error":
113 | throw new Error(`WebSpeechEngine: ${message}`);
114 | case "warn":
115 | default:
116 | console.warn(`WebSpeechEngine: ${message}`);
117 | }
118 | }
119 | }
120 |
121 | private getCurrentVoiceForUtterance(voice?: ReadiumSpeechVoice | string | null): ReadiumSpeechVoice | null {
122 | if (voice && typeof voice === "object") {
123 | return voice;
124 | }
125 | if (typeof voice === "string") {
126 | return this.voices.find(v => v.name === voice || v.language === voice) || null;
127 | }
128 |
129 | return this.currentVoice || this.defaultVoice;
130 | }
131 |
132 | getCurrentVoice(): ReadiumSpeechVoice | null {
133 | return this.currentVoice;
134 | }
135 |
136 | // SSML Escaping
137 | private escapeSSML(utterances: ReadiumSpeechUtterance[]): ReadiumSpeechUtterance[] {
138 | return utterances.map(content => ({
139 | ...content,
140 | text: content.ssml ? stripHtml(content.text).result : content.text
141 | }));
142 | }
143 |
144 | // Queue Management
145 | loadUtterances(contents: ReadiumSpeechUtterance[]): void {
146 | // Escape SSML entirely for the time being
147 | this.currentUtterances = this.escapeSSML(contents);
148 | this.currentUtteranceIndex = 0;
149 | this.setState("ready");
150 | this.emitEvent({ type: "ready" });
151 | }
152 |
153 | // Voice Configuration
154 | setVoice(voice: ReadiumSpeechVoice | string): void {
155 | const previousVoice = this.currentVoice;
156 |
157 | if (typeof voice === "string") {
158 | // Find voice by name or language
159 | this.getAvailableVoices().then(voices => {
160 | const foundVoice = voices.find(v => v.name === voice || v.language === voice);
161 | if (foundVoice) {
162 | this.currentVoice = foundVoice;
163 | // Reset position when voice changes for fresh start with new voice
164 | if (previousVoice && previousVoice.name !== foundVoice.name) {
165 | this.currentUtteranceIndex = 0;
166 | }
167 | } else {
168 | console.warn(`Voice "${voice}" not found`);
169 | }
170 | });
171 | } else {
172 | this.currentVoice = voice;
173 | // Reset position when voice changes for fresh start with new voice
174 | if (previousVoice && previousVoice.name !== voice.name) {
175 | this.currentUtteranceIndex = 0;
176 | }
177 | }
178 | }
179 |
180 | getAvailableVoices(): Promise {
181 | return new Promise((resolve) => {
182 | if (this.voices.length > 0) {
183 | resolve(this.voices);
184 | } else {
185 | // If voices not loaded yet, initialize first
186 | this.initialize().then(() => {
187 | resolve(this.voices);
188 | }).catch(() => {
189 | resolve([]);
190 | });
191 | }
192 | });
193 | }
194 |
195 | // Playback Control
196 | speak(utteranceIndex?: number): void {
197 | if (utteranceIndex !== undefined) {
198 | if (utteranceIndex < 0 || utteranceIndex >= this.currentUtterances.length) {
199 | throw new Error("Invalid utterance index");
200 | }
201 | this.currentUtteranceIndex = utteranceIndex;
202 | }
203 |
204 | if (this.currentUtterances.length === 0) {
205 | console.warn("No utterances loaded");
206 | return;
207 | }
208 |
209 | // Cancel any ongoing speech with Firefox workaround
210 | this.cancelCurrentSpeech();
211 |
212 | // Reset internal state
213 | this.isSpeakingInternal = true;
214 | this.isPausedInternal = false;
215 |
216 | // Set state to playing before starting new speech
217 | this.setState("playing");
218 | this.emitEvent({ type: "start" });
219 | this.stopResumeInfinity();
220 |
221 | // Reset utterance index to ensure we're starting fresh
222 | this.currentUtteranceIndex = utteranceIndex ?? 0;
223 |
224 | // Ensure the utterance index is valid
225 | if (this.currentUtteranceIndex >= this.currentUtterances.length) {
226 | this.currentUtteranceIndex = 0;
227 | }
228 |
229 | // Speak immediately for responsive navigation
230 | this.speakCurrentUtterance();
231 | }
232 |
233 | private cancelCurrentSpeech(): void {
234 | if (this.patches.isFirefox && this.speechSynthesis.speaking) {
235 | // Firefox workaround: set flag to ignore delayed onend events
236 | this.utterancesBeingCancelled = true;
237 |
238 | // Clear cancelled flag after delay
239 | setTimeout(() => {
240 | this.utterancesBeingCancelled = false;
241 | }, 100);
242 | }
243 |
244 | this.speechSynthesis.cancel();
245 | }
246 |
247 | private async speakCurrentUtterance(): Promise {
248 | if (this.currentUtteranceIndex >= this.currentUtterances.length) {
249 | this.setState("idle");
250 | this.emitEvent({ type: "end" });
251 | return;
252 | }
253 |
254 | const content = this.currentUtterances[this.currentUtteranceIndex];
255 | const text = content.ssml ? content.text : content.text;
256 |
257 | // Validate text length
258 | this.validateText(text);
259 |
260 | const utterance = this.createUtterance(text);
261 |
262 | // Configure utterance
263 | if (content.language) {
264 | utterance.lang = content.language;
265 | }
266 |
267 | // Enhanced voice selection with MSNatural detection
268 | const selectedVoice = this.getCurrentVoiceForUtterance(this.currentVoice);
269 |
270 | if (selectedVoice) {
271 | // Find the matching voice in our cached browser voices
272 | // as converting ReadiumSpeechVoice to SpeechSynthesisVoice is not possible
273 | const nativeVoice = this.browserVoices.find(v =>
274 | v.name === selectedVoice.name &&
275 | v.lang === (selectedVoice.__lang || selectedVoice.language)
276 | );
277 |
278 | if (nativeVoice) {
279 | utterance.voice = nativeVoice; // Use the real native voice from cache
280 | }
281 | }
282 |
283 | utterance.rate = this.rate;
284 | utterance.pitch = this.pitch;
285 | utterance.volume = this.volume;
286 |
287 | // Set up event handlers with resume infinity pattern
288 | utterance.onstart = () => {
289 | this.isSpeakingInternal = true;
290 | this.isPausedInternal = false;
291 | this.setState("playing");
292 | this.emitEvent({ type: "start" });
293 |
294 | const shouldUseResumeInfinity = this.shouldUseResumeInfinity();
295 | if (shouldUseResumeInfinity) {
296 | this.startResumeInfinity(utterance);
297 | }
298 | };
299 |
300 | utterance.onend = () => {
301 | // Firefox workaround: ignore onend from cancelled utterances
302 | if (this.utterancesBeingCancelled) {
303 | this.utterancesBeingCancelled = false;
304 | return;
305 | }
306 |
307 | // Don't continue if stopped
308 | if (this.playbackState === "idle") {
309 | return;
310 | }
311 |
312 | // Just report completion - navigator handles playback decisions
313 | this.isSpeakingInternal = false;
314 | this.isPausedInternal = false;
315 | this.stopResumeInfinity();
316 |
317 | // Set idle state if we've reached the end
318 | if (this.currentUtteranceIndex >= this.currentUtterances.length - 1) {
319 | this.setState("idle");
320 | }
321 |
322 | this.emitEvent({ type: "end" });
323 | };
324 |
325 | utterance.onerror = (event) => {
326 | this.isSpeakingInternal = false;
327 | this.isPausedInternal = false;
328 | this.stopResumeInfinity();
329 |
330 | // Fatal errors that break playback completely - reset to beginning
331 | const fatalErrors = ["synthesis-unavailable", "audio-hardware", "voice-unavailable"];
332 | if (fatalErrors.includes(event.error)) {
333 | console.log(`[ENGINE] fatal error detected, resetting index to 0`);
334 | this.currentUtteranceIndex = 0;
335 | }
336 |
337 | this.setState("idle");
338 | this.emitEvent({
339 | type: "error",
340 | detail: {
341 | error: event.error, // Preserve original error type
342 | message: `Speech synthesis error: ${event.error}`
343 | }
344 | });
345 | };
346 |
347 | utterance.onpause = () => {
348 | this.isPausedInternal = true;
349 | this.isSpeakingInternal = false;
350 | this.emitEvent({ type: "pause" });
351 | };
352 |
353 | utterance.onresume = () => {
354 | this.isPausedInternal = false;
355 | this.isSpeakingInternal = true;
356 | this.emitEvent({ type: "resume" });
357 | };
358 |
359 | // Handle word and sentence boundaries
360 | utterance.onboundary = (event) => {
361 | this.emitEvent({
362 | type: "boundary",
363 | detail: {
364 | charIndex: event.charIndex,
365 | charLength: event.charLength,
366 | elapsedTime: event.elapsedTime,
367 | name: event.name
368 | }
369 | });
370 | };
371 |
372 | // Handle SSML marks
373 | utterance.onmark = (event) => {
374 | this.emitEvent({
375 | type: "mark",
376 | detail: {
377 | name: event.name
378 | }
379 | });
380 | };
381 |
382 | this.speechSynthesis.speak(utterance);
383 | }
384 |
385 | private startResumeInfinity(utterance: SpeechSynthesisUtterance): void {
386 | const shouldUseResumeInfinity = this.shouldUseResumeInfinity();
387 |
388 | if (!shouldUseResumeInfinity) {
389 | return;
390 | }
391 |
392 | // Use the same logic as EasySpeech with internal patching
393 | this.resumeInfinityTimer = window.setTimeout(() => {
394 | // Check if utterance still exists and speech is active
395 | if (utterance) {
396 | // Include internal patching, since some systems have problems with
397 | // pause/resume and updating the internal state on speechSynthesis
398 | const { paused, speaking } = this.speechSynthesis;
399 | const isSpeaking = speaking || this.isSpeakingInternal;
400 | const isPaused = paused || this.isPausedInternal;
401 |
402 | if (isSpeaking && !isPaused) {
403 | this.speechSynthesis.pause();
404 | this.speechSynthesis.resume();
405 | }
406 | }
407 |
408 | // Continue the pattern (matches EasySpeech recursive pattern)
409 | this.startResumeInfinity(utterance);
410 | }, 5000);
411 | }
412 |
413 | private stopResumeInfinity(): void {
414 | if (this.resumeInfinityTimer) {
415 | clearTimeout(this.resumeInfinityTimer);
416 | this.resumeInfinityTimer = undefined;
417 | }
418 | }
419 |
420 | pause(): void {
421 | if (this.playbackState === "playing") {
422 | // Android-specific handling: pause causes speech to end but not fire end-event
423 | // so we simply do it manually instead of pausing
424 | if (this.patches.isAndroid) {
425 | this.speechSynthesis.cancel();
426 | return;
427 | }
428 |
429 | this.speechSynthesis.pause();
430 | // in some cases, pause does not update the internal state,
431 | // so we need to update it manually using an own state
432 | this.isPausedInternal = true;
433 | this.isSpeakingInternal = false;
434 | this.setState("paused");
435 | // Emit pause event since speechSynthesis.pause() may not trigger utterance.onpause
436 | this.emitEvent({ type: "pause" });
437 | }
438 | }
439 |
440 | resume(): void {
441 | if (this.playbackState === "paused") {
442 | this.speechSynthesis.resume();
443 | // in some cases, resume does not update the internal state,
444 | // so we need to update it manually using an own state
445 | this.isPausedInternal = false;
446 | this.isSpeakingInternal = true;
447 | this.setState("playing");
448 | // Emit resume event since speechSynthesis.resume() may not trigger utterance.onresume
449 | this.emitEvent({ type: "resume" });
450 | }
451 | }
452 |
453 | stop(): void {
454 | this.speechSynthesis.cancel();
455 | this.currentUtteranceIndex = 0; // Reset to beginning when stopped
456 | this.setState("idle");
457 | this.emitEvent({ type: "stop" }); // Emit immediately
458 | }
459 |
460 | // Playback Parameters
461 | setRate(rate: number): void {
462 | this.rate = Math.max(0.1, Math.min(10, rate));
463 | }
464 |
465 | setPitch(pitch: number): void {
466 | this.pitch = Math.max(0, Math.min(2, pitch));
467 | }
468 |
469 | setVolume(volume: number): void {
470 | this.volume = Math.max(0, Math.min(1, volume));
471 | }
472 |
473 | // State
474 | getState(): ReadiumSpeechPlaybackState {
475 | return this.playbackState;
476 | }
477 |
478 | getCurrentUtteranceIndex(): number {
479 | return this.currentUtteranceIndex;
480 | }
481 |
482 | getUtteranceCount(): number {
483 | return this.currentUtterances.length;
484 | }
485 |
486 | // Events
487 | on(event: ReadiumSpeechPlaybackEvent["type"], callback: (event: ReadiumSpeechPlaybackEvent) => void): () => void {
488 | if (!this.eventListeners.has(event)) {
489 | this.eventListeners.set(event, []);
490 | }
491 | this.eventListeners.get(event)!.push(callback);
492 |
493 | // Return unsubscribe function
494 | return () => {
495 | const listeners = this.eventListeners.get(event);
496 | if (listeners) {
497 | const index = listeners.indexOf(callback);
498 | if (index > -1) {
499 | listeners.splice(index, 1);
500 | }
501 | }
502 | };
503 | }
504 |
505 | private emitEvent(event: ReadiumSpeechPlaybackEvent): void {
506 | const listeners = this.eventListeners.get(event.type);
507 | if (listeners) {
508 | listeners.forEach(callback => callback(event));
509 | }
510 | }
511 |
512 | private setState(state: ReadiumSpeechPlaybackState): void {
513 | const oldState = this.playbackState;
514 | this.playbackState = state;
515 |
516 | // Emit state change events
517 | if (oldState !== state) {
518 | switch (state) {
519 | case "idle":
520 | this.emitEvent({ type: "idle" });
521 | break;
522 | case "loading":
523 | this.emitEvent({ type: "loading" });
524 | break;
525 | case "ready":
526 | this.emitEvent({ type: "ready" });
527 | break;
528 | }
529 | }
530 | }
531 |
532 | // Cleanup with comprehensive error handling
533 | async destroy(): Promise {
534 | this.stop();
535 | this.stopResumeInfinity();
536 | this.eventListeners.clear();
537 | this.currentUtterances = [];
538 | this.currentVoice = null;
539 | this.voices = [];
540 | this.defaultVoice = null;
541 | this.initialized = false;
542 | }
543 | }
--------------------------------------------------------------------------------
/reader/main.ts:
--------------------------------------------------------------------------------
1 | import './css/style.css';
2 | import './css/highlighting.css';
3 | import playIcon from './icons/play.svg';
4 | import pauseIcon from './icons/pause.svg';
5 |
6 | import { type FrameClickEvent, type BasicTextSelection } from '@readium/navigator-html-injectables';
7 | import { EpubNavigator, type EpubNavigatorListeners } from "@readium/navigator";
8 | import type { Fetcher, Locator } from "@readium/shared";
9 | import { HttpFetcher, Manifest, Publication } from "@readium/shared";
10 | import { Link } from "@readium/shared";
11 | import { gatherAndPrepareTextNodes, getFirstVisibleWordCharPos, getWordCharPosAtXY, isTextNodeVisible } from './core/textNodeHelper';
12 | import { type ReadAloudHighlight, type WordPositionInfo } from "./core/types";
13 | import { WebSpeechReadAloudNavigator, type ReadiumSpeechPlaybackEvent, type ReadiumSpeechVoice } from './readium-speech';
14 | import { detectPlatformFeatures } from './readium-speech/utils/patches';
15 | import { createPoorMansConsole } from "./util/poorMansConsole";
16 | import { store } from './core/store';
17 | import { setDocumentTextNodes, setHighlights, setLastKnownWordPosition, setPublicationIsLoading, setSelection } from './core/readaloudNavigationSlice';
18 |
19 | const { isAndroid } = detectPlatformFeatures()
20 | const pmc = createPoorMansConsole(document.getElementById("debug")!);
21 | const container = document.getElementById("container")!;
22 |
23 | function renderHtmlElements() {
24 | const { publicationIsLoading, highlights, lastKnownWordPosition } = store.getState().readaloudNavigation;
25 | document.querySelectorAll("#loading-message").forEach((el) => (el as HTMLElement).style.display = publicationIsLoading ? "block" : "none");
26 | document.querySelectorAll(".word-highlight").forEach((el) => el.remove())
27 | highlights.forEach(({ rect, characters }) => {
28 | const hlDiv = document.createElement("div");
29 | hlDiv.innerHTML = characters;
30 | hlDiv.className = "word-highlight";
31 | hlDiv.style.top = `${rect.top}px`;
32 | hlDiv.style.left = `${rect.left}px`;
33 | hlDiv.style.width = `${rect.width}px`;
34 | hlDiv.style.height = `${rect.height}px`;
35 | container.appendChild(hlDiv);
36 | });
37 | pmc.debug("rendered", highlights, lastKnownWordPosition);
38 | }
39 |
40 | store.subscribe(renderHtmlElements);
41 |
42 | const navigator = new WebSpeechReadAloudNavigator()
43 | const playButton = document.getElementById("play-readaloud")!;
44 | const rateSlowerButton = document.getElementById("rate-slower")!;
45 | const rateFasterButton = document.getElementById("rate-faster")!;
46 | const rateNormalButton = document.getElementById("rate-percentage")!;
47 | const VOICE_URI_KEY = "voiceURI";
48 | let voicesInitialized = false;
49 | const voiceSelect = document.getElementById("voice-select")!;
50 |
51 | async function initVoices() {
52 | if (voicesInitialized) {
53 | return;
54 | }
55 | voicesInitialized = true;
56 | try {
57 | const unfilteredVoices = await navigator.getVoices();
58 | pmc.warn("CHECK VOICES ", unfilteredVoices)
59 | const dutchVoices = (unfilteredVoices).filter(v => v.language.startsWith("nl"))
60 | const voices = dutchVoices.length === 0 ? unfilteredVoices : dutchVoices;
61 | if (voices.length > 0) {
62 | voices.forEach((voice, idx) => {
63 | const opt = document.createElement("option");
64 | opt.setAttribute("value", `${idx}`);
65 | if (voice.voiceURI === localStorage.getItem(VOICE_URI_KEY)) {
66 | opt.setAttribute("selected", "selected");
67 | }
68 | opt.innerHTML = `${voice.name} - ${voice.language}`
69 | voiceSelect.appendChild(opt);
70 | })
71 | const storedVoice = voices.find((v) => v.voiceURI === localStorage.getItem(VOICE_URI_KEY));
72 | if (storedVoice) {
73 | navigator.setVoice(storedVoice);
74 | } else {
75 | navigator.setVoice(voices[0])
76 | }
77 | voiceSelect.addEventListener("change", (ev) => {
78 | changeVoiceTo(voices[parseInt((ev.target as HTMLOptionElement).value)]);
79 | })
80 |
81 | document.getElementById("voices-are-pending")?.remove();
82 | if (voices.length === 1) {
83 | voiceSelect.setAttribute("disabled", "disabled");
84 | } else {
85 | voiceSelect.removeAttribute("disabled");
86 | }
87 | playButton.addEventListener("click", onPlayButtonClicked);
88 | rateFasterButton.addEventListener("click", () => adjustPlaybackRate(navigator.getPlaybackRate() + 0.25));
89 | rateSlowerButton.addEventListener("click", () => adjustPlaybackRate(navigator.getPlaybackRate() - 0.25));
90 | rateNormalButton.addEventListener("click", () => adjustPlaybackRate(1.0));
91 | document.querySelectorAll(".readaloud-control").forEach((el) => { (el as HTMLElement).style.display = "inline-block"; })
92 | } else {
93 | document.getElementById("no-voices-found")!.style.display = "inline";
94 | document.querySelectorAll(".readaloud-control").forEach((el) => { (el as HTMLElement).style.display = "none"; })
95 | }
96 |
97 | } catch (error) {
98 | pmc.error("Error initializing voices:", error);
99 | document.getElementById("no-voices-found")!.style.display = "inline";
100 | document.querySelectorAll(".readaloud-control").forEach((el) => { (el as HTMLElement).style.display = "none"; })
101 | }
102 | }
103 | navigator.on("ready", initVoices);
104 |
105 |
106 | function initializePreferenceButtons(nav : EpubNavigator) {
107 | document.querySelectorAll("[name='paginate']")?.forEach((el) => el.addEventListener("change", (ev) => {
108 | const scrollWasSelected = (ev.target as HTMLInputElement).value === "no";
109 | const editor = nav.preferencesEditor;
110 | if (scrollWasSelected !== editor.scroll.effectiveValue) {
111 | editor.scroll.toggle();
112 | nav.submitPreferences(editor.preferences);
113 | }
114 | }));
115 | }
116 |
117 |
118 | function findClickedOnWordPosition({x, y} : {x: number, y : number}): WordPositionInfo {
119 | const { documentTextNodes } = store.getState().readaloudNavigation;
120 | for (let dtnIdx = 0; dtnIdx < documentTextNodes.length; dtnIdx++) {
121 | const dtn = documentTextNodes[dtnIdx];
122 | for (let rtnIdx = 0; rtnIdx < dtn.rangedTextNodes.length; rtnIdx++) {
123 | const rtn = dtn.rangedTextNodes[rtnIdx];
124 | const wordCharPos = getWordCharPosAtXY(x, y, rtn.textNode);
125 | if (wordCharPos > -1) {
126 | return {rangedTextNodeIndex: rtnIdx, documentTextNodeChunkIndex: dtnIdx, wordCharPos: wordCharPos}
127 | }
128 | }
129 | }
130 | return {rangedTextNodeIndex: -1, documentTextNodeChunkIndex: -1, wordCharPos: -1};
131 | }
132 |
133 |
134 | function jumpToWord({ rangedTextNodeIndex, documentTextNodeChunkIndex, wordCharPos} : WordPositionInfo, shouldPause = false) {
135 | pmc.debug(`jumping to word at: dtn=${documentTextNodeChunkIndex}, rtn=${rangedTextNodeIndex}, wrd=${wordCharPos}`);
136 | if (documentTextNodeChunkIndex < 0) {
137 | return
138 | } else if (rangedTextNodeIndex < 0 || wordCharPos < 0) {
139 | navigator.jumpTo(documentTextNodeChunkIndex);
140 | return
141 | }
142 | const { documentTextNodes } = store.getState().readaloudNavigation;
143 | const dtn = documentTextNodes[documentTextNodeChunkIndex];
144 | const rtn = dtn.rangedTextNodes[rangedTextNodeIndex];
145 | const utChIdx = rtn.parentStartCharIndex + wordCharPos
146 | const utteranceStrAfter = dtn.utteranceStr.substring(utChIdx, dtn.utteranceStr.length - 1);
147 |
148 | store.dispatch(setLastKnownWordPosition({documentTextNodeChunkIndex: documentTextNodeChunkIndex, rangedTextNodeIndex: rangedTextNodeIndex, wordCharPos: wordCharPos}));
149 |
150 | navigator.stop()
151 | navigator.loadContent(documentTextNodes.map((dtn, idx) => ({
152 | id: `${idx}`,
153 | text: idx === documentTextNodeChunkIndex ? " ".repeat(utChIdx) + utteranceStrAfter : dtn.utteranceStr
154 | })));
155 | if (!shouldPause) {
156 | navigator.jumpTo(documentTextNodeChunkIndex);
157 | }
158 | }
159 |
160 |
161 | function onPublicationClicked({x, y} : {x: number, y : number}) {
162 | pmc.debug(`Frame clicked at ${x}/${y}`);
163 | reloadDocumentTextNodes();
164 | const result = findClickedOnWordPosition({x, y});
165 | jumpToWord(result);
166 | }
167 |
168 |
169 | function reloadDocumentTextNodes() {
170 | const { documentTextNodes } = store.getState().readaloudNavigation
171 | navigator.loadContent([]);
172 | navigator.loadContent(documentTextNodes.map((dtn, idx) => ({
173 | id: `${idx}`,
174 | text: dtn.utteranceStr
175 | })));
176 | }
177 |
178 |
179 | function changeVoiceTo(voice: ReadiumSpeechVoice) {
180 | const shouldResume = navigator.getState() === "playing";
181 | navigator.stop();
182 | navigator.setVoice(voice);
183 | localStorage.setItem(VOICE_URI_KEY, voice.voiceURI)
184 | if (shouldResume) {
185 | const { lastKnownWordPosition, selection } = store.getState().readaloudNavigation
186 | if (selection) {
187 | navigator.play();
188 | } else {
189 | jumpToWord(lastKnownWordPosition);
190 | }
191 | }
192 | }
193 |
194 |
195 | function adjustPlaybackRate(newRate : number) {
196 | if (newRate > 0.0 && newRate <= 2.0) {
197 | navigator.setRate(newRate);
198 | rateNormalButton.innerHTML = `${Math.floor(navigator.getPlaybackRate() * 100)}%`;
199 | const shouldResume = navigator.getState() === "playing";
200 | navigator.stop();
201 | if (shouldResume) {
202 | const { lastKnownWordPosition, selection } = store.getState().readaloudNavigation
203 | if (selection) {
204 | navigator.play();
205 | } else {
206 | jumpToWord(lastKnownWordPosition);
207 | }
208 | }
209 | }
210 | }
211 |
212 | function onPlayButtonClicked() {
213 | const { lastKnownWordPosition, selection } = store.getState().readaloudNavigation
214 | pmc.debug(`Play button clicked with navigator state: ${navigator.getState()}`);
215 | if (navigator.getState() === "playing") {
216 | if (isAndroid) {
217 | navigator.stop();
218 | } else {
219 | navigator.pause();
220 | }
221 | playButton.querySelector("img")?.setAttribute("src", playIcon)
222 | } else if (navigator.getState() === "paused") {
223 | navigator.play()
224 | } else if (lastKnownWordPosition.documentTextNodeChunkIndex > -1) {
225 | if (selection) {
226 | navigator.play();
227 | } else {
228 | jumpToWord(lastKnownWordPosition);
229 | }
230 | }
231 | }
232 |
233 |
234 | function handleWebSpeechNavigatorEvent({ type, detail } : ReadiumSpeechPlaybackEvent) {
235 | pmc.debug(`WebSpeechNavigatorEvent state: ${navigator.getState()}`, `Event type: ${type}`, "details:", detail)
236 | switch (navigator.getState()) {
237 | case "playing":
238 | playButton.removeAttribute("disabled");
239 | playButton.querySelector("img")?.setAttribute("src", pauseIcon)
240 | break;
241 | case "loading":
242 | playButton.setAttribute("disabled", "disabled");
243 | playButton.querySelector("img")?.setAttribute("src", playIcon)
244 | store.dispatch(setHighlights([]))
245 | break;
246 | case "ready":
247 | case "idle":
248 | playButton.removeAttribute("disabled");
249 | playButton.querySelector("img")?.setAttribute("src", playIcon)
250 | break
251 | case "paused":
252 | default:
253 | playButton.querySelector("img")?.setAttribute("src", playIcon)
254 | }
255 |
256 | if (type === "end") {
257 | store.dispatch(setHighlights([]))
258 | }
259 |
260 | if (type === "boundary" && navigator.getState() === "playing") {
261 | const { documentTextNodes } = store.getState().readaloudNavigation
262 | const { charIndex, charLength, name } = detail;
263 | if (name !== "word") { return; }
264 | const utIdx = parseInt(navigator.getCurrentContent()!.id!);
265 | let firstTextNodeIndex = -1, lastTextNodeIndex = -1;
266 | for (let idx = 0; idx < (documentTextNodes[utIdx]?.rangedTextNodes || []).length; idx++) {
267 | const rtn = documentTextNodes[utIdx].rangedTextNodes[idx];
268 | if (rtn.parentStartCharIndex <= charIndex) {
269 | firstTextNodeIndex = idx;
270 | lastTextNodeIndex = idx
271 | }
272 | if (firstTextNodeIndex > -1 && rtn.parentStartCharIndex + rtn.textNode.textContent!.length <= charIndex + charLength) {
273 | lastTextNodeIndex = idx
274 | }
275 | }
276 | if (firstTextNodeIndex > -1) {
277 | let newWordRects : ReadAloudHighlight[] = []
278 | for (let rtnIdx = firstTextNodeIndex; rtnIdx <= lastTextNodeIndex; rtnIdx++) {
279 | const rtn = documentTextNodes[utIdx].rangedTextNodes[rtnIdx];
280 | const chBegin = charIndex - rtn.parentStartCharIndex;
281 | const chEnd = charIndex - rtn.parentStartCharIndex + charLength;
282 | const rangeBegin = chBegin < 0 ? 0 : chBegin > (rtn.textNode.textContent || "").length ? (rtn.textNode.textContent || "").length : chBegin;
283 | const rangeEnd = chEnd > (rtn.textNode.textContent || "").length ? (rtn.textNode.textContent || "").length : chEnd
284 | const range = new Range()
285 | range.setStart(rtn.textNode, rangeBegin);
286 | range.setEnd(rtn.textNode, rangeEnd);
287 | for (let i = 0; i < range.getClientRects().length; i++) {
288 | const domRect = range.getClientRects().item(i)!
289 | newWordRects.push({
290 | characters: range.cloneContents().textContent,
291 | rect: domRect
292 | });
293 | }
294 | }
295 | store.dispatch(setHighlights(newWordRects))
296 | store.dispatch(setLastKnownWordPosition({
297 | wordCharPos: (charIndex + charLength) - documentTextNodes[utIdx].rangedTextNodes[firstTextNodeIndex].parentStartCharIndex,
298 | rangedTextNodeIndex: firstTextNodeIndex,
299 | documentTextNodeChunkIndex: utIdx
300 | }));
301 | }
302 | }
303 | }
304 |
305 |
306 |
307 | navigator.on("start",handleWebSpeechNavigatorEvent);
308 | navigator.on("end", handleWebSpeechNavigatorEvent);
309 | navigator.on("pause", handleWebSpeechNavigatorEvent);
310 | navigator.on("resume", handleWebSpeechNavigatorEvent);
311 | navigator.on("ready", handleWebSpeechNavigatorEvent);
312 | navigator.on("boundary", handleWebSpeechNavigatorEvent);
313 | navigator.on("mark", handleWebSpeechNavigatorEvent);
314 | navigator.on("voiceschanged", handleWebSpeechNavigatorEvent);
315 | navigator.on("stop", handleWebSpeechNavigatorEvent);
316 |
317 | function handleIframeClick(e : PointerEvent) {
318 | onPublicationClicked({x: e.x, y: e.y + container.clientTop});
319 | }
320 |
321 | function handleIframeRelease(e : MouseEvent|TouchEvent) {
322 | const selectedText = e.view?.getSelection()?.toString() ?? "";
323 | if (selectedText.length === 0) {
324 | store.dispatch(setSelection(undefined));
325 | } else {
326 | store.dispatch(setSelection(selectedText));
327 | }
328 | }
329 |
330 |
331 | async function init(bookId: string) {
332 | const publicationURL = `${import.meta.env.VITE_MANIFEST_SRC}/${bookId}/manifest.json`;
333 | const manifestLink = new Link({ href: "manifest.json" });
334 | const fetcher: Fetcher = new HttpFetcher(undefined, publicationURL);
335 | const fetched = fetcher.get(manifestLink);
336 | const selfLink = (await fetched.link()).toURL(publicationURL)!;
337 |
338 | await fetched.readAsJSON()
339 | .then(async (response: unknown) => {
340 | const manifest = Manifest.deserialize(response as string)!;
341 | manifest.setSelfLink(selfLink);
342 | const publication = new Publication({ manifest: manifest, fetcher: fetcher });
343 |
344 | const listeners : EpubNavigatorListeners = {
345 | frameLoaded: function (wnd: Window): void {
346 | pmc.debug("--frame loaded---", wnd);
347 |
348 | },
349 | positionChanged: function (locator: Locator): void {
350 | store.dispatch(setHighlights([]))
351 | console.log(publication.readingOrder.items.length)
352 | pmc.info("positionChanged locator=", locator)
353 | const shouldResume = navigator.getState() === "playing";
354 | navigator.stop();
355 | const visibleFrames = [...document.querySelectorAll("iframe")].filter((fr) => fr.style.visibility !== "hidden");
356 | if (visibleFrames.length > 0) {
357 | store.dispatch(setPublicationIsLoading(false));
358 | const fr = visibleFrames[0];
359 | fr.contentWindow?.removeEventListener("click", handleIframeClick);
360 | fr.contentWindow?.addEventListener("click", handleIframeClick)
361 | fr.contentWindow?.removeEventListener("mouseup", handleIframeRelease)
362 | fr.contentWindow?.addEventListener("mouseup", handleIframeRelease)
363 | fr.contentWindow?.removeEventListener("touchend", handleIframeRelease)
364 | fr.contentWindow?.addEventListener("touchend", handleIframeRelease)
365 |
366 | const navWnd = (fr as HTMLIFrameElement).contentWindow;
367 |
368 | const newDocumentTextNodes = gatherAndPrepareTextNodes(navWnd!);
369 | store.dispatch(setDocumentTextNodes(newDocumentTextNodes));
370 | reloadDocumentTextNodes()
371 | const utteranceIndices = newDocumentTextNodes.map((dtn, idx) => {
372 | if (dtn.rangedTextNodes.find((rt) => isTextNodeVisible(navWnd!, rt.textNode))) {
373 | return idx;
374 | }
375 | return -1;
376 | }).filter((idx) => idx > -1);
377 |
378 | if (utteranceIndices.length > 0) {
379 | const rtnIdx = newDocumentTextNodes[utteranceIndices[0]].rangedTextNodes.findIndex((rt) => isTextNodeVisible(navWnd!, rt.textNode));
380 | const wordCharPos = getFirstVisibleWordCharPos(navWnd!, newDocumentTextNodes[utteranceIndices[0]].rangedTextNodes[rtnIdx].textNode);
381 | jumpToWord({
382 | documentTextNodeChunkIndex: utteranceIndices[0],
383 | rangedTextNodeIndex: rtnIdx,
384 | wordCharPos: wordCharPos
385 | }, !shouldResume)
386 | } else {
387 | store.dispatch(setLastKnownWordPosition({documentTextNodeChunkIndex: 0, wordCharPos: 0, rangedTextNodeIndex: 0}));
388 | }
389 | } else {
390 | store.dispatch(setPublicationIsLoading(true));
391 | }
392 |
393 | if (nav.preferencesEditor.scroll.effectiveValue) {
394 | if (nav.canGoForward && nav.isScrollEnd) {
395 | document.getElementById("next-page")!.style.visibility = "visible";
396 | } else {
397 | document.getElementById("next-page")!.style.visibility = "hidden";
398 | }
399 | if (nav.canGoBackward && nav.isScrollStart) {
400 | document.getElementById("previous-page")!.style.visibility = "visible";
401 | } else {
402 | document.getElementById("previous-page")!.style.visibility = "hidden";
403 | }
404 | } else {
405 | if (nav.canGoForward) {
406 | document.getElementById("next-page")!.style.visibility = "visible";
407 | } else {
408 | document.getElementById("next-page")!.style.visibility = "hidden";
409 | }
410 | if (nav.canGoBackward) {
411 | document.getElementById("previous-page")!.style.visibility = "visible";
412 | } else {
413 | document.getElementById("previous-page")!.style.visibility = "hidden";
414 | }
415 | }
416 | },
417 | tap: function (e: FrameClickEvent): boolean {
418 | pmc.debug("tap e=", e)
419 | return true;
420 | },
421 | click: function (e: FrameClickEvent): boolean {
422 | pmc.debug("click e=", e)
423 | return true;
424 | },
425 | zoom: function (scale: number): void {
426 | pmc.debug("zoom scale=", scale)
427 | },
428 | miscPointer: function (amount: number): void {
429 | pmc.debug("miscPointer amount=", amount)
430 | },
431 | scroll: function (delta: number): void {
432 | pmc.debug("scroll delta=", delta)
433 | },
434 | customEvent: function (key: string, data: unknown): void {
435 | pmc.debug("customEvent key=", key, "data=", data)
436 | },
437 | handleLocator: function (locator: Locator): boolean {
438 | pmc.info("handleLocator locator=", locator);
439 | return false;
440 | },
441 | textSelected: function (selection: BasicTextSelection): void {
442 | pmc.log("textSelected selection=", selection);
443 | navigator.stop();
444 | navigator.loadContent([{id: "selection", text: selection.text}]);
445 | navigator.play();
446 | }
447 | };
448 | const nav = new EpubNavigator(container, publication, listeners);
449 | await nav.load();
450 | document.getElementById("next-page")?.addEventListener("click", () => {
451 | nav.goForward(true, (done) => {
452 | if (!done) {
453 | document.getElementById("next-page")!.style.visibility = 'hidden';
454 | store.dispatch(setPublicationIsLoading(false));
455 | }
456 | });
457 | store.dispatch(setPublicationIsLoading(true));
458 | });
459 | document.getElementById("previous-page")?.addEventListener("click", () => {
460 | nav.goBackward(true, (done) => {
461 | if (!done) {
462 | document.getElementById("previous-page")!.style.visibility = 'hidden';
463 | store.dispatch(setPublicationIsLoading(false));
464 | }
465 | });
466 | store.dispatch(setPublicationIsLoading(true));
467 | })
468 |
469 | initializePreferenceButtons(nav);
470 |
471 | }).catch((error) => {
472 | pmc.error("Error loading manifest", error);
473 | alert(`Failed loading manifest ${selfLink}`);
474 | });
475 |
476 | }
477 |
478 | document.addEventListener("DOMContentLoaded", () => {
479 | const params = new URLSearchParams(location.search.replace("?", ""));
480 | const bookId = `${params.get("boek")}`;
481 | if (bookId) {
482 | init(bookId);
483 | } else {
484 | pmc.error("Er mist een boek ID");
485 | }
486 | });
487 |
488 | window.addEventListener("beforeunload", () => navigator.stop());
489 |
--------------------------------------------------------------------------------
/reader/readium-speech/voices.ts:
--------------------------------------------------------------------------------
1 |
2 | import { novelty, quality, recommended, veryLowQuality, type TGender, type TQuality, type IRecommended, defaultRegion } from "./data.gen";
3 |
4 | // export type TOS = 'Android' | 'ChromeOS' | 'iOS' | 'iPadOS' | 'macOS' | 'Windows';
5 | // export type TBrowser = 'ChromeDesktop' | 'Edge' | 'Firefox' | 'Safari';
6 |
7 | const navigatorLanguages = () => window?.navigator?.languages || [];
8 | const navigatorLang = () => (navigator?.language || "").split("-")[0].toLowerCase();
9 |
10 | export interface ReadiumSpeechVoice {
11 | label: string;
12 | voiceURI: string;
13 | name: string;
14 | __lang?: string | undefined;
15 | language: string;
16 | gender?: TGender | undefined;
17 | age?: string | undefined;
18 | offlineAvailability: boolean;
19 | quality?: TQuality | undefined;
20 | pitchControl: boolean;
21 | recommendedPitch?: number | undefined;
22 | recommendedRate?: number | undefined;
23 | }
24 |
25 | const normalQuality = Object.values(quality).map(({ normal }) => normal);
26 | const highQuality = Object.values(quality).map(({ high }) => high);
27 |
28 | function compareQuality(a?: TQuality, b?: TQuality): number {
29 | const qualityToNumber = (quality: TQuality) => {
30 | switch (quality) {
31 | case "veryLow": {return 0;}
32 | case "low": {return 1;}
33 | case "normal": {return 2;}
34 | case "high": {return 3;}
35 | case "veryHigh": {return 4;}
36 | default: {return -1};
37 | }
38 | }
39 |
40 | return qualityToNumber(b || "low") - qualityToNumber(a || "low");
41 | };
42 |
43 | export async function getSpeechSynthesisVoices(maxTimeout = 10000, interval = 10): Promise {
44 | const a = () => speechSynthesis.getVoices();
45 |
46 | // Step 1: Try to load voices directly (best case scenario)
47 | const voices = a();
48 | if (Array.isArray(voices) && voices.length) return voices;
49 |
50 | return new Promise((resolve, reject) => {
51 | // Calculate iterations from total timeout
52 | let counter = Math.floor(maxTimeout / interval);
53 | // Flag to ensure polling only starts once
54 | let pollingStarted = false;
55 |
56 | // Polling function: Checks for voices periodically until counter expires
57 | const startPolling = () => {
58 | // Prevent multiple starts
59 | if (pollingStarted) return;
60 | pollingStarted = true;
61 | const tick = () => {
62 | // Resolve with empty array if no voices found
63 | if (counter < 1) return resolve([]);
64 | --counter;
65 | const voices = a();
66 | // Resolve if voices loaded
67 | if (Array.isArray(voices) && voices.length) return resolve(voices);
68 | // Continue polling
69 | setTimeout(tick, interval);
70 | };
71 | // Initial start
72 | setTimeout(tick, interval);
73 | };
74 |
75 | // Step 2: Use onvoiceschanged if available (prioritizes event over polling)
76 | if (speechSynthesis.onvoiceschanged) {
77 | speechSynthesis.onvoiceschanged = () => {
78 | const voices = a();
79 | if (Array.isArray(voices) && voices.length) {
80 | // Resolve immediately if voices are available
81 | resolve(voices);
82 | } else {
83 | // Fallback to polling if event fires but no voices
84 | startPolling();
85 | }
86 | };
87 | } else {
88 | // Step 3: No onvoiceschanged support, start polling directly
89 | startPolling();
90 | }
91 |
92 | // Step 4: Overall safety timeout - fail if nothing happens after maxTimeout
93 | setTimeout(() => reject(new Error("No voices available after timeout")), maxTimeout);
94 | });
95 | }
96 |
97 | const _strHash = ({voiceURI, name, language, offlineAvailability}: ReadiumSpeechVoice) => `${voiceURI}_${name}_${language}_${offlineAvailability}`;
98 |
99 | function removeDuplicate(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] {
100 |
101 | const voicesStrMap = [...new Set(voices.map((v) => _strHash(v)))];
102 |
103 | const voicesFiltered = voicesStrMap
104 | .map((s) => voices.find((v) => _strHash(v) === s))
105 | .filter((v) => !!v);
106 |
107 | return voicesFiltered;
108 | }
109 |
110 | export function parseSpeechSynthesisVoices(speechSynthesisVoices: SpeechSynthesisVoice[]): ReadiumSpeechVoice[] {
111 |
112 | const parseAndFormatBCP47 = (lang: string) => {
113 | const speechVoiceLang = lang.replace("_", "-");
114 | if (/\w{2,3}-\w{2,3}/.test(speechVoiceLang)) {
115 | return `${speechVoiceLang.split("-")[0].toLowerCase()}-${speechVoiceLang.split("-")[1].toUpperCase()}`;
116 | }
117 |
118 | // bad formated !?
119 | return lang;
120 | };
121 | return speechSynthesisVoices.map((speechVoice) => ({
122 | label: speechVoice.name,
123 | voiceURI: speechVoice.voiceURI ,
124 | name: speechVoice.name,
125 | __lang: speechVoice.lang,
126 | language: parseAndFormatBCP47(speechVoice.lang) ,
127 | gender: undefined,
128 | age: undefined,
129 | offlineAvailability: speechVoice.localService,
130 | quality: undefined,
131 | pitchControl: true,
132 | recommendedPitch: undefined,
133 | recomendedRate: undefined,
134 | }));
135 | }
136 |
137 | // Note: This does not work as browsers expect an actual SpeechSynthesisVoice
138 | // Here it is just an object with the same-ish properties
139 | export function convertToSpeechSynthesisVoices(voices: ReadiumSpeechVoice[]): SpeechSynthesisVoice[] {
140 | return voices.map((voice) => ({
141 | default: false,
142 | lang: voice.__lang || voice.language,
143 | localService: voice.offlineAvailability,
144 | name: voice.name,
145 | voiceURI: voice.voiceURI,
146 | }));
147 | }
148 |
149 | export function filterOnOfflineAvailability(voices: ReadiumSpeechVoice[], offline = true): ReadiumSpeechVoice[] {
150 | return voices.filter(({offlineAvailability}) => {
151 | return offlineAvailability === offline;
152 | });
153 | }
154 |
155 | export function filterOnGender(voices: ReadiumSpeechVoice[], gender: TGender): ReadiumSpeechVoice[] {
156 | return voices.filter(({gender: voiceGender}) => {
157 | return voiceGender === gender;
158 | })
159 | }
160 |
161 | export function filterOnLanguage(voices: ReadiumSpeechVoice[], language: string | string[] = navigatorLang()): ReadiumSpeechVoice[] {
162 | language = Array.isArray(language) ? language : [language];
163 | language = language.map((l) => extractLangRegionFromBCP47(l)[0]);
164 | return voices.filter(({language: voiceLanguage}) => {
165 | const [lang] = extractLangRegionFromBCP47(voiceLanguage);
166 | return language.includes(lang);
167 | })
168 | }
169 |
170 | export function filterOnQuality(voices: ReadiumSpeechVoice[], quality: TQuality | TQuality[]): ReadiumSpeechVoice[] {
171 | quality = Array.isArray(quality) ? quality : [quality];
172 | return voices.filter(({quality: voiceQuality}) => {
173 | return quality.some((qual) => qual === voiceQuality);
174 | });
175 | }
176 |
177 | export function filterOnNovelty(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] {
178 | return voices.filter(({ name }) => {
179 | return !novelty.includes(name);
180 | });
181 | }
182 |
183 | export function filterOnVeryLowQuality(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] {
184 | return voices.filter(({ name }) => {
185 | return !veryLowQuality.find((v) => name.startsWith(v));
186 | });
187 | }
188 |
189 | function updateVoiceInfo(recommendedVoice: IRecommended, voice: ReadiumSpeechVoice) {
190 | voice.label = recommendedVoice.label;
191 | voice.gender = recommendedVoice.gender;
192 | voice.recommendedPitch = recommendedVoice.recommendedPitch;
193 | voice.recommendedRate = recommendedVoice.recommendedRate;
194 |
195 | return voice;
196 | }
197 | export type TReturnFilterOnRecommended = [voicesRecommended: ReadiumSpeechVoice[], voicesLowerQuality: ReadiumSpeechVoice[]];
198 | export function filterOnRecommended(voices: ReadiumSpeechVoice[], _recommended: IRecommended[] = recommended): TReturnFilterOnRecommended {
199 |
200 | const voicesRecommended: ReadiumSpeechVoice[] = [];
201 | const voicesLowerQuality: ReadiumSpeechVoice[] = [];
202 |
203 | recommendedVoiceLoop:
204 | for (const recommendedVoice of _recommended) {
205 | if (Array.isArray(recommendedVoice.quality) && recommendedVoice.quality.length > 1) {
206 |
207 | const voicesFound = voices.filter(({ name }) => name.startsWith(recommendedVoice.name));
208 | if (voicesFound.length) {
209 |
210 | for (const qualityTested of ["high", "normal"] as TQuality[]) {
211 | for (let i = 0; i < voicesFound.length; i++) {
212 | const voice = voicesFound[i];
213 |
214 | const rxp = /^.*\((.*)\)$/;
215 | if (rxp.test(voice.name)) {
216 | const res = rxp.exec(voice.name);
217 | const maybeQualityString = res ? res[1] || "" : "";
218 | const qualityDataArray = qualityTested === "high" ? highQuality : normalQuality;
219 |
220 | if (recommendedVoice.quality.includes(qualityTested) && qualityDataArray.includes(maybeQualityString)) {
221 | voice.quality = qualityTested;
222 | voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice));
223 |
224 | voicesFound.splice(i, 1);
225 | voicesLowerQuality.push(...(voicesFound.map((v) => {
226 | v.quality = "low"; // Todo need to be more precise for 'normal' quality voices
227 | return updateVoiceInfo(recommendedVoice, v);
228 | })));
229 |
230 | continue recommendedVoiceLoop;
231 | }
232 | }
233 | }
234 | }
235 | const voice = voicesFound[0];
236 | for (let i = 1; i < voicesFound.length; i++) {
237 | voicesLowerQuality.push(voicesFound[i]);
238 | }
239 |
240 | voice.quality = voicesFound.length > 3 ? "veryHigh" : voicesFound.length > 2 ? "high" : "normal";
241 | voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice));
242 |
243 | }
244 | } else if (Array.isArray(recommendedVoice.altNames) && recommendedVoice.altNames.length) {
245 |
246 | const voiceFound = voices.find(({ name }) => name === recommendedVoice.name);
247 | if (voiceFound) {
248 | const voice = voiceFound;
249 |
250 | voice.quality = Array.isArray(recommendedVoice.quality) ? recommendedVoice.quality[0] : undefined;
251 | voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice));
252 |
253 | // voice Name found so altNames array must be filter and push to voicesLowerQuality
254 | const altNamesVoicesFound = voices.filter(({name}) => recommendedVoice.altNames!.includes(name));
255 | // TODO: Typescript bug type assertion doesn't work, need to force the compiler with the Non-null Assertion Operator
256 |
257 | voicesLowerQuality.push(...(altNamesVoicesFound.map((v) => {
258 | v.quality = recommendedVoice.quality[0];
259 | return updateVoiceInfo(recommendedVoice, v);
260 | })));
261 | } else {
262 |
263 | // filter voices on altNames, keep the first and push the remaining to voicesLowerQuality
264 | const altNamesVoicesFound = voices.filter(({name}) => recommendedVoice.altNames!.includes(name));
265 | if (altNamesVoicesFound.length) {
266 |
267 | const voice = altNamesVoicesFound.shift() as ReadiumSpeechVoice;
268 |
269 | voice.quality = Array.isArray(recommendedVoice.quality) ? recommendedVoice.quality[0] : undefined;
270 | voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice));
271 |
272 |
273 | voicesLowerQuality.push(...(altNamesVoicesFound.map((v) => {
274 | v.quality = recommendedVoice.quality[0];
275 | return updateVoiceInfo(recommendedVoice, v);
276 | })));
277 | }
278 | }
279 | } else {
280 |
281 | const voiceFound = voices.find(({ name }) => name === recommendedVoice.name);
282 | if (voiceFound) {
283 |
284 | const voice = voiceFound;
285 |
286 | voice.quality = Array.isArray(recommendedVoice.quality) ? recommendedVoice.quality[0] : undefined;
287 | voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice));
288 |
289 | }
290 | }
291 | }
292 |
293 | return [removeDuplicate(voicesRecommended), removeDuplicate(voicesLowerQuality)];
294 | }
295 |
296 | const extractLangRegionFromBCP47 = (l: string) => [l.split("-")[0].toLowerCase(), l.split("-")[1]?.toUpperCase()];
297 |
298 | export function sortByQuality(voices: ReadiumSpeechVoice[]) {
299 | return voices.sort(({quality: qa}, {quality: qb}) => {
300 | return compareQuality(qa, qb);
301 | });
302 | }
303 |
304 | export function sortByName(voices: ReadiumSpeechVoice[]) {
305 | return voices.sort(({name: na}, {name: nb}) => {
306 | return na.localeCompare(nb);
307 | })
308 | }
309 |
310 | export function sortByGender(voices: ReadiumSpeechVoice[], genderFirst: TGender) {
311 | return voices.sort(({gender: ga}, {gender: gb}) => {
312 | return ga === gb ? 0 : ga === genderFirst ? -1 : gb === genderFirst ? -1 : 1;
313 | })
314 | }
315 |
316 | function orderByPreferredLanguage(preferredLanguage?: string[] | string): string[] {
317 | preferredLanguage = Array.isArray(preferredLanguage) ? preferredLanguage :
318 | preferredLanguage ? [preferredLanguage] : [];
319 |
320 | return [...(new Set([...preferredLanguage, ...navigatorLanguages()]))];
321 | }
322 | function orderByPreferredRegion(preferredLanguage?: string[] | string): string[] {
323 | preferredLanguage = Array.isArray(preferredLanguage) ? preferredLanguage :
324 | preferredLanguage ? [preferredLanguage] : [];
325 |
326 | const regionByDefaultArray = Object.values(defaultRegion);
327 |
328 | return [...(new Set([...preferredLanguage, ...navigatorLanguages(), ...regionByDefaultArray]))];
329 | }
330 |
331 | const getLangFromBCP47Array = (a: string[]) => {
332 | return [...(new Set(a.map((v) => extractLangRegionFromBCP47(v)[0]).filter((v) => !!v)))];
333 | }
334 | const getRegionFromBCP47Array = (a: string[]) => {
335 | return [...(new Set(a.map((v) => (extractLangRegionFromBCP47(v)[1] || "").toUpperCase()).filter((v) => !!v)))];
336 | }
337 |
338 | export function sortByLanguage(voices: ReadiumSpeechVoice[], preferredLanguage: string[] | string = [], localization: string | undefined = navigatorLang()): ReadiumSpeechVoice[] {
339 |
340 | const languages = getLangFromBCP47Array(orderByPreferredLanguage(preferredLanguage));
341 |
342 | const voicesSorted: ReadiumSpeechVoice[] = [];
343 | for (const lang of languages) {
344 | voicesSorted.push(...voices.filter(({language: voiceLanguage}) => lang === extractLangRegionFromBCP47(voiceLanguage)[0]));
345 | }
346 |
347 | let langueName: Intl.DisplayNames | undefined = undefined;
348 | if (localization) {
349 | try {
350 | langueName = new Intl.DisplayNames([localization], { type: "language" });
351 | } catch (e) {
352 | console.error("Intl.DisplayNames throw an exception with ", localization, e);
353 | }
354 | }
355 |
356 | const remainingVoices = voices.filter((v) => !voicesSorted.includes(v));
357 | remainingVoices.sort(({ language: a }, { language: b }) => {
358 |
359 | let nameA = a, nameB = b;
360 | try {
361 | if (langueName) {
362 | nameA = langueName.of(extractLangRegionFromBCP47(a)[0]) || a;
363 | nameB = langueName.of(extractLangRegionFromBCP47(b)[0]) || b;
364 | }
365 | } catch (e) {
366 | // ignore
367 | }
368 | return nameA.localeCompare(nameB);
369 | });
370 |
371 | return [...voicesSorted, ...remainingVoices];
372 | }
373 |
374 | export function sortByRegion(voices: ReadiumSpeechVoice[], preferredRegions: string[] | string = [], localization: string | undefined = navigatorLang()): ReadiumSpeechVoice[] {
375 |
376 | const regions = getRegionFromBCP47Array(orderByPreferredRegion(preferredRegions));
377 |
378 | const voicesSorted: ReadiumSpeechVoice[] = [];
379 | for (const reg of regions) {
380 | voicesSorted.push(...voices.filter(({language: voiceLanguage}) => reg === extractLangRegionFromBCP47(voiceLanguage)[1]));
381 | }
382 |
383 | let regionName: Intl.DisplayNames | undefined = undefined;
384 | if (localization) {
385 | try {
386 | regionName = new Intl.DisplayNames([localization], { type: "region" });
387 | } catch (e) {
388 | console.error("Intl.DisplayNames throw an exception with ", localization, e);
389 | }
390 | }
391 |
392 | const remainingVoices = voices.filter((v) => !voicesSorted.includes(v));
393 | remainingVoices.sort(({ language: a }, { language: b }) => {
394 |
395 | let nameA = a, nameB = b;
396 | try {
397 | if (regionName) {
398 | nameA = regionName.of(extractLangRegionFromBCP47(a)[1]) || a;
399 | nameB = regionName.of(extractLangRegionFromBCP47(b)[1]) || b;
400 | }
401 | } catch (e) {
402 | // ignore
403 | }
404 | return nameA.localeCompare(nameB);
405 | });
406 |
407 | return [...voicesSorted, ...remainingVoices];
408 | }
409 |
410 | export interface ILanguages {
411 | label: string;
412 | code: string;
413 | count: number;
414 | }
415 | export function listLanguages(voices: ReadiumSpeechVoice[], localization: string | undefined = navigatorLang()): ILanguages[] {
416 | let langueName: Intl.DisplayNames | undefined = undefined;
417 | if (localization) {
418 | try {
419 | langueName = new Intl.DisplayNames([localization], { type: "language" });
420 | } catch (e) {
421 | console.error("Intl.DisplayNames throw an exception with ", localization, e);
422 | }
423 | }
424 | return voices.reduce((acc, cv) => {
425 | const [lang] = extractLangRegionFromBCP47(cv.language);
426 | let name = lang;
427 | try {
428 | if (langueName) {
429 | name = langueName.of(lang) || lang;
430 | }
431 | } catch (e) {
432 | console.error("langueName.of throw an error with ", lang, e);
433 | }
434 | const found = acc.find(({code}) => code === lang)
435 | if (found) {
436 | found.count++;
437 | } else {
438 | acc.push({code: lang, count: 1, label: name});
439 | }
440 | return acc;
441 | }, []);
442 | }
443 | export function listRegions(voices: ReadiumSpeechVoice[], localization: string | undefined = navigatorLang()): ILanguages[] {
444 | let regionName: Intl.DisplayNames | undefined = undefined;
445 | if (localization) {
446 | try {
447 | regionName = new Intl.DisplayNames([localization], { type: "region" });
448 | } catch (e) {
449 | console.error("Intl.DisplayNames throw an exception with ", localization, e);
450 | }
451 | }
452 | return voices.reduce((acc, cv) => {
453 | const [,region] = extractLangRegionFromBCP47(cv.language);
454 | let name = region;
455 | try {
456 | if (regionName) {
457 | name = regionName.of(region) || region;
458 | }
459 | } catch (e) {
460 | console.error("regionName.of throw an error with ", region, e);
461 | }
462 | const found = acc.find(({code}) => code === region);
463 | if (found) {
464 | found.count++;
465 | } else {
466 | acc.push({code: region, count: 1, label: name});
467 | }
468 | return acc;
469 | }, []);
470 | }
471 |
472 | export type TGroupVoices = Map;
473 | export function groupByLanguages(voices: ReadiumSpeechVoice[], preferredLanguage: string[] | string = [], localization: string | undefined = navigatorLang()): TGroupVoices {
474 |
475 | const voicesSorted = sortByLanguage(voices, preferredLanguage, localization);
476 |
477 | const languagesStructure = listLanguages(voicesSorted, localization);
478 | const res: TGroupVoices = new Map();
479 | for (const { code, label } of languagesStructure) {
480 | res.set(label, voicesSorted
481 | .filter(({ language: voiceLang }) => {
482 | const [l] = extractLangRegionFromBCP47(voiceLang);
483 | return l === code;
484 | }));
485 | }
486 | return res;
487 | }
488 |
489 | export function groupByRegions(voices: ReadiumSpeechVoice[], preferredRegions: string[] | string = [], localization: string | undefined = navigatorLang()): TGroupVoices {
490 |
491 | const voicesSorted = sortByRegion(voices, preferredRegions, localization);
492 |
493 | const languagesStructure = listRegions(voicesSorted, localization);
494 | const res: TGroupVoices = new Map();
495 | for (const { code, label } of languagesStructure) {
496 | res.set(label, voicesSorted
497 | .filter(({ language: voiceLang }) => {
498 | const [, r] = extractLangRegionFromBCP47(voiceLang);
499 | return r === code;
500 | }));
501 | }
502 | return res;
503 | }
504 |
505 | export function groupByKindOfVoices(allVoices: ReadiumSpeechVoice[]): TGroupVoices {
506 |
507 | const [recommendedVoices, lowQualityVoices] = filterOnRecommended(allVoices);
508 | const remainingVoice = allVoices.filter((v) => !recommendedVoices.includes(v) && !lowQualityVoices.includes(v));
509 | const noveltyFiltered = filterOnNovelty(remainingVoice);
510 | const noveltyVoices = remainingVoice.filter((v) => !noveltyFiltered.includes(v));
511 | const veryLowQualityFiltered = filterOnVeryLowQuality(remainingVoice);
512 | const veryLowQualityVoices = remainingVoice.filter((v) => !veryLowQualityFiltered.includes(v));
513 | const remainingVoiceFiltered = filterOnNovelty(filterOnVeryLowQuality(remainingVoice));
514 |
515 | const res: TGroupVoices = new Map();
516 | res.set("recommendedVoices", recommendedVoices);
517 | res.set("lowerQuality", lowQualityVoices);
518 | res.set("novelty", noveltyVoices);
519 | res.set("veryLowQuality", veryLowQualityVoices);
520 | res.set("remaining", remainingVoiceFiltered);
521 |
522 | return res;
523 | }
524 |
525 | export function getLanguages(voices: ReadiumSpeechVoice[], preferredLanguage: string[] | string = [], localization: string | undefined = navigatorLang()): ILanguages[] {
526 |
527 | const group = groupByLanguages(voices, preferredLanguage, localization);
528 |
529 | return Array.from(group.entries()).map(([label, _voices]) => {
530 | return {label, count: _voices.length, code: extractLangRegionFromBCP47(_voices[0]?.language || "")[0]}
531 | });
532 | }
533 |
534 | /**
535 | * Parse and extract SpeechSynthesisVoices,
536 | * @returns ReadiumSpeechVoice[]
537 | */
538 | export async function getVoices(preferredLanguage?: string[] | string, localization?: string) {
539 |
540 | const speechVoices = await getSpeechSynthesisVoices();
541 | const allVoices = removeDuplicate(parseSpeechSynthesisVoices(speechVoices));
542 | const recommendedTuple = filterOnRecommended(allVoices);
543 | const [recommendedVoices, lowQualityVoices] = recommendedTuple;
544 | const recommendedTupleFlatten = recommendedTuple.flat();
545 | const remainingVoices = allVoices
546 | .map((allVoicesItem) => _strHash(allVoicesItem))
547 | .filter((str) => !recommendedTupleFlatten.find((recommendedVoicesPtr) => _strHash(recommendedVoicesPtr) === str))
548 | .map((str) => allVoices.find((allVoicesPtr) => _strHash(allVoicesPtr) === str))
549 | .filter((v) => !!v);
550 | const remainingVoiceFiltered = filterOnNovelty(filterOnVeryLowQuality(remainingVoices));
551 |
552 |
553 | // console.log("PRE_recommendedVoices_GET_VOICES", recommendedVoices.filter(({label}) => label === "Paulina"), recommendedVoices.length);
554 |
555 | // console.log("PRE_lowQualityVoices_GET_VOICES", lowQualityVoices.filter(({label}) => label === "Paulina"), lowQualityVoices.length);
556 |
557 | // console.log("PRE_remainingVoiceFiltered_GET_VOICES", remainingVoiceFiltered.filter(({label}) => label === "Paulina"), remainingVoiceFiltered.length);
558 |
559 | // console.log("PRE_allVoices_GET_VOICES", allVoices.filter(({label}) => label === "Paulina"), allVoices.length);
560 |
561 | const voices = [recommendedVoices, lowQualityVoices, remainingVoiceFiltered].flat();
562 |
563 | // console.log("MID_GET_VOICES", voices.filter(({label}) => label === "Paulina"), voices.length);
564 |
565 | const voicesSorted = sortByLanguage(sortByQuality(voices), preferredLanguage, localization || navigatorLang());
566 |
567 | // console.log("POST_GET_VOICES", voicesSorted.filter(({ label }) => label === "Paulina"), voicesSorted.length);
568 |
569 | return voicesSorted;
570 | }
--------------------------------------------------------------------------------