├── .gitignore ├── tsconfig.json ├── script ├── fixup.sh └── extract-json.mjs ├── ava.config.js ├── tsconfig-test.json ├── src ├── utterance.ts ├── index.ts ├── provider.ts ├── WebSpeech │ ├── webSpeechEngineProvider.ts │ ├── TmpNavigator.ts │ └── webSpeechEngine.ts ├── engine.ts ├── utils │ ├── patches.ts │ └── features.ts ├── navigator.ts └── voices.ts ├── tsconfig-types.json ├── demo ├── index.html ├── navigator │ ├── index.html │ └── navigator-demo-script.js ├── styles.css ├── script.js └── lit-html_3-2-0_esm.js ├── .github └── workflows │ ├── node.yml │ ├── build.yml │ └── gh-pages.yml ├── vite.config.js ├── package.json ├── LICENSE ├── _layouts └── default.html ├── README.md ├── tsconfig-base.json └── test └── voices.test.ts /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules/ 3 | script/web-speech-recommended-voices/ 4 | .DS_Store 5 | build/ 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig-base.json", 3 | "include": ["src/**/*"], 4 | "exclude": ["node_modules", "dist", "test", "demo", "build"] 5 | } 6 | -------------------------------------------------------------------------------- /script/fixup.sh: -------------------------------------------------------------------------------- 1 | cat >build/cjs/package.json <build/mjs/package.json < 2 | 3 | 4 | 5 | 6 | 7 | Readium Speech Demo 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /demo/navigator/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Readium Speech Navigator Demo 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/provider.ts: -------------------------------------------------------------------------------- 1 | import { ReadiumSpeechPlaybackEngine } from "./engine"; 2 | import { 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 | } -------------------------------------------------------------------------------- /.github/workflows/node.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Use Node.js 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: '22' 17 | - name: Install dependencies 18 | run: npm ci --foreground-scripts 19 | - name: Run Build 20 | run: npm run build 21 | - name: Run tests 22 | run: npm test 23 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | import dts from "vite-plugin-dts" 3 | 4 | export default defineConfig({ 5 | build: { 6 | outDir: "build", 7 | lib: { 8 | entry: "src/index.ts", 9 | name: "ReadiumSpeech", 10 | fileName: "index", 11 | formats: ["es"] 12 | }, 13 | rollupOptions: { 14 | external: [], 15 | output: { 16 | format: "es" 17 | } 18 | } 19 | }, 20 | define: { 21 | global: 'globalThis', 22 | 'process.env': {}, 23 | 'process.version': '""', 24 | 'process.platform': '"browser"', 25 | 'process.browser': true, 26 | }, 27 | plugins: [ 28 | dts({ 29 | outDir: "build", 30 | insertTypesEntry: true, 31 | include: ["src/**/*"] 32 | }) 33 | ] 34 | }) 35 | -------------------------------------------------------------------------------- /src/WebSpeech/webSpeechEngineProvider.ts: -------------------------------------------------------------------------------- 1 | import { ReadiumSpeechEngineProvider } from "../provider"; 2 | import { ReadiumSpeechPlaybackEngine } from "../engine"; 3 | import { 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 | -------------------------------------------------------------------------------- /demo/styles.css: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | margin: 0; 4 | } 5 | 6 | html { 7 | height: 100%; 8 | } 9 | 10 | body { 11 | height: 90%; 12 | max-width: 800px; 13 | margin: 0 auto; 14 | } 15 | 16 | h1, 17 | p { 18 | font-family: sans-serif; 19 | text-align: center; 20 | padding: 10px; 21 | } 22 | 23 | .txt, 24 | select, 25 | form > div { 26 | display: block; 27 | margin: 0 auto; 28 | font-family: sans-serif; 29 | font-size: 16px; 30 | padding: 5px; 31 | } 32 | 33 | .txt { 34 | width: 82%; 35 | } 36 | 37 | select { 38 | width: 83%; 39 | } 40 | 41 | form > div { 42 | width: 81%; 43 | } 44 | 45 | .txt, 46 | form > div { 47 | margin-bottom: 10px; 48 | overflow: auto; 49 | } 50 | 51 | .clearfix { 52 | clear: both; 53 | } 54 | 55 | .controls { 56 | text-align: center; 57 | margin-top: 50px; 58 | } 59 | 60 | .controls > * { 61 | margin-bottom: 10px; 62 | text-align: center; 63 | } 64 | .controls fieldset { 65 | display: inline-block; 66 | text-align: left; 67 | } 68 | 69 | .controls button { 70 | padding: 10px; 71 | width: 100px; 72 | } 73 | 74 | .checkbox { 75 | text-align: center; 76 | } 77 | 78 | .debug { 79 | margin-top: 100px; 80 | text-align: center; 81 | } -------------------------------------------------------------------------------- /src/engine.ts: -------------------------------------------------------------------------------- 1 | import { ReadiumSpeechPlaybackEvent, ReadiumSpeechPlaybackState } from "./navigator"; 2 | import { ReadiumSpeechUtterance } from "./utterance"; 3 | import { 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 | 14 | // Playback Control 15 | speak(utteranceIndex?: number): void; 16 | pause(): void; 17 | resume(): void; 18 | stop(): void; 19 | 20 | // Playback Parameters 21 | setRate(rate: number): void; 22 | setPitch(pitch: number): void; 23 | setVolume(volume: number): void; 24 | 25 | // State 26 | getState(): ReadiumSpeechPlaybackState; 27 | getCurrentUtteranceIndex(): number; 28 | getUtteranceCount(): number; 29 | 30 | // Events 31 | on( 32 | event: ReadiumSpeechPlaybackEvent["type"], 33 | callback: (event: ReadiumSpeechPlaybackEvent) => void 34 | ): () => void; 35 | 36 | // Cleanup 37 | destroy(): Promise; 38 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "readium-speech", 3 | "version": "2.0.0-alpha.1", 4 | "description": "Readium Speech is a TypeScript library for implementing a read aloud feature with Web technologies. It follows [best practices](https://github.com/HadrienGardeur/read-aloud-best-practices) gathered through interviews with members of the digital publishing industry.", 5 | "main": "build/index.js", 6 | "module": "build/index.js", 7 | "scripts": { 8 | "test": "ava test/**/*.test.ts", 9 | "clean": "rimraf ./build", 10 | "types": "tsc -p tsconfig-types.json", 11 | "build": "vite build", 12 | "start": "node build/index.js", 13 | "extract-json-data": "node script/extract-json.mjs", 14 | "serve": "http-server ./", 15 | "watch": "tsc -w" 16 | }, 17 | "author": "", 18 | "license": "BSD-3-Clause", 19 | "type": "module", 20 | "devDependencies": { 21 | "@ava/typescript": "^6.0.0", 22 | "ava": "^6.4.0", 23 | "cpy-cli": "^5.0.0", 24 | "http-server": "^14.1.1", 25 | "rimraf": "^6.0.1", 26 | "ts-node": "^10.9.2", 27 | "typescript": "^5.8.3", 28 | "vite": "^7.1.9", 29 | "vite-plugin-dts": "^4.5.4" 30 | }, 31 | "dependencies": { 32 | "string-strip-html": "^13.4.23" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/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 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Readium Foundation 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% seo %} 9 | 10 | 18 | 19 | 20 |
21 | 22 | {% if page.url == "/" %} 23 | 24 | Readium Logo 25 | 26 | {% else %} 27 | 28 | Readium Logo 29 | 30 | {% endif %} 31 | 32 | {{ content }} 33 | 34 | {% if site.github.private != true and site.github.license %} 35 | 38 | {% endif %} 39 |
40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Build and commit to `build` branch 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: write 15 | 16 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 17 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 18 | concurrency: 19 | group: "build" 20 | cancel-in-progress: false 21 | 22 | jobs: 23 | # Single deploy job since we're just deploying 24 | deploy: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | 30 | - name: Node build 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: 22 34 | - run: | 35 | npm ci --foreground-scripts 36 | npm run build 37 | ls -la ./ 38 | ls -laR ./build 39 | 40 | - name: Deploy 41 | uses: peaceiris/actions-gh-pages@v4 42 | # If you're changing the branch from main, 43 | # also change the `main` in `refs/heads/main` 44 | # below accordingly. 45 | if: github.ref == 'refs/heads/main' 46 | with: 47 | github_token: ${{ secrets.GITHUB_TOKEN }} 48 | publish_dir: ./build 49 | publish_branch: build # default: gh-pages 50 | destination_dir: ./ 51 | enable_jekyll: true # do not write .nojekyll empty file 52 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy demo page to gh-pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: write 15 | 16 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 17 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 18 | concurrency: 19 | group: "pages" 20 | cancel-in-progress: false 21 | 22 | jobs: 23 | # Single deploy job since we're just deploying 24 | deploy: 25 | environment: 26 | name: github-pages 27 | url: ${{ steps.deployment.outputs.page_url }} 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Node build 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: 22 37 | - run: | 38 | npm ci --foreground-scripts 39 | npm run build 40 | ls -la ./ 41 | ls -laR ./build 42 | ls -laR ./demo 43 | mkdir build-demo 44 | cp README.md ./build-demo/ 45 | cp -r ./build ./build-demo/ 46 | cp -r ./demo ./build-demo/ 47 | ls -laR ./build-demo 48 | 49 | 50 | - name: Deploy 51 | uses: peaceiris/actions-gh-pages@v4 52 | # If you're changing the branch from main, 53 | # also change the `main` in `refs/heads/main` 54 | # below accordingly. 55 | if: github.ref == 'refs/heads/main' 56 | with: 57 | github_token: ${{ secrets.GITHUB_TOKEN }} 58 | publish_dir: ./build-demo 59 | publish_branch: gh-pages # default: gh-pages 60 | destination_dir: ./ 61 | enable_jekyll: true # yes for README.md -------------------------------------------------------------------------------- /src/navigator.ts: -------------------------------------------------------------------------------- 1 | import { ReadiumSpeechVoice } from "./voices"; 2 | import { 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 | } -------------------------------------------------------------------------------- /src/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 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Readium Speech 2 | 3 | Readium Speech is a TypeScript library for implementing a read aloud feature with Web technologies. It follows [best practices](https://github.com/HadrienGardeur/read-aloud-best-practices) gathered through interviews with members of the digital publishing industry. 4 | 5 | While this project is still in a very early stage, it is meant to power the read aloud feature for two different Readium projects: [Readium Web](https://readium.org/guided-navigation) and [Thorium](https://thorium.edrlab.org/). 6 | 7 | Readium Speech was spun out as a separate project in order to facilitate its integration as a shared component, but also because of its potential outside of the realm of ebook reading apps. 8 | 9 | ## Scope 10 | 11 | * Extracting [Guided Navigation objects](https://readium.org/guided-navigation) from a document (or a fragment of a document) 12 | * Generating utterances from these Guided Navigation objects 13 | * Processing utterances (prepending/appending text to utterances based on context, pronunciation through SSML/PLS…) 14 | * Voice selection 15 | * TTS playback 16 | * Highlighting 17 | 18 | ## Current focus 19 | 20 | For our initial work on this project, we're focusing on voice selection based on [recommended voices](https://github.com/HadrienGardeur/web-speech-recommended-voices). 21 | 22 | The outline of this work has been explored in a [GitHub discussion](https://github.com/HadrienGardeur/web-speech-recommended-voices/discussions/9) and through a [best practices document](https://github.com/HadrienGardeur/read-aloud-best-practices/blob/main/voice-selection.md). 23 | 24 | ## Demo 25 | 26 | [A live demo](https://readium.org/speech/demo/) of the voice selection API is available. 27 | 28 | It demonstrates the following features: 29 | 30 | - fetching a list of all available languages, translating them to the user's locale and sorting them based on these translations 31 | - returning a list of voices for a given language, grouped by region and sorted based on quality 32 | - filtering languages and voices based on gender and offline availability 33 | - using embedded test utterances to demo voices 34 | 35 | ## QuickStart 36 | 37 | `npm install https://github.com/readium/speech#build` 38 | 39 | ``` 40 | import { voicesSelection} from "readium-speech"; 41 | console.log(voicesSelection); 42 | 43 | // or with cjs only : 44 | const { getVoices } = require("readium-speech/cjs/voices.js"); 45 | console.log(getVoices); 46 | 47 | // or with esm mjs : 48 | import { getVoices } from "readium-speech/mjs/voices.js"; 49 | console.log(getVoices); 50 | 51 | const voices = await voicesSelection.getVoices(); 52 | console.log(voices); 53 | 54 | ``` 55 | 56 | ## API 57 | 58 | ### Interface 59 | 60 | ``` 61 | export interface IVoices { 62 | label: string; 63 | voiceURI: string; 64 | name: string; 65 | language: string; 66 | gender?: TGender | undefined; 67 | age?: string | undefined; 68 | offlineAvailability: boolean; 69 | quality?: TQuality | undefined; 70 | pitchControl: boolean; 71 | recommendedPitch?: number | undefined; 72 | recommendedRate?: number | undefined; 73 | } 74 | 75 | export interface ILanguages { 76 | label: string; 77 | code: string; 78 | count: number; 79 | } 80 | ``` 81 | 82 | #### Parse and Extract IVoices from speechSynthesis WebAPI 83 | ``` 84 | function getVoices(preferredLanguage?: string[] | string, localization?: string): Promise 85 | ``` 86 | 87 | #### List languages from IVoices 88 | ``` 89 | function getLanguages(voices: IVoices[], preferredLanguage?: string[] | string, localization?: string | undefined): ILanguages[] 90 | ``` 91 | 92 | #### helpers 93 | 94 | ``` 95 | function listLanguages(voices: IVoices[], localization?: string): ILanguages[] 96 | 97 | function ListRegions(voices: IVoices[], localization?: string): ILanguages[] 98 | 99 | function parseSpeechSynthesisVoices(speechSynthesisVoices: SpeechSynthesisVoice[]): IVoices[] 100 | 101 | function getSpeechSynthesisVoices(): Promise 102 | ``` 103 | 104 | #### groupBy 105 | 106 | ``` 107 | function groupByKindOfVoices(allVoices: IVoices[]): TGroupVoices 108 | 109 | function groupByRegions(voices: IVoices[], language: string, preferredRegions?: string[] | string, localization?: string): TGroupVoices 110 | 111 | function groupByLanguage(voices: IVoices[], preferredLanguage?: string[] | string, localization?: string): TGroupVoices 112 | ``` 113 | 114 | #### sortBy 115 | 116 | ``` 117 | function sortByLanguage(voices: IVoices[], preferredLanguage?: string[] | string): IVoices[] 118 | 119 | function sortByRegion(voices: IVoices[], preferredRegions?: string[] | string, localization?: string | undefined): IVoices[] 120 | 121 | function sortByGender(voices: IVoices[], genderFirst: TGender): IVoices[] 122 | 123 | function sortByName(voices: IVoices[]): IVoices[] 124 | 125 | function sortByQuality(voices: IVoices[]): IVoices[] 126 | ``` 127 | 128 | #### filterOn 129 | 130 | ``` 131 | function filterOnRecommended(voices: IVoices[], _recommended?: IRecommended[]): TReturnFilterOnRecommended 132 | 133 | function filterOnVeryLowQuality(voices: IVoices[]): IVoices[] 134 | 135 | function filterOnNovelty(voices: IVoices[]): IVoices[] 136 | 137 | function filterOnQuality(voices: IVoices[], quality: TQuality | TQuality[]): IVoices[] 138 | 139 | function filterOnLanguage(voices: IVoices[], language: string | string[]): IVoices[] 140 | 141 | function filterOnGender(voices: IVoices[], gender: TGender): IVoices[] 142 | ``` 143 | -------------------------------------------------------------------------------- /demo/script.js: -------------------------------------------------------------------------------- 1 | 2 | import { getSpeechSynthesisVoices, parseSpeechSynthesisVoices, filterOnNovelty, filterOnVeryLowQuality, 3 | filterOnRecommended, sortByLanguage, sortByQuality, getVoices, groupByKindOfVoices, groupByRegions, 4 | getLanguages, filterOnOfflineAvailability, listLanguages, filterOnGender, filterOnLanguage } from "../build/index.js"; 5 | 6 | import * as lit from './lit-html_3-2-0_esm.js' 7 | const { html, render } = lit; 8 | 9 | async function loadJSONData(url) { 10 | try { 11 | const response = await fetch(url); 12 | const jsonData = JSON.parse(await response.text()); 13 | return jsonData; 14 | } catch (error) { 15 | console.error('Error loading JSON data:', error); 16 | return null; 17 | } 18 | } 19 | 20 | function downloadJSON(obj, filename) { 21 | // Convert the JSON object to a string 22 | const data = JSON.stringify(obj, null, 2); 23 | 24 | // Create a blob from the string 25 | const blob = new Blob([data], { type: "application/json" }); 26 | 27 | // Generate an object URL 28 | const jsonObjectUrl = URL.createObjectURL(blob); 29 | 30 | // Create an anchor element 31 | const anchorEl = document.createElement("a"); 32 | anchorEl.href = jsonObjectUrl; 33 | anchorEl.download = `${filename}.json`; 34 | 35 | // Simulate a click on the anchor element 36 | anchorEl.click(); 37 | 38 | // Revoke the object URL 39 | URL.revokeObjectURL(jsonObjectUrl); 40 | } 41 | 42 | const viewRender = () => render(content(), document.body); 43 | 44 | const voices = await getVoices(); 45 | console.log(voices); 46 | 47 | const languages = getLanguages(voices); 48 | 49 | let voicesFiltered = voices; 50 | let languagesFiltered = languages; 51 | 52 | let textToRead = ""; 53 | let textToReadFormated = ""; 54 | 55 | let selectedLanguage = undefined; 56 | 57 | let voicesSelectElem = []; 58 | 59 | let selectedVoice = ""; 60 | 61 | let selectedGender = "all"; 62 | 63 | let checkboxOfflineChecked = false; 64 | 65 | const readTextWithSelectedVoice = () => { 66 | const voices = window.speechSynthesis.getVoices(); 67 | 68 | const utterance = new SpeechSynthesisUtterance(); 69 | utterance.text = textToReadFormated; 70 | 71 | for (const voice of voices) { 72 | if (voice.name === selectedVoice) { 73 | utterance.voice = voice; 74 | utterance.lang = voice.lang; 75 | break; 76 | } 77 | } 78 | 79 | if (!utterance.voice) { 80 | console.error("Speech : Voice NOT FOUND"); 81 | alert("voice not found"); 82 | } 83 | 84 | console.log("Speech", utterance); 85 | 86 | 87 | speechSynthesis.speak(utterance); 88 | } 89 | 90 | const filterVoices = () => { 91 | 92 | voicesFiltered = voices; 93 | 94 | if (selectedGender !== "all") { 95 | voicesFiltered = filterOnGender(voicesFiltered, selectedGender); 96 | } 97 | 98 | if (checkboxOfflineChecked) { 99 | voicesFiltered = filterOnOfflineAvailability(voicesFiltered, true); 100 | } 101 | 102 | languagesFiltered = getLanguages(voicesFiltered); 103 | 104 | const voicesFilteredOnLanguage = filterOnLanguage(voicesFiltered, selectedLanguage); 105 | const voicesGroupedByRegions = groupByRegions(voicesFilteredOnLanguage); 106 | 107 | voicesSelectElem = listVoicesWithLanguageSelected(voicesGroupedByRegions); 108 | 109 | viewRender(); 110 | } 111 | 112 | const setSelectVoice = (name) => { 113 | 114 | selectedVoice = name; 115 | textToReadFormated = textToRead.replace("{name}", selectedVoice); 116 | } 117 | 118 | const languageSelectOnChange = async (ev) => { 119 | 120 | selectedLanguage = ev.target.value; 121 | 122 | const jsonData = await loadJSONData("https://raw.githubusercontent.com/HadrienGardeur/web-speech-recommended-voices/main/json/" + selectedLanguage + ".json"); 123 | 124 | textToRead = jsonData?.testUtterance || ""; 125 | 126 | filterVoices(); 127 | } 128 | 129 | const listVoicesWithLanguageSelected = (voiceMap) => { 130 | 131 | const elem = []; 132 | selectedVoice = ""; 133 | 134 | for (const [region, voice] of voiceMap) { 135 | const option = []; 136 | 137 | for (const {name, label} of voice) { 138 | option.push(html``); 139 | if (!selectedVoice) setSelectVoice(name); 140 | } 141 | elem.push(html` 142 | 143 | ${option} 144 | 145 | `) 146 | } 147 | 148 | return elem; 149 | } 150 | 151 | const aboutVoice = () => { 152 | return html` 153 |
154 |
${JSON.stringify(voicesFiltered.filter(({name}) => name === selectedVoice), null, 4)}
155 |
156 | `; 157 | } 158 | 159 | const getVoicesInputForDebug = () => { 160 | const a = window.speechSynthesis.getVoices() || []; 161 | return a.map(({ default: def, lang, localService, name, voiceURI}) => ({default: def, lang, localService, name, voiceURI})); 162 | } 163 | 164 | const content = () => html` 165 |

ReadiumSpeech

166 | 167 |

Language :

168 | 172 | 173 |

Voices :

174 | 180 | 181 |

Gender :

182 | 188 | 189 |

Filter :

190 |
191 | { 192 | checkboxOfflineChecked = e.target.checked; 193 | filterVoices(); 194 | }}> 195 | 196 |
197 | 198 |

Text :

199 | textToReadFormated = e.target.value ? e.target.value : textToReadFormated}> 200 | 201 |
202 | 203 |
204 | 205 |
206 | ${selectedVoice ? aboutVoice() : undefined} 207 |
208 | 209 |
210 | 211 |
212 | 213 | `; 214 | viewRender(); 215 | -------------------------------------------------------------------------------- /demo/lit-html_3-2-0_esm.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bundled by jsDelivr using Rollup v2.79.1 and Terser v5.19.2. 3 | * Original file: /npm/lit-html@3.2.0/lit-html.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | /** 8 | * @license 9 | * Copyright 2017 Google LLC 10 | * SPDX-License-Identifier: BSD-3-Clause 11 | */ 12 | const t=globalThis,e=t.trustedTypes,s=e?e.createPolicy("lit-html",{createHTML:t=>t}):void 0,i="$lit$",n=`lit$${Math.random().toFixed(9).slice(2)}$`,o="?"+n,r=`<${o}>`,h=document,l=()=>h.createComment(""),$=t=>null===t||"object"!=typeof t&&"function"!=typeof t,a=Array.isArray,A=t=>a(t)||"function"==typeof t?.[Symbol.iterator],c="[ \t\n\f\r]",_=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,d=/-->/g,p=/>/g,u=RegExp(`>|${c}(?:([^\\s"'>=/]+)(${c}*=${c}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),g=/'/g,v=/"/g,f=/^(?:script|style|textarea|title)$/i,m=t=>(e,...s)=>({_$litType$:t,strings:e,values:s}),y=m(1),H=m(2),x=m(3),N=Symbol.for("lit-noChange"),T=Symbol.for("lit-nothing"),b=new WeakMap,M=h.createTreeWalker(h,129);function w(t,e){if(!a(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==s?s.createHTML(e):e}const S=(t,e)=>{const s=t.length-1,o=[];let h,l=2===e?"":3===e?"":"",$=_;for(let e=0;e"===A[0]?($=h??_,c=-1):void 0===A[1]?c=-2:(c=$.lastIndex-A[2].length,a=A[1],$=void 0===A[3]?u:'"'===A[3]?v:g):$===v||$===g?$=u:$===d||$===p?$=_:($=u,h=void 0);const y=$===u&&t[e+1].startsWith("/>")?" ":"";l+=$===_?s+r:c>=0?(o.push(a),s.slice(0,c)+i+s.slice(c)+n+y):s+n+(-2===c?e:y)}return[w(t,l+(t[s]||"")+(2===e?"":3===e?"":"")),o]};class I{constructor({strings:t,_$litType$:s},r){let h;this.parts=[];let $=0,a=0;const A=t.length-1,c=this.parts,[_,d]=S(t,s);if(this.el=I.createElement(_,r),M.currentNode=this.el.content,2===s||3===s){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(h=M.nextNode())&&c.length0){h.textContent=e?e.emptyScript:"";for(let e=0;e2||""!==s[0]||""!==s[1]?(this._$AH=Array(s.length-1).fill(new String),this.strings=s):this._$AH=T}_$AI(t,e=this,s,i){const n=this.strings;let o=!1;if(void 0===n)t=C(this,t,e,0),o=!$(t)||t!==this._$AH&&t!==N,o&&(this._$AH=t);else{const i=t;let r,h;for(t=n[0],r=0;r{const i=s?.renderBefore??e;let n=i._$litPart$;if(void 0===n){const t=s?.renderBefore??null;i._$litPart$=n=new B(e.insertBefore(l(),t),t,void 0,s??{})}return n._$AI(t),n};export{W as _$LH,y as html,x as mathml,N as noChange,T as nothing,D as render,H as svg};export default null; -------------------------------------------------------------------------------- /script/extract-json.mjs: -------------------------------------------------------------------------------- 1 | 2 | import { spawn } from 'node:child_process'; 3 | import { rm } from 'node:fs/promises'; 4 | import { join } from 'node:path'; 5 | import { writeFileSync } from 'node:fs'; 6 | 7 | const repoUrl = 'https://github.com/HadrienGardeur/web-speech-recommended-voices.git'; 8 | // const repoBranch = 'locales-for-voice-names'; 9 | const repoBranch = 'main'; 10 | const repoPath = 'script/web-speech-recommended-voices'; 11 | 12 | // Clone the repository 13 | await new Promise((resolve, reject) => { 14 | const cloneProcess = spawn('git', ['clone', '--depth=1', '--branch', repoBranch, repoUrl, repoPath]); 15 | cloneProcess.on('close', (code) => { 16 | if (code === 0) { 17 | resolve(); 18 | } else { 19 | reject(new Error(`Git clone failed with code ${code}`)); 20 | } 21 | }); 22 | }); 23 | 24 | 25 | const jsonFiles = [ 26 | 'ar.json', 27 | 'bg.json', 28 | 'bho.json', 29 | 'bn.json', 30 | 'ca.json', 31 | 'cmn.json', 32 | 'cs.json', 33 | 'da.json', 34 | 'de.json', 35 | 'el.json', 36 | 'en.json', 37 | 'es.json', 38 | 'eu.json', 39 | 'fa.json', 40 | 'fi.json', 41 | 'fr.json', 42 | 'gl.json', 43 | 'he.json', 44 | 'hi.json', 45 | 'hr.json', 46 | 'hu.json', 47 | 'id.json', 48 | 'it.json', 49 | 'ja.json', 50 | 'kn.json', 51 | 'ko.json', 52 | 'mr.json', 53 | 'ms.json', 54 | 'nb.json', 55 | 'nl.json', 56 | 'pl.json', 57 | 'pt.json', 58 | 'ro.json', 59 | 'ru.json', 60 | 'sk.json', 61 | 'sl.json', 62 | 'sv.json', 63 | 'ta.json', 64 | 'te.json', 65 | 'th.json', 66 | 'tr.json', 67 | 'uk.json', 68 | 'vi.json', 69 | 'wuu.json', 70 | 'yue.json', 71 | ]; 72 | 73 | const filters = [ 74 | 'novelty.json', 75 | 'veryLowQuality.json', 76 | ]; 77 | 78 | // const localizedNames = [ 79 | // 'ca.json', 80 | // 'da.json', 81 | // 'de.json', 82 | // 'en.json', 83 | // 'es.json', 84 | // 'fi.json', 85 | // 'fr.json', 86 | // 'it.json', 87 | // 'nb.json', 88 | // 'nl.json', 89 | // 'pt.json', 90 | // 'sv.json', 91 | // ]; 92 | 93 | let novelty = []; 94 | let veryLowQuality = []; 95 | 96 | // let localization = {}; 97 | 98 | let recommended = [] 99 | 100 | let quality = []; 101 | 102 | const defaultRegion = {}; 103 | 104 | // function generateLanguageRegionStrings(languages, regions) { 105 | 106 | // const result = {}; 107 | // for (const languageCode in languages) { 108 | // for (const regionCode in regions) { 109 | // const bcp47Code = `${languageCode.toLowerCase()}-${regionCode.toLowerCase()}`; 110 | // const translation = `(${languages[languageCode]} (${regions[regionCode]}))`; 111 | // result[bcp47Code] = translation; 112 | // } 113 | // } 114 | 115 | // return result; 116 | // } 117 | 118 | // function getAltName(languages) { 119 | 120 | // if (!languages.length) { 121 | // return []; 122 | // } 123 | 124 | // const result = []; 125 | // for (const language of languages) { 126 | // for (const langLocalization in localization) { 127 | 128 | // const v = localization[langLocalization][language.toLowerCase()]; 129 | // if (v) { 130 | // result.push(v); 131 | // } 132 | // } 133 | // } 134 | 135 | // return result; 136 | // } 137 | 138 | function filterBCP47(data) { 139 | return data.filter((v) => /\w{2,3}-\w{2,3}/.test(v)); 140 | } 141 | 142 | { 143 | const file = 'apple.json'; 144 | const filePath = join(process.cwd(), repoPath, 'json', 'localizedNames', file); 145 | try { 146 | const { default: jsonData } = await import(filePath, { with: { type: 'json' } }); 147 | console.log(`Imported localizedNames/${file}:` /*, jsonData*/); 148 | 149 | quality = jsonData.quality; 150 | } catch (error) { 151 | console.error(`Failed to import localizedNames/${file}: ${error.message}`); 152 | } 153 | } 154 | 155 | // for (const file of localizedNames) { 156 | // const filePath = join(process.cwd(), repoPath, 'json', 'localizedNames', 'full', file); 157 | // try { 158 | // const { default: jsonData } = await import(filePath, { with: { type: 'json' } }); 159 | // console.log(`Imported localizedNames/${file}:` /*, jsonData*/); 160 | 161 | // const lang = file.split(".")[0]; 162 | // localization[lang] = generateLanguageRegionStrings(jsonData.languages, jsonData.regions); 163 | // } catch (error) { 164 | // console.error(`Failed to import localizedNames/${file}: ${error.message}`); 165 | // } 166 | // } 167 | // // console.log(localization); 168 | 169 | 170 | for (const file of jsonFiles) { 171 | const filePath = join(process.cwd(), repoPath, 'json', file); 172 | try { 173 | const { default: jsonData } = await import(filePath, { with: { type: 'json' } }); 174 | console.log(`Imported ${file}:` /*, jsonData*/); 175 | 176 | defaultRegion[jsonData.language] = jsonData.defaultRegion; 177 | 178 | const voices = jsonData.voices; 179 | 180 | for (const voice of voices) { 181 | 182 | recommended.push({ 183 | label: voice.label, 184 | name: voice.name || undefined, 185 | altNames: voice.altNames || undefined, 186 | language: voice.language || undefined, 187 | gender: voice.gender || undefined, 188 | age: voice.age || undefined, 189 | quality: Array.isArray(voice.quality) ? voice.quality : [], 190 | recommendedPitch: voice.pitchControl === false ? undefined : voice.pitch || 1, 191 | recommendedRate: voice.pitchControl === false ? undefined : voice.rate || 1, 192 | localizedName: voice.localizedName || "", 193 | }); 194 | } 195 | 196 | } catch (error) { 197 | console.error(`Failed to import ${file}: ${error.message}`); 198 | } 199 | } 200 | 201 | for (const file of filters) { 202 | const filePath = join(process.cwd(), repoPath, 'json', 'filters', file); 203 | try { 204 | const { default: jsonData } = await import(filePath, { with: { type: 'json' } }); 205 | console.log(`Imported filters/${file}:` /*, jsonData*/); 206 | 207 | if (file.startsWith("novelty")) { 208 | novelty = jsonData.voices.map(({ name, altNames }) => [name, ...(Array.isArray(altNames) ? altNames : [])]).flat(); 209 | } 210 | 211 | if (file.startsWith("veryLow")) { 212 | veryLowQuality = jsonData.voices.map(({ name, language, otherLanguages }) => { 213 | // const languages = filterBCP47([language, otherLanguages].flat()); 214 | // const altNamesGenerated = getAltName(languages); 215 | // const altNames = altNamesGenerated.map((v) => name + " " + v); 216 | 217 | // return [name, altNames].flat(); 218 | return name; 219 | }).flat(); 220 | } 221 | } catch (error) { 222 | console.error(`Failed to import filters/${file}: ${error.message}`); 223 | } 224 | } 225 | 226 | 227 | 228 | const content = ` 229 | // https://github.com/readium/speech 230 | // file script-generated by : npm run extract-json-data 231 | // 232 | 233 | export const novelty = ${JSON.stringify(novelty)}; 234 | 235 | export const veryLowQuality = ${JSON.stringify(veryLowQuality)}; 236 | 237 | export type TGender = "female" | "male" | "nonbinary" 238 | export type TQuality = "veryLow" | "low" | "normal" | "high" | "veryHigh"; 239 | 240 | export interface IRecommended { 241 | label: string; 242 | name: string; 243 | altNames?: string[]; 244 | language: string; 245 | gender?: TGender | undefined; 246 | age?: string | undefined; 247 | quality: TQuality[]; 248 | recommendedPitch?: number | undefined; 249 | recommendedRate?: number | undefined; 250 | localizedName: string; 251 | }; 252 | 253 | export const recommended: Array = ${JSON.stringify(recommended)}; 254 | 255 | export const quality = ${JSON.stringify(quality)}; 256 | 257 | export const defaultRegion = ${JSON.stringify(defaultRegion)}; 258 | 259 | // EOF 260 | `; 261 | 262 | const filePath = './src/data.gen.ts'; 263 | 264 | try { 265 | writeFileSync(filePath, content); 266 | console.log('File has been written successfully'); 267 | } catch (err) { 268 | console.error(err); 269 | } 270 | 271 | // Delete the cloned repository 272 | try { 273 | await rm(repoPath, { recursive: true, force: true }); 274 | console.log(`Deleted repository at ${repoPath}`); 275 | } catch (error) { 276 | console.error(`Failed to delete repository: ${error.message}`); 277 | } 278 | -------------------------------------------------------------------------------- /src/WebSpeech/TmpNavigator.ts: -------------------------------------------------------------------------------- 1 | import { ReadiumSpeechPlaybackEngine } from "../engine"; 2 | import { ReadiumSpeechNavigator, ReadiumSpeechPlaybackEvent, ReadiumSpeechPlaybackState } from "../navigator"; 3 | import { ReadiumSpeechUtterance } from "../utterance"; 4 | import { 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(); 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 | private getCurrentUtteranceIndex(): number { 131 | return this.engine.getCurrentUtteranceIndex(); 132 | } 133 | 134 | // Playback Control - Navigator coordinates engine operations 135 | async play(): Promise { 136 | if (this.navigatorState === "paused") { 137 | // Resume from pause 138 | this.setNavigatorState("playing"); 139 | this.engine.resume(); 140 | } else if (this.navigatorState === "ready" || this.navigatorState === "idle") { 141 | // Start playing from beginning 142 | this.setNavigatorState("playing"); 143 | this.engine.speak(); 144 | } else if (this.navigatorState === "playing") { 145 | // Already playing, do nothing or restart 146 | return; 147 | } 148 | } 149 | 150 | pause(): void { 151 | if (this.navigatorState === "playing") { 152 | this.setNavigatorState("paused"); 153 | this.engine.pause(); 154 | } 155 | } 156 | 157 | stop(): void { 158 | this.setNavigatorState("idle"); 159 | this.engine.stop(); // Reset engine index first 160 | this.emitEvent({ type: "stop" }); // Then emit event for UI update 161 | } 162 | 163 | async togglePlayPause(): Promise { 164 | if (this.navigatorState === "playing") { 165 | this.pause(); 166 | } else { 167 | await this.play(); 168 | } 169 | } 170 | 171 | // Navigation - Navigator coordinates with proper state management 172 | async next(): Promise { 173 | const currentIndex = this.getCurrentUtteranceIndex(); 174 | const totalCount = this.engine.getUtteranceCount(); 175 | 176 | if (currentIndex < totalCount - 1) { 177 | this.engine.speak(currentIndex + 1); 178 | return true; 179 | } 180 | return false; 181 | } 182 | 183 | async previous(): Promise { 184 | const currentIndex = this.getCurrentUtteranceIndex(); 185 | 186 | if (currentIndex > 0) { 187 | this.engine.speak(currentIndex - 1); 188 | return true; 189 | } 190 | return false; 191 | } 192 | 193 | jumpTo(utteranceIndex: number): void { 194 | if (utteranceIndex >= 0 && utteranceIndex < this.contentQueue.length) { 195 | this.engine.speak(utteranceIndex); 196 | } 197 | } 198 | 199 | // Playback Parameters 200 | setRate(rate: number): void { 201 | this.engine.setRate(rate); 202 | } 203 | 204 | setPitch(pitch: number): void { 205 | this.engine.setPitch(pitch); 206 | } 207 | 208 | setVolume(volume: number): void { 209 | this.engine.setVolume(volume); 210 | } 211 | 212 | // State - Navigator is the single source of truth 213 | getState(): ReadiumSpeechPlaybackState { 214 | return this.navigatorState; 215 | } 216 | 217 | // Events 218 | on(event: ReadiumSpeechPlaybackEvent["type"] | "contentchange", listener: (event: ReadiumSpeechPlaybackEvent) => void): () => void { 219 | if (!this.eventListeners.has(event)) { 220 | this.eventListeners.set(event, []); 221 | } 222 | this.eventListeners.get(event)!.push(listener); 223 | 224 | return () => { 225 | const listeners = this.eventListeners.get(event); 226 | if (listeners) { 227 | const index = listeners.indexOf(listener); 228 | if (index > -1) { 229 | listeners.splice(index, 1); 230 | } 231 | } 232 | }; 233 | } 234 | 235 | private emitEvent(event: ReadiumSpeechPlaybackEvent): void { 236 | const listeners = this.eventListeners.get(event.type); 237 | if (listeners) { 238 | listeners.forEach(callback => callback(event)); 239 | } 240 | } 241 | 242 | private emitContentChangeEvent(event: { content: ReadiumSpeechUtterance[] }): void { 243 | const listeners = this.eventListeners.get("contentchange"); 244 | if (listeners) { 245 | listeners.forEach(callback => callback({ type: "contentchange", detail: event } as unknown as ReadiumSpeechPlaybackEvent)); 246 | } 247 | } 248 | 249 | async destroy(): Promise { 250 | this.eventListeners.clear(); 251 | await this.engine.destroy(); 252 | } 253 | } -------------------------------------------------------------------------------- /tsconfig-base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "ES2022", 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | "outDir": "./build", /* Specify an output folder for all emitted files. */ 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 63 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 64 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 65 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 66 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 67 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 68 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 69 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 70 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 71 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 72 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 73 | 74 | /* Interop Constraints */ 75 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 76 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 77 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ 78 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 79 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 80 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 81 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 82 | 83 | /* Type Checking */ 84 | "strict": true, /* Enable all strict type-checking options. */ 85 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 86 | "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 87 | "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 88 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 89 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 90 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 91 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 92 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 93 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 94 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 95 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 96 | "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 97 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 98 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 99 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 100 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 101 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 102 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 103 | 104 | /* Completeness */ 105 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 106 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 107 | }, 108 | "exclude": ["node_modules", "dist"], 109 | "include": ["src"] 110 | } 111 | -------------------------------------------------------------------------------- /src/WebSpeech/webSpeechEngine.ts: -------------------------------------------------------------------------------- 1 | import { ReadiumSpeechPlaybackEngine } from "../engine"; 2 | import { ReadiumSpeechPlaybackEvent, ReadiumSpeechPlaybackState } from "../navigator"; 3 | import { ReadiumSpeechUtterance } from "../utterance"; 4 | import { ReadiumSpeechVoice } from "../voices"; 5 | import { getSpeechSynthesisVoices, parseSpeechSynthesisVoices, filterOnLanguage } from "../voices"; 6 | 7 | import { detectFeatures, WebSpeechFeatures } from "../utils/features"; 8 | import { detectPlatformFeatures, 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 | 53 | // From Easy Speech, 54 | // Check infinity pattern for long texts (except on problematic platforms) 55 | // Skip resume infinity for Microsoft Natural voices as they have different behavior 56 | private shouldUseResumeInfinity(): boolean { 57 | const selectedVoice = this.currentVoice; 58 | const isMsNatural = !!(selectedVoice?.name && 59 | typeof selectedVoice.name === "string" && 60 | selectedVoice.name.toLocaleLowerCase().includes("(natural)")); 61 | return this.patches.isAndroid !== true && !this.patches.isFirefox && !this.patches.isSafari && !isMsNatural; 62 | } 63 | 64 | // Creates a new SpeechSynthesisUtterance using detected constructor 65 | private createUtterance(text: string): SpeechSynthesisUtterance { 66 | return new this.speechSynthesisUtterance(text); 67 | } 68 | 69 | async initialize(options: { 70 | maxTimeout?: number; 71 | interval?: number; 72 | maxLengthExceeded?: "error" | "none" | "warn"; 73 | } = {}): Promise { 74 | const { maxTimeout = 10000, interval = 10, maxLengthExceeded = "warn" } = options; 75 | 76 | if (this.initialized) { 77 | return false; 78 | } 79 | 80 | this.maxLengthExceeded = maxLengthExceeded; 81 | 82 | try { 83 | // Get and cache the browser's native voices 84 | this.browserVoices = await getSpeechSynthesisVoices(maxTimeout, interval); 85 | // Parse them into our internal format 86 | this.voices = parseSpeechSynthesisVoices(this.browserVoices); 87 | 88 | // Try to find voice matching user's language 89 | const langVoices = filterOnLanguage(this.voices); 90 | this.defaultVoice = langVoices.length > 0 ? langVoices[0] : this.voices[0] || null; 91 | 92 | this.initialized = true; 93 | return true; 94 | } catch (error) { 95 | console.error("Failed to initialize WebSpeechEngine:", error); 96 | this.initialized = false; 97 | return false; 98 | } 99 | } 100 | 101 | // Text length validation matching EasySpeech 102 | private validateText(text: string): void { 103 | const textBytes = new TextEncoder().encode(text); 104 | if (textBytes.length > 4096) { 105 | const message = "Text exceeds max length of 4096 bytes, which may not work with some voices."; 106 | switch (this.maxLengthExceeded) { 107 | case "none": 108 | break; 109 | case "error": 110 | throw new Error(`WebSpeechEngine: ${message}`); 111 | case "warn": 112 | default: 113 | console.warn(`WebSpeechEngine: ${message}`); 114 | } 115 | } 116 | } 117 | 118 | private getCurrentVoiceForUtterance(voice?: ReadiumSpeechVoice | string | null): ReadiumSpeechVoice | null { 119 | if (voice && typeof voice === "object") { 120 | return voice; 121 | } 122 | if (typeof voice === "string") { 123 | return this.voices.find(v => v.name === voice || v.language === voice) || null; 124 | } 125 | 126 | return this.currentVoice || this.defaultVoice; 127 | } 128 | 129 | getCurrentVoice(): ReadiumSpeechVoice | null { 130 | return this.currentVoice; 131 | } 132 | 133 | // SSML Escaping 134 | private escapeSSML(utterances: ReadiumSpeechUtterance[]): ReadiumSpeechUtterance[] { 135 | return utterances.map(content => ({ 136 | ...content, 137 | text: content.ssml ? stripHtml(content.text).result : content.text 138 | })); 139 | } 140 | 141 | // Queue Management 142 | loadUtterances(contents: ReadiumSpeechUtterance[]): void { 143 | // Escape SSML entirely for the time being 144 | this.currentUtterances = this.escapeSSML(contents); 145 | this.currentUtteranceIndex = 0; 146 | this.setState("ready"); 147 | this.emitEvent({ type: "ready" }); 148 | } 149 | 150 | // Voice Configuration 151 | setVoice(voice: ReadiumSpeechVoice | string): void { 152 | const previousVoice = this.currentVoice; 153 | 154 | if (typeof voice === "string") { 155 | // Find voice by name or language 156 | this.getAvailableVoices().then(voices => { 157 | const foundVoice = voices.find(v => v.name === voice || v.language === voice); 158 | if (foundVoice) { 159 | this.currentVoice = foundVoice; 160 | // Reset position when voice changes for fresh start with new voice 161 | if (previousVoice && previousVoice.name !== foundVoice.name) { 162 | this.currentUtteranceIndex = 0; 163 | } 164 | } else { 165 | console.warn(`Voice "${voice}" not found`); 166 | } 167 | }); 168 | } else { 169 | this.currentVoice = voice; 170 | // Reset position when voice changes for fresh start with new voice 171 | if (previousVoice && previousVoice.name !== voice.name) { 172 | this.currentUtteranceIndex = 0; 173 | } 174 | } 175 | } 176 | 177 | getAvailableVoices(): Promise { 178 | return new Promise((resolve) => { 179 | if (this.voices.length > 0) { 180 | resolve(this.voices); 181 | } else { 182 | // If voices not loaded yet, initialize first 183 | this.initialize().then(() => { 184 | resolve(this.voices); 185 | }).catch(() => { 186 | resolve([]); 187 | }); 188 | } 189 | }); 190 | } 191 | 192 | // Playback Control 193 | speak(utteranceIndex?: number): void { 194 | if (utteranceIndex !== undefined) { 195 | if (utteranceIndex < 0 || utteranceIndex >= this.currentUtterances.length) { 196 | throw new Error("Invalid utterance index"); 197 | } 198 | this.currentUtteranceIndex = utteranceIndex; 199 | } 200 | 201 | if (this.currentUtterances.length === 0) { 202 | console.warn("No utterances loaded"); 203 | return; 204 | } 205 | 206 | // Cancel any ongoing speech with Firefox workaround 207 | this.cancelCurrentSpeech(); 208 | 209 | // Reset internal state 210 | this.isSpeakingInternal = true; 211 | this.isPausedInternal = false; 212 | 213 | // Set state to playing before starting new speech 214 | this.setState("playing"); 215 | this.emitEvent({ type: "start" }); 216 | this.stopResumeInfinity(); 217 | 218 | // Reset utterance index to ensure we're starting fresh 219 | this.currentUtteranceIndex = utteranceIndex ?? 0; 220 | 221 | // Ensure the utterance index is valid 222 | if (this.currentUtteranceIndex >= this.currentUtterances.length) { 223 | this.currentUtteranceIndex = 0; 224 | } 225 | 226 | // Speak immediately for responsive navigation 227 | this.speakCurrentUtterance(); 228 | } 229 | 230 | private cancelCurrentSpeech(): void { 231 | if (this.patches.isFirefox && this.speechSynthesis.speaking) { 232 | // Firefox workaround: set flag to ignore delayed onend events 233 | this.utterancesBeingCancelled = true; 234 | 235 | // Clear cancelled flag after delay 236 | setTimeout(() => { 237 | this.utterancesBeingCancelled = false; 238 | }, 100); 239 | } 240 | 241 | this.speechSynthesis.cancel(); 242 | } 243 | 244 | private async speakCurrentUtterance(): Promise { 245 | if (this.currentUtteranceIndex >= this.currentUtterances.length) { 246 | this.setState("idle"); 247 | this.emitEvent({ type: "end" }); 248 | return; 249 | } 250 | 251 | const content = this.currentUtterances[this.currentUtteranceIndex]; 252 | const text = content.ssml ? content.text : content.text; 253 | 254 | // Validate text length 255 | this.validateText(text); 256 | 257 | const utterance = this.createUtterance(text); 258 | 259 | // Configure utterance 260 | if (content.language) { 261 | utterance.lang = content.language; 262 | } 263 | 264 | // Enhanced voice selection with MSNatural detection 265 | const selectedVoice = this.getCurrentVoiceForUtterance(this.currentVoice); 266 | 267 | if (selectedVoice) { 268 | // Find the matching voice in our cached browser voices 269 | // as converting ReadiumSpeechVoice to SpeechSynthesisVoice is not possible 270 | const nativeVoice = this.browserVoices.find(v => 271 | v.name === selectedVoice.name && 272 | v.lang === (selectedVoice.__lang || selectedVoice.language) 273 | ); 274 | 275 | if (nativeVoice) { 276 | utterance.voice = nativeVoice; // Use the real native voice from cache 277 | } 278 | } 279 | 280 | utterance.rate = this.rate; 281 | utterance.pitch = this.pitch; 282 | utterance.volume = this.volume; 283 | 284 | // Set up event handlers with resume infinity pattern 285 | utterance.onstart = () => { 286 | this.isSpeakingInternal = true; 287 | this.isPausedInternal = false; 288 | this.setState("playing"); 289 | this.emitEvent({ type: "start" }); 290 | 291 | const shouldUseResumeInfinity = this.shouldUseResumeInfinity(); 292 | if (shouldUseResumeInfinity) { 293 | this.startResumeInfinity(utterance); 294 | } 295 | }; 296 | 297 | utterance.onend = () => { 298 | // Firefox workaround: ignore onend from cancelled utterances 299 | if (this.utterancesBeingCancelled) { 300 | this.utterancesBeingCancelled = false; 301 | return; 302 | } 303 | 304 | // Don't continue if stopped 305 | if (this.playbackState === "idle") { 306 | return; 307 | } 308 | 309 | // Just report completion - navigator handles playback decisions 310 | this.isSpeakingInternal = false; 311 | this.isPausedInternal = false; 312 | this.stopResumeInfinity(); 313 | 314 | // Set idle state if we've reached the end 315 | if (this.currentUtteranceIndex >= this.currentUtterances.length - 1) { 316 | this.setState("idle"); 317 | } 318 | 319 | this.emitEvent({ type: "end" }); 320 | }; 321 | 322 | utterance.onerror = (event) => { 323 | this.isSpeakingInternal = false; 324 | this.isPausedInternal = false; 325 | this.stopResumeInfinity(); 326 | 327 | // Fatal errors that break playback completely - reset to beginning 328 | const fatalErrors = ["synthesis-unavailable", "audio-hardware", "voice-unavailable"]; 329 | if (fatalErrors.includes(event.error)) { 330 | console.log(`[ENGINE] fatal error detected, resetting index to 0`); 331 | this.currentUtteranceIndex = 0; 332 | } 333 | 334 | this.setState("idle"); 335 | this.emitEvent({ 336 | type: "error", 337 | detail: { 338 | error: event.error, // Preserve original error type 339 | message: `Speech synthesis error: ${event.error}` 340 | } 341 | }); 342 | }; 343 | 344 | utterance.onpause = () => { 345 | this.isPausedInternal = true; 346 | this.isSpeakingInternal = false; 347 | this.emitEvent({ type: "pause" }); 348 | }; 349 | 350 | utterance.onresume = () => { 351 | this.isPausedInternal = false; 352 | this.isSpeakingInternal = true; 353 | this.emitEvent({ type: "resume" }); 354 | }; 355 | 356 | // Handle word and sentence boundaries 357 | utterance.onboundary = (event) => { 358 | this.emitEvent({ 359 | type: "boundary", 360 | detail: { 361 | charIndex: event.charIndex, 362 | charLength: event.charLength, 363 | elapsedTime: event.elapsedTime, 364 | name: event.name 365 | } 366 | }); 367 | }; 368 | 369 | // Handle SSML marks 370 | utterance.onmark = (event) => { 371 | this.emitEvent({ 372 | type: "mark", 373 | detail: { 374 | name: event.name 375 | } 376 | }); 377 | }; 378 | 379 | this.speechSynthesis.speak(utterance); 380 | } 381 | 382 | private startResumeInfinity(utterance: SpeechSynthesisUtterance): void { 383 | const shouldUseResumeInfinity = this.shouldUseResumeInfinity(); 384 | 385 | if (!shouldUseResumeInfinity) { 386 | return; 387 | } 388 | 389 | // Use the same logic as EasySpeech with internal patching 390 | this.resumeInfinityTimer = window.setTimeout(() => { 391 | // Check if utterance still exists and speech is active 392 | if (utterance) { 393 | // Include internal patching, since some systems have problems with 394 | // pause/resume and updating the internal state on speechSynthesis 395 | const { paused, speaking } = this.speechSynthesis; 396 | const isSpeaking = speaking || this.isSpeakingInternal; 397 | const isPaused = paused || this.isPausedInternal; 398 | 399 | if (isSpeaking && !isPaused) { 400 | this.speechSynthesis.pause(); 401 | this.speechSynthesis.resume(); 402 | } 403 | } 404 | 405 | // Continue the pattern (matches EasySpeech recursive pattern) 406 | this.startResumeInfinity(utterance); 407 | }, 5000); 408 | } 409 | 410 | private stopResumeInfinity(): void { 411 | if (this.resumeInfinityTimer) { 412 | clearTimeout(this.resumeInfinityTimer); 413 | this.resumeInfinityTimer = undefined; 414 | } 415 | } 416 | 417 | pause(): void { 418 | if (this.playbackState === "playing") { 419 | // Android-specific handling: pause causes speech to end but not fire end-event 420 | // so we simply do it manually instead of pausing 421 | if (this.patches.isAndroid) { 422 | this.speechSynthesis.cancel(); 423 | return; 424 | } 425 | 426 | this.speechSynthesis.pause(); 427 | // in some cases, pause does not update the internal state, 428 | // so we need to update it manually using an own state 429 | this.isPausedInternal = true; 430 | this.isSpeakingInternal = false; 431 | this.setState("paused"); 432 | // Emit pause event since speechSynthesis.pause() may not trigger utterance.onpause 433 | this.emitEvent({ type: "pause" }); 434 | } 435 | } 436 | 437 | resume(): void { 438 | if (this.playbackState === "paused") { 439 | this.speechSynthesis.resume(); 440 | // in some cases, resume does not update the internal state, 441 | // so we need to update it manually using an own state 442 | this.isPausedInternal = false; 443 | this.isSpeakingInternal = true; 444 | this.setState("playing"); 445 | // Emit resume event since speechSynthesis.resume() may not trigger utterance.onresume 446 | this.emitEvent({ type: "resume" }); 447 | } 448 | } 449 | 450 | stop(): void { 451 | this.speechSynthesis.cancel(); 452 | this.currentUtteranceIndex = 0; // Reset to beginning when stopped 453 | this.setState("idle"); 454 | this.emitEvent({ type: "stop" }); // Emit immediately 455 | } 456 | 457 | // Playback Parameters 458 | setRate(rate: number): void { 459 | this.rate = Math.max(0.1, Math.min(10, rate)); 460 | } 461 | 462 | setPitch(pitch: number): void { 463 | this.pitch = Math.max(0, Math.min(2, pitch)); 464 | } 465 | 466 | setVolume(volume: number): void { 467 | this.volume = Math.max(0, Math.min(1, volume)); 468 | } 469 | 470 | // State 471 | getState(): ReadiumSpeechPlaybackState { 472 | return this.playbackState; 473 | } 474 | 475 | getCurrentUtteranceIndex(): number { 476 | return this.currentUtteranceIndex; 477 | } 478 | 479 | getUtteranceCount(): number { 480 | return this.currentUtterances.length; 481 | } 482 | 483 | // Events 484 | on(event: ReadiumSpeechPlaybackEvent["type"], callback: (event: ReadiumSpeechPlaybackEvent) => void): () => void { 485 | if (!this.eventListeners.has(event)) { 486 | this.eventListeners.set(event, []); 487 | } 488 | this.eventListeners.get(event)!.push(callback); 489 | 490 | // Return unsubscribe function 491 | return () => { 492 | const listeners = this.eventListeners.get(event); 493 | if (listeners) { 494 | const index = listeners.indexOf(callback); 495 | if (index > -1) { 496 | listeners.splice(index, 1); 497 | } 498 | } 499 | }; 500 | } 501 | 502 | private emitEvent(event: ReadiumSpeechPlaybackEvent): void { 503 | const listeners = this.eventListeners.get(event.type); 504 | if (listeners) { 505 | listeners.forEach(callback => callback(event)); 506 | } 507 | } 508 | 509 | private setState(state: ReadiumSpeechPlaybackState): void { 510 | const oldState = this.playbackState; 511 | this.playbackState = state; 512 | 513 | // Emit state change events 514 | if (oldState !== state) { 515 | switch (state) { 516 | case "idle": 517 | this.emitEvent({ type: "idle" }); 518 | break; 519 | case "loading": 520 | this.emitEvent({ type: "loading" }); 521 | break; 522 | case "ready": 523 | this.emitEvent({ type: "ready" }); 524 | break; 525 | } 526 | } 527 | } 528 | 529 | // Cleanup with comprehensive error handling 530 | async destroy(): Promise { 531 | this.stop(); 532 | this.stopResumeInfinity(); 533 | this.eventListeners.clear(); 534 | this.currentUtterances = []; 535 | this.currentVoice = null; 536 | this.voices = []; 537 | this.defaultVoice = null; 538 | this.initialized = false; 539 | } 540 | } -------------------------------------------------------------------------------- /demo/navigator/navigator-demo-script.js: -------------------------------------------------------------------------------- 1 | import { WebSpeechReadAloudNavigator } from "../../build/index.js"; 2 | 3 | import * as lit from "../lit-html_3-2-0_esm.js" 4 | const { html, render } = lit; 5 | 6 | // Sample text from Moby Dick by Herman Melville 7 | const sampleText = ` 8 | Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world. It is a way I have of driving off the spleen and regulating the circulation. 9 | 10 | Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul; whenever I find myself involuntarily pausing before coffin warehouses, and bringing up the rear of every funeral I meet; and especially whenever my hypos get such an upper hand of me, that it requires a strong moral principle to prevent me from deliberately stepping into the street, and methodically knocking people's hats off—then, I account it high time to get to sea as soon as I can. 11 | 12 | This is my substitute for pistol and ball. With a philosophical flourish Cato throws himself upon his sword; I quietly take to the ship. There is nothing surprising in this. If they but knew it, almost all men in their degree, some time or other, cherish very nearly the same feelings towards the ocean with me. 13 | 14 | There now is your insular city of the Manhattoes, belted round by wharves as Indian isles by coral reefs—commerce surrounds it with her surf. Right and left, the streets take you waterward. Its extreme downtown is the battery, where that noble mole is washed by waves, and cooled by breezes, which a few hours previous were out of sight of land. Look at the crowds of water-gazers there.`; 15 | 16 | // Create navigator instance 17 | const navigator = new WebSpeechReadAloudNavigator(); 18 | 19 | // Main render function 20 | const viewRender = () => { 21 | const state = { 22 | isPlaying: navigator.getState() === "playing", 23 | currentUtteranceIndex: navigator.getCurrentUtteranceIndex() || 0, 24 | totalUtterances: navigator.getContentQueue().length, 25 | currentVoice: navigator.getCurrentVoice() 26 | }; 27 | 28 | render(content(state), document.body); 29 | 30 | // Update input field only if user hasn't manually changed it 31 | updateJumpInputIfNeeded(state.currentUtteranceIndex + 1); 32 | 33 | // Initialize position tracking on first render 34 | if (lastNavigatorPosition === 0) { 35 | lastNavigatorPosition = state.currentUtteranceIndex + 1; 36 | } 37 | }; 38 | 39 | // Split text into sentences for utterances 40 | function createUtterancesFromText(text) { 41 | // Split by sentences (basic implementation) 42 | const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); 43 | return sentences.map((sentence, index) => ({ 44 | id: `utterance-${index}`, 45 | text: sentence.trim() + (sentence.endsWith(".") || sentence.endsWith("!") || sentence.endsWith("?") ? "" : "."), 46 | language: "en-US" 47 | })); 48 | } 49 | 50 | const utterances = createUtterancesFromText(sampleText); 51 | console.log(`Created ${utterances.length} utterances`); 52 | 53 | let voices = []; 54 | let currentVoice = null; 55 | let currentWordHighlight = null; // Track current word being highlighted 56 | 57 | // Initialize voices 58 | async function initVoices() { 59 | try { 60 | // Get all voices 61 | voices = await navigator.getVoices(); 62 | // Filter for English voices 63 | const englishVoices = voices.filter(v => v.language.startsWith("en")); 64 | // Set the first English voice as default, or fallback to first available 65 | currentVoice = englishVoices.length > 0 ? englishVoices[0] : voices[0]; 66 | 67 | if (currentVoice) { 68 | await navigator.setVoice(currentVoice); 69 | } 70 | 71 | // Re-render to show the voice selector 72 | viewRender(); 73 | } catch (error) { 74 | console.error("Error initializing voices:", error); 75 | } 76 | } 77 | 78 | // Handle voice selection 79 | async function handleVoiceChange(event) { 80 | const voiceName = event.target.value; 81 | const selectedVoice = voices.find(v => v.name === voiceName); 82 | if (selectedVoice) { 83 | // Stop any ongoing speech before changing the voice 84 | await navigator.stop(); 85 | currentVoice = selectedVoice; 86 | await navigator.setVoice(selectedVoice); 87 | // The view will be updated automatically through the state change events 88 | } 89 | } 90 | 91 | // Load utterances into navigator and initialize voices 92 | navigator.loadContent(utterances); 93 | initVoices(); 94 | 95 | // Input value management 96 | let jumpInputUserChanged = false; 97 | let lastNavigatorPosition = 0; 98 | 99 | function updateJumpInputIfNeeded(navigatorPosition) { 100 | const input = document.getElementById("utterance-index"); 101 | if (!input) return; 102 | 103 | // If user has changed the input, don't update it 104 | if (jumpInputUserChanged) { 105 | return; 106 | } 107 | 108 | // Only update if position actually changed 109 | if (navigatorPosition !== lastNavigatorPosition) { 110 | input.value = navigatorPosition; 111 | lastNavigatorPosition = navigatorPosition; 112 | } 113 | } 114 | 115 | // Track when user manually changes the input 116 | const jumpInput = document.getElementById("utterance-index"); 117 | if (jumpInput) { 118 | jumpInput.addEventListener("input", () => { 119 | jumpInputUserChanged = true; 120 | }); 121 | 122 | // Set initial value once input is ready 123 | jumpInput.addEventListener("focus", () => { 124 | if (jumpInput.value === "" && !jumpInputUserChanged) { 125 | const currentPos = navigator.getCurrentUtteranceIndex() + 1; 126 | jumpInput.value = currentPos; 127 | lastNavigatorPosition = currentPos; 128 | } 129 | }, { once: true }); 130 | } 131 | 132 | // Event listeners for navigator 133 | navigator.on("start", () => { 134 | clearWordHighlighting(); // Clear any previous highlighting 135 | viewRender(); 136 | }); 137 | 138 | navigator.on("pause", () => { 139 | viewRender(); 140 | }); 141 | 142 | navigator.on("resume", () => { 143 | viewRender(); 144 | }); 145 | 146 | navigator.on("stop", () => { 147 | clearWordHighlighting(); 148 | viewRender(); 149 | }); 150 | 151 | navigator.on("end", () => { 152 | viewRender(); 153 | }); 154 | 155 | navigator.on("error", (event) => { 156 | console.error("Navigator error:", event.detail); 157 | viewRender(); 158 | }); 159 | 160 | navigator.on("boundary", (event) => { 161 | // Handle word boundaries for highlighting 162 | if (event.detail.name === "word") { 163 | highlightCurrentWord(event.detail.charIndex, event.detail.charLength); 164 | } 165 | viewRender(); 166 | }); 167 | 168 | // Playback control functions 169 | const playPause = async () => { 170 | const state = navigator.getState(); 171 | if (state === "playing") { 172 | navigator.pause(); 173 | } else { 174 | await navigator.play(); 175 | } 176 | }; 177 | 178 | const stop = () => { 179 | clearWordHighlighting(); 180 | navigator.stop(); 181 | }; 182 | 183 | const next = async () => { 184 | clearWordHighlighting(); 185 | await navigator.next(); 186 | }; 187 | 188 | const previous = async () => { 189 | clearWordHighlighting(); 190 | await navigator.previous(); 191 | }; 192 | 193 | const jumpToUtterance = () => { 194 | const input = document.getElementById("utterance-index"); 195 | const index = parseInt(input.value) - 1; // Convert to 0-based index 196 | if (index >= 0 && index < navigator.getContentQueue().length) { 197 | clearWordHighlighting(); 198 | navigator.jumpTo(index); 199 | // Clear user changed flag and update position tracking 200 | jumpInputUserChanged = false; 201 | lastNavigatorPosition = index + 1; 202 | // Update input to reflect the new position 203 | input.value = lastNavigatorPosition; 204 | } else { 205 | alert(`Please enter a number between 1 and ${navigator.getContentQueue().length}`); 206 | // Reset input to current position and clear user changed flag 207 | const currentPos = navigator.getCurrentUtteranceIndex() + 1; 208 | input.value = currentPos; 209 | jumpInputUserChanged = false; 210 | lastNavigatorPosition = currentPos; 211 | } 212 | }; 213 | 214 | function highlightCurrentWord(charIndex, charLength) { 215 | // Clear previous highlighting 216 | clearWordHighlighting(); 217 | 218 | // Find the current utterance being spoken 219 | const currentUtterance = navigator.getCurrentContent(); 220 | if (!currentUtterance) return; 221 | 222 | // Extract the word based on character index and length 223 | const text = currentUtterance.text; 224 | if (charIndex >= 0 && charIndex < text.length) { 225 | const wordEnd = Math.min(charIndex + charLength, text.length); 226 | const word = text.substring(charIndex, wordEnd); 227 | 228 | // Find the specific occurrence of this word at this position 229 | highlightSpecificWord(text, word, charIndex); 230 | 231 | currentWordHighlight = { 232 | utteranceIndex: navigator.getCurrentUtteranceIndex(), 233 | charIndex: charIndex, 234 | charLength: charLength, 235 | word: word 236 | }; 237 | } 238 | } 239 | 240 | function highlightSpecificWord(fullText, targetWord, startIndex) { 241 | const utteranceElements = document.querySelectorAll('.utterance-text'); 242 | const currentUtteranceIndex = navigator.getCurrentUtteranceIndex(); 243 | 244 | if (utteranceElements.length > currentUtteranceIndex) { 245 | const currentElement = utteranceElements[currentUtteranceIndex]; 246 | if (currentElement) { 247 | // Find the specific occurrence of the word at the given character position 248 | const beforeText = fullText.substring(0, startIndex); 249 | const afterText = fullText.substring(startIndex + targetWord.length); 250 | 251 | // Reconstruct the HTML with only the specific word highlighted 252 | currentElement.innerHTML = 253 | beforeText + 254 | '' + targetWord + '' + 255 | afterText; 256 | } 257 | } 258 | } 259 | 260 | function clearWordHighlighting() { 261 | // Remove all word highlighting 262 | const highlightedWords = document.querySelectorAll('.highlighted-word'); 263 | highlightedWords.forEach(el => { 264 | el.outerHTML = el.textContent; 265 | }); 266 | 267 | currentWordHighlight = null; 268 | } 269 | 270 | // UI Components 271 | const content = (state) => { 272 | // Show loading state if navigator isn't ready 273 | if (navigator.getState() === "loading" || !state) { 274 | return html` 275 |
276 |

Readium Speech Navigator Demo

277 |
278 |

Loading speech engine...

279 |
280 |
`; 281 | } 282 | 283 | return html` 284 |
285 |

Readium Speech Navigator Demo

286 | 287 |
288 |
289 |

Voice Settings

290 | ${voices.length > 0 ? html` 291 |
292 | 293 | 306 |
307 |
308 |
309 | Voice Details 310 |
311 | ${(() => { 312 | const voice = navigator.getCurrentVoice(); 313 | if (!voice) return html`

No voice selected

`; 314 | 315 | // Get all properties from the voice object 316 | const voiceProps = []; 317 | 318 | // Add all properties from the voice object 319 | for (const [key, value] of Object.entries(voice)) { 320 | if (key.startsWith("_")) continue; 321 | 322 | let displayValue = value; 323 | if (value === undefined) displayValue = "undefined"; 324 | else if (value === null) displayValue = "null"; 325 | else if (typeof value === "boolean") displayValue = value ? "Yes" : "No"; 326 | else if (typeof value === "object") displayValue = JSON.stringify(value); 327 | 328 | voiceProps.push({ key, value: displayValue }); 329 | } 330 | 331 | return html` 332 |
333 | ${voiceProps.map(({key, value}) => html` 334 |
335 |
${key}:
336 |
${value}
337 |
338 | `)} 339 |
340 | `; 341 | })()} 342 |
343 |
344 | 345 | 363 |
364 | ` : html`
Loading voices...
`} 365 |
366 |
367 | 368 |
369 | 372 | 373 | 374 | 375 |
376 | 377 |
378 | 386 | of ${state.totalUtterances} 387 | 388 |
389 | 390 |
391 |

State: ${navigator.getState()}

392 |
393 | 394 |
395 |

Content Preview

396 |
397 | ${navigator.getContentQueue().map((utterance, index) => html` 398 |
399 | ${index + 1}. 400 | ${utterance.text} 401 |
402 | `)} 403 |
404 |
405 | 406 | 666 | `; 667 | }; 668 | 669 | // Initial render with loading state 670 | viewRender(); 671 | 672 | // Re-render once voices are loaded 673 | initVoices().then(() => viewRender()); 674 | -------------------------------------------------------------------------------- /src/voices.ts: -------------------------------------------------------------------------------- 1 | 2 | import { novelty, quality, recommended, veryLowQuality, TGender, TQuality, IRecommended, defaultRegion } from "./data.gen.js"; 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 | 62 | const tick = () => { 63 | // Resolve with empty array if no voices found 64 | if (counter < 1) return resolve([]); 65 | --counter; 66 | const voices = a(); 67 | // Resolve if voices loaded 68 | if (Array.isArray(voices) && voices.length) return resolve(voices); 69 | // Continue polling 70 | setTimeout(tick, interval); 71 | }; 72 | // Initial start 73 | setTimeout(tick, interval); 74 | }; 75 | 76 | // Step 2: Use onvoiceschanged if available (prioritizes event over polling) 77 | if (speechSynthesis.onvoiceschanged) { 78 | speechSynthesis.onvoiceschanged = () => { 79 | const voices = a(); 80 | if (Array.isArray(voices) && voices.length) { 81 | // Resolve immediately if voices are available 82 | resolve(voices); 83 | } else { 84 | // Fallback to polling if event fires but no voices 85 | startPolling(); 86 | } 87 | }; 88 | } else { 89 | // Step 3: No onvoiceschanged support, start polling directly 90 | startPolling(); 91 | } 92 | 93 | // Step 4: Overall safety timeout - fail if nothing happens after maxTimeout 94 | setTimeout(() => reject(new Error("No voices available after timeout")), maxTimeout); 95 | }); 96 | } 97 | 98 | const _strHash = ({voiceURI, name, language, offlineAvailability}: ReadiumSpeechVoice) => `${voiceURI}_${name}_${language}_${offlineAvailability}`; 99 | 100 | function removeDuplicate(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { 101 | 102 | const voicesStrMap = [...new Set(voices.map((v) => _strHash(v)))]; 103 | 104 | const voicesFiltered = voicesStrMap 105 | .map((s) => voices.find((v) => _strHash(v) === s)) 106 | .filter((v) => !!v); 107 | 108 | return voicesFiltered; 109 | } 110 | 111 | export function parseSpeechSynthesisVoices(speechSynthesisVoices: SpeechSynthesisVoice[]): ReadiumSpeechVoice[] { 112 | 113 | const parseAndFormatBCP47 = (lang: string) => { 114 | const speechVoiceLang = lang.replace("_", "-"); 115 | if (/\w{2,3}-\w{2,3}/.test(speechVoiceLang)) { 116 | return `${speechVoiceLang.split("-")[0].toLowerCase()}-${speechVoiceLang.split("-")[1].toUpperCase()}`; 117 | } 118 | 119 | // bad formated !? 120 | return lang; 121 | }; 122 | return speechSynthesisVoices.map((speechVoice) => ({ 123 | label: speechVoice.name, 124 | voiceURI: speechVoice.voiceURI , 125 | name: speechVoice.name, 126 | __lang: speechVoice.lang, 127 | language: parseAndFormatBCP47(speechVoice.lang) , 128 | gender: undefined, 129 | age: undefined, 130 | offlineAvailability: speechVoice.localService, 131 | quality: undefined, 132 | pitchControl: true, 133 | recommendedPitch: undefined, 134 | recomendedRate: undefined, 135 | })); 136 | } 137 | 138 | // Note: This does not work as browsers expect an actual SpeechSynthesisVoice 139 | // Here it is just an object with the same-ish properties 140 | export function convertToSpeechSynthesisVoices(voices: ReadiumSpeechVoice[]): SpeechSynthesisVoice[] { 141 | return voices.map((voice) => ({ 142 | default: false, 143 | lang: voice.__lang || voice.language, 144 | localService: voice.offlineAvailability, 145 | name: voice.name, 146 | voiceURI: voice.voiceURI, 147 | })); 148 | } 149 | 150 | export function filterOnOfflineAvailability(voices: ReadiumSpeechVoice[], offline = true): ReadiumSpeechVoice[] { 151 | return voices.filter(({offlineAvailability}) => { 152 | return offlineAvailability === offline; 153 | }); 154 | } 155 | 156 | export function filterOnGender(voices: ReadiumSpeechVoice[], gender: TGender): ReadiumSpeechVoice[] { 157 | return voices.filter(({gender: voiceGender}) => { 158 | return voiceGender === gender; 159 | }) 160 | } 161 | 162 | export function filterOnLanguage(voices: ReadiumSpeechVoice[], language: string | string[] = navigatorLang()): ReadiumSpeechVoice[] { 163 | language = Array.isArray(language) ? language : [language]; 164 | language = language.map((l) => extractLangRegionFromBCP47(l)[0]); 165 | return voices.filter(({language: voiceLanguage}) => { 166 | const [lang] = extractLangRegionFromBCP47(voiceLanguage); 167 | return language.includes(lang); 168 | }) 169 | } 170 | 171 | export function filterOnQuality(voices: ReadiumSpeechVoice[], quality: TQuality | TQuality[]): ReadiumSpeechVoice[] { 172 | quality = Array.isArray(quality) ? quality : [quality]; 173 | return voices.filter(({quality: voiceQuality}) => { 174 | return quality.some((qual) => qual === voiceQuality); 175 | }); 176 | } 177 | 178 | export function filterOnNovelty(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { 179 | return voices.filter(({ name }) => { 180 | return !novelty.includes(name); 181 | }); 182 | } 183 | 184 | export function filterOnVeryLowQuality(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { 185 | return voices.filter(({ name }) => { 186 | return !veryLowQuality.find((v) => name.startsWith(v)); 187 | }); 188 | } 189 | 190 | function updateVoiceInfo(recommendedVoice: IRecommended, voice: ReadiumSpeechVoice) { 191 | voice.label = recommendedVoice.label; 192 | voice.gender = recommendedVoice.gender; 193 | voice.recommendedPitch = recommendedVoice.recommendedPitch; 194 | voice.recommendedRate = recommendedVoice.recommendedRate; 195 | 196 | return voice; 197 | } 198 | export type TReturnFilterOnRecommended = [voicesRecommended: ReadiumSpeechVoice[], voicesLowerQuality: ReadiumSpeechVoice[]]; 199 | export function filterOnRecommended(voices: ReadiumSpeechVoice[], _recommended: IRecommended[] = recommended): TReturnFilterOnRecommended { 200 | 201 | const voicesRecommended: ReadiumSpeechVoice[] = []; 202 | const voicesLowerQuality: ReadiumSpeechVoice[] = []; 203 | 204 | recommendedVoiceLoop: 205 | for (const recommendedVoice of _recommended) { 206 | if (Array.isArray(recommendedVoice.quality) && recommendedVoice.quality.length > 1) { 207 | 208 | const voicesFound = voices.filter(({ name }) => name.startsWith(recommendedVoice.name)); 209 | if (voicesFound.length) { 210 | 211 | for (const qualityTested of ["high", "normal"] as TQuality[]) { 212 | for (let i = 0; i < voicesFound.length; i++) { 213 | const voice = voicesFound[i]; 214 | 215 | const rxp = /^.*\((.*)\)$/; 216 | if (rxp.test(voice.name)) { 217 | const res = rxp.exec(voice.name); 218 | const maybeQualityString = res ? res[1] || "" : ""; 219 | const qualityDataArray = qualityTested === "high" ? highQuality : normalQuality; 220 | 221 | if (recommendedVoice.quality.includes(qualityTested) && qualityDataArray.includes(maybeQualityString)) { 222 | voice.quality = qualityTested; 223 | voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice)); 224 | 225 | voicesFound.splice(i, 1); 226 | voicesLowerQuality.push(...(voicesFound.map((v) => { 227 | v.quality = "low"; // Todo need to be more precise for 'normal' quality voices 228 | return updateVoiceInfo(recommendedVoice, v); 229 | }))); 230 | 231 | continue recommendedVoiceLoop; 232 | } 233 | } 234 | } 235 | } 236 | const voice = voicesFound[0]; 237 | for (let i = 1; i < voicesFound.length; i++) { 238 | voicesLowerQuality.push(voicesFound[i]); 239 | } 240 | 241 | voice.quality = voicesFound.length > 3 ? "veryHigh" : voicesFound.length > 2 ? "high" : "normal"; 242 | voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice)); 243 | 244 | } 245 | } else if (Array.isArray(recommendedVoice.altNames) && recommendedVoice.altNames.length) { 246 | 247 | const voiceFound = voices.find(({ name }) => name === recommendedVoice.name); 248 | if (voiceFound) { 249 | const voice = voiceFound; 250 | 251 | voice.quality = Array.isArray(recommendedVoice.quality) ? recommendedVoice.quality[0] : undefined; 252 | voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice)); 253 | 254 | // voice Name found so altNames array must be filter and push to voicesLowerQuality 255 | const altNamesVoicesFound = voices.filter(({name}) => recommendedVoice.altNames!.includes(name)); 256 | // TODO: Typescript bug type assertion doesn't work, need to force the compiler with the Non-null Assertion Operator 257 | 258 | voicesLowerQuality.push(...(altNamesVoicesFound.map((v) => { 259 | v.quality = recommendedVoice.quality[0]; 260 | return updateVoiceInfo(recommendedVoice, v); 261 | }))); 262 | } else { 263 | 264 | // filter voices on altNames, keep the first and push the remaining to voicesLowerQuality 265 | const altNamesVoicesFound = voices.filter(({name}) => recommendedVoice.altNames!.includes(name)); 266 | if (altNamesVoicesFound.length) { 267 | 268 | const voice = altNamesVoicesFound.shift() as ReadiumSpeechVoice; 269 | 270 | voice.quality = Array.isArray(recommendedVoice.quality) ? recommendedVoice.quality[0] : undefined; 271 | voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice)); 272 | 273 | 274 | voicesLowerQuality.push(...(altNamesVoicesFound.map((v) => { 275 | v.quality = recommendedVoice.quality[0]; 276 | return updateVoiceInfo(recommendedVoice, v); 277 | }))); 278 | } 279 | } 280 | } else { 281 | 282 | const voiceFound = voices.find(({ name }) => name === recommendedVoice.name); 283 | if (voiceFound) { 284 | 285 | const voice = voiceFound; 286 | 287 | voice.quality = Array.isArray(recommendedVoice.quality) ? recommendedVoice.quality[0] : undefined; 288 | voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice)); 289 | 290 | } 291 | } 292 | } 293 | 294 | return [removeDuplicate(voicesRecommended), removeDuplicate(voicesLowerQuality)]; 295 | } 296 | 297 | const extractLangRegionFromBCP47 = (l: string) => [l.split("-")[0].toLowerCase(), l.split("-")[1]?.toUpperCase()]; 298 | 299 | export function sortByQuality(voices: ReadiumSpeechVoice[]) { 300 | return voices.sort(({quality: qa}, {quality: qb}) => { 301 | return compareQuality(qa, qb); 302 | }); 303 | } 304 | 305 | export function sortByName(voices: ReadiumSpeechVoice[]) { 306 | return voices.sort(({name: na}, {name: nb}) => { 307 | return na.localeCompare(nb); 308 | }) 309 | } 310 | 311 | export function sortByGender(voices: ReadiumSpeechVoice[], genderFirst: TGender) { 312 | return voices.sort(({gender: ga}, {gender: gb}) => { 313 | return ga === gb ? 0 : ga === genderFirst ? -1 : gb === genderFirst ? -1 : 1; 314 | }) 315 | } 316 | 317 | function orderByPreferredLanguage(preferredLanguage?: string[] | string): string[] { 318 | preferredLanguage = Array.isArray(preferredLanguage) ? preferredLanguage : 319 | preferredLanguage ? [preferredLanguage] : []; 320 | 321 | return [...(new Set([...preferredLanguage, ...navigatorLanguages()]))]; 322 | } 323 | function orderByPreferredRegion(preferredLanguage?: string[] | string): string[] { 324 | preferredLanguage = Array.isArray(preferredLanguage) ? preferredLanguage : 325 | preferredLanguage ? [preferredLanguage] : []; 326 | 327 | const regionByDefaultArray = Object.values(defaultRegion); 328 | 329 | return [...(new Set([...preferredLanguage, ...navigatorLanguages(), ...regionByDefaultArray]))]; 330 | } 331 | 332 | const getLangFromBCP47Array = (a: string[]) => { 333 | return [...(new Set(a.map((v) => extractLangRegionFromBCP47(v)[0]).filter((v) => !!v)))]; 334 | } 335 | const getRegionFromBCP47Array = (a: string[]) => { 336 | return [...(new Set(a.map((v) => (extractLangRegionFromBCP47(v)[1] || "").toUpperCase()).filter((v) => !!v)))]; 337 | } 338 | 339 | export function sortByLanguage(voices: ReadiumSpeechVoice[], preferredLanguage: string[] | string = [], localization: string | undefined = navigatorLang()): ReadiumSpeechVoice[] { 340 | 341 | const languages = getLangFromBCP47Array(orderByPreferredLanguage(preferredLanguage)); 342 | 343 | const voicesSorted: ReadiumSpeechVoice[] = []; 344 | for (const lang of languages) { 345 | voicesSorted.push(...voices.filter(({language: voiceLanguage}) => lang === extractLangRegionFromBCP47(voiceLanguage)[0])); 346 | } 347 | 348 | let langueName: Intl.DisplayNames | undefined = undefined; 349 | if (localization) { 350 | try { 351 | langueName = new Intl.DisplayNames([localization], { type: "language" }); 352 | } catch (e) { 353 | console.error("Intl.DisplayNames throw an exception with ", localization, e); 354 | } 355 | } 356 | 357 | const remainingVoices = voices.filter((v) => !voicesSorted.includes(v)); 358 | remainingVoices.sort(({ language: a }, { language: b }) => { 359 | 360 | let nameA = a, nameB = b; 361 | try { 362 | if (langueName) { 363 | nameA = langueName.of(extractLangRegionFromBCP47(a)[0]) || a; 364 | nameB = langueName.of(extractLangRegionFromBCP47(b)[0]) || b; 365 | } 366 | } catch (e) { 367 | // ignore 368 | } 369 | return nameA.localeCompare(nameB); 370 | }); 371 | 372 | return [...voicesSorted, ...remainingVoices]; 373 | } 374 | 375 | export function sortByRegion(voices: ReadiumSpeechVoice[], preferredRegions: string[] | string = [], localization: string | undefined = navigatorLang()): ReadiumSpeechVoice[] { 376 | 377 | const regions = getRegionFromBCP47Array(orderByPreferredRegion(preferredRegions)); 378 | 379 | const voicesSorted: ReadiumSpeechVoice[] = []; 380 | for (const reg of regions) { 381 | voicesSorted.push(...voices.filter(({language: voiceLanguage}) => reg === extractLangRegionFromBCP47(voiceLanguage)[1])); 382 | } 383 | 384 | let regionName: Intl.DisplayNames | undefined = undefined; 385 | if (localization) { 386 | try { 387 | regionName = new Intl.DisplayNames([localization], { type: "region" }); 388 | } catch (e) { 389 | console.error("Intl.DisplayNames throw an exception with ", localization, e); 390 | } 391 | } 392 | 393 | const remainingVoices = voices.filter((v) => !voicesSorted.includes(v)); 394 | remainingVoices.sort(({ language: a }, { language: b }) => { 395 | 396 | let nameA = a, nameB = b; 397 | try { 398 | if (regionName) { 399 | nameA = regionName.of(extractLangRegionFromBCP47(a)[1]) || a; 400 | nameB = regionName.of(extractLangRegionFromBCP47(b)[1]) || b; 401 | } 402 | } catch (e) { 403 | // ignore 404 | } 405 | return nameA.localeCompare(nameB); 406 | }); 407 | 408 | return [...voicesSorted, ...remainingVoices]; 409 | } 410 | 411 | export interface ILanguages { 412 | label: string; 413 | code: string; 414 | count: number; 415 | } 416 | export function listLanguages(voices: ReadiumSpeechVoice[], localization: string | undefined = navigatorLang()): ILanguages[] { 417 | let langueName: Intl.DisplayNames | undefined = undefined; 418 | if (localization) { 419 | try { 420 | langueName = new Intl.DisplayNames([localization], { type: "language" }); 421 | } catch (e) { 422 | console.error("Intl.DisplayNames throw an exception with ", localization, e); 423 | } 424 | } 425 | return voices.reduce((acc, cv) => { 426 | const [lang] = extractLangRegionFromBCP47(cv.language); 427 | let name = lang; 428 | try { 429 | if (langueName) { 430 | name = langueName.of(lang) || lang; 431 | } 432 | } catch (e) { 433 | console.error("langueName.of throw an error with ", lang, e); 434 | } 435 | const found = acc.find(({code}) => code === lang) 436 | if (found) { 437 | found.count++; 438 | } else { 439 | acc.push({code: lang, count: 1, label: name}); 440 | } 441 | return acc; 442 | }, []); 443 | } 444 | export function listRegions(voices: ReadiumSpeechVoice[], localization: string | undefined = navigatorLang()): ILanguages[] { 445 | let regionName: Intl.DisplayNames | undefined = undefined; 446 | if (localization) { 447 | try { 448 | regionName = new Intl.DisplayNames([localization], { type: "region" }); 449 | } catch (e) { 450 | console.error("Intl.DisplayNames throw an exception with ", localization, e); 451 | } 452 | } 453 | return voices.reduce((acc, cv) => { 454 | const [,region] = extractLangRegionFromBCP47(cv.language); 455 | let name = region; 456 | try { 457 | if (regionName) { 458 | name = regionName.of(region) || region; 459 | } 460 | } catch (e) { 461 | console.error("regionName.of throw an error with ", region, e); 462 | } 463 | const found = acc.find(({code}) => code === region); 464 | if (found) { 465 | found.count++; 466 | } else { 467 | acc.push({code: region, count: 1, label: name}); 468 | } 469 | return acc; 470 | }, []); 471 | } 472 | 473 | export type TGroupVoices = Map; 474 | export function groupByLanguages(voices: ReadiumSpeechVoice[], preferredLanguage: string[] | string = [], localization: string | undefined = navigatorLang()): TGroupVoices { 475 | 476 | const voicesSorted = sortByLanguage(voices, preferredLanguage, localization); 477 | 478 | const languagesStructure = listLanguages(voicesSorted, localization); 479 | const res: TGroupVoices = new Map(); 480 | for (const { code, label } of languagesStructure) { 481 | res.set(label, voicesSorted 482 | .filter(({ language: voiceLang }) => { 483 | const [l] = extractLangRegionFromBCP47(voiceLang); 484 | return l === code; 485 | })); 486 | } 487 | return res; 488 | } 489 | 490 | export function groupByRegions(voices: ReadiumSpeechVoice[], preferredRegions: string[] | string = [], localization: string | undefined = navigatorLang()): TGroupVoices { 491 | 492 | const voicesSorted = sortByRegion(voices, preferredRegions, localization); 493 | 494 | const languagesStructure = listRegions(voicesSorted, localization); 495 | const res: TGroupVoices = new Map(); 496 | for (const { code, label } of languagesStructure) { 497 | res.set(label, voicesSorted 498 | .filter(({ language: voiceLang }) => { 499 | const [, r] = extractLangRegionFromBCP47(voiceLang); 500 | return r === code; 501 | })); 502 | } 503 | return res; 504 | } 505 | 506 | export function groupByKindOfVoices(allVoices: ReadiumSpeechVoice[]): TGroupVoices { 507 | 508 | const [recommendedVoices, lowQualityVoices] = filterOnRecommended(allVoices); 509 | const remainingVoice = allVoices.filter((v) => !recommendedVoices.includes(v) && !lowQualityVoices.includes(v)); 510 | const noveltyFiltered = filterOnNovelty(remainingVoice); 511 | const noveltyVoices = remainingVoice.filter((v) => !noveltyFiltered.includes(v)); 512 | const veryLowQualityFiltered = filterOnVeryLowQuality(remainingVoice); 513 | const veryLowQualityVoices = remainingVoice.filter((v) => !veryLowQualityFiltered.includes(v)); 514 | const remainingVoiceFiltered = filterOnNovelty(filterOnVeryLowQuality(remainingVoice)); 515 | 516 | const res: TGroupVoices = new Map(); 517 | res.set("recommendedVoices", recommendedVoices); 518 | res.set("lowerQuality", lowQualityVoices); 519 | res.set("novelty", noveltyVoices); 520 | res.set("veryLowQuality", veryLowQualityVoices); 521 | res.set("remaining", remainingVoiceFiltered); 522 | 523 | return res; 524 | } 525 | 526 | export function getLanguages(voices: ReadiumSpeechVoice[], preferredLanguage: string[] | string = [], localization: string | undefined = navigatorLang()): ILanguages[] { 527 | 528 | const group = groupByLanguages(voices, preferredLanguage, localization); 529 | 530 | return Array.from(group.entries()).map(([label, _voices]) => { 531 | return {label, count: _voices.length, code: extractLangRegionFromBCP47(_voices[0]?.language || "")[0]} 532 | }); 533 | } 534 | 535 | /** 536 | * Parse and extract SpeechSynthesisVoices, 537 | * @returns ReadiumSpeechVoice[] 538 | */ 539 | export async function getVoices(preferredLanguage?: string[] | string, localization?: string) { 540 | 541 | const speechVoices = await getSpeechSynthesisVoices(); 542 | const allVoices = removeDuplicate(parseSpeechSynthesisVoices(speechVoices)); 543 | const recommendedTuple = filterOnRecommended(allVoices); 544 | const [recommendedVoices, lowQualityVoices] = recommendedTuple; 545 | const recommendedTupleFlatten = recommendedTuple.flat(); 546 | const remainingVoices = allVoices 547 | .map((allVoicesItem) => _strHash(allVoicesItem)) 548 | .filter((str) => !recommendedTupleFlatten.find((recommendedVoicesPtr) => _strHash(recommendedVoicesPtr) === str)) 549 | .map((str) => allVoices.find((allVoicesPtr) => _strHash(allVoicesPtr) === str)) 550 | .filter((v) => !!v); 551 | const remainingVoiceFiltered = filterOnNovelty(filterOnVeryLowQuality(remainingVoices)); 552 | 553 | 554 | // console.log("PRE_recommendedVoices_GET_VOICES", recommendedVoices.filter(({label}) => label === "Paulina"), recommendedVoices.length); 555 | 556 | // console.log("PRE_lowQualityVoices_GET_VOICES", lowQualityVoices.filter(({label}) => label === "Paulina"), lowQualityVoices.length); 557 | 558 | // console.log("PRE_remainingVoiceFiltered_GET_VOICES", remainingVoiceFiltered.filter(({label}) => label === "Paulina"), remainingVoiceFiltered.length); 559 | 560 | // console.log("PRE_allVoices_GET_VOICES", allVoices.filter(({label}) => label === "Paulina"), allVoices.length); 561 | 562 | const voices = [recommendedVoices, lowQualityVoices, remainingVoiceFiltered].flat(); 563 | 564 | // console.log("MID_GET_VOICES", voices.filter(({label}) => label === "Paulina"), voices.length); 565 | 566 | const voicesSorted = sortByLanguage(sortByQuality(voices), preferredLanguage, localization || navigatorLang()); 567 | 568 | // console.log("POST_GET_VOICES", voicesSorted.filter(({ label }) => label === "Paulina"), voicesSorted.length); 569 | 570 | return voicesSorted; 571 | } -------------------------------------------------------------------------------- /test/voices.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { filterOnRecommended, groupByLanguages, ReadiumSpeechVoice, sortByLanguage, groupByRegions } from "../src/voices.js"; 3 | import { IRecommended } from "../src/data.gen.js"; 4 | 5 | test('dumb test', t => { 6 | t.deepEqual([], []); 7 | }); 8 | 9 | test.before(t => { 10 | // This runs before all tests 11 | globalThis.window = { navigator: { languages: [] } as any } as any; 12 | }); 13 | 14 | test('sortByLanguage: Empty preferred language list', t => { 15 | const voices = [ 16 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 17 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 18 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, 19 | ]; 20 | 21 | const result = sortByLanguage(voices, [], ""); 22 | t.true(result.length === voices.length); 23 | t.true(result[0].language === 'en-US'); 24 | t.true(result[1].language === 'en-US'); 25 | t.true(result[2].language === 'fr-FR'); 26 | }); 27 | 28 | test('sortByLanguage: Preferred language list with one language', t => { 29 | const voices = [ 30 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 31 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 32 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, 33 | ]; 34 | 35 | const result = sortByLanguage(voices, ['fr-FR'], ""); 36 | t.true(result.length === voices.length); 37 | t.true(result[0].language === 'fr-FR'); 38 | t.true(result[1].language === 'en-US'); 39 | t.true(result[2].language === 'en-US'); 40 | }); 41 | 42 | test('sortByLanguage: Preferred language list with multiple languages', t => { 43 | const voices = [ 44 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 45 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 46 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, 47 | { label: 'Voice 4', voiceURI: 'uri4', name: 'Name 4', language: 'es-ES', offlineAvailability: true, pitchControl: true }, 48 | ]; 49 | 50 | const result = sortByLanguage(voices, ['fr-FR', 'es-ES'], ""); 51 | t.true(result.length === voices.length); 52 | t.true(result[0].language === 'fr-FR'); 53 | t.true(result[1].language === 'es-ES'); 54 | t.true(result[2].language === 'en-US'); 55 | t.true(result[3].language === 'en-US'); 56 | }); 57 | 58 | test('sortByLanguage: No matching languages', t => { 59 | const voices = [ 60 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 61 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 62 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, 63 | ]; 64 | 65 | const result = sortByLanguage(voices, ['de-DE'], ""); 66 | t.true(result.length === voices.length); 67 | t.true(result[0].language === 'en-US'); 68 | t.true(result[1].language === 'en-US'); 69 | t.true(result[2].language === 'fr-FR'); 70 | }); 71 | 72 | test('sortByLanguage: Preferred language list is not an array', t => { 73 | const voices = [ 74 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 75 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 76 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, 77 | ]; 78 | 79 | const result = sortByLanguage(voices, 'en-US', ""); 80 | t.true(result.length === voices.length); 81 | t.true(result[0].language === 'en-US'); 82 | t.true(result[1].language === 'en-US'); 83 | t.true(result[2].language === 'fr-FR'); 84 | }); 85 | 86 | test('sortByLanguage: Preferred language undefined and navigator langua', t => { 87 | const voices = [ 88 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 89 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 90 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, 91 | ]; 92 | 93 | const result = sortByLanguage(voices, 'en-US', ""); 94 | t.true(result.length === voices.length); 95 | t.true(result[0].language === 'en-US'); 96 | t.true(result[1].language === 'en-US'); 97 | t.true(result[2].language === 'fr-FR'); 98 | }); 99 | 100 | test('sortByLanguage: Preferred language list with one language and navigator.languages', t => { 101 | (globalThis.window.navigator as any).languages = ['fr-FR', 'en-US']; 102 | const voices = [ 103 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 104 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 105 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, 106 | ]; 107 | 108 | const result = sortByLanguage(voices, ['fr-FR'], ""); 109 | t.true(result.length === voices.length); 110 | t.true(result[0].language === 'fr-FR'); 111 | t.true(result[1].language === 'en-US'); 112 | t.true(result[2].language === 'en-US'); 113 | }); 114 | 115 | test('sortByLanguage: Preferred language list with multiple languages and navigator.languages', t => { 116 | (globalThis.window.navigator as any).languages = ['fr-FR', 'en-US']; 117 | const voices = [ 118 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 119 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 120 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, 121 | { label: 'Voice 4', voiceURI: 'uri4', name: 'Name 4', language: 'es-ES', offlineAvailability: true, pitchControl: true }, 122 | ]; 123 | 124 | const result = sortByLanguage(voices, ['fr-FR', 'es-ES'], ""); 125 | t.true(result.length === voices.length); 126 | t.true(result[0].language === 'fr-FR'); 127 | t.true(result[1].language === 'es-ES'); 128 | t.true(result[2].language === 'en-US'); 129 | t.true(result[3].language === 'en-US'); 130 | }); 131 | 132 | test('sortByLanguage: No matching languages and navigator.languages', t => { 133 | (globalThis.window.navigator as any).languages = ['de-DE', 'en-US']; 134 | const voices = [ 135 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 136 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 137 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, 138 | ]; 139 | 140 | const result = sortByLanguage(voices, ['de-DE'], ""); 141 | t.true(result.length === voices.length); 142 | t.true(result[0].language === 'en-US'); 143 | t.true(result[1].language === 'en-US'); 144 | t.true(result[2].language === 'fr-FR'); 145 | }); 146 | 147 | test('sortByLanguage: Preferred language list is not an array and navigator.languages', t => { 148 | (globalThis.window.navigator as any).languages = ['fr-FR', 'en-US']; 149 | const voices = [ 150 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 151 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 152 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, 153 | ]; 154 | 155 | const result = sortByLanguage(voices, 'en-US', ""); 156 | t.true(result.length === voices.length); 157 | t.true(result[0].language === 'en-US'); 158 | t.true(result[1].language === 'en-US'); 159 | t.true(result[2].language === 'fr-FR'); 160 | }); 161 | 162 | test('filterOnRecommended: Empty input', t => { 163 | const voices: ReadiumSpeechVoice[] = []; 164 | const result = filterOnRecommended(voices); 165 | t.deepEqual(result, [[], []]); 166 | }); 167 | 168 | test('filterOnRecommended: No recommended voices', t => { 169 | const voices = [ 170 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 171 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, 172 | ]; 173 | const result = filterOnRecommended(voices, []); 174 | t.deepEqual(result, [[], []]); 175 | }); 176 | 177 | test('filterOnRecommended: Single recommended voice with single quality', t => { 178 | const voices = [ 179 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 180 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, 181 | ]; 182 | const recommended: IRecommended[] = [ 183 | { name: 'Name 1', label: 'Voice 1', quality: ['high'], language: 'en-US', localizedName: "" }, 184 | ]; 185 | const result = filterOnRecommended(voices, recommended); 186 | t.deepEqual(result, [ 187 | [ 188 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'high', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 189 | ], 190 | [], 191 | ]); 192 | }); 193 | 194 | test('filterOnRecommended: Single recommended voice with multiple qualities', t => { 195 | const voices = [ 196 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 197 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, 198 | ]; 199 | const recommended: IRecommended[] = [ 200 | { name: 'Name 1', label: 'Voice 1', quality: ['high', 'normal'], language: 'en-US', localizedName: "" }, 201 | ]; 202 | const result = filterOnRecommended(voices, recommended); 203 | t.deepEqual(result, [ 204 | [ 205 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'normal', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 206 | ], 207 | [], 208 | ]); 209 | }); 210 | 211 | test('filterOnRecommended: Single recommended voice with multiple qualities and remaining lowQuality', t => { 212 | const voices = [ 213 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 214 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, 215 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 1 (Premium)', language: 'en-US', offlineAvailability: true, pitchControl: true }, 216 | ]; 217 | const recommended: IRecommended[] = [ 218 | { name: 'Name 1', label: 'Voice 1', quality: ['high', 'normal'], language: 'en-US', localizedName: "" }, 219 | ]; 220 | const result = filterOnRecommended(voices, recommended); 221 | t.deepEqual(result, [ 222 | [ 223 | { label: 'Voice 1', voiceURI: 'uri3', name: 'Name 1 (Premium)', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'high', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 224 | ], 225 | [ 226 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'low', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 227 | ], 228 | ]); 229 | }); 230 | 231 | test('filterOnRecommended: Multiple recommended voices', t => { 232 | const voices = [ 233 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 234 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, 235 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 236 | ]; 237 | const recommended: IRecommended[] = [ 238 | { name: 'Name 1', label: 'Voice 1', quality: ['high'], language: 'en-US', localizedName: "" }, 239 | { name: 'Name 2', label: 'Voice 2', quality: ['normal'], language: 'es-ES', localizedName: "" }, 240 | ]; 241 | const result = filterOnRecommended(voices, recommended); 242 | t.deepEqual(result, [ 243 | [ 244 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'high', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 245 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false, quality: 'normal', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 246 | ], 247 | [], 248 | ]); 249 | }); 250 | test('filterOnRecommended: Recommended voices with altNames', t => { 251 | const voices = [ 252 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 253 | { label: 'Voice 1-1', voiceURI: 'uri1-1', name: 'Name 1 with an altNames', language: 'en-US', offlineAvailability: true, pitchControl: true }, 254 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, 255 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 256 | ]; 257 | const recommended: IRecommended[] = [ 258 | { name: 'Name 1', label: 'Voice 1', quality: ['high'], altNames: ['Name 1 with an altNames'], language: 'en-US', localizedName: "" }, 259 | { name: 'Name 2', label: 'Voice 2', quality: ['normal'], language: 'es-ES', localizedName: "" }, 260 | ]; 261 | const result = filterOnRecommended(voices, recommended); 262 | t.deepEqual(result, [ 263 | [ 264 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'high', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 265 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false, quality: 'normal', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 266 | ], 267 | [ 268 | { label: 'Voice 1', voiceURI: 'uri1-1', name: 'Name 1 with an altNames', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'high', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 269 | ], 270 | ]); 271 | }); 272 | test('filterOnRecommended: Recommended voices with altNames only and voices not in name', t => { 273 | const voices = [ 274 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1 with an altNames', language: 'en-US', offlineAvailability: true, pitchControl: true }, 275 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, 276 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 277 | ]; 278 | const recommended: IRecommended[] = [ 279 | { name: 'Name 1', label: 'Voice 1', quality: ['high'], altNames: ['Name 1 with an altNames'], language: 'en-US', localizedName: "" }, 280 | { name: 'Name 2', label: 'Voice 2', quality: ['normal'], language: 'es-ES', localizedName: "" }, 281 | ]; 282 | const result = filterOnRecommended(voices, recommended); 283 | t.deepEqual(result, [ 284 | [ 285 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1 with an altNames', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'high', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 286 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false, quality: 'normal', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 287 | ], 288 | [ 289 | ], 290 | ]); 291 | }); 292 | test('filterOnRecommended: Recommended voices with multiple altNames and voices not in name', t => { 293 | const voices = [ 294 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1 with an altNames', language: 'en-US', offlineAvailability: true, pitchControl: true }, 295 | { label: 'Voice 1-1', voiceURI: 'uri1-1', name: 'Name 1 with a second altNames', language: 'en-US', offlineAvailability: true, pitchControl: true }, 296 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, 297 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 298 | ]; 299 | const recommended: IRecommended[] = [ 300 | { name: 'Name 1', label: 'Voice 1', quality: ['high'], altNames: ['Name 1 with an altNames', 'Name 1 with a second altNames'], language: 'en-US', localizedName: "" }, 301 | { name: 'Name 2', label: 'Voice 2', quality: ['normal'], language: 'es-ES', localizedName: "" }, 302 | ]; 303 | const result = filterOnRecommended(voices, recommended); 304 | t.deepEqual(result, [ 305 | [ 306 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1 with an altNames', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'high', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 307 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false, quality: 'normal', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 308 | ], 309 | [ 310 | { label: 'Voice 1', voiceURI: 'uri1-1', name: 'Name 1 with a second altNames', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'high', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, 311 | ], 312 | ]); 313 | }); 314 | test('groupByLanguage: ', t => { 315 | const voices = [ 316 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 317 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 318 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, 319 | { label: 'Voice 4', voiceURI: 'uri4', name: 'Name 4', language: 'es-ES', offlineAvailability: true, pitchControl: true }, 320 | ]; 321 | const result = groupByLanguages(voices, ['fr-FR', 'es-ES'], ""); 322 | t.deepEqual(result, new Map([ 323 | ['fr', [ 324 | { 325 | label: 'Voice 2', 326 | language: 'fr-FR', 327 | name: 'Name 2', 328 | offlineAvailability: true, 329 | pitchControl: true, 330 | voiceURI: 'uri2', 331 | }, 332 | ]], 333 | ['es', [ 334 | { 335 | label: 'Voice 4', 336 | language: 'es-ES', 337 | name: 'Name 4', 338 | offlineAvailability: true, 339 | pitchControl: true, 340 | voiceURI: 'uri4', 341 | }, 342 | ]], 343 | ['en', [ 344 | { 345 | label: 'Voice 1', 346 | language: 'en-US', 347 | name: 'Name 1', 348 | offlineAvailability: true, 349 | pitchControl: true, 350 | voiceURI: 'uri1', 351 | }, 352 | { 353 | label: 'Voice 3', 354 | language: 'en-US', 355 | name: 'Name 3', 356 | offlineAvailability: true, 357 | pitchControl: true, 358 | voiceURI: 'uri3', 359 | }, 360 | ]], 361 | ])); 362 | }); 363 | test('groupByLanguage: localized en', t => { 364 | const voices = [ 365 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 366 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 367 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, 368 | { label: 'Voice 4', voiceURI: 'uri4', name: 'Name 4', language: 'es-ES', offlineAvailability: true, pitchControl: true }, 369 | ]; 370 | const result = groupByLanguages(voices, ['fr-FR', 'es-ES'], "en"); 371 | t.deepEqual(result, new Map([ 372 | ['French', [ 373 | { 374 | label: 'Voice 2', 375 | language: 'fr-FR', 376 | name: 'Name 2', 377 | offlineAvailability: true, 378 | pitchControl: true, 379 | voiceURI: 'uri2', 380 | }, 381 | ]], 382 | ['Spanish', [ 383 | { 384 | label: 'Voice 4', 385 | language: 'es-ES', 386 | name: 'Name 4', 387 | offlineAvailability: true, 388 | pitchControl: true, 389 | voiceURI: 'uri4', 390 | }, 391 | ]], 392 | ['English', [ 393 | { 394 | label: 'Voice 1', 395 | language: 'en-US', 396 | name: 'Name 1', 397 | offlineAvailability: true, 398 | pitchControl: true, 399 | voiceURI: 'uri1', 400 | }, 401 | { 402 | label: 'Voice 3', 403 | language: 'en-US', 404 | name: 'Name 3', 405 | offlineAvailability: true, 406 | pitchControl: true, 407 | voiceURI: 'uri3', 408 | }, 409 | ]], 410 | ])); 411 | }); 412 | test('groupByRegion: ', t => { 413 | const voices = [ 414 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 415 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 416 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-GB', offlineAvailability: true, pitchControl: true }, 417 | { label: 'Voice 4', voiceURI: 'uri4', name: 'Name 4', language: 'es-ES', offlineAvailability: true, pitchControl: true }, 418 | { label: 'Voice 5', voiceURI: 'uri5', name: 'Name 5', language: 'en-CA', offlineAvailability: true, pitchControl: true }, 419 | { label: 'Voice 6', voiceURI: 'uri6', name: 'Name 6', language: 'fr-CA', offlineAvailability: true, pitchControl: true }, 420 | ]; 421 | const result = groupByRegions(voices, ['fr-FR', 'es-ES'], ""); 422 | t.deepEqual(result, new Map([ 423 | ['FR', [ 424 | { 425 | label: 'Voice 2', 426 | language: 'fr-FR', 427 | name: 'Name 2', 428 | offlineAvailability: true, 429 | pitchControl: true, 430 | voiceURI: 'uri2', 431 | }, 432 | ]], 433 | ['ES', [ 434 | { 435 | label: 'Voice 4', 436 | language: 'es-ES', 437 | name: 'Name 4', 438 | offlineAvailability: true, 439 | pitchControl: true, 440 | voiceURI: 'uri4', 441 | }, 442 | ]], 443 | ['US', [ 444 | { 445 | label: 'Voice 1', 446 | language: 'en-US', 447 | name: 'Name 1', 448 | offlineAvailability: true, 449 | pitchControl: true, 450 | voiceURI: 'uri1', 451 | }, 452 | ]], 453 | ['CA', [ 454 | { 455 | label: 'Voice 5', 456 | language: 'en-CA', 457 | name: 'Name 5', 458 | offlineAvailability: true, 459 | pitchControl: true, 460 | voiceURI: 'uri5', 461 | }, 462 | { 463 | label: 'Voice 6', 464 | language: 'fr-CA', 465 | name: 'Name 6', 466 | offlineAvailability: true, 467 | pitchControl: true, 468 | voiceURI: 'uri6', 469 | }, 470 | ]], 471 | ['GB', [ 472 | { 473 | label: 'Voice 3', 474 | language: 'en-GB', 475 | name: 'Name 3', 476 | offlineAvailability: true, 477 | pitchControl: true, 478 | voiceURI: 'uri3', 479 | }, 480 | ]], 481 | ])); 482 | }); 483 | test('groupByRegion: localized fr', t => { 484 | const voices = [ 485 | { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, 486 | { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, 487 | { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-GB', offlineAvailability: true, pitchControl: true }, 488 | { label: 'Voice 4', voiceURI: 'uri4', name: 'Name 4', language: 'es-ES', offlineAvailability: true, pitchControl: true }, 489 | { label: 'Voice 5', voiceURI: 'uri5', name: 'Name 5', language: 'en-CA', offlineAvailability: true, pitchControl: true }, 490 | { label: 'Voice 6', voiceURI: 'uri6', name: 'Name 6', language: 'fr-CA', offlineAvailability: true, pitchControl: true }, 491 | ]; 492 | const result = groupByRegions(voices, ['fr-FR', 'es-ES'], "fr"); 493 | t.deepEqual(result, new Map([ 494 | ['France', [ 495 | { 496 | label: 'Voice 2', 497 | language: 'fr-FR', 498 | name: 'Name 2', 499 | offlineAvailability: true, 500 | pitchControl: true, 501 | voiceURI: 'uri2', 502 | }, 503 | ]], 504 | ['Espagne', [ 505 | { 506 | label: 'Voice 4', 507 | language: 'es-ES', 508 | name: 'Name 4', 509 | offlineAvailability: true, 510 | pitchControl: true, 511 | voiceURI: 'uri4', 512 | }, 513 | ]], 514 | ['États-Unis', [ 515 | { 516 | label: 'Voice 1', 517 | language: 'en-US', 518 | name: 'Name 1', 519 | offlineAvailability: true, 520 | pitchControl: true, 521 | voiceURI: 'uri1', 522 | }, 523 | ]], 524 | ['Canada', [ 525 | { 526 | label: 'Voice 5', 527 | language: 'en-CA', 528 | name: 'Name 5', 529 | offlineAvailability: true, 530 | pitchControl: true, 531 | voiceURI: 'uri5', 532 | }, 533 | { 534 | label: 'Voice 6', 535 | language: 'fr-CA', 536 | name: 'Name 6', 537 | offlineAvailability: true, 538 | pitchControl: true, 539 | voiceURI: 'uri6', 540 | }, 541 | ]], 542 | ['Royaume-Uni', [ 543 | { 544 | label: 'Voice 3', 545 | language: 'en-GB', 546 | name: 'Name 3', 547 | offlineAvailability: true, 548 | pitchControl: true, 549 | voiceURI: 'uri3', 550 | }, 551 | ]], 552 | ])); 553 | }); --------------------------------------------------------------------------------