├── epubs ├── De Nieuwe Fiets.epub ├── Een Nieuwe Start.epub ├── Lesboek, versie 1.epub └── test document verschillende stijlen.epub ├── reader ├── assets │ └── AGaramondPro-Regular.woff ├── icons │ ├── remove.svg │ ├── arrow_drop_down.svg │ ├── add.svg │ ├── check.svg │ ├── density_large.svg │ ├── arrow_back.svg │ ├── arrow_forward.svg │ ├── chevron_right.svg │ ├── play.svg │ ├── density_medium.svg │ ├── close.svg │ ├── density_small.svg │ ├── pause.svg │ ├── home.svg │ ├── horizontal_rule.svg │ ├── text_decrease.svg │ ├── format_align_left.svg │ ├── format_align_justify.svg │ ├── newsstand.svg │ ├── format_align_right.svg │ ├── fullscreen.svg │ ├── fullscreen_exit.svg │ ├── rate-faster.svg │ ├── rate-slower.svg │ ├── text_increase.svg │ ├── undo.svg │ ├── dialogs.svg │ ├── tune.svg │ ├── dock_to_left.svg │ ├── dock_to_right.svg │ ├── delete.svg │ ├── book.svg │ ├── article.svg │ ├── docs.svg │ ├── accessibility.svg │ ├── search.svg │ ├── contract.svg │ ├── stack.svg │ ├── zoom_out.svg │ ├── more_vert.svg │ ├── zoom_in.svg │ ├── pin_drop.svg │ ├── document_scanner.svg │ ├── toc.svg │ ├── match_case.svg │ ├── format_bold_wght200.svg │ ├── format_bold_wght500.svg │ ├── settings.svg │ └── menu_book.svg ├── css │ ├── highlighting.css │ ├── reset.css │ └── style.css ├── readium-speech │ ├── utterance.ts │ ├── index.ts │ ├── provider.ts │ ├── WebSpeech │ │ ├── webSpeechEngineProvider.ts │ │ ├── TmpNavigator.ts │ │ └── webSpeechEngine.ts │ ├── engine.ts │ ├── utils │ │ ├── patches.ts │ │ └── features.ts │ ├── navigator.ts │ └── voices.ts ├── core │ ├── store.ts │ ├── types.ts │ ├── readaloudNavigationSlice.ts │ └── textNodeHelper.ts ├── listing.ts ├── util │ └── poorMansConsole.ts ├── index.html └── main.ts ├── dist ├── assets │ ├── reader-Bro2NdH4.css │ ├── AGaramondPro-Regular-B8Dtnlpi.woff │ ├── main-fTsoFgoC.js │ ├── style-4nMSlQ8f.js │ └── style-4xmtn6Nt.css ├── index.html └── reader │ └── index.html ├── .gitignore ├── vite.config.js ├── package.json ├── index.html ├── tsconfig.json └── README.md /epubs/De Nieuwe Fiets.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KBNLresearch/lean-reader/master/epubs/De Nieuwe Fiets.epub -------------------------------------------------------------------------------- /epubs/Een Nieuwe Start.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KBNLresearch/lean-reader/master/epubs/Een Nieuwe Start.epub -------------------------------------------------------------------------------- /epubs/Lesboek, versie 1.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KBNLresearch/lean-reader/master/epubs/Lesboek, versie 1.epub -------------------------------------------------------------------------------- /reader/assets/AGaramondPro-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KBNLresearch/lean-reader/master/reader/assets/AGaramondPro-Regular.woff -------------------------------------------------------------------------------- /dist/assets/reader-Bro2NdH4.css: -------------------------------------------------------------------------------- 1 | .word-highlight{border-bottom:4px solid #ff0066;position:fixed;z-index:1000;pointer-events:none;color:transparent} 2 | -------------------------------------------------------------------------------- /dist/assets/AGaramondPro-Regular-B8Dtnlpi.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KBNLresearch/lean-reader/master/dist/assets/AGaramondPro-Regular-B8Dtnlpi.woff -------------------------------------------------------------------------------- /epubs/test document verschillende stijlen.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KBNLresearch/lean-reader/master/epubs/test document verschillende stijlen.epub -------------------------------------------------------------------------------- /reader/icons/remove.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/css/highlighting.css: -------------------------------------------------------------------------------- 1 | .word-highlight { 2 | border-bottom: 4px solid #ff0066; 3 | position: fixed; 4 | z-index: 1000; 5 | pointer-events: none; 6 | color: transparent; 7 | } -------------------------------------------------------------------------------- /reader/icons/arrow_drop_down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/add.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/check.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/density_large.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/arrow_back.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/arrow_forward.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/chevron_right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /reader/icons/density_medium.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/density_small.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /reader/icons/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/horizontal_rule.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/text_decrease.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/format_align_left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/format_align_justify.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/newsstand.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/format_align_right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/fullscreen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/fullscreen_exit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/rate-faster.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /reader/icons/rate-slower.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /reader/icons/text_increase.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/undo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/readium-speech/utterance.ts: -------------------------------------------------------------------------------- 1 | export interface ReadiumSpeechUtterance { 2 | id?: string; // Unique identifier for this content 3 | text: string; // Text or SSML content 4 | ssml?: boolean; // If true, text contains SSML 5 | language?: string; // Language of this content (BCP 47) 6 | } -------------------------------------------------------------------------------- /reader/icons/dialogs.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/tune.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/dock_to_left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/dock_to_right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/readium-speech/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./voices"; 2 | export * from "./engine"; 3 | export * from "./navigator"; 4 | export * from "./provider"; 5 | export * from "./utterance"; 6 | export * from "./WebSpeech/webSpeechEngine"; 7 | export * from "./WebSpeech/webSpeechEngineProvider"; 8 | export * from "./WebSpeech/TmpNavigator"; -------------------------------------------------------------------------------- /reader/icons/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/book.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist-ssr 12 | *.local 13 | 14 | # Editor directories and files 15 | .vscode/* 16 | !.vscode/extensions.json 17 | .idea 18 | .DS_Store 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | *.sh -------------------------------------------------------------------------------- /reader/icons/article.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/docs.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/accessibility.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/contract.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/stack.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/zoom_out.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/more_vert.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/zoom_in.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dist/assets/main-fTsoFgoC.js: -------------------------------------------------------------------------------- 1 | import"./style-4nMSlQ8f.js";document.addEventListener("DOMContentLoaded",async()=>{const l=await(await fetch("https://www.kbresearch.nl/epub/list.json")).json(),s=document.getElementById("boek-entry-list");l.forEach(e=>{const n=document.createElement("li"),t=document.createElement("a");console.log(e),n.appendChild(t),t.setAttribute("href",`reader/?boek=${e.path}`),t.innerHTML=e.filename.replaceAll(".epub",""),s.appendChild(n)})}); 2 | -------------------------------------------------------------------------------- /reader/icons/pin_drop.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/document_scanner.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/toc.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/match_case.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/readium-speech/provider.ts: -------------------------------------------------------------------------------- 1 | import { type ReadiumSpeechPlaybackEngine } from "./engine"; 2 | import { type ReadiumSpeechVoice } from "./voices"; 3 | 4 | export interface ReadiumSpeechEngineProvider { 5 | readonly id: string; 6 | readonly name: string; 7 | 8 | // Voice Management 9 | getVoices(): Promise; 10 | 11 | // Engine Creation 12 | createEngine(voice?: ReadiumSpeechVoice | string): Promise; 13 | 14 | // Lifecycle 15 | destroy(): Promise; 16 | } -------------------------------------------------------------------------------- /reader/core/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import readaloudNavigationReducer from "./readaloudNavigationSlice"; 3 | 4 | export const store = configureStore({ 5 | reducer: { 6 | readaloudNavigation: readaloudNavigationReducer 7 | }, 8 | middleware: (getDefaultMiddleWare) => getDefaultMiddleWare({serializableCheck: false}) 9 | }) 10 | 11 | export type RootState = ReturnType 12 | export type AppDispatch = typeof store.dispatch 13 | export type AppStore = typeof store -------------------------------------------------------------------------------- /reader/core/types.ts: -------------------------------------------------------------------------------- 1 | export type RangedTextNode = { 2 | textNode: Node; 3 | parentStartCharIndex: number; 4 | }; 5 | 6 | export type DocumentTextNodesChunk = { 7 | rangedTextNodes: RangedTextNode[]; 8 | utteranceStr: string; 9 | }; 10 | 11 | export type WordPositionInfo = { 12 | rangedTextNodeIndex : number 13 | documentTextNodeChunkIndex : number 14 | wordCharPos : number 15 | } 16 | 17 | export type ReadAloudHighlight = { 18 | characters : string 19 | rect : { top: number, left: number, width: number, height: number} 20 | } -------------------------------------------------------------------------------- /reader/icons/format_bold_wght200.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/icons/format_bold_wght500.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import { defineConfig } from 'vite' 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)) 6 | 7 | export default defineConfig(({ mode }) => { 8 | return { 9 | base: mode === "production" ? "/lean-reader" : "/", 10 | build: { 11 | rollupOptions: { 12 | input: { 13 | main: resolve(__dirname, 'index.html'), 14 | reader: resolve(__dirname, 'reader/index.html'), 15 | }, 16 | }, 17 | }, 18 | } 19 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lean-reader", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "@material/web": "^2.4.0", 13 | "@readium/css": "^2.0.0-beta.19", 14 | "@readium/navigator": "^2.2.0", 15 | "@readium/navigator-html-injectables": "^2.2.0", 16 | "@readium/shared": "^2.1.1", 17 | "debounce": "^2.2.0", 18 | "string-strip-html": "^13.5.0", 19 | "typescript": "~5.9.3", 20 | "vite": "^7.1.7" 21 | }, 22 | "dependencies": { 23 | "@reduxjs/toolkit": "^2.10.1" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /dist/assets/style-4nMSlQ8f.js: -------------------------------------------------------------------------------- 1 | (function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})(); 2 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | lean-reader 7 | 8 | 9 | 15 |
16 |
    17 | 18 |
19 |
20 | 21 |
22 |

23 |     
24 |   
25 | 
26 | 


--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "compilerOptions": {
 3 |     "target": "ES2022",
 4 |     "useDefineForClassFields": true,
 5 |     "module": "ESNext",
 6 |     "lib": ["ES2022", "DOM", "DOM.Iterable"],
 7 |     "types": ["vite/client"],
 8 |     "skipLibCheck": true,
 9 | 
10 |     /* Bundler mode */
11 |     "moduleResolution": "bundler",
12 |     "allowImportingTsExtensions": true,
13 |     "verbatimModuleSyntax": true,
14 |     "moduleDetection": "force",
15 |     "noEmit": true,
16 | 
17 |     /* Linting */
18 |     "strict": true,
19 |     "noUnusedLocals": true,
20 |     "noUnusedParameters": true,
21 |     "erasableSyntaxOnly": true,
22 |     "noFallthroughCasesInSwitch": true,
23 |     "noUncheckedSideEffectImports": true
24 |   },
25 |   "include": ["reader"]
26 | }
27 | 


--------------------------------------------------------------------------------
/reader/icons/settings.svg:
--------------------------------------------------------------------------------
1 | 


--------------------------------------------------------------------------------
/reader/listing.ts:
--------------------------------------------------------------------------------
 1 | import './css/style.css'
 2 | 
 3 | type BookEntry = {
 4 |     filename : String
 5 |     path : String
 6 | }
 7 | 
 8 | document.addEventListener("DOMContentLoaded", async () => {
 9 |     const result = await fetch(`${import.meta.env.VITE_MANIFEST_SRC}/list.json`);
10 |     const list = await result.json();
11 |     const listing = document.getElementById("boek-entry-list")!;
12 | 
13 |     (list as BookEntry[]).forEach((bookEntry) => {
14 |         const item = document.createElement("li");
15 |         const link = document.createElement("a");
16 |         console.log(bookEntry);
17 |         item.appendChild(link);
18 |         link.setAttribute("href", `reader/?boek=${bookEntry.path}`);
19 |         link.innerHTML = bookEntry.filename.replaceAll(".epub", "");
20 |         listing.appendChild(item);
21 |     })
22 | })


--------------------------------------------------------------------------------
/reader/icons/menu_book.svg:
--------------------------------------------------------------------------------
1 | 


--------------------------------------------------------------------------------
/dist/index.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |   
 4 |     
 5 |     
 6 |     lean-reader
 7 |     
 8 |     
 9 |     
10 |   
11 |   
12 |     
18 |     
19 |
    20 | 21 |
22 |
23 | 24 |
25 |


26 |   
27 | 
28 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | # Acceptatiecriteria
 2 | 
 3 | (Ik wil)
 4 | - [x] Zelf ePub bestanden kunnen toevoegen aan, bekijken in en verwijderen van de reader
 5 | - [x] In de edge browser uit alle Nederlandstalige stemmen kunnen kiezen
 6 | - [x] Kunnen aanklikken waar er wordt begonnen met voorlezen
 7 | - [x] Kunnnen pauzeren en hervatten op woordniveau op een android device (alleen firefox momenteel)
 8 | - [ ] Automatisch doorbladeren wanneer de voorlezer een volgende 'bladzijde' heeft bereikt
 9 | - [ ] Automatisch doorscrollen wanneer de voorlezer dat punt bereikt
10 | - [ ] Een voorlezer die ook de hele zin highlight (*)
11 | - [ ] Een voorlezer die ook de hele paragraaf highlight (*)
12 | - [ ] Een voorlezer die gebieden uitgrijst / blokkeert die momenteel niet worden voorgelezen (*)
13 |   - op 'zinsniveau'?
14 |   - op 'paragraafsniveau'?
15 |   - op 'woordniveau'?
16 | - [ ] Op basis van afstemming met developer aanpassingen kunnen doen aan de weergave (toegankelijkheid)
17 | 
18 | 
19 | (*) met door mij te bepalen kleur+vorm -> in eerste instantie via feedback op de software
20 | 


--------------------------------------------------------------------------------
/reader/readium-speech/WebSpeech/webSpeechEngineProvider.ts:
--------------------------------------------------------------------------------
 1 | import type { ReadiumSpeechEngineProvider } from "../provider";
 2 | import type { ReadiumSpeechPlaybackEngine } from "../engine";
 3 | import type { ReadiumSpeechVoice } from "../voices";
 4 | import { WebSpeechEngine } from "./webSpeechEngine";
 5 | 
 6 | export class WebSpeechEngineProvider implements ReadiumSpeechEngineProvider {
 7 |   readonly id: string = "webspeech";
 8 |   readonly name: string = "Web Speech API";
 9 | 
10 |   private engine: WebSpeechEngine | null = null;
11 | 
12 |   async getVoices(): Promise {
13 |     if (!this.engine) {
14 |       throw new Error("No engine available. Create an engine first.");
15 |     }
16 |     return this.engine.getAvailableVoices();
17 |   }
18 | 
19 |   async createEngine(voice?: ReadiumSpeechVoice | string): Promise {
20 |     const engine = new WebSpeechEngine();
21 |     await engine.initialize();
22 |     if (voice) {
23 |       engine.setVoice(voice);
24 |     }
25 |     return engine;
26 |   }
27 | 
28 |   async destroy(): Promise {
29 |     if (this.engine) {
30 |       await this.engine.destroy();
31 |       this.engine = null;
32 |     }
33 |   }
34 | }
35 | 


--------------------------------------------------------------------------------
/reader/readium-speech/engine.ts:
--------------------------------------------------------------------------------
 1 | import { type ReadiumSpeechPlaybackEvent, type ReadiumSpeechPlaybackState } from "./navigator";
 2 | import { type ReadiumSpeechUtterance } from "./utterance";
 3 | import { type ReadiumSpeechVoice } from "./voices";
 4 | 
 5 | export interface ReadiumSpeechPlaybackEngine {
 6 |   // Queue Management
 7 |   loadUtterances(contents: ReadiumSpeechUtterance[]): void;
 8 |   
 9 |   // Voice Configuration
10 |   setVoice(voice: ReadiumSpeechVoice | string): void;
11 |   getCurrentVoice(): ReadiumSpeechVoice | null;
12 |   getAvailableVoices(): Promise;
13 |   // Playback Control
14 |   speak(utteranceIndex?: number): void;
15 |   pause(): void;
16 |   resume(): void;
17 |   stop(): void;
18 |   
19 |   // Playback Parameters
20 |   setRate(rate: number): void;
21 |   setPitch(pitch: number): void;
22 |   setVolume(volume: number): void;
23 |   
24 |   // State
25 |   getState(): ReadiumSpeechPlaybackState;
26 |   getCurrentUtteranceIndex(): number;
27 |   getUtteranceCount(): number;
28 |   getRate(): number;
29 |   
30 |   // Events
31 |   on(
32 |     event: ReadiumSpeechPlaybackEvent["type"],
33 |     callback: (event: ReadiumSpeechPlaybackEvent) => void
34 |   ): () => void;
35 |   
36 |   // Cleanup
37 |   destroy(): Promise;
38 | }


--------------------------------------------------------------------------------
/reader/css/reset.css:
--------------------------------------------------------------------------------
 1 | /* http://meyerweb.com/eric/tools/css/reset/ 
 2 |    v2.0 | 20110126
 3 |    License: none (public domain)
 4 | */
 5 | 
 6 | html, body, div, span, applet, object, iframe,
 7 | h1, h2, h3, h4, h5, h6, p, blockquote,
 8 | a, abbr, acronym, address, big, cite, code,
 9 | del, dfn, em, img, ins, kbd, q, s, samp,
10 | small, strike, strong, sub, sup, tt, var,
11 | b, u, i, center,
12 | dl, dt, dd, ol, ul, li,
13 | fieldset, form, label, legend,
14 | table, caption, tbody, tfoot, thead, tr, th, td,
15 | article, aside, canvas, details, embed, 
16 | figure, figcaption, footer, header, hgroup, 
17 | menu, nav, output, ruby, section, summary,
18 | time, mark, audio, video {
19 | 	margin: 0;
20 | 	padding: 0;
21 | 	border: 0;
22 | 	font-size: 100%;
23 | 	font: inherit;
24 | 	vertical-align: baseline;
25 | }
26 | /* HTML5 display-role reset for older browsers */
27 | article, aside, details, figcaption, figure, 
28 | footer, header, hgroup, menu, nav, section {
29 | 	display: block;
30 | }
31 | body {
32 | 	line-height: 1;
33 | }
34 | ol, ul {
35 | 	list-style: none;
36 | }
37 | blockquote, q {
38 | 	quotes: none;
39 | }
40 | blockquote:before, blockquote:after,
41 | q:before, q:after {
42 | 	content: '';
43 | 	content: none;
44 | }
45 | table {
46 | 	border-collapse: collapse;
47 | 	border-spacing: 0;
48 | }


--------------------------------------------------------------------------------
/reader/readium-speech/utils/patches.ts:
--------------------------------------------------------------------------------
 1 | // This is heavily inspired by Easy Speech
 2 | 
 3 | export interface WebSpeechPlatformPatches {
 4 |   isAndroid: boolean;
 5 |   isFirefox: boolean;
 6 |   isSafari: boolean;
 7 |   isKaiOS: boolean;
 8 | }
 9 | 
10 | /**
11 |  * Detects platform features
12 |  * @returns {WebSpeechPlatformPatches} Object containing platform features
13 |  */
14 | export const detectPlatformFeatures = (): WebSpeechPlatformPatches => {
15 |   const getUA = () => (typeof window !== "undefined" && (window.navigator || {}).userAgent) || "";
16 |   const userAgent = getUA();
17 | 
18 |   const isAndroid = () => /android/i.test(userAgent);
19 |   const isKaiOS = () => /kaios/i.test(userAgent);
20 |   const isFirefox = () => {
21 |     // InstallTrigger will soon be deprecated but still works
22 |     if (typeof (window as any).InstallTrigger !== "undefined") {
23 |       return true;
24 |     }
25 |     return /firefox/i.test(userAgent);
26 |   };
27 |   const isSafari = () => {
28 |     // Check for Safari-specific features
29 |     return typeof (window as any).GestureEvent !== "undefined" ||
30 |            /safari/i.test(userAgent);
31 |   };
32 | 
33 |   return {
34 |     isAndroid: isAndroid(),
35 |     isFirefox: isFirefox() || isKaiOS(),
36 |     isSafari: isSafari(),
37 |     isKaiOS: isKaiOS()
38 |   };
39 | };


--------------------------------------------------------------------------------
/reader/core/readaloudNavigationSlice.ts:
--------------------------------------------------------------------------------
 1 | import { createSlice } from "@reduxjs/toolkit";
 2 | import type { DocumentTextNodesChunk, WordPositionInfo, ReadAloudHighlight } from "./types";
 3 | 
 4 | type ReadaloudNavigationState = {
 5 |     documentTextNodes : DocumentTextNodesChunk[]
 6 |     lastKnownWordPosition : WordPositionInfo
 7 |     publicationIsLoading : boolean
 8 |     highlights : ReadAloudHighlight[]
 9 |     selection? : string
10 | }
11 | 
12 | const initialState : ReadaloudNavigationState = {
13 |     publicationIsLoading: true,
14 |     documentTextNodes: [],
15 |     lastKnownWordPosition: {
16 |         rangedTextNodeIndex: -1,
17 |         documentTextNodeChunkIndex: -1,
18 |         wordCharPos: -1
19 |     },
20 |     highlights: []
21 | }
22 | 
23 | export const readaloudNavigationSlice = createSlice({
24 |     name: "readaloudNavigation",
25 |     initialState,
26 |     reducers: {
27 |         setLastKnownWordPosition(state, { payload }) {
28 |             state.lastKnownWordPosition = payload;
29 |         },
30 |         setDocumentTextNodes(state, { payload }) {
31 |             state.documentTextNodes = payload;
32 |         },
33 |         setPublicationIsLoading(state, { payload }) {
34 |             state.publicationIsLoading = payload;
35 |         },
36 |         setHighlights(state, { payload }) {
37 |             state.highlights = payload
38 |         },
39 |         setSelection(state, { payload }) {
40 |             state.selection = payload
41 |             state.highlights = []
42 |         }
43 |     }
44 | });
45 | 
46 | export const {
47 |     setLastKnownWordPosition,
48 |     setDocumentTextNodes,
49 |     setPublicationIsLoading,
50 |     setHighlights,
51 |     setSelection
52 | } = readaloudNavigationSlice.actions;
53 | export default readaloudNavigationSlice.reducer;


--------------------------------------------------------------------------------
/reader/readium-speech/navigator.ts:
--------------------------------------------------------------------------------
 1 | import type { ReadiumSpeechVoice } from "./voices";
 2 | import type { ReadiumSpeechUtterance } from "./utterance";
 3 | 
 4 | export type ReadiumSpeechPlaybackState = "playing" | "paused" | "idle" | "loading" | "ready";
 5 | 
 6 | export interface ReadiumSpeechPlaybackEvent {
 7 |   type: 
 8 |     | "start"     // Playback started
 9 |     | "pause"     // Playback paused
10 |     | "resume"    // Playback resumed
11 |     | "end"       // Playback ended naturally
12 |     | "stop"      // Playback stopped manually
13 |     | "error"     // An error occurred
14 |     | "boundary"  // Reached a word/sentence boundary
15 |     | "mark"      // Reached a named mark in SSML
16 |     | "idle"      // No content loaded
17 |     | "loading"   // Loading content
18 |     | "ready"     // Ready to play
19 |     | "voiceschanged"; // Available voices changed
20 |   detail?: any;  // Event-specific data
21 | }
22 | 
23 | // This should evolve dramatically as WebSpeech is kind of an outlier
24 | // And it will be impacted by adapters from external services
25 | export interface ReadiumSpeechNavigator {
26 |   // Voice Management
27 |   getVoices(): Promise;
28 |   setVoice(voice: ReadiumSpeechVoice | string): Promise;
29 |   getCurrentVoice(): ReadiumSpeechVoice | null;
30 |   
31 |   // Content Management
32 |   loadContent(content: ReadiumSpeechUtterance | ReadiumSpeechUtterance[]): void;
33 |   getCurrentContent(): ReadiumSpeechUtterance | null;
34 |   getContentQueue(): ReadiumSpeechUtterance[];
35 |   
36 |   // Playback Control
37 |   play(): Promise;
38 |   pause(): void;
39 |   stop(): void;
40 |   togglePlayPause(): Promise;
41 |   
42 |   // Navigation
43 |   next(): Promise; 
44 |   previous(): Promise;
45 |   jumpTo(utteranceIndex: number): void;
46 |   
47 |   // Playback Parameters
48 |   setRate(rate: number): void;
49 |   setPitch(pitch: number): void;
50 |   setVolume(volume: number): void;
51 |   
52 |   // State
53 |   getState(): ReadiumSpeechPlaybackState;
54 |   
55 |   // Events
56 |   on(
57 |     event: ReadiumSpeechPlaybackEvent["type"] | "contentchange",
58 |     listener: (event: ReadiumSpeechPlaybackEvent) => void
59 |   ): () => void;
60 |   
61 |   // Lifecycle
62 |   destroy(): Promise;
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 |
44 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
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 |
48 | 51 | 52 | 53 | 54 | 55 | 56 | 57 |
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 | } --------------------------------------------------------------------------------