From 5ea671676d34d1a99ad4e0fca034fca41d9e2eb8 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 1 Dec 2025 12:28:04 +0100 Subject: [PATCH 01/32] Update package --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index be7c562..3417967 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "readium-speech", - "version": "2.0.0-alpha.2", + "name": "@readium/speech", + "version": "0.1.0-beta.1", "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.", "main": "build/index.js", "module": "build/index.js", From 2f5b4e3c54e10761da85e84bc4792d805ff2de32 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 2 Dec 2025 10:13:16 +0100 Subject: [PATCH 02/32] Remove gh-pages workflow Since we switched to develop --- .github/workflows/build.yml | 8 ++--- .github/workflows/gh-pages.yml | 61 ---------------------------------- 2 files changed, 4 insertions(+), 65 deletions(-) delete mode 100644 .github/workflows/gh-pages.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 86e5fe2..277f470 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ name: Build and commit to `build` branch on: # Runs on pushes targeting the default branch push: - branches: ["main"] + branches: ["develop"] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -39,10 +39,10 @@ jobs: - name: Deploy uses: peaceiris/actions-gh-pages@v4 - # If you're changing the branch from main, - # also change the `main` in `refs/heads/main` + # If you're changing the branch from develop, + # also change the `develop` in `refs/heads/develop` # below accordingly. - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/develop' with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./build diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml deleted file mode 100644 index 2709120..0000000 --- a/.github/workflows/gh-pages.yml +++ /dev/null @@ -1,61 +0,0 @@ -# Simple workflow for deploying static content to GitHub Pages -name: Deploy demo page to gh-pages - -on: - # Runs on pushes targeting the default branch - push: - branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Single deploy job since we're just deploying - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Node build - uses: actions/setup-node@v4 - with: - node-version: 22 - - run: | - npm ci --foreground-scripts - npm run build - ls -la ./ - ls -laR ./build - ls -laR ./demo - mkdir build-demo - cp README.md ./build-demo/ - cp -r ./build ./build-demo/ - cp -r ./demo ./build-demo/ - ls -laR ./build-demo - - - - name: Deploy - uses: peaceiris/actions-gh-pages@v4 - # If you're changing the branch from main, - # also change the `main` in `refs/heads/main` - # below accordingly. - if: github.ref == 'refs/heads/main' - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./build-demo - publish_branch: gh-pages # default: gh-pages - destination_dir: ./ - enable_jekyll: true # yes for README.md \ No newline at end of file From 6016221ca5b2bcbdcaea5707e70af269613bb9da Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 2 Dec 2025 11:01:00 +0100 Subject: [PATCH 03/32] Revert "Remove gh-pages workflow" This reverts commit 2f5b4e3c54e10761da85e84bc4792d805ff2de32. --- .github/workflows/build.yml | 8 ++--- .github/workflows/gh-pages.yml | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/gh-pages.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 277f470..86e5fe2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ name: Build and commit to `build` branch on: # Runs on pushes targeting the default branch push: - branches: ["develop"] + branches: ["main"] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -39,10 +39,10 @@ jobs: - name: Deploy uses: peaceiris/actions-gh-pages@v4 - # If you're changing the branch from develop, - # also change the `develop` in `refs/heads/develop` + # If you're changing the branch from main, + # also change the `main` in `refs/heads/main` # below accordingly. - if: github.ref == 'refs/heads/develop' + if: github.ref == 'refs/heads/main' with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./build diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml new file mode 100644 index 0000000..2709120 --- /dev/null +++ b/.github/workflows/gh-pages.yml @@ -0,0 +1,61 @@ +# Simple workflow for deploying static content to GitHub Pages +name: Deploy demo page to gh-pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Single deploy job since we're just deploying + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Node build + uses: actions/setup-node@v4 + with: + node-version: 22 + - run: | + npm ci --foreground-scripts + npm run build + ls -la ./ + ls -laR ./build + ls -laR ./demo + mkdir build-demo + cp README.md ./build-demo/ + cp -r ./build ./build-demo/ + cp -r ./demo ./build-demo/ + ls -laR ./build-demo + + + - name: Deploy + uses: peaceiris/actions-gh-pages@v4 + # If you're changing the branch from main, + # also change the `main` in `refs/heads/main` + # below accordingly. + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./build-demo + publish_branch: gh-pages # default: gh-pages + destination_dir: ./ + enable_jekyll: true # yes for README.md \ No newline at end of file From 2cef0a13e45174c861add5ea22d0636eb69cae36 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 2 Dec 2025 11:02:45 +0100 Subject: [PATCH 04/32] Revert to gh-pgages --- .github/workflows/build.yml | 8 ++++---- .github/workflows/gh-pages.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 86e5fe2..277f470 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ name: Build and commit to `build` branch on: # Runs on pushes targeting the default branch push: - branches: ["main"] + branches: ["develop"] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -39,10 +39,10 @@ jobs: - name: Deploy uses: peaceiris/actions-gh-pages@v4 - # If you're changing the branch from main, - # also change the `main` in `refs/heads/main` + # If you're changing the branch from develop, + # also change the `develop` in `refs/heads/develop` # below accordingly. - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/develop' with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./build diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 2709120..37284e8 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -4,7 +4,7 @@ name: Deploy demo page to gh-pages on: # Runs on pushes targeting the default branch push: - branches: ["main"] + branches: ["develop"] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -52,7 +52,7 @@ jobs: # If you're changing the branch from main, # also change the `main` in `refs/heads/main` # below accordingly. - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/develop' with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./build-demo From 0bf7823928b931ea182f4351a725212714069530 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Sat, 13 Dec 2025 10:30:14 +0100 Subject: [PATCH 05/32] Init VoiceManager (#21) This effectively consolidate Voices and Playback, with a class replacing the previous helpers, new demos, JSON data migrated into this repository. --- .github/workflows/gh-pages.yml | 2 + .github/workflows/validate-json.yml | 32 + README.md | 282 ++- ava.config.js | 7 +- demo/article/index.html | 51 + demo/article/script.js | 500 ++++++ demo/article/styles.css | 192 ++ demo/index.html | 95 +- demo/lit-html_3-2-0_esm.js | 12 - demo/navigator/index.html | 15 - demo/navigator/navigator-demo-script.js | 684 ------- demo/sampleText.json | 182 ++ demo/script.js | 1031 +++++++++-- demo/styles.css | 515 +++++- docs/VoicesAndFiltering.md | 351 ++++ docs/WebSpeech.md | 99 + json/ar.json | 684 +++++++ json/bg.json | 95 + json/bho.json | 26 + json/bn.json | 205 +++ json/ca.json | 140 ++ json/cmn.json | 844 +++++++++ json/cs.json | 116 ++ json/da.json | 190 ++ json/de.json | 481 +++++ json/el.json | 115 ++ json/en.json | 2082 ++++++++++++++++++++++ json/es.json | 1233 +++++++++++++ json/eu.json | 25 + json/fa.json | 56 + json/fi.json | 115 ++ json/filters/novelty.json | 245 +++ json/filters/veryLowQuality.json | 629 +++++++ json/fr.json | 684 +++++++ json/gl.json | 55 + json/he.json | 172 ++ json/hi.json | 255 +++ json/hr.json | 122 ++ json/hu.json | 98 + json/id.json | 188 ++ json/it.json | 307 ++++ json/ja.json | 271 +++ json/kn.json | 103 ++ json/ko.json | 280 +++ json/localizedNames/apple.json | 140 ++ json/localizedNames/full/en.json | 147 ++ json/localizedNames/full/fr.json | 147 ++ json/mr.json | 79 + json/ms.json | 165 ++ json/nb.json | 215 +++ json/nl.json | 357 ++++ json/pl.json | 280 +++ json/pt.json | 425 +++++ json/ro.json | 95 + json/ru.json | 269 +++ json/sk.json | 97 + json/sl.json | 93 + json/sv.json | 231 +++ json/ta.json | 209 +++ json/te.json | 103 ++ json/th.json | 115 ++ json/tr.json | 219 +++ json/uk.json | 82 + json/vi.json | 197 ++ json/wuu.json | 25 + json/yue.json | 270 +++ package-lock.json | 1812 ++++++++----------- package.json | 22 +- script/extract-json.mjs | 277 --- src/WebSpeech/TmpNavigator.ts | 4 +- src/WebSpeech/WebSpeechVoiceManager.ts | 764 ++++++++ src/WebSpeech/index.ts | 5 + src/WebSpeech/webSpeechEngine.ts | 50 +- src/WebSpeech/webSpeechEngineProvider.ts | 2 +- src/data.gen.ts | 32 - src/engine.ts | 2 +- src/index.ts | 13 +- src/navigator.ts | 4 +- src/provider.ts | 2 +- src/types/json.d.ts | 5 + src/utils/language.ts | 26 + src/voices.ts | 571 ------ src/voices/filters.ts | 36 + src/voices/languages.ts | 199 +++ src/voices/types.ts | 78 + test/WebSpeechVoiceManager.test.ts | 1384 ++++++++++++++ test/voices.test.ts | 553 ------ tsconfig.json | 13 +- vite.config.js | 30 +- voices.schema.json | 168 ++ 90 files changed, 20321 insertions(+), 3557 deletions(-) create mode 100644 .github/workflows/validate-json.yml create mode 100644 demo/article/index.html create mode 100644 demo/article/script.js create mode 100644 demo/article/styles.css delete mode 100644 demo/lit-html_3-2-0_esm.js delete mode 100644 demo/navigator/index.html delete mode 100644 demo/navigator/navigator-demo-script.js create mode 100644 demo/sampleText.json create mode 100644 docs/VoicesAndFiltering.md create mode 100644 docs/WebSpeech.md create mode 100644 json/ar.json create mode 100644 json/bg.json create mode 100644 json/bho.json create mode 100644 json/bn.json create mode 100644 json/ca.json create mode 100644 json/cmn.json create mode 100644 json/cs.json create mode 100644 json/da.json create mode 100644 json/de.json create mode 100644 json/el.json create mode 100644 json/en.json create mode 100644 json/es.json create mode 100644 json/eu.json create mode 100644 json/fa.json create mode 100644 json/fi.json create mode 100644 json/filters/novelty.json create mode 100644 json/filters/veryLowQuality.json create mode 100644 json/fr.json create mode 100644 json/gl.json create mode 100644 json/he.json create mode 100644 json/hi.json create mode 100644 json/hr.json create mode 100644 json/hu.json create mode 100644 json/id.json create mode 100644 json/it.json create mode 100644 json/ja.json create mode 100644 json/kn.json create mode 100644 json/ko.json create mode 100644 json/localizedNames/apple.json create mode 100644 json/localizedNames/full/en.json create mode 100644 json/localizedNames/full/fr.json create mode 100644 json/mr.json create mode 100644 json/ms.json create mode 100644 json/nb.json create mode 100644 json/nl.json create mode 100644 json/pl.json create mode 100644 json/pt.json create mode 100644 json/ro.json create mode 100644 json/ru.json create mode 100644 json/sk.json create mode 100644 json/sl.json create mode 100644 json/sv.json create mode 100644 json/ta.json create mode 100644 json/te.json create mode 100644 json/th.json create mode 100644 json/tr.json create mode 100644 json/uk.json create mode 100644 json/vi.json create mode 100644 json/wuu.json create mode 100644 json/yue.json delete mode 100644 script/extract-json.mjs create mode 100644 src/WebSpeech/WebSpeechVoiceManager.ts create mode 100644 src/WebSpeech/index.ts delete mode 100644 src/data.gen.ts create mode 100644 src/types/json.d.ts create mode 100644 src/utils/language.ts delete mode 100644 src/voices.ts create mode 100644 src/voices/filters.ts create mode 100644 src/voices/languages.ts create mode 100644 src/voices/types.ts create mode 100644 test/WebSpeechVoiceManager.test.ts delete mode 100644 test/voices.test.ts create mode 100644 voices.schema.json diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 37284e8..c343a7d 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -40,10 +40,12 @@ jobs: ls -la ./ ls -laR ./build ls -laR ./demo + ls -laR ./json mkdir build-demo cp README.md ./build-demo/ cp -r ./build ./build-demo/ cp -r ./demo ./build-demo/ + cp -r ./json ./build-demo/ ls -laR ./build-demo diff --git a/.github/workflows/validate-json.yml b/.github/workflows/validate-json.yml new file mode 100644 index 0000000..b42dc8d --- /dev/null +++ b/.github/workflows/validate-json.yml @@ -0,0 +1,32 @@ +name: Validate JSON Schema + +on: + push: + branches: + - main + - develop + paths: + - "json/**/*.json" + pull_request: + branches: + - main + - develop + paths: + - "json/**/*.json" + workflow_dispatch: + +jobs: + validate-json: + name: Validate JSON Schema + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate JSON against schema + uses: dsanders11/json-schema-validate-action@v1.4.0 + with: + schema: "./voices.schema.json" + files: | + json/**/*.json + !json/localizedNames/** \ No newline at end of file diff --git a/README.md b/README.md index 2e5bc28..4069bad 100644 --- a/README.md +++ b/README.md @@ -17,165 +17,269 @@ Readium Speech was spun out as a separate project in order to facilitate its int ## Current focus -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). +For our initial work on this project, we focused on voice selection based on [recommended voices](https://github.com/HadrienGardeur/web-speech-recommended-voices). 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). -## Demo +In the second phase, we focused on implementing a WebSpeech API-based solution with an architecture designed for future extensibility: -[A live demo](https://readium.org/speech/demo/) of the voice selection API is available. +- **Engine Layer**: Core TTS functionality through `ReadiumSpeechPlaybackEngine` +- **Navigator Layer**: Content and playback management via (a temporary) `ReadiumSpeechNavigator` +- **Current Implementation**: WebSpeech API with cross-browser compatibility +- **Future-Proof Design**: Architecture prepared for additional TTS service adapters -It demonstrates the following features: +Key features include advanced voice selection, cross-browser playback control, flexible content loading, and comprehensive event handling for UI feedback. The architecture is designed to be extensible for different TTS backends while maintaining TypeScript-first development practices. + +## Demos + +Two live demos are available: + +1. [Voice selection with playback demo](https://readium.org/speech/demo) +2. [In-context demo](https://readium.org/speech/demo/article) + +The first demo showcases the following features: - fetching a list of all available languages, translating them to the user's locale and sorting them based on these translations - returning a list of voices for a given language, grouped by region and sorted based on quality - filtering languages and voices based on gender and offline availability - using embedded test utterances to demo voices +- using the current Navigator for playback control + +The second demo focuses on in-context reading with seamless voice selection (grouped by region and sorted based on quality), and playback control, providing an optional read-along experience that integrates naturally with the content. ## QuickStart -At the moment, the new alpha version of the library is not published on npm, so you need to clone the repository and build it yourself. +### Prerequisites -```sh -git clone https://github.com/readium/speech.git -``` +- Node.js +- npm -```sh -cd speech -npm install -npm run build -``` +### Installation -You can then link the library to your project, for example using `npm link`. +1. Clone the repository: + ```bash + git clone https://github.com/readium/speech.git + cd speech + ``` -```typescript -import { getVoices } from "readium-speech"; -console.log(getVoices); +2. Install dependencies: + ```bash + npm install + ``` -const voices = await getVoices(); -console.log(voices); +3. Build the package: + ```bash + npm run build + ``` -``` +4. Link the package locally (optional, for development): + ```bash + npm link + # Then in your project directory: + # npm link readium-speech + ``` ### Basic Usage -Here's how to get started with the Readium Speech library: - ```typescript -import { WebSpeechReadAloudNavigator } from "readium-speech"; +import { WebSpeechVoiceManager } from "readium-speech"; + +async function setupVoices() { + try { + // Initialize the voice manager + const voiceManager = await WebSpeechVoiceManager.initialize(); + + // Get all available voices + const allVoices = voiceManager.getVoices(); + console.log("Available voices:", allVoices); + + // Get voices with filters + const filteredVoices = voiceManager.getVoices({ + language: ["en", "fr"], + gender: "female", + quality: "high", + offlineOnly: true, + excludeNovelty: true, + excludeVeryLowQuality: true + }); + + // Get voices grouped by language + const voices = voiceManager.getVoices(); + const groupedByLanguage = voiceManager.groupVoices(voices, "language"); + + // Get a test utterance for a specific language + const testText = voiceManager.getTestUtterance("en"); + + } catch (error) { + console.error("Error initializing voice manager:", error); + } +} -// Initialize the navigator with default WebSpeech engine -const navigator = new WebSpeechReadAloudNavigator(); +await setupVoices(); +``` -// Load content to be read -navigator.loadContent([ - { text: "Hello, this is the first sentence.", language: "en-US" }, - { text: "And this is the second sentence.", language: "en-US" } -]); +## API Reference -// Set up event listeners -navigator.on("start", () => console.log("Playback started")); -navigator.on("end", () => console.log("Playback finished")); +### Class: WebSpeechVoiceManager -// Start playback -navigator.play(); +The main class for managing Web Speech API voices with enhanced functionality. -// Later, you can pause, resume, or stop -// navigator.pause(); -// navigator.stop(); +#### Initialize the Voice Manager -// Clean up when done -// navigator.destroy(); +```typescript +static initialize(maxTimeout?: number, interval?: number): Promise ``` -## Voices API +Creates and initializes a new WebSpeechVoiceManager instance. This static factory method must be called to create an instance. -### Interface +- `maxTimeout`: Maximum time in milliseconds to wait for voices to load (default: 10000ms) +- `interval`: Interval in milliseconds between voice loading checks (default: 100ms) +- Returns: Promise that resolves with a new WebSpeechVoiceManager instance -```typescript -export interface ReadiumSpeechVoices { - label: string; - voiceURI: string; - name: string; - language: string; - gender?: TGender | undefined; - age?: string | undefined; - offlineAvailability: boolean; - quality?: TQuality | undefined; - pitchControl: boolean; - recommendedPitch?: number | undefined; - recommendedRate?: number | undefined; -} +#### Get Available Voices -export interface ILanguages { - label: string; - code: string; - count: number; -} +```typescript +voiceManager.getVoices(options?: VoiceFilterOptions): ReadiumSpeechVoice[] ``` -#### Parse and Extract ReadiumSpeechVoices from speechSynthesis WebAPI +Fetches all available voices that match the specified filter criteria. ```typescript -function getVoices(preferredLanguage?: string[] | string, localization?: string): Promise +interface VoiceFilterOptions { + language?: string | string[]; // Filter by language code(s) (e.g., "en", "fr") + source?: TSource; // Filter by voice source ("json" | "browser") + gender?: TGender; // "male" | "female" | "other" + quality?: TQuality | TQuality[]; // "high" | "medium" | "low" | "veryLow" + offlineOnly?: boolean; // Only return voices available offline + provider?: string; // Filter by voice provider + excludeNovelty?: boolean; // Exclude novelty voices, true by default + excludeVeryLowQuality?: boolean; // Exclude very low quality voices, true by default +} ``` -#### List languages from ReadiumSpeechVoices +#### Filter Voices ```typescript -function getLanguages(voices: ReadiumSpeechVoices[], preferredLanguage?: string[] | string, localization?: string | undefined): ILanguages[] +voiceManager.filterVoices(voices: ReadiumSpeechVoice[], options: VoiceFilterOptions): ReadiumSpeechVoice[] ``` -#### helpers +Filters voices based on the specified criteria. -```typescript -function listLanguages(voices: ReadiumSpeechVoices[], localization?: string): ILanguages[] +#### Group Voices -function ListRegions(voices: ReadiumSpeechVoices[], localization?: string): ILanguages[] +```typescript +voiceManager.groupVoices(voices: ReadiumSpeechVoice[], groupBy: "language" | "region" | "gender" | "quality" | "provider"): VoiceGroup +``` -function parseSpeechSynthesisVoices(speechSynthesisVoices: SpeechSynthesisVoice[]): ReadiumSpeechVoices[] +Organizes voices into groups based on the specified criteria. The available grouping options are: -function getSpeechSynthesisVoices(): Promise -``` +- `"language"`: Groups voices by their language code +- `"region"`: Groups voices by their region +- `"gender"`: Groups voices by gender +- `"quality"`: Groups voices by quality level +- `"provider"`: Groups voices by their provider -#### groupBy +#### Sort Voices ```typescript -function groupByKindOfVoices(allVoices: ReadiumSpeechVoices[]): TGroupVoices +voiceManager.sortVoices(voices: ReadiumSpeechVoice[], options: SortOptions): ReadiumSpeechVoice[] +``` -function groupByRegions(voices: ReadiumSpeechVoices[], language: string, preferredRegions?: string[] | string, localization?: string): TGroupVoices +Arranges voices according to the specified sorting criteria. The `SortOptions` interface allows you to sort by various properties and specify sort order. -function groupByLanguage(voices: ReadiumSpeechVoices[], preferredLanguage?: string[] | string, localization?: string): TGroupVoices +```typescript +interface SortOptions { + by: "name" | "language" | "gender" | "quality" | "region"; + order?: "asc" | "desc"; +} ``` -#### sortBy +### Testing + +#### Get Test Utterance ```typescript -function sortByLanguage(voices: ReadiumSpeechVoices[], preferredLanguage?: string[] | string): ReadiumSpeechVoices[] +voiceManager.getTestUtterance(language: string): string +``` -function sortByRegion(voices: ReadiumSpeechVoices[], preferredRegions?: string[] | string, localization?: string | undefined): ReadiumSpeechVoices[] +Retrieves a sample text string suitable for testing text-to-speech functionality in the specified language. If no sample text is available for the specified language, it returns an empty string. -function sortByGender(voices: ReadiumSpeechVoices[], genderFirst: TGender): ReadiumSpeechVoices[] +### Interfaces -function sortByName(voices: ReadiumSpeechVoices[]): ReadiumSpeechVoices[] +#### `ReadiumSpeechVoice` -function sortByQuality(voices: ReadiumSpeechVoices[]): ReadiumSpeechVoices[] +```typescript +interface ReadiumSpeechVoice { + source: TSource; // "json" | "browser" + + // Core identification (required) + label: string; // Human-friendly label for the voice + name: string; // System/technical name (matches Web Speech API voiceURI) + voiceURI?: string; // For Web Speech API compatibility + + // Localization + language: string; // BCP-47 language tag + localizedName?: TLocalizedName; // Localization pattern (android/apple) + altNames?: string[]; // Alternative names (mostly for Apple voices) + altLanguage?: string; // Alternative BCP-47 language tag + otherLanguages?: string[]; // Other languages this voice can speak + multiLingual?: boolean; // If voice can handle multiple languages + + // Voice characteristics + gender?: TGender; // Voice gender ("female" | "male" | "neutral") + children?: boolean; // If this is a children's voice + + // Quality and capabilities + quality?: TQuality[]; // Available quality levels for this voice ("veryLow" | "low" | "normal" | "high" | "veryHigh") + pitchControl?: boolean; // Whether pitch can be controlled + + // Performance settings + pitch?: number; // Current pitch (0-2, where 1 is normal) + rate?: number; // Speech rate (0.1-10, where 1 is normal) + + // Platform and compatibility + browser?: string[]; // Supported browsers + os?: string[]; // Supported operating systems + preloaded?: boolean; // If the voice is preloaded on the system + nativeID?: string | string[]; // Platform-specific voice ID(s) + + // Additional metadata + note?: string; // Additional notes about the voice + provider?: string; // Voice provider (e.g., "Microsoft", "Google") + + // Allow any additional properties that might be in the JSON + [key: string]: any; +} ``` -#### filterOn +#### `LanguageInfo` ```typescript -function filterOnRecommended(voices: ReadiumSpeechVoices[], _recommended?: IRecommended[]): TReturnFilterOnRecommended +interface LanguageInfo { + code: string; + label: string; + count: number; +} +``` -function filterOnVeryLowQuality(voices: ReadiumSpeechVoices[]): ReadiumSpeechVoices[] +### Enums -function filterOnNovelty(voices: ReadiumSpeechVoices[]): ReadiumSpeechVoices[] +#### `TQuality` -function filterOnQuality(voices: ReadiumSpeechVoices[], quality: TQuality | TQuality[]): ReadiumSpeechVoices[] +```typescript +type TQuality = "veryLow" | "low" | "normal" | "high" | "veryHigh"; +``` + +#### `TGender` + +```typescript +type TGender = "female" | "male" | "neutral"; +``` -function filterOnLanguage(voices: ReadiumSpeechVoices[], language: string | string[]): ReadiumSpeechVoices[] +#### `TSource` -function filterOnGender(voices: ReadiumSpeechVoices[], gender: TGender): ReadiumSpeechVoices[] +```typescript +type TSource = "json" | "browser"; ``` ## Playback API diff --git a/ava.config.js b/ava.config.js index e84e800..0129c8a 100644 --- a/ava.config.js +++ b/ava.config.js @@ -1,11 +1,8 @@ export default { extensions: { - ts: 'module' - }, - environmentVariables: { - TS_NODE_COMPILER_OPTIONS: '{"module":"ES2022"}' + ts: "module" }, nodeArguments: [ - '--loader=ts-node/esm' + "--loader=ts-node/esm" ] } diff --git a/demo/article/index.html b/demo/article/index.html new file mode 100644 index 0000000..fb692b4 --- /dev/null +++ b/demo/article/index.html @@ -0,0 +1,51 @@ + + + + + + Speech Synthesis - Article Demo + + + +
+
+ + +
+
+ + + + +
+
+ + +
+
+ Utterance: 1/- +
+
+ +
+

Speech Synthesis

+ +

Speech synthesis is the artificial production of human speech. A computer system used for this purpose is called a speech synthesizer, and can be implemented in software or hardware products. A text-to-speech (TTS) system converts normal language text into speech; other systems render symbolic linguistic representations like phonetic transcriptions into speech.

+ +

Synthesized speech can be created by concatenating pieces of recorded speech that are stored in a database. Systems differ in the size of the stored speech units; a system that stores phones or diphones provides the largest output range, but may lack clarity. For specific usage domains, the storage of entire words or sentences allows for high-quality output. Alternatively, a synthesizer can incorporate a model of the vocal tract and other human voice characteristics to create a completely "synthetic" voice output.

+ +

The quality of a speech synthesizer is judged by its similarity to the human voice and by its ability to be understood clearly. An intelligible text-to-speech program allows people with visual impairments or reading disabilities to listen to written words on a home computer. The earliest computer operating system to have included a speech synthesizer was Unix in 1974, through the Unix speak utility. In 2000, Microsoft Sam was the default text-to-speech voice synthesizer used by the narrator accessibility feature, which shipped with all Windows 2000 operating systems, and subsequent Windows XP systems.

+ +

A text-to-speech system (or "engine") is composed of two parts: a front-end and a back-end. The front-end has two major tasks. First, it converts raw text containing symbols like numbers and abbreviations into the equivalent of written-out words. This process is often called text normalization, pre-processing, or tokenization. The front-end then assigns phonetic transcriptions to each word, and divides and marks the text into prosodic units, like phrases, clauses, and sentences. The process of assigning phonetic transcriptions to words is called text-to-phoneme or grapheme-to-phoneme conversion. Phonetic transcriptions and prosody information together make up the symbolic linguistic representation that is output by the front-end. The back-end—often referred to as the synthesizer—then converts the symbolic linguistic representation into sound. In certain systems, this part includes the computation of the target prosody (pitch contour, phoneme durations), which is then imposed on the output speech.

+
+ + + + + + diff --git a/demo/article/script.js b/demo/article/script.js new file mode 100644 index 0000000..86e9fc4 --- /dev/null +++ b/demo/article/script.js @@ -0,0 +1,500 @@ +import { WebSpeechVoiceManager, WebSpeechReadAloudNavigator } from "../../build/index.js"; + +// DOM Elements +const content = document.getElementById("content"); +const voiceSelect = document.getElementById("voiceSelect"); +const playPauseBtn = document.getElementById("playPauseBtn"); +const stopBtn = document.getElementById("stopBtn"); +const prevBtn = document.getElementById("prevBtn"); +const nextBtn = document.getElementById("nextBtn"); +const currentUtteranceSpan = document.getElementById("currentUtterance"); +const totalUtterancesSpan = document.getElementById("totalUtterances"); +const readAlongCheckbox = document.getElementById("readAlong"); + +// State +let voiceManager; +let navigator; +let allVoices = []; +let currentVoice = null; +let isPlaying = false; +let utterances = []; +let currentWordHighlight = null; +let readAlongEnabled = true; // Default to true to match default checkbox state + +// Initialize voice manager and navigator +async function initialize() { + try { + // Initialize the voice manager + voiceManager = await WebSpeechVoiceManager.initialize(); + + // Only get English voices + allVoices = voiceManager.getVoices({language: "en"}); + + // Initialize the navigator + navigator = new WebSpeechReadAloudNavigator(); + + // Set up event listeners + setupEventListeners(); + + // Initialize the UI + updateUI(); + + // Populate voice select + populateVoiceSelect(); + + // Get the default voice for English + currentVoice = voiceManager.getDefaultVoice("en-US"); + + if (currentVoice && navigator) { + navigator.setVoice(currentVoice); + // Update the select element to reflect the selected voice + if (voiceSelect) { + const option = voiceSelect.querySelector(`option[data-voice-uri="${currentVoice.voiceURI}"]`); + if (option) { + option.selected = true; + } + } + } + + // Initialize content + await initializeContent(); + + } catch (error) { + console.error("Initialization error:", error); + } +} + +// Set up event listeners +function setupEventListeners() { + + // Navigator events + navigator.on("start", () => { + isPlaying = true; + updateUI(); + }); + + navigator.on("pause", () => { + isPlaying = false; + updateUI(); + }); + + navigator.on("stop", () => { + isPlaying = false; + clearWordHighlighting(); + updateUI(); + }); + + navigator.on("end", () => { + isPlaying = false; + clearWordHighlighting(); + updateUI(); + }); + + navigator.on("error", (event) => { + console.error("Navigator error:", event.detail); + updateUI(); + }); + + navigator.on("boundary", (event) => { + if (event.detail && event.detail.name === "word") { + highlightCurrentWord(event.detail.charIndex, event.detail.charLength); + } + updateUI(); + }); + + // Button events + if (playPauseBtn) playPauseBtn.addEventListener("click", togglePlayback); + if (stopBtn) stopBtn.addEventListener("click", stopPlayback); + if (prevBtn) prevBtn.addEventListener("click", previousUtterance); + if (nextBtn) nextBtn.addEventListener("click", nextUtterance); + + // Checkbox events + if (readAlongCheckbox) { + readAlongCheckbox.checked = readAlongEnabled; + readAlongCheckbox.addEventListener("change", handleReadAlongChange); + } + + // Voice selection + if (voiceSelect) voiceSelect.addEventListener("change", handleVoiceChange); +} + +// Handle read along checkbox change +function handleReadAlongChange(e) { + readAlongEnabled = e.target.checked; + if (!readAlongEnabled) { + clearWordHighlighting(); + } else if (isPlaying) { + const currentIndex = navigator?.getCurrentUtteranceIndex(); + if (currentIndex !== undefined) { + const utterance = utterances[currentIndex]; + if (utterance) { + const charIndex = utterance.text.indexOf(utterance.word); + if (charIndex !== -1) { + highlightCurrentWord(charIndex, utterance.word?.length || 0); + } + } + } + } +} + +// Initialize content with proper segmentation +async function initializeContent() { + const paragraphs = Array.from(content.querySelectorAll("p, h1, h2, h3, h4, h5, h6")); + utterances = []; + + // Process each paragraph/heading + paragraphs.forEach((p) => { + const text = p.textContent; + if (!text.trim()) return; + + // Use Intl.Segmenter for sentence segmentation + const segmenter = new Intl.Segmenter("en", { granularity: "sentence" }); + const segments = Array.from(segmenter.segment(text)); + + // Process each sentence + segments.forEach(({ segment }) => { + const sentence = segment.trim(); + if (!sentence) return; + + // Add to utterances + utterances.push({ + id: `utterance-${utterances.length}`, + text: sentence, + language: "en" + }); + }); + }); + + // Load utterances into the navigator + await navigator.loadContent(utterances); + + // Update UI + updateUI(); +} + +// Populate voice select dropdown +function populateVoiceSelect() { + if (!voiceSelect) return; + + voiceSelect.innerHTML = ""; + + if (!allVoices || !allVoices.length) { + const option = document.createElement("option"); + option.disabled = true; + option.textContent = "No voices available. Please check your browser settings and internet connection."; + voiceSelect.appendChild(option); + return; + } + + try { + // First sort by quality within each language/region + const sortedByQuality = voiceManager.sortVoices(allVoices, { + by: "quality", + order: "desc" + }); + + // Then sort by region while preserving quality order within each region + const sortedVoices = voiceManager.sortVoices(sortedByQuality, { + by: "region", + order: "asc", + preferredLanguages: window.navigator.languages + }); + + let currentRegion = null; + let optgroup = null; + + for (const voice of sortedVoices) { + // Extract region from language code (e.g., "US" from "en-US") + const region = voice.language.split("-")[1] || "Other"; + + // Create new optgroup when region changes + if (region !== currentRegion) { + currentRegion = region; + optgroup = document.createElement("optgroup"); + // Add emoji flag before the region name using Intl.DisplayNames + const flag = getCountryFlag(region === "Other" ? null : region); + const regionName = region === "Other" ? region : + new Intl.DisplayNames(window.navigator.languages, { type: "region" }).of(region) || region; + optgroup.label = `${flag} ${regionName}`; + voiceSelect.appendChild(optgroup); + } + + const option = document.createElement("option"); + option.value = voice.voiceURI; + option.textContent = `${voice.label || voice.name}`; + option.dataset.voiceUri = voice.voiceURI; + + if (currentVoice && voice.voiceURI === currentVoice.voiceURI) { + option.selected = true; + } + + optgroup?.appendChild(option); + } + + // Set the default voice selection + if (currentVoice) { + const option = voiceSelect.querySelector(`option[data-voice-uri="${currentVoice.voiceURI}"]`); + if (option) { + option.selected = true; + } + } + + } catch (error) { + console.error("Error populating voice dropdown:", error); + // Fallback to simple list if there's an error + allVoices.forEach(voice => { + const option = document.createElement("option"); + option.value = voice.name; + option.textContent = [ + voice.label || voice.name, + voice.gender ? `• ${voice.gender}` : "", + voice.offlineAvailability ? "• offline" : "• online" + ].filter(Boolean).join(" "); + option.dataset.voiceUri = voice.voiceURI; + voiceSelect.appendChild(option); + }); + } + + // Set up voice change event listener + voiceSelect.addEventListener("change", handleVoiceChange); + + // Helper function to get country flag emoji from country code + function getCountryFlag(countryCode) { + if (!countryCode) return "🌐"; + + try { + const codePoints = countryCode + .toUpperCase() + .split("") + .map(char => 127397 + char.charCodeAt(0)); + + return String.fromCodePoint(...codePoints); + } catch (e) { + console.warn("Could not generate flag for country code:", countryCode); + return "🌐"; + } + } +} + +// Toggle sample text playback +async function togglePlayback() { + if (!currentVoice) { + console.error("No voice selected"); + return; + } + + try { + const state = navigator.getState(); + if (state === "playing") { + await navigator.pause(); + } else if (state === "paused") { + // Use play() to resume from paused state + await navigator.play(); + } else { + // Start from beginning if stopped or in an unknown state + await navigator.jumpTo(0); + await navigator.play(); + } + } catch (error) { + console.error("Error toggling playback:", error); + } +} + +function stopPlayback() { + if (!navigator) return; + navigator.stop(); + clearWordHighlighting(); + updateUI(); +} + +function previousUtterance() { + if (!navigator) return; + navigator.previous(); + updateUI(); +} + +function nextUtterance() { + if (!navigator) return; + navigator.next(); + updateUI(); +} + +// Handle voice change +async function handleVoiceChange(e) { + const voiceName = e.target.value; + if (!voiceName) return; + + // Find the selected voice by name + currentVoice = allVoices.find(v => v.name === voiceName); + + if (!currentVoice) { + console.error("Voice not found:", voiceName); + return; + } + + // Stop any current playback + if (navigator) { + try { + // Stop the current speech + await navigator.stop(); + + // Set the new voice + navigator.setVoice(currentVoice); + + // Update UI to reflect the change + updateUI(); + + } catch (error) { + console.error("Error changing voice:", error); + } + } +} + +// Clear any previous highlighting +function clearWordHighlighting() { + if (window.CSS?.highlights) { + CSS.highlights.clear(); + } +} + +// Highlight current word in the content +function highlightCurrentWord(charIndex, charLength) { + // Check if read-along is enabled + if (!readAlongEnabled) return; + + // Clear previous highlighting + clearWordHighlighting(); + + // Get the current utterance + const currentIndex = navigator.getCurrentUtteranceIndex(); + const currentUtterance = utterances[currentIndex]; + if (!currentUtterance) return; + + // Get the content element + const contentElement = document.getElementById("content"); + if (!contentElement) return; + + // Create a range for the current word + const range = document.createRange(); + const walker = document.createTreeWalker( + contentElement, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let node; + let found = false; + + while ((node = walker.nextNode())) { + const nodeText = node.nodeValue; + const nodeLength = nodeText.length; + + // Check if this node contains the current utterance + const utteranceText = currentUtterance.text; + const nodeStart = nodeText.indexOf(utteranceText); + + if (nodeStart !== -1) { + // Calculate the position within this node + const startPos = nodeStart + charIndex; + const endPos = Math.min(startPos + charLength, nodeLength); + + // Ensure the range is valid + if (startPos >= 0 && endPos <= nodeLength) { + try { + range.setStart(node, startPos); + range.setEnd(node, endPos); + + // Use CSS Highlight API + const highlight = new Highlight(range); + if (window.CSS?.highlights) { + CSS.highlights.set("current-word", highlight); + } + + // Store current highlight info + currentWordHighlight = { + utteranceIndex: currentIndex, + charIndex: charIndex, + charLength: charLength, + range: range + }; + + // Scroll the highlighted word into view with smooth behavior + const rect = range.getBoundingClientRect(); + const isVisible = ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); + + if (!isVisible) { + range.startContainer.parentElement.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest" + }); + } + + found = true; + } catch (e) { + console.error("Error setting highlight range:", e); + } + } + break; + } + } + + if (!found) { + console.warn("Could not find position for highlight"); + } +} + +// Update UI +function updateUI() { + if (!navigator) return; + + const currentIndex = navigator.getCurrentUtteranceIndex(); + const total = utterances.length; + const state = navigator.getState(); + const hasContent = total > 0; + + // Update play/pause button + if (playPauseBtn) { + playPauseBtn.disabled = !currentVoice || !hasContent; + if (state === "playing") { + playPauseBtn.innerHTML = `⏸️ Pause`; + playPauseBtn.classList.remove("play-state"); + playPauseBtn.classList.add("pause-state"); + } else { + playPauseBtn.innerHTML = `▶️ Play`; + playPauseBtn.classList.remove("pause-state"); + playPauseBtn.classList.add("play-state"); + } + } + + // Update other buttons + if (stopBtn) { + stopBtn.disabled = !currentVoice || !hasContent || (state !== "playing" && state !== "paused"); + } + + if (prevBtn) { + prevBtn.disabled = !currentVoice || !hasContent || currentIndex <= 0; + } + + if (nextBtn) { + nextBtn.disabled = !currentVoice || !hasContent || currentIndex >= total - 1; + } + + // Update utterance counter + if (currentUtteranceSpan) { + currentUtteranceSpan.textContent = currentIndex + 1; + } + + if (totalUtterancesSpan) { + totalUtterancesSpan.textContent = total; + } +} + +// Initialize the application +initialize().catch(console.error); \ No newline at end of file diff --git a/demo/article/styles.css b/demo/article/styles.css new file mode 100644 index 0000000..cdf486b --- /dev/null +++ b/demo/article/styles.css @@ -0,0 +1,192 @@ +/* Base styles */ +body, +html { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + line-height: 1.6; + color: #333; + max-width: 800px; + margin: 0 auto; + padding: 20px; + box-sizing: border-box; +} + +/* Control panel */ +.controls { + position: sticky; + top: 0; + background: #f5f5f5; + padding: 20px; + border-radius: 8px; + margin: 0 0 20px 0; + width: 100%; + z-index: 100; + box-sizing: border-box; +} + +/* Form elements */ +.control-group { + margin-bottom: 15px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; +} + +label { + font-weight: 500; + margin: 0; + cursor: pointer; +} + +input[type="checkbox"] { + margin: 0; + width: auto; +} + +select { + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; + min-width: 200px; +} + +/* Base button styles */ +button { + padding: 8px 16px; + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + cursor: pointer; + transition: opacity 0.2s; + font-size: 14px; + margin: 0; + display: inline-flex; + align-items: center; + gap: 6px; +} + +/* Button icons and text */ +.btn-icon { + display: inline-block; + width: 20px; + text-align: center; +} + +.btn-text { + display: inline-block; +} + +button:disabled { + background-color: #cccccc !important; + cursor: not-allowed; + opacity: 0.7; +} + +button:hover:not(:disabled) { + opacity: 0.9; +} + +/* Play/Pause button */ +#playPauseBtn { + background-color: #4CAF50; /* Green for play */ +} + +#playPauseBtn.paused { + background-color: #ff9800; /* Orange for pause */ +} + +#playPauseBtn:hover:not(:disabled) { + background-color: #45a049; /* Darker green on hover */ +} + +#playPauseBtn.paused:hover:not(:disabled) { + background-color: #e68900; /* Darker orange on hover */ +} + +/* Stop button */ +#stopBtn { + background-color: #f44336; /* Red for stop */ +} + +#stopBtn:hover:not(:disabled) { + background-color: #d32f2f; /* Darker red on hover */ +} + +/* Navigation buttons */ +#prevBtn, +#nextBtn { + background-color: #2196F3; /* Blue for navigation */ +} + +#prevBtn:hover:not(:disabled), +#nextBtn:hover:not(:disabled) { + background-color: #1976D2; /* Darker blue on hover */ +} + +/* Utterance counter */ +#currentUtterance { + font-weight: bold; + color: #2196F3; +} + +/* Content area */ +#content { + line-height: 1.8; +} + +#content p { + margin-bottom: 1.2em; +} + +/* Highlight for current word */ +::highlight(current-word) { + background-color: #ffeb3b; + color: black; +} + +/* Footer */ +footer { + margin: 2em 0 1em; + padding: 1em 0 0; + border-top: 1px solid #e0e0e0; + font-size: 0.8em; + color: #666; + line-height: 1.5; + max-width: 800px; + margin-left: auto; + margin-right: auto; + padding-left: 1em; + padding-right: 1em; +} + +footer p { + margin: 0.3em 0; +} + +footer a { + color: #1a73e8; +} + +/* Responsive adjustments */ +@media (max-width: 600px) { + .control-group { + flex-direction: column; + align-items: flex-start; + } + + select { + width: 100%; + } + + button { + padding: 8px 12px; + font-size: 13px; + } +} \ No newline at end of file diff --git a/demo/index.html b/demo/index.html index 58f63ca..879420b 100644 --- a/demo/index.html +++ b/demo/index.html @@ -1,15 +1,98 @@ - Readium Speech Demo - - + - - +

Readium Speech Demo

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
- \ No newline at end of file +
+
+
+ + +
+
+ +
+
+
+ +
+
+ Voice Details +
+

Select a voice to see its properties

+
+
+
+
+ +
+
+
+ + + + +
+ +
+ + of - + +
+
+ +
+
+ Select a language to load sample text... +
+
+
+ + + + diff --git a/demo/lit-html_3-2-0_esm.js b/demo/lit-html_3-2-0_esm.js deleted file mode 100644 index 93b0419..0000000 --- a/demo/lit-html_3-2-0_esm.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Bundled by jsDelivr using Rollup v2.79.1 and Terser v5.19.2. - * Original file: /npm/lit-html@3.2.0/lit-html.js - * - * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files - */ -/** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ -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; \ No newline at end of file diff --git a/demo/navigator/index.html b/demo/navigator/index.html deleted file mode 100644 index 4830a35..0000000 --- a/demo/navigator/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Readium Speech Navigator Demo - - - - - - - - diff --git a/demo/navigator/navigator-demo-script.js b/demo/navigator/navigator-demo-script.js deleted file mode 100644 index 1959d61..0000000 --- a/demo/navigator/navigator-demo-script.js +++ /dev/null @@ -1,684 +0,0 @@ -import { WebSpeechReadAloudNavigator } from "../../build/index.js"; - -import * as lit from "../lit-html_3-2-0_esm.js" -const { html, render } = lit; - -// Sample text from Moby Dick by Herman Melville -const sampleText = ` -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. - -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. - -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. - -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.`; - -// Create navigator instance -const navigator = new WebSpeechReadAloudNavigator(); - -// Main render function -const viewRender = () => { - const state = { - isPlaying: navigator.getState() === "playing", - currentUtteranceIndex: navigator.getCurrentUtteranceIndex() || 0, - totalUtterances: navigator.getContentQueue().length, - currentVoice: navigator.getCurrentVoice() - }; - - render(content(state), document.body); - - // Update input field only if user hasn't manually changed it - updateJumpInputIfNeeded(state.currentUtteranceIndex + 1); - - // Initialize position tracking on first render - if (lastNavigatorPosition === 0) { - lastNavigatorPosition = state.currentUtteranceIndex + 1; - } -}; - -// Split text into sentences for utterances -function createUtterancesFromText(text) { - // Split by sentences (basic implementation) - const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); - return sentences.map((sentence, index) => ({ - id: `utterance-${index}`, - text: sentence.trim() + (sentence.endsWith(".") || sentence.endsWith("!") || sentence.endsWith("?") ? "" : "."), - language: "en-US" - })); -} - -const utterances = createUtterancesFromText(sampleText); -console.log(`Created ${utterances.length} utterances`); - -let voices = []; -let currentVoice = null; -let currentWordHighlight = null; // Track current word being highlighted - -// Initialize voices -async function initVoices() { - try { - // Get all voices - voices = await navigator.getVoices(); - // Filter for English voices - const englishVoices = voices.filter(v => v.language.startsWith("en")); - // Set the first English voice as default, or fallback to first available - currentVoice = englishVoices.length > 0 ? englishVoices[0] : voices[0]; - - if (currentVoice) { - await navigator.setVoice(currentVoice); - } - - // Re-render to show the voice selector - viewRender(); - } catch (error) { - console.error("Error initializing voices:", error); - } -} - -// Handle voice selection -async function handleVoiceChange(event) { - const voiceName = event.target.value; - const selectedVoice = voices.find(v => v.name === voiceName); - if (selectedVoice) { - // Stop any ongoing speech before changing the voice - navigator.stop(); - currentVoice = selectedVoice; - await navigator.setVoice(selectedVoice); - // The view will be updated automatically through the state change events - } -} - -// Load utterances into navigator and initialize voices -navigator.loadContent(utterances); -initVoices(); - -// Input value management -let jumpInputUserChanged = false; -let lastNavigatorPosition = 0; - -function updateJumpInputIfNeeded(navigatorPosition) { - const input = document.getElementById("utterance-index"); - if (!input) return; - - // If user has changed the input, don't update it - if (jumpInputUserChanged) { - return; - } - - // Only update if position actually changed - if (navigatorPosition !== lastNavigatorPosition) { - input.value = navigatorPosition; - lastNavigatorPosition = navigatorPosition; - } -} - -// Track when user manually changes the input -const jumpInput = document.getElementById("utterance-index"); -if (jumpInput) { - jumpInput.addEventListener("input", () => { - jumpInputUserChanged = true; - }); - - // Set initial value once input is ready - jumpInput.addEventListener("focus", () => { - if (jumpInput.value === "" && !jumpInputUserChanged) { - const currentPos = navigator.getCurrentUtteranceIndex() + 1; - jumpInput.value = currentPos; - lastNavigatorPosition = currentPos; - } - }, { once: true }); -} - -// Event listeners for navigator -navigator.on("start", () => { - clearWordHighlighting(); // Clear any previous highlighting - viewRender(); -}); - -navigator.on("pause", () => { - viewRender(); -}); - -navigator.on("resume", () => { - viewRender(); -}); - -navigator.on("stop", () => { - clearWordHighlighting(); - viewRender(); -}); - -navigator.on("end", () => { - viewRender(); -}); - -navigator.on("error", (event) => { - console.error("Navigator error:", event.detail); - viewRender(); -}); - -navigator.on("skip", (event) => { - // Update the UI when the position changes programmatically - const newPosition = (event.detail?.position ?? 0) + 1; // Convert to 1-based index for display - lastNavigatorPosition = newPosition; - const input = document.getElementById("utterance-index"); - if (input && input !== document.activeElement) { - input.value = newPosition; - } - viewRender(); -}); - -navigator.on("boundary", (event) => { - // Handle word boundaries for highlighting - if (event.detail.name === "word") { - highlightCurrentWord(event.detail.charIndex, event.detail.charLength); - } - viewRender(); -}); - -// Playback control functions -const playPause = async () => { - const state = navigator.getState(); - if (state === "playing") { - navigator.pause(); - } else { - navigator.play(); - } -}; - -const stop = () => { - clearWordHighlighting(); - navigator.stop(); -}; - -const next = async () => { - clearWordHighlighting(); - navigator.next(); -}; - -const previous = async () => { - clearWordHighlighting(); - navigator.previous(); -}; - -const jumpToUtterance = () => { - const input = document.getElementById("utterance-index"); - const index = parseInt(input.value) - 1; // Convert to 0-based index - if (index >= 0 && index < navigator.getContentQueue().length) { - clearWordHighlighting(); - navigator.jumpTo(index); - // Clear user changed flag and update position tracking - jumpInputUserChanged = false; - lastNavigatorPosition = index + 1; - // Update input to reflect the new position - input.value = lastNavigatorPosition; - } else { - alert(`Please enter a number between 1 and ${navigator.getContentQueue().length}`); - // Reset input to current position and clear user changed flag - const currentPos = navigator.getCurrentUtteranceIndex() + 1; - input.value = currentPos; - jumpInputUserChanged = false; - lastNavigatorPosition = currentPos; - } -}; - -function highlightCurrentWord(charIndex, charLength) { - // Clear previous highlighting - clearWordHighlighting(); - - // Find the current utterance being spoken - const currentUtterance = navigator.getCurrentContent(); - if (!currentUtterance) return; - - // Extract the word based on character index and length - const text = currentUtterance.text; - if (charIndex >= 0 && charIndex < text.length) { - const wordEnd = Math.min(charIndex + charLength, text.length); - const word = text.substring(charIndex, wordEnd); - - // Find the specific occurrence of this word at this position - highlightSpecificWord(text, word, charIndex); - - currentWordHighlight = { - utteranceIndex: navigator.getCurrentUtteranceIndex(), - charIndex: charIndex, - charLength: charLength, - word: word - }; - } -} - -function highlightSpecificWord(fullText, targetWord, startIndex) { - const utteranceElements = document.querySelectorAll('.utterance-text'); - const currentUtteranceIndex = navigator.getCurrentUtteranceIndex(); - - if (utteranceElements.length > currentUtteranceIndex) { - const currentElement = utteranceElements[currentUtteranceIndex]; - if (currentElement) { - // Find the specific occurrence of the word at the given character position - const beforeText = fullText.substring(0, startIndex); - const afterText = fullText.substring(startIndex + targetWord.length); - - // Reconstruct the HTML with only the specific word highlighted - currentElement.innerHTML = - beforeText + - '' + targetWord + '' + - afterText; - } - } -} - -function clearWordHighlighting() { - // Remove all word highlighting - const highlightedWords = document.querySelectorAll('.highlighted-word'); - highlightedWords.forEach(el => { - el.outerHTML = el.textContent; - }); - - currentWordHighlight = null; -} - -// UI Components -const content = (state) => { - // Show loading state if navigator isn't ready - if (navigator.getState() === "loading" || !state) { - return html` -
-

Readium Speech Navigator Demo

-
-

Loading speech engine...

-
-
`; - } - - return html` -
-

Readium Speech Navigator Demo

- -
-
-

Voice Settings

- ${voices.length > 0 ? html` -
- - -
-
-
- Voice Details -
- ${(() => { - const voice = navigator.getCurrentVoice(); - if (!voice) return html`

No voice selected

`; - - // Get all properties from the voice object - const voiceProps = []; - - // Add all properties from the voice object - for (const [key, value] of Object.entries(voice)) { - if (key.startsWith("_")) continue; - - let displayValue = value; - if (value === undefined) displayValue = "undefined"; - else if (value === null) displayValue = "null"; - else if (typeof value === "boolean") displayValue = value ? "Yes" : "No"; - else if (typeof value === "object") displayValue = JSON.stringify(value); - - voiceProps.push({ key, value: displayValue }); - } - - return html` -
- ${voiceProps.map(({key, value}) => html` -
-
${key}:
-
${value}
-
- `)} -
- `; - })()} -
-
- - -
- ` : html`
Loading voices...
`} -
-
- -
- - - - -
- -
- - of ${state.totalUtterances} - -
- -
-

State: ${navigator.getState()}

-
- -
-

Content Preview

-
- ${navigator.getContentQueue().map((utterance, index) => html` -
- ${index + 1}. - ${utterance.text} -
- `)} -
-
- - -`; -}; - -// Initial render with loading state -viewRender(); - -// Re-render once voices are loaded -initVoices().then(() => viewRender()); diff --git a/demo/sampleText.json b/demo/sampleText.json new file mode 100644 index 0000000..bc84135 --- /dev/null +++ b/demo/sampleText.json @@ -0,0 +1,182 @@ +{ + "ar": { + "language": "Arabic", + "text": "كانت أليس تبدأ بالشعور بالتعب الشديد من الجلوس بجانب أختها على الضفة. مرة أو مرتين كانت قد ألقت نظرة خاطفة على الكتاب الذي كانت أختها تقرأه، لكنه لم يكن يحتوي على صور أو محادثات. 'وما فائدة الكتاب،' فكرت أليس، 'بدون صور أو محادثات؟' لذا كانت تفكر في نفسها ما إذا كانت متعة صنع سلسلة أزهار الأقحوان تستحق عناء النهوض وقطف الأقحوان. وفجأة ركض أرنب أبيض بعينين ورديتين بالقرب منها. لم يكن هناك ما يستحق الذكر كثيرًا في ذلك؛ ولم تظن أليس أنه غريب جدًا أن تسمع الأرنب يقول لنفسه، 'يا إلهي! يا إلهي! سأكون متأخرة!' لكن عندما أخرج الأرنب ساعة من جيب صدر سترته ونظر إليها، قفزت أليس على قدميها. لم ترَ من قبل أرنبًا لديه جيب صدر أو ساعة ليخرجها." + }, + "bg": { + "language": "Bulgarian", + "text": "Алиса започваше да се уморява много от това да седи до сестра си на брега. Веднъж или два пъти тя поглеждаше в книгата, която сестра ѝ четеше, но тя нямаше нито картинки, нито разговори. „И каква е ползата от книга,“ мислеше Алиса, „без картинки или разговори?“ Така че тя обмисляше в ума си дали удоволствието да направи гирлянда от маргаритки ще си заслужава усилието да се изправи и да бере маргаритките. И изведнъж бял заек с розови очи пробяга покрай нея. Нямаше нищо толкова забележително в това; нито Алиса смяташе, че е толкова необичайно да чуе заека да казва на себе си: „О, Боже! О, Боже! Ще закъснея!“ Но когато заекът всъщност извади часовник от джоба на жилетката си и го погледна, Алиса скочи на крака си." + }, + "bho": { + "language": "Bhojpuri", + "text": "एलिस आपन बहिन का पास किनारे पर बइठल-बइठल बहुत थाक गइल रही। एक-दू बार उ बहिन जे किताब पढ़त रहली ओकरा में झांकली, बाकिर ओह में कवनो चित्र या बातचीत ना रहे। 'और किताब के फायदा का बा,' एलिस सोचली, 'बिना चित्र या बातचीत के?' त ओह सोच में पड़ गइल की फूल के माला बनावे के मज़ा उठावे खातिर उठ के फूल तोड़े के मेहनत करल जाय कि ना। अचानक एगो गोरो रंग के खरगोश गुलाबी आंख के पास से दौड़ल। एकर कवनो खास बात ना रहल; न एलिस ई सोचली की खरगोश के अपने से कहत सुनल अजीब बा, 'हे भगवान! हे भगवान! हम देर हो जाएब!' बाकिर जब खरगोश वाकई में अपनी जेब से घड़ी निकाललस आ ओकरा देखलस, एलिस तुरंते खड़ा हो गइल।" + }, + "bn": { + "language": "Bengali", + "text": "অ্যালিস তার বোনের পাশে নদীর ধারে বসে বসে খুব ক্লান্ত হয়ে যাচ্ছিল। এক-দুইবার সে তার বোন যে বই পড়ছিল তাতে চেয়ে দেখেছিল, কিন্তু এতে কোনো ছবি বা কথোপকথন ছিল না। 'একটি বইয়ের ব্যবহার কী,' অ্যালিস ভাবল, 'ছবি বা কথোপকথন ছাড়া?' তাই সে নিজের মনে বিবেচনা করছিল যে দাইজি-চেইন বানানো আনন্দের মূল্য কি বসে উঠে দাইজি তোলা ঝামেলার সমান। হঠাৎ করেই একটি সাদা খরগোশ গোলাপি চোখ নিয়ে তার পাশ দিয়ে ছুটে গেল। এতে তেমন কিছু আশ্চর্যজনক কিছু ছিল না; অ্যালিসও মনে করল না এটি অস্বাভাবিক যে খরগোশ নিজের সঙ্গে বলল, 'ওহ দ্যাখ! ওহ দ্যাখ! আমি দেরি হয়ে যাব!' কিন্তু যখন খরগোশ সত্যিই তার কোমরের পকেট থেকে একটি ঘড়ি বের করল এবং তাকাল, অ্যালিস লাফিয়ে দাঁড়াল।" + }, + "ca": { + "language": "Catalan", + "text": "A l'Alice li començava a cansar molt seure al costat de la seva germana a la riba. Un parell de vegades havia mirat el llibre que la seva germana llegia, però no tenia ni imatges ni converses. 'I de què serveix un llibre,' pensava l'Alice, 'sense imatges ni converses?' Així que es preguntava si el plaer de fer una cadena de margarides valia la pena de llevar-se i recollir les margarides. De sobte, un Conill Blanc amb els ulls roses va passar corrent a prop seu. No hi havia res de tan extraordinari en això; ni l'Alice trobava tan fora del normal sentir el conill dir-se a si mateix: 'Ai, Déu! Ai, Déu! Arribaré tard!' Però quan el Conill va treure realment un rellotge del seu butxaca de jupó i el va mirar, l'Alice es va aixecar de cop." + }, + "cmn": { + "language": "Mandarin Chinese", + "text": "爱丽丝开始感到坐在河岸上陪着她姐姐非常无聊。有一两次,她偷看了她姐姐正在读的书,但书中没有图片或对话。‘一本没有图片或对话的书有什么用呢,’爱丽丝想。于是她在心里考虑制作一串雏菊花链的乐趣是否值得起身去采摘雏菊。突然,一只粉眼睛的白兔跑到她身边。这没什么特别的;爱丽丝也不觉得听到兔子自言自语‘天哪!天哪!我要迟到了!’有什么奇怪。但当兔子真的从马甲口袋里掏出一块表看时,爱丽丝立刻跳了起来。" + }, + "cs": { + "language": "Czech", + "text": "Alice začínala být velmi unavená z toho, že seděla u své sestry na břehu. Jednou nebo dvakrát nahlédla do knihy, kterou její sestra četla, ale neměla v ní žádné obrázky ani rozhovory. 'A k čemu je kniha,' přemýšlela Alice, 'bez obrázků nebo rozhovorů?' Tak přemýšlela, zda potěšení z výroby řetízku z kopretin stojí za to vstát a sbírat kopretiny. Najednou kolem ní běžel Bílý králík s růžovýma očima. Na tom nebylo nic tak zvláštního; ani Alice nepovažovala za tak divné slyšet, jak králík říká sám sobě: 'Ach ne! Ach ne! Budu pozdě!' Ale když králík skutečně vytáhl hodinky z kapsy na vestě a podíval se na ně, Alice vyskočila na nohy." + }, + "da": { + "language": "Danish", + "text": "Alice begyndte at blive meget træt af at sidde ved siden af sin søster på bredden. En eller to gange havde hun kigget i den bog, hendes søster læste, men den havde ingen billeder eller samtaler i sig. 'Og hvad er nytten af en bog,' tænkte Alice, 'uden billeder eller samtaler?' Så hun overvejede for sig selv, om glæden ved at lave en margeritkæde ville være værd besværet ved at rejse sig og plukke margeritter. Pludselig løb en Hvid Kanin med lyserøde øjne forbi hende. Der var ikke noget særligt bemærkelsesværdigt ved det; Alice syntes heller ikke, det var så mærkeligt at høre kaninen sige til sig selv: 'Åh kære! Åh kære! Jeg kommer for sent!' Men da kaninen faktisk tog et ur op af sin vestlomme og kiggede på det, sprang Alice op på sine fødder." + }, + "de": { + "language": "German", + "text": "Alice begann sehr müde zu werden, neben ihrer Schwester am Ufer zu sitzen. Ein- oder zweimal hatte sie in das Buch ihrer Schwester hineingeschaut, aber es hatte keine Bilder oder Gespräche. ‚Und was nützt ein Buch,‘ dachte Alice, ‚ohne Bilder oder Gespräche?‘ Also überlegte sie in ihrem Kopf, ob der Spaß daran, eine Gänseblümchenkette zu machen, den Aufwand wert sei, aufzustehen und die Gänseblümchen zu pflücken. Plötzlich rannte ein Weißes Kaninchen mit rosa Augen dicht an ihr vorbei. Daran war nichts besonders Bemerkenswertes; auch hielt Alice es nicht für sonderlich ungewöhnlich, das Kaninchen zu sich selbst sagen zu hören: ‚Oje! Oje! Ich werde zu spät kommen!‘ Aber als das Kaninchen tatsächlich eine Uhr aus seiner Westentasche zog und darauf sah, sprang Alice auf die Füße." + }, + "el": { + "language": "Greek", + "text": "Η Αλίκη άρχισε να κουράζεται πολύ από το να κάθεται δίπλα στην αδελφή της στην όχθη. Μία ή δύο φορές είχε ρίξει μια ματιά στο βιβλίο που διάβαζε η αδελφή της, αλλά δεν είχε εικόνες ή διάλογους. «Και ποιο το όφελος ενός βιβλίου,» σκέφτηκε η Αλίκη, «χωρίς εικόνες ή διάλογους;» Έτσι σκεφτόταν μέσα της αν η ευχαρίστηση να φτιάξει μια αλυσίδα από μαργαρίτες άξιζε τον κόπο να σηκωθεί και να μαζέψει τα λουλούδια. Ξαφνικά, ένα Λευκό Κουνέλι με ροζ μάτια πέρασε κοντά της. Δεν υπήρχε κάτι τόσο αξιοσημείωτο σε αυτό· ούτε η Αλίκη το βρήκε παράξενο να ακούει το κουνέλι να λέει στον εαυτό του: «Ωχ! Ωχ! Θα αργήσω!» Αλλά όταν το Κουνέλι έβγαλε πραγματικά ένα ρολόι από την τσέπη του γιλέκου του και το κοίταξε, η Αλίκη πετάχτηκε όρθια." + }, + "en": { + "language": "English", + "text": "Alice was beginning to get very tired of sitting by her sister on the bank. Once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it. ‘And what is the use of a book,’ thought Alice, ‘without pictures or conversations?’ So she was considering in her own mind whether the pleasure of making a daisy-chain would be worth the trouble of getting up and picking the daisies. When suddenly a White Rabbit with pink eyes ran close by her. There was nothing so very remarkable in that; nor did Alice think it so very much out of the way to hear the rabbit say to itself, ‘Oh dear! Oh dear! I shall be late!’ But when the Rabbit actually took a watch out of its waistcoat-pocket and looked at it, Alice jumped up on her feet. She had never before seen a rabbit with either a waistcoat-pocket, or a watch to take out of it." + }, + "es": { + "language": "Spanish", + "text": "A Alicia le empezaba a cansar mucho estar sentada junto a su hermana en la orilla. Una o dos veces había echado un vistazo al libro que su hermana estaba leyendo, pero no tenía imágenes ni conversaciones. '¿Y de qué sirve un libro,' pensaba Alicia, 'sin imágenes ni conversaciones?' Así que estaba considerando en su mente si el placer de hacer una guirnalda de margaritas valdría el esfuerzo de levantarse y recoger las margaritas. De repente, un Conejo Blanco con ojos rosas corrió cerca de ella. No había nada tan notable en eso; ni Alicia pensó que fuera tan extraño oír al conejo decirse a sí mismo: '¡Oh cielos! ¡Oh cielos! ¡Llegaré tarde!' Pero cuando el Conejo realmente sacó un reloj de su bolsillo del chaleco y lo miró, Alicia se levantó de un salto." + }, + "eu": { + "language": "Basque", + "text": "Alisek oso nekatuta hasi zen ibiltzen bere arrebaren ondoan bankuan esertzeaz. Behin edo bi aldiz begiratu zion bere arreba irakurtzen ari zen liburuari, baina ez zuen argazkirik edo elkarrizketarik. 'Eta zertarako balio du liburu batek,' pentsatu zuen Alisek, 'argazkirik edo elkarrizketarik gabe?' Beraz, bere buruan pentsatzen ari zen margarita-kate bat egiteak altxatzeko eta margarita jasotzeko emango lukeen ahalegina merezi ote zuen. Bat-batean, begi arrosadun Errabo Zuria bere ondoan korrika ibili zen. Ez zegoen horren bitxia; ez Alisek uste zuen hain arraroa zenik erraboa bere buruarentzat 'Aizu! Aizu! Berandu iritsiko naiz!' esaten entzutea. Baina Erraboak benetan bere kamiseta-poltsikotik erloju bat atera eta begiratu zuenean, Alisek jauzi egin zuen oinetan." + }, + "fa": { + "language": "Persian", + "text": "آلیس داشت از نشستن کنار خواهرش روی کرانه بسیار خسته می‌شد. یک یا دو بار به کتابی که خواهرش می‌خواند نگاه کرده بود، اما هیچ تصویر یا گفت‌وگویی در آن نبود. «یک کتاب چه فایده‌ای دارد،» آلیس فکر کرد، «بدون تصویر یا گفت‌وگو؟» بنابراین در ذهن خود بررسی می‌کرد که آیا لذت ساختن زنجیره‌ای از گل‌های بابونه ارزش زحمت برخاستن و چیدن گل‌ها را دارد یا نه. ناگهان یک خرگوش سفید با چشم‌های صورتی از کنار او دوید. در این کار چیز بسیار عجیبی نبود؛ آلیس هم فکر نمی‌کرد عجیب باشد که خرگوش به خودش بگوید: «وای! وای! دیرم خواهد شد!» اما وقتی خرگوش واقعاً ساعتی از جیب جلیقه‌اش بیرون آورد و به آن نگاه کرد، آلیس روی پاهایش پرید." + }, + "fi": { + "language": "Finnish", + "text": "Alice alkoi väsyä istuessaan sisarensa vieressä joen rannalla. Kerran tai pari hän oli kurkistanut sisarensa lukemaan kirjaan, mutta siinä ei ollut kuvia tai keskusteluja. 'Mihin hyötyyn kirja on,' Alice ajatteli, 'jos siinä ei ole kuvia tai keskusteluja?' Niinpä hän pohti mielessään, olisiko daisy-ketjun tekemisen ilo vaivan arvoista nousta ylös ja poimia päivänkakkarat. Yhtäkkiä valkoinen kani, jolla oli vaaleanpunaiset silmät, juoksi hänen ohi. Siinä ei ollut mitään erityisen merkittävää; Alice ei myöskään pitänyt kovin outona kuulla kanin sanovan itselleen: 'Oi voi! Oi voi! Myöhästyn!' Mutta kun kani oikeasti otti kellon liivintaskustaan ja katsoi sitä, Alice hyppäsi jaloilleen." + }, + "fr": { + "language": "French", + "text": "Alice commençait à se lasser de rester assise près de sa sœur sur la berge. Une ou deux fois, elle avait jeté un coup d’œil dans le livre que sa sœur lisait, mais il n’y avait ni images ni conversations. « Et à quoi sert un livre, » pensait Alice, « sans images ni conversations ? » Elle réfléchissait donc en elle-même pour savoir si le plaisir de faire une guirlande de marguerites valait la peine de se lever et de cueillir les marguerites. Soudain, un Lapin Blanc aux yeux roses passa près d’elle. Il n’y avait rien de très remarquable là-dedans ; Alice ne pensait pas non plus qu’il soit si étrange d’entendre le lapin se dire à lui-même : « Oh là là ! Oh là là ! Je vais être en retard ! » Mais lorsque le Lapin sortit effectivement une montre de sa poche de gilet et la regarda, Alice bondit sur ses pieds." + }, + "gl": { + "language": "Galician", + "text": "A Alicia comezaba a cansarse moito de estar sentada xunto á súa irmá na beira. Un par de veces espiou o libro que a súa irmá estaba lendo, pero non tiña imaxes nin conversacións. 'E de que serve un libro,' pensaba Alicia, 'sen imaxes nin conversacións?' Así que estaba a considerar na súa mente se o pracer de facer unha cadea de margaridas valería a pena levantarse e recoller as margaridas. De súpeto, un Coello Branco con ollos rosas pasou correndo preto dela. Non había nada tan notable en iso; tampouco Alicia pensou que fose tan raro escoitar ao coello dicir para si mesmo: '¡Ai Deus! ¡Ai Deus! Chegarei tarde!' Pero cando o Coello realmente sacou un reloxo do peto do chaleco e mirouno, Alicia deu un salto sobre os pés." + }, + "he": { + "language": "Hebrew", + "text": "אליס התחילה להתעייף מאוד מלהיות יושבת ליד אחותה על הגדה. פעם או פעמיים היא הציצה בספר שאחותה קראה, אך לא היו בו תמונות או שיחות. 'ומה תועלת ספר,' חשבה אליס, 'ללא תמונות או שיחות?' כך היא שקלה בליבה האם ההנאה של עשיית שרשרת פרחי דייזי שווה את המאמץ לקום ולאסוף את הפרחים. פתאום ארנב לבן עם עיניים ורודות רץ לידה. לא היה בכך משהו מיוחד; ולא חשבה אליס שזה מוזר מדי לשמוע את הארנב אומר לעצמו: 'או, אלוהים! או, אלוהים! אני אגיע באיחור!' אבל כאשר הארנב באמת הוציא שעון מכיס אפודו והביט בו, אליס קפצה על רגליה." + }, + "hi": { + "language": "Hindi", + "text": "ऐलिस अपनी बहन के पास किनारे पर बैठकर बहुत थकने लगी थी। एक-दो बार उसने उस किताब में झांक लिया जो उसकी बहन पढ़ रही थी, लेकिन उसमें न तो चित्र थे और न ही संवाद। 'और किताब का क्या फायदा,' ऐलिस ने सोचा, 'बिना चित्र या संवाद के?' इसलिए वह अपने मन में सोच रही थी कि डेज़ी-चेन बनाने का आनंद उठाना खड़ा होकर फूल तोड़ने के कष्ट के लायक है या नहीं। अचानक गुलाबी आंखों वाला एक सफेद खरगोश उसके पास से दौड़ता हुआ गुजरा। इसमें कुछ विशेष आश्चर्यजनक नहीं था; न ही ऐलिस को यह अजीब लगा कि खरगोश अपने आप से कहे, 'हे भगवान! हे भगवान! मैं देर हो जाऊंगी!' लेकिन जब खरगोश ने वास्तव में अपनी वेस्टकोट की जेब से घड़ी निकाली और उसे देखा, ऐलिस तुरंत अपने पैरों पर कूद पड़ी।" + }, + "hr": { + "language": "Croatian", + "text": "Alice je počinjala biti vrlo umorna od sjedenja kraj svoje sestre na obali. Jednom ili dvaput je zavirila u knjigu koju je njezina sestra čitala, ali nije imala slike ili dijaloge. 'I čemu služi knjiga,' mislila je Alice, 'bez slika ili dijaloga?' Tako je razmišljala u svom umu je li zadovoljstvo izrade lančića od tratinčica vrijedno truda ustati i brati tratinčice. Odjednom je bijeli Zec s ružičastim očima protrčao pokraj nje. U tome nije bilo ništa posebno zapanjujuće; Alice također nije mislila da je čudno čuti zeca kako sam sebi kaže: 'O, dragi! O, dragi! Kasnit ću!' Ali kada je Zec zapravo izvadio sat iz džepa svog prsluka i pogledao ga, Alice je skočila na noge." + }, + "hu": { + "language": "Hungarian", + "text": "Alice kezdett nagyon elfáradni attól, hogy a parton ült a nővére mellett. Egyszer-kétszer belesett a könyvbe, amit a nővére olvasott, de abban nem voltak képek vagy párbeszédek. 'És mi haszna van egy könyvnek,' gondolta Alice, 'képek vagy párbeszéd nélkül?' Így hát azon töprengett a fejében, vajon megéri-e a fáradságot felállni és margarétaláncot készíteni. Hirtelen egy fehér Nyúl rózsaszín szemekkel futott el mellette. Ebben nem volt semmi különösen figyelemre méltó; Alice sem találta túl szokatlannak hallani a nyulat, ahogy magának mondja: 'Ó jaj! Ó jaj! Elkésem!' De amikor a Nyúl tényleg elővett egy órát a mellényzsebéből és rápillantott, Alice felugrott a lábára." + }, + "id": { + "language": "Indonesian", + "text": "Alice mulai sangat lelah duduk di samping saudara perempuannya di tepi sungai. Sekali atau dua kali ia mengintip buku yang dibaca saudaranya, tetapi tidak ada gambar atau percakapan di dalamnya. 'Dan apa gunanya sebuah buku,' pikir Alice, 'tanpa gambar atau percakapan?' Jadi ia sedang mempertimbangkan dalam pikirannya apakah kesenangan membuat rantai bunga daisy sepadan dengan repot bangun dan memetik bunga-bunga itu. Tiba-tiba seekor Kelinci Putih bermata merah muda berlari dekat dengannya. Tidak ada yang begitu luar biasa; Alice juga tidak menganggapnya aneh mendengar kelinci berkata pada dirinya sendiri: 'Astaga! Astaga! Aku akan terlambat!' Tetapi ketika Kelinci benar-benar mengeluarkan jam dari saku rompinya dan melihatnya, Alice meloncat berdiri." + }, + "it": { + "language": "Italian", + "text": "Alice stava cominciando a stancarsi molto di stare seduta accanto a sua sorella sulla riva. Una o due volte aveva dato un’occhiata al libro che sua sorella stava leggendo, ma non aveva né immagini né conversazioni. 'E a cosa serve un libro,' pensava Alice, 'senza immagini o conversazioni?' Così stava considerando nella sua mente se il piacere di fare una collana di margherite valesse la fatica di alzarsi e raccogliere i fiori. Improvvisamente un Coniglio Bianco dagli occhi rosa corse vicino a lei. Non c’era nulla di particolarmente notevole in ciò; né Alice trovava così strano sentire il coniglio dire a se stesso: 'Oh cielo! Oh cielo! Farò tardi!' Ma quando il Coniglio tirò davvero fuori un orologio dalla tasca del panciotto e lo guardò, Alice saltò in piedi." + }, + "ja": { + "language": "Japanese", + "text": "アリスは川岸で姉のそばに座っているのにとても疲れ始めていた。何度か姉が読んでいる本を覗き込んだが、そこには絵も会話もなかった。『絵も会話もない本なんて、何の役に立つのだろう』とアリスは思った。そこで彼女は、デイジーチェーンを作る楽しみが立ち上がって花を摘む手間に見合うかどうか、自分の心の中で考えていた。突然、ピンクの目をした白ウサギが彼女のそばを走り抜けた。それに特に驚くことはなかった;アリスもウサギが自分自身に『ああ、しまった!ああ、しまった!遅れる!』と言うのを聞いても特に変だとは思わなかった。しかし、ウサギが実際にチョッキのポケットから時計を取り出して見たとき、アリスは飛び上がった。" + }, + "kn": { + "language": "Kannada", + "text": "ಆಲಿಸ್ ತನ್ನ ಸಹೋದರಿಯ ಬಳಿಯಲ್ಲಿ ತೀರದಲ್ಲಿ ಕೂತಿರುವುದರಿಂದ ತುಂಬಾ ಥಕತಿಹೋಗುತ್ತಿದ್ದುದು ಪ್ರಾರಂಭಿಸಿತು. ಒಮ್ಮೆ ಅಥವಾ ಎರಡು ಬಾರಿ ಅವಳು ತನ್ನ ಸಹೋದರಿ ಓದುತ್ತಿದ್ದ ಪುಸ್ತಕವನ್ನು ನೋಡಿದಳು, ಆದರೆ ಅದರಲ್ಲಿ ಚಿತ್ರಗಳು ಅಥವಾ ಸಂಭಾಷಣೆಗಳೇ ಇರಲಿಲ್ಲ. 'ಚಿತ್ರಗಳು ಅಥವಾ ಸಂಭಾಷಣೆ ಇಲ್ಲದ ಪುಸ್ತಕದ ಉಪಯೋಗವೇನು,' ಎಂದು ಆಲಿಸ್ ಚಿಂತನೆ ಮಾಡಿದರು. ಆದ್ದರಿಂದ ದೇಸಿ-ಚೈನ್ ಮಾಡುವ ಸಂತೋಷವು ಎದ್ದುಕೊಂಡು ಹೂವುಗಳನ್ನು ತೂಗುವ ಕಷ್ಟಕ್ಕೆ ಸಮರ್ಥವಾಗುವುದೇ ಎಂದು ಅವಳು ತನ್ನ ಮನಸ್ಸಿನಲ್ಲಿ ಪರಿಗಣಿಸುತ್ತಿದ್ದಳು. ಹಠಾತ್ತಾಗಿ, ಪಿಂಕ್ ಕಣ್ಣುಳ್ಳ ಬಿಳಿ ಮೊಲ ಅವಳ ಹತ್ತಿರ ಓಡಿತು. ಅದರಲ್ಲಿ ಯಾವುದೇ ವಿಶೇಷವಾದ ಅಚ್ಚರಿ ಏನೂ ಇರಲಿಲ್ಲ; ಆಲಿಸಿಗೂ ಮೊಲವು ತನ್ನನ್ನೇ 'ಅಯ್ಯೋ! ಅಯ್ಯೋ! ನಾನು ತಡವಾಗಿ ಆಗುತ್ತೇನೆ!' ಎಂದು ಹೇಳುತ್ತಿರುವುದು ಅನ್ಯಾಯವೆಂದು ಕಂಡಿಲ್ಲ. ಆದರೆ ಮೊಲವು ವಾಸ್ಟ್ಕೋಟ್ ಜೇಬಿನಿಂದ ಘಡಿಯನ್ನು ತೆಗೆದು ಅದನ್ನು ನೋಡಿದಾಗ, ಆಲಿಸ್ ತನ್ನ ಪಾದಗಳ ಮೇಲೆ ಜಿಗಿಯಿತು." + }, + "ko": { + "language": "Korean", + "text": "앨리스는 강둑에서 그녀의 언니 옆에 앉아 있는 것에 매우 지쳐가기 시작했다. 한두 번 그녀는 언니가 읽고 있는 책을 살짝 들여다보았지만, 거기에는 그림이나 대화가 없었다. '그림이나 대화 없는 책이 무슨 소용이지,' 앨리스는 생각했다. 그래서 그녀는 데이지 체인을 만드는 즐거움이 일어나서 꽃을 따는 수고를 할 가치가 있는지 마음속으로 고민하고 있었다. 갑자기 분홍색 눈을 가진 흰 토끼가 그녀 옆을 달려 지나갔다. 그것에 특별히 놀랄 일은 없었다; 앨리스도 토끼가 혼잣말로 '오 이런! 오 이런! 늦겠어!'라고 말하는 것을 듣고 그렇게 이상하다고 생각하지 않았다. 그러나 토끼가 실제로 조끼 주머니에서 시계를 꺼내 들여다보았을 때, 앨리스는 벌떡 일어섰다." + }, + "mr": { + "language": "Marathi", + "text": "ऍलिस तिच्या बहिणीच्या बाजूला तटावर बसून खूप थकायला लागली होती. एक-दोन वेळा तिने पाहिले की तिची बहिण वाचत असलेली पुस्तक पाहिली, पण त्यात चित्रे किंवा संवाद नव्हते. 'आणि पुस्तकाचा काय उपयोग,' असे ऍलिसला वाटले, 'चित्रे किंवा संवादांशिवाय?' त्यामुळे ती स्वतःच्या मनात विचार करत होती की डेझी चेन बनवण्याचा आनंद उभे राहून फुले तोडण्याच्या त्रासाच्या लायक आहे की नाही. अचानक गुलाबी डोळ्यांचा पांढरा ससा तिच्या जवळून धावून गेला. यात काहीतरी फारच विशेष नव्हते; ऍलिसला असे वाटले नाही की ससाने स्वतःशी सांगताना ऐकणे एवढे विचित्र आहे: 'अरे देवा! अरे देवा! मी उशिरा पोहचणार!' पण जेव्हा ससाने खरोखरच आपला वेस्टकोट पॉकेटमधून घड्याळ काढले आणि पाहिले, तेव्हा ऍलिस उभी राहिली." + }, + "ms": { + "language": "Malay", + "text": "Alice mula merasa sangat letih duduk di sebelah kakaknya di tebing. Sekali atau dua kali dia mengintip buku yang dibaca oleh kakaknya, tetapi tiada gambar atau perbualan di dalamnya. 'Dan apa guna sebuah buku,' fikir Alice, 'tanpa gambar atau perbualan?' Jadi dia sedang memikirkan dalam fikirannya sama ada keseronokan membuat rantai daisy berbaloi dengan kesukaran untuk bangun dan memetik bunga-bunga itu. Tiba-tiba seekor Arnab Putih dengan mata merah jambu berlari di dekatnya. Tiada apa yang sangat luar biasa dalam itu; Alice juga tidak menganggapnya pelik mendengar arnab itu berkata pada dirinya sendiri: 'Ya ampun! Ya ampun! Aku akan lambat!' Tetapi apabila Arnab itu benar-benar mengeluarkan jam dari poket rompinya dan melihatnya, Alice melompat bangun." + }, + "nb": { + "language": "Norwegian Bokmål", + "text": "Alice begynte å bli veldig sliten av å sitte ved siden av søsteren sin på bredden. En eller to ganger hadde hun tittet inn i boken søsteren hennes leste, men den hadde ingen bilder eller samtaler. 'Og hva er nytten av en bok,' tenkte Alice, 'uten bilder eller samtaler?' Så hun vurderte i sitt eget sinn om gleden ved å lage en prestekragekjede var verdt bryet med å reise seg og plukke prestekragene. Plutselig løp en Hvit Kanin med rosa øyne nær henne. Det var ikke noe veldig bemerkelsesverdig med det; heller ikke syntes Alice at det var så merkelig å høre kaninen si til seg selv: 'Å nei! Å nei! Jeg kommer til å bli sen!' Men da kaninen faktisk tok ut et ur fra vestlommen og kikket på det, hoppet Alice opp på beina." + }, + "nl": { + "language": "Dutch", + "text": "Alice begon erg moe te worden van het zitten naast haar zus aan de oever. Een of twee keer had ze in het boek gekeken dat haar zus aan het lezen was, maar het bevatte geen plaatjes of gesprekken. 'En wat heb je aan een boek,' dacht Alice, 'zonder plaatjes of gesprekken?' Dus dacht ze in zichzelf na of het plezier van het maken van een madeliefjesketting de moeite waard was om op te staan en de madeliefjes te plukken. Plotseling rende een Wit Konijn met roze ogen dicht langs haar heen. Er was niets zo merkwaardigs aan; ook vond Alice het niet zo vreemd om het konijn tegen zichzelf te horen zeggen: 'O je! O je! Ik zal te laat komen!' Maar toen het Konijn daadwerkelijk een horloge uit zijn vestzak haalde en erop keek, sprong Alice op." + }, + "pl": { + "language": "Polish", + "text": "Alicja zaczynała być bardzo zmęczona siedzeniem obok swojej siostry na brzegu. Raz czy dwa zajrzała do książki, którą czytała jej siostra, ale nie było w niej ani obrazków, ani dialogów. 'I cóż za pożytek z książki,' pomyślała Alicja, 'bez obrazków i rozmów?' Tak więc rozważała w duchu, czy przyjemność robienia girlandy z stokrotek jest warta wysiłku wstania i zerwania kwiatów. Nagle obok niej przebiegł Biały Królik o różowych oczach. Nie było w tym nic nadzwyczajnego; Alicja też nie uważała, że to dziwne, gdy słyszy, jak królik mówi do siebie: 'Ojej! Ojej! Będę spóźniona!' Ale kiedy Królik naprawdę wyciągnął zegarek z kieszeni kamizelki i na niego spojrzał, Alicja podskoczyła na nogi." + }, + "pt": { + "language": "Portuguese", + "text": "Alice estava começando a ficar muito cansada de sentar-se ao lado de sua irmã na margem. Uma ou duas vezes ela espiou o livro que sua irmã estava lendo, mas não havia imagens nem conversas nele. 'E de que serve um livro,' pensou Alice, 'sem imagens ou conversas?' Assim, ela considerava em sua mente se o prazer de fazer uma corrente de margaridas valeria o esforço de se levantar e colher as flores. De repente, um Coelho Branco com olhos rosas correu perto dela. Não havia nada de muito notável nisso; Alice também não achou tão estranho ouvir o coelho dizendo a si mesmo: 'Oh, céus! Oh, céus! Vou me atrasar!' Mas quando o Coelho realmente tirou um relógio do bolso do colete e olhou para ele, Alice pulou de pé." + }, + "ro": { + "language": "Romanian", + "text": "Alice începea să se simtă foarte obosită stând lângă sora ei pe mal. O dată sau de două ori a aruncat o privire în cartea pe care o citea sora ei, dar nu avea imagini sau conversații. 'Și la ce folosește o carte,' se gândi Alice, 'fără imagini sau conversații?' Așa că se gândea în sinea ei dacă plăcerea de a face un lanț de margarete merită efortul de a se ridica și a culege florile. Deodată, un Iepure Alb cu ochi roz a alergat pe lângă ea. Nu era nimic atât de remarcabil în asta; nici Alice nu considera ciudat să audă iepurele spunându-și: 'Oh, Doamne! Oh, Doamne! O să întârzii!' Dar când Iepurele a scos cu adevărat un ceas din buzunarul vestei și s-a uitat la el, Alice a sărit în picioare." + }, + "ru": { + "language": "Russian", + "text": "Алиса начинала очень уставать от того, что сидела рядом с сестрой на берегу. Один или два раза она заглядывала в книгу, которую читала её сестра, но в ней не было ни картинок, ни диалогов. «И какая польза от книги, — думала Алиса, — без картинок и разговоров?» Поэтому она размышляла про себя, стоит ли удовольствие от создания цепочки из маргариток того труда, чтобы встать и собрать цветы. Вдруг мимо неё пробежал Белый Кролик с розовыми глазами. В этом не было ничего особо примечательного; Алиса также не считала странным слышать, как кролик говорит сам себе: «О, боже! О, боже! Я опоздаю!» Но когда Кролик действительно достал часы из кармана жилета и посмотрел на них, Алиса вскочила на ноги." + }, + "sk": { + "language": "Slovak", + "text": "Alice sa začala veľmi unavovať z toho, že sedela pri svojej sestre na brehu. Raz alebo dvakrát nakukla do knihy, ktorú čítala jej sestra, ale neobsahovala žiadne obrázky ani rozhovory. 'A na čo je kniha,' pomyslela si Alice, 'bez obrázkov alebo rozhovorov?' Takže si v duchu rozvažovala, či radosť z vytvorenia reťazca z margarét stojí za námahu vstať a nazbierať margaréty. Zrazu okolo nej prebehol Biely Králik s ružovými očami. V tom nebolo nič zvlášť pozoruhodného; Alice si tiež nemyslela, že by bolo zvláštne počuť králika hovoriť si sám pre seba: 'Och, drahý! Och, drahý! Meškám!' Ale keď králik naozaj vytiahol hodinky z vrecka vesty a pozrel sa na ne, Alice vyskočila na nohy." + }, + "sl": { + "language": "Slovenian", + "text": "Alice je začela biti zelo utrujena od sedenja ob svoji sestri na bregu. Enkrat ali dvakrat je pokukala v knjigo, ki jo je brala njena sestra, vendar v njej ni bilo nobenih slik ali pogovorov. 'In kakšen namen ima knjiga,' je razmišljala Alice, 'brez slik ali pogovorov?' Tako je v svojem notranjem svetu razmišljala, ali bi bil užitek izdelovanja verige iz marjetic vreden truda vstati in pobrati cvetlice. Nenadoma je mimo nje pritekel Bel Zajec z roza očmi. V tem ni bilo nič posebej nenavadnega; Alice se tudi ni zdelo čudno slišati, kako zajec sam sebi reče: 'O, dragi! O, dragi! Zamudila bom!' Ko pa je zajec res izvlekel uro iz žepka telovnika in pogledal nanjo, je Alice poskočila na noge." + }, + "sv": { + "language": "Swedish", + "text": "Alice började bli mycket trött på att sitta bredvid sin syster vid flodbanken. En eller två gånger hade hon tittat i boken som hennes syster läste, men den hade inga bilder eller samtal. 'Och vad är nyttan med en bok,' tänkte Alice, 'utan bilder eller samtal?' Så hon funderade i sitt eget sinne om nöjet att göra en prästkragakedja var värt besväret att resa sig och plocka prästkragar. Plötsligt sprang en Vit Kanin med rosa ögon nära henne. Det var inget särskilt anmärkningsvärt med det; Alice tyckte inte heller att det var särskilt märkligt att höra kaninen säga till sig själv: 'Åh nej! Åh nej! Jag kommer att bli sen!' Men när Kaninen faktiskt tog fram en klocka ur sin västficka och tittade på den, hoppade Alice upp på fötterna." + }, + "ta": { + "language": "Tamil", + "text": "அலிஸ் தனது சகோதரியின் அருகே நதிக்கரை மீது உட்கார்ந்து மிகவும் சோர்வடைந்துவிட்டாள். ஒருமுறை அல்லது இருமுறை அவள் தனது சகோதரியின் படிக்கிற புத்தகத்தை சிறிது பார்வையிட்டாள், ஆனால் அதில் படங்கள் அல்லது உரையாடல்கள் எதுவும் இல்லை. 'படங்களோ உரையாடல்களோ இல்லாத புத்தகம் எந்த பயனுடையது?' என்று அலிஸ் நினைத்தாள். எனவே அவள் மனதில் சிந்தித்தாள், டெய்சி சங்கிலியை உருவாக்கும் மகிழ்ச்சி எழுந்து பூக்களை எடுக்கக்கூடிய முயற்சிக்குரியதா என்று. திடீரென ஒரு வெள்ளை முயல் ரோஜா கண்களுடன் அவளின் அருகே ஓடிச் சென்றது. அதில் மிக விசித்திரமானதல்ல; அலிஸ் அந்த முயல் தனக்கே சொல்லிக்கொண்டதை கேட்டு அதிர்ச்சியடைந்தாள் என்று எண்ணவில்லை: 'ஓ அன்பே! ஓ அன்பே! நான் தாமதமாகி விடுவேன்!' ஆனால் முயல் உண்மையில் வஸ்கோட் பாக்கெட் இருந்து கடிகாரத்தை எடுத்துச் பார்த்தபோது, அலிஸ் நின்றாள்." + }, + "te": { + "language": "Telugu", + "text": "అలిస్ తన చెల్లెదరి పక్కన తీరానికి కూర్చొని చాలా అలసిపోతుండేది. ఒకసారి లేదా రెండు సార్లు ఆమె తన చెల్లెదరి చదువుతున్న పుస్తకంలో చుడుతుందివి, కానీ అందులో చిత్రాలు లేదా సంభాషణలు లేవు. 'చిత్రాలు లేదా సంభాషణలు లేకుండా పుస్తకానికి ఏమి ఉపయోగం,' అని అలిస్ ఆలోచించింది. కాబట్టి డైసీ చైన్ తయారు చేసే ఆనందం లేపి పూలను కోసుకునే కష్టం విలువైనదా అనే విషయాన్ని ఆమె తన మనసులో ఆలోచిస్తోంది. అకస్మాత్తుగా గులాబీ కళ్లతో ఉన్న తెల్ల శూక్రగొంగ ఆమె పక్కన పరిగెత్తింది. అందులో ఏ ప్రత్యేకంగా ఆశ్చర్యకరమైనది లేదు; అలిస్ కప్పు తనకే చెప్పుకుంటూ ఉంది అని విన్నా సాధారణంగా ఆశ్చర్యంగా అనిపించలేదు: 'ఓ దేవా! ఓ దేవా! నేను ఆలస్యమవుతాను!' కానీ శూక్రగొంగ నిజంగా తన వెస్ట్‌కోట్ పొకెట్ నుండి గడియారం తీసుకుని దానిని చూసినప్పుడు, అలిస్ కదిలింది." + }, + "th": { + "language": "Thai", + "text": "อลิซเริ่มรู้สึกเหนื่อยมากที่นั่งอยู่ข้างๆ พี่สาวของเธอที่ริมฝั่ง แม้เธอจะชำเลืองดูหนังสือที่พี่สาวกำลังอ่านอยู่หนึ่งหรือสองครั้ง แต่ก็ไม่มีภาพหรือบทสนทนาใดๆ 'แล้วหนังสือมีประโยชน์อะไร,' อลิซคิด, 'ถ้าไม่มีภาพหรือบทสนทนา?' ดังนั้นเธอจึงคิดอยู่ในใจเองว่าความเพลิดเพลินจากการทำพวงมาลัยดอกเดซี่นั้นคุ้มค่ากับความเหนื่อยที่จะลุกขึ้นไปเก็บดอกไม้หรือไม่ อยู่ดีๆ กระต่ายขาวที่มีดวงตาสีชมพูวิ่งผ่านมาใกล้เธอ ไม่มีอะไรน่าประหลาดใจมากนัก; อลิซก็ไม่คิดว่าการได้ยินกระต่ายพูดกับตัวเองว่า 'โอ้ พระเจ้า! โอ้ พระเจ้า! ฉันจะสาย!' เป็นเรื่องแปลก แต่เมื่อกระต่ายเอานาฬิกาออกจากกระเป๋าเสื้อกั๊กและดูเวลา อลิซก็ลุกขึ้นทันที" + }, + "tr": { + "language": "Turkish", + "text": "Alice, kız kardeşinin yanında kıyıda oturmaktan çok yorulmaya başlamıştı. Bir veya iki kez, kız kardeşinin okuduğu kitaba göz atmıştı, ama içinde ne resim ne de konuşma vardı. 'Resimsiz veya konuşmasız bir kitabın ne faydası var,' diye düşündü Alice. Bu yüzden, papatya zinciri yapmak gibi bir zevkin, kalkıp papatyaları toplamanın zahmetine değip değmeyeceğini kafasında tartıyordu. Aniden, pembe gözlü Beyaz Bir Tavşan yanından koşarak geçti. Bu o kadar da dikkate değer bir şey değildi; Alice de tavşanın kendi kendine 'Ah hayır! Ah hayır! Geç kalacağım!' dediğini duymanın garip olduğunu düşünmedi. Ama Tavşan gerçekten yelek cebinden bir saat çıkardığında ve ona baktığında, Alice ayağa fırladı." + }, + "uk": { + "language": "Ukrainian", + "text": "Аліса почала дуже втомлюватися, сидячи біля своєї сестри на березі. Один або два рази вона зазирала в книгу, яку читала її сестра, але там не було ні зображень, ні діалогів. «А яка користь від книги,» подумала Аліса, «якщо в ній немає зображень або діалогів?» Тож вона розмірковувала про себе, чи варто задоволення від створення гірлянди з маргариток клопоту встати і зірвати квіти. Раптом повз неї пробіг Білий Кролик з рожевими очима. У цьому не було нічого особливо примітного; Аліса також не вважала дивним почути, як кролик каже сам собі: «О Боже! О Боже! Я запізнюся!» Але коли Кролик справді витягнув годинник із кишені жилета і подивився на нього, Аліса вскочила на ноги." + }, + "vi": { + "language": "Vietnamese", + "text": "Alice bắt đầu cảm thấy rất mệt khi ngồi bên cạnh chị gái của mình trên bờ sông. Một hoặc hai lần cô liếc vào cuốn sách mà chị gái đang đọc, nhưng trong đó không có hình ảnh hay đối thoại. 'Một cuốn sách để làm gì,' Alice nghĩ, 'nếu không có hình ảnh hay đối thoại?' Vì vậy cô tự hỏi liệu niềm vui làm vòng hoa cúc có xứng đáng với công sức đứng dậy và hái những bông hoa hay không. Bất ngờ, một Con Thỏ Trắng với đôi mắt hồng chạy gần cô. Không có gì quá đáng chú ý trong đó; Alice cũng không thấy lạ khi nghe con thỏ tự nói với chính nó: 'Ôi trời! Ôi trời! Mình sẽ đến trễ!' Nhưng khi con thỏ thực sự lấy đồng hồ ra khỏi túi áo ghi lê và nhìn vào nó, Alice nhảy lên." + }, + "wuu": { + "language": "Wu Chinese", + "text": "阿丽思坐在河边,陪着她姐姐,越坐越困倦。她瞄了一眼姐姐在读的书,可书里没有图画,也没有对话。‘一本没有图画和对话的书有什么用呢,’阿丽思心想。于是她心里盘算着,做雏菊链子的乐趣是否值得起身去采花。突然,一只粉色眼睛的白兔跑过她身边。其实并不特别惊讶;阿丽思也没觉得听见兔子自言自语‘哎呀!哎呀!我要迟到了!’很奇怪。但当兔子真的从马甲口袋里掏出怀表看时,阿丽思跳了起来。" + }, + "yue": { + "language": "Cantonese", + "text": "愛麗絲開始覺得好累,因為她坐喺河邊陪住佢嘅姐姐。佢望咗一兩次佢姐姐睇緊嘅書,但入面冇圖畫同對話。『冇圖畫同對話嘅書有咩用呀,』愛麗絲諗。於是佢心入面諗住,做雛菊鏈嘅樂趣值唔值得企起身去摘花。突然,一隻粉紅色眼睛嘅白兔跑過佢身邊。其實冇乜特別驚訝;愛麗絲都唔覺得聽到隻兔自言自語『哎呀!哎呀!我要遲到啦!』咁奇怪。但當兔仔真係從背心袋攞出懷錶睇嘅時候,愛麗絲跳咗起身。" + } +} \ No newline at end of file diff --git a/demo/script.js b/demo/script.js index f796597..321c802 100644 --- a/demo/script.js +++ b/demo/script.js @@ -1,214 +1,905 @@ +import { WebSpeechVoiceManager, WebSpeechReadAloudNavigator, chineseVariantMap } from "../build/index.js"; + +let samples = null; + +const highlight = new Highlight(); +CSS.highlights.set("readium-speech-highlight", highlight); +let currentWordHighlight = null; + +// DOM Elements +const languageSelect = document.getElementById("language-select"); +const genderSelect = document.getElementById("gender-select"); +const sourceSelect = document.getElementById("source-select"); +const offlineOnlyCheckbox = document.getElementById("offline-only"); +const voiceSelect = document.getElementById("voice-select"); +const testUtteranceInput = document.getElementById("test-utterance"); +const playPauseBtn = document.getElementById("play-pause-btn"); +const stopBtn = document.getElementById("stop-btn"); +const testUtteranceBtn = document.getElementById("test-utterance-btn"); +const prevUtteranceBtn = document.getElementById("prev-utterance-btn"); +const nextUtteranceBtn = document.getElementById("next-utterance-btn"); +const jumpToBtn = document.getElementById("jump-to-btn"); +const utteranceIndexInput = document.getElementById("utterance-index"); +const totalUtterancesSpan = document.getElementById("total-utterances"); +const sampleTextDisplay = document.getElementById("sample-text"); + +// Track if user has manually changed the jump input +let jumpInputUserChanged = false; + +// State +let voiceManager; +let allVoices = []; +let filteredVoices = []; +let languages = []; +let currentVoice = null; +let testUtterance = ""; +let lastNavigatorPosition = 1; + +const navigator = new WebSpeechReadAloudNavigator(); + +// Set up event listeners for the navigator +navigator.on("boundary", (event) => { + if (event.detail && event.detail.name === "word") { + highlightCurrentWord(event.detail.charIndex, event.detail.charLength); + } +}); + +navigator.on("start", () => { + clearWordHighlighting(); + updateUI(); +}); + +navigator.on("pause", updateUI); +navigator.on("resume", updateUI); +navigator.on("stop", () => { + clearWordHighlighting(); + updateUI(); +}); + +navigator.on("end", updateUI); +navigator.on("error", (event) => { + console.error("Navigator error:", event.detail); + updateUI(); +}); + +// Initialize the application +async function init() { + try { + // Initialize the voice manager + voiceManager = await WebSpeechVoiceManager.initialize(); + + const initOptions = { + excludeNovelty: true, + excludeVeryLowQuality: true + }; + + // Load all available voices + allVoices = voiceManager.getVoices(initOptions); + + // Get languages, excluding novelty and very low quality voices + const allLanguages = voiceManager.getLanguages(window.navigator.language, initOptions); + + // Sort languages with browser's preferred languages first + languages = voiceManager.sortVoices( + allLanguages.map(lang => ({ + ...lang, + language: lang.code, + name: lang.label + })), + { + by: "language", + order: "asc", + preferredLanguages: window.navigator.languages + } + ).map(voice => ({ + code: voice.language, + label: voice.name, + count: voice.count + })); + + // Populate language dropdown + populateLanguageDropdown(); + + // Set up event listeners + setupEventListeners(); + + // Update UI + updateUI(); + + } catch (error) { + console.error("Error initializing application:", error); + const errorDiv = document.createElement("div"); + errorDiv.style.color = "red"; + errorDiv.textContent = "Error loading voices. Please check console for details."; + document.body.prepend(errorDiv); + } +} -import { getSpeechSynthesisVoices, parseSpeechSynthesisVoices, filterOnNovelty, filterOnVeryLowQuality, - filterOnRecommended, sortByLanguage, sortByQuality, getVoices, groupByKindOfVoices, groupByRegions, - getLanguages, filterOnOfflineAvailability, listLanguages, filterOnGender, filterOnLanguage } from "../build/index.js"; +// Populate the language dropdown +function populateLanguageDropdown() { + languageSelect.innerHTML = ""; + + languages.forEach(lang => { + const option = document.createElement("option"); + option.value = lang.code; + option.textContent = `${lang.label} (${lang.count})`; + languageSelect.appendChild(option); + }); +} -import * as lit from './lit-html_3-2-0_esm.js' -const { html, render } = lit; +// Filter voices based on current filters +function filterVoices() { + const language = languageSelect.value; + const gender = genderSelect.value; + const source = sourceSelect.value; + const offlineOnly = offlineOnlyCheckbox.checked; -async function loadJSONData(url) { - try { - const response = await fetch(url); - const jsonData = JSON.parse(await response.text()); - return jsonData; - } catch (error) { - console.error('Error loading JSON data:', error); - return null; - } + const filterOptions = {}; + + if (language) { + filterOptions.language = language; + } + + if (gender !== "all") { + filterOptions.gender = gender; + } + + if (source !== "all") { + filterOptions.source = source; + } + + if (offlineOnly) { + filterOptions.offlineOnly = true; + } + + // Apply filters + filteredVoices = voiceManager.filterVoices(allVoices, filterOptions); + + // Sort voices by quality (highest first) + filteredVoices = voiceManager.sortVoices(filteredVoices, { + by: "quality", + order: "desc" + }); + populateVoiceDropdown(language); + updateUI(); } -function downloadJSON(obj, filename) { - // Convert the JSON object to a string - const data = JSON.stringify(obj, null, 2); - - // Create a blob from the string - const blob = new Blob([data], { type: "application/json" }); +// Populate the voice dropdown with filtered voices +function populateVoiceDropdown(language = "") { + voiceSelect.innerHTML = ""; + + try { + if (!filteredVoices.length) { + const option = document.createElement("option"); + option.disabled = true; + option.textContent = "No voices match the current filters"; + voiceSelect.appendChild(option); + return; + } - // Generate an object URL - const jsonObjectUrl = URL.createObjectURL(blob); + // Sort voices with browser's preferred languages first + const sortedVoices = voiceManager.sortVoices([...filteredVoices], { + by: "language", + order: "asc", + preferredLanguages: window.navigator.languages + }); + + // Group the sorted voices by region + const voiceGroups = voiceManager.groupVoices(sortedVoices, "region"); + + // Add optgroups for each region + for (const [region, voices] of Object.entries(voiceGroups)) { + if (!voices.length) continue; + + const countryCode = region.split("-").pop() || region; + const regionName = new Intl.DisplayNames(window.navigator.languages, { type: "region" }).of(countryCode) || region; + const optgroup = document.createElement("optgroup"); + optgroup.label = `${getCountryFlag(countryCode)} ${regionName}`; + + // Sort voices by quality within each region + const sortedVoicesInRegion = voiceManager.sortVoices(voices, { + by: "quality", + order: "desc" + }); + + for (const voice of sortedVoicesInRegion) { + const option = document.createElement("option"); + option.value = voice.name; + option.textContent = [ + voice.label || voice.name, + voice.gender ? `• ${voice.gender}` : "", + voice.offlineAvailability ? "• offline" : "• online" + ].filter(Boolean).join(" "); + option.dataset.voiceUri = voice.voiceURI; + optgroup.appendChild(option); + } + + voiceSelect.appendChild(optgroup); + } + + // If we have a current voice, try to select it + if (currentVoice) { + const option = voiceSelect.querySelector(`option[data-voice-uri="${currentVoice.voiceURI}"]`); + if (option) { + option.selected = true; + } + } + + // Only show error message if we don't have any valid voice options + const hasValidOptions = Array.from(voiceSelect.options).some(opt => !opt.disabled); + if (!hasValidOptions) { + const option = document.createElement("option"); + option.disabled = true; + option.textContent = "No voices available. Please check your browser settings and internet connection."; + voiceSelect.appendChild(option); + } + } catch (error) { + console.error("Error populating voice dropdown:", error); + // Log the error but don't add any error message to the dropdown + } + + // Helper function to get country flag emoji from country code + function getCountryFlag(countryCode) { + if (!countryCode) return "🌐"; + + // Convert country code to flag emoji + try { + const codePoints = countryCode + .toUpperCase() + .split("") + .map(char => 127397 + char.charCodeAt(0)); + + return String.fromCodePoint(...codePoints); + } catch (e) { + console.warn("Could not generate flag for country code:", countryCode); + return "🌐"; + } + } +} - // Create an anchor element - const anchorEl = document.createElement("a"); - anchorEl.href = jsonObjectUrl; - anchorEl.download = `${filename}.json`; +// Load sample text for the selected language +async function loadSampleText(languageCode) { + try { + // Show loading state + sampleTextDisplay.innerHTML = "
Loading text...
"; + + // Load sample texts if not already loaded + if (!samples) { + const response = await fetch("sampleText.json"); + if (!response.ok) { + throw new Error("Failed to load sample texts"); + } + samples = await response.json(); + } + + // Normalize the language code to lowercase for case-insensitive comparison + const langLower = languageCode.toLowerCase(); + + // Function to find a case-insensitive match in the samples + const findCaseInsensitiveMatch = (lang) => { + const normalizedLang = lang.toLowerCase(); + const matchingKey = Object.keys(samples).find(key => key.toLowerCase() === normalizedLang); + return matchingKey ? samples[matchingKey]?.text : null; + }; + + // Try direct case-insensitive match first + let sampleText = findCaseInsensitiveMatch(languageCode); + + // Try with Chinese variant mapping if no direct match + if (!sampleText) { + const mappedLang = chineseVariantMap[langLower]; + if (mappedLang) { + sampleText = samples[mappedLang]?.text; + } + } + + // If still no match, try with base language + if (!sampleText) { + const [baseLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(languageCode); + const baseLangLower = baseLang.toLowerCase(); + + // Try case-insensitive match with base language + sampleText = findCaseInsensitiveMatch(baseLang); + + // Try Chinese variant mapping for base language if still no match + if (!sampleText && chineseVariantMap[baseLangLower]) { + sampleText = samples[chineseVariantMap[baseLangLower]]?.text; + } + } + + // If no match found, return a message + if (!sampleText) { + return `No sample text available for language: ${languageCode}`; + } + + // Create utterances from the sample text + const utterances = createUtterancesFromText(sampleText); + + // Clear any existing content + sampleTextDisplay.innerHTML = ""; + + // Create a demo section container + const demoSection = document.createElement("div"); + demoSection.className = "demo-section"; + + // Add a heading + const heading = document.createElement("h2"); + heading.textContent = "Content Preview"; + demoSection.appendChild(heading); + + // Create a container for the utterances list + const utterancesList = document.createElement("div"); + utterancesList.className = "utterances-list"; + + // Add each utterance with number indicator + utterances.forEach((utterance, index) => { + const utteranceElement = document.createElement("div"); + utteranceElement.className = `utterance ${index === 0 ? "current" : ""}`; + utteranceElement.dataset.utteranceIndex = index; + + // Add utterance number + const numberSpan = document.createElement("span"); + numberSpan.className = "utterance-number"; + numberSpan.textContent = `${index + 1}.`; + + // Add text content + const textSpan = document.createElement("span"); + textSpan.className = "utterance-text"; + textSpan.dataset.utteranceId = utterance.id; + textSpan.textContent = utterance.text; + + // Assemble the elements + utteranceElement.appendChild(numberSpan); + utteranceElement.appendChild(textSpan); + + utterancesList.appendChild(utteranceElement); + }); + + // Assemble the section + demoSection.appendChild(utterancesList); + sampleTextDisplay.appendChild(demoSection); + + // Load utterances into the navigator + await navigator.loadContent(utterances); + + // Update total utterances display + const totalUtterancesSpan = document.getElementById("total-utterances"); + if (totalUtterancesSpan) { + totalUtterancesSpan.textContent = utterances.length; + } + + // Update UI to enable playback controls + updateUI(); + + // Update utterance input + if (utteranceIndexInput) { + utteranceIndexInput.max = utterances.length; + utteranceIndexInput.value = "1"; + } + } catch (error) { + console.error("Error loading sample text:", error); + sampleTextDisplay.textContent = "Error loading sample text"; + } +} - // Simulate a click on the anchor element - anchorEl.click(); +// Update the test utterance based on the current voice and language +function updateTestUtterance(voice, languageCode) { + if (!voice) { + testUtterance = ""; + testUtteranceInput.value = ""; + testUtteranceBtn.disabled = true; + return; + } + + // Use the voice's language as the primary source, fall back to the language selector, then default to "en" + const language = voice.language || languageCode || "en"; + const baseUtterance = voiceManager.getTestUtterance(language) || + `This is a test of the {name} voice.`; + testUtterance = baseUtterance.replace(/\{\s*name\s*\}/g, voice.label || voice.name || "this voice"); + testUtteranceInput.value = testUtterance; + testUtteranceBtn.disabled = false; +} - // Revoke the object URL - URL.revokeObjectURL(jsonObjectUrl); +// Create utterances from text with better sentence splitting +function createUtterancesFromText(text) { + // Use Intl.Segmenter for proper sentence segmentation + const segmenter = new Intl.Segmenter(languageSelect.value || "en", { + granularity: "sentence" + }); + + // Convert segments to array and extract text + const sentences = Array.from(segmenter.segment(text), + ({ segment }) => segment.trim() + ).filter(Boolean); // Remove any empty strings + + // Create utterances from sentences + return sentences.map((sentence, index) => ({ + id: `utterance-${index}`, + text: sentence, + language: languageSelect.value || "en" + })); } -const viewRender = () => render(content(), document.body); +// Set up event listeners +function setupEventListeners() { + // Language selection + languageSelect.addEventListener("change", async () => { + const baseLanguage = languageSelect.value; + + // Reset voice selection and clear test utterance + voiceSelect.disabled = false; + currentVoice = null; + testUtterance = ""; + testUtteranceInput.value = ""; + testUtteranceBtn.disabled = true; + + // Clear voice properties + displayVoiceProperties(null); + + // Filter voices for the selected language + filterVoices(); + + // Get the default voice for the selected language using pre-filtered voices + if (baseLanguage) { + // Find the first matching language from the user's preferences + const preferredLanguage = (window.navigator.languages || [window.navigator.language] || []) + .find(lang => lang && lang.startsWith(baseLanguage)) || baseLanguage; + + currentVoice = voiceManager.getDefaultVoice( + preferredLanguage, + filteredVoices.length ? filteredVoices : undefined + ); + + if (currentVoice) { + try { + // Set the voice for the navigator + navigator.setVoice(currentVoice); + + // Update the voice dropdown to reflect the selected voice + const voiceOption = voiceSelect.querySelector(`option[value="${currentVoice.name}"]`); + if (voiceOption) { + voiceOption.selected = true; + } + + // Display voice properties + displayVoiceProperties(currentVoice); + + // Update the test utterance with the new voice + updateTestUtterance(currentVoice, languageCode); + + } catch (error) { + console.error("Error setting default voice:", error); + } + } + } + + // Load sample text using the voice's language code if available, otherwise use the selector's value + const languageToUse = currentVoice?.language || languageCode; + loadSampleText(languageToUse); + + updateUI(); + }); + + /** + * Format a value for display in the voice properties + */ +function formatValue(value) { + if (value === undefined || value === null) { + return { display: "undefined", className: "undefined" }; + } + + if (typeof value === "boolean") { + return { + display: value ? "true" : "false", + className: `boolean-${value}` + }; + } + + if (Array.isArray(value)) { + return { + display: value.length > 0 ? value.join(", ") : "[]", + className: "" + }; + } + + if (typeof value === "object") { + return { + display: JSON.stringify(value, null, 2).replace(/"/g, ""), + className: "object-value" + }; + } + + return { display: String(value), className: "" }; +} -const voices = await getVoices(); -console.log(voices); +/** + * Display voice properties in the UI + */ +function displayVoiceProperties(voice) { + const propertiesContainer = document.getElementById("voice-properties"); + + if (!voice) { + propertiesContainer.innerHTML = "

No voice selected

"; + return; + } + + // Sort properties alphabetically + const sortedProps = Object.keys(voice).sort(); + + // Create HTML for each property + const propertiesHtml = sortedProps.map(prop => { + // Skip internal/private properties that start with underscore + if (prop.startsWith("_")) return ""; + + const value = voice[prop]; + const { display, className } = formatValue(value); + + return ` +
+
${prop}
+
${display}
+
+ `; + }).join(""); + + propertiesContainer.innerHTML = propertiesHtml || "

No properties available

"; +} -const languages = getLanguages(voices); + // Voice selection + voiceSelect.addEventListener("change", async () => { + const selectedVoiceName = voiceSelect.value; + currentVoice = filteredVoices.find(v => v.name === selectedVoiceName) || null; + + if (currentVoice) { + try { + // Set the voice for the navigator + navigator.setVoice(currentVoice); + + // Display voice properties + displayVoiceProperties(currentVoice); + + // Update the test utterance with the new voice + updateTestUtterance(currentVoice, currentVoice.language || languageSelect.value); + + // Load sample text using the voice's language code (fire and forget) + loadSampleText(currentVoice.language || languageSelect.value); + } catch (error) { + console.error("Error setting voice:", error); + } + } else { + if (testUtteranceBtn) { + testUtteranceBtn.disabled = true; + } + } + + updateUI(); + }); -let voicesFiltered = voices; -let languagesFiltered = languages; + // Test utterance button + testUtteranceBtn.addEventListener("click", playTestUtterance); -let textToRead = ""; -let textToReadFormated = ""; + // Play/Pause button (for sample text) + playPauseBtn.addEventListener("click", togglePlayback); + playPauseBtn.disabled = !currentVoice; -let selectedLanguage = undefined; + // Stop button (for sample text) + stopBtn.addEventListener("click", stopPlayback); + stopBtn.disabled = !currentVoice; -let voicesSelectElem = []; + // Previous utterance button + prevUtteranceBtn.addEventListener("click", previousUtterance); -let selectedVoice = ""; + // Next utterance button + nextUtteranceBtn.addEventListener("click", nextUtterance); -let selectedGender = "all"; + // Jump to utterance button + jumpToBtn.addEventListener("click", jumpToUtterance); + + // Handle Enter key in jump input + utteranceIndexInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + jumpToUtterance(); + } + }); + + // Track manual changes to jump input + utteranceIndexInput.addEventListener("input", () => { + jumpInputUserChanged = true; + }); -let checkboxOfflineChecked = false; + // Update voices when gender filter changes + genderSelect.addEventListener("change", () => { + filterVoices(); + }); -const readTextWithSelectedVoice = () => { - const voices = window.speechSynthesis.getVoices(); + // Update voices when source filter changes + sourceSelect.addEventListener("change", () => { + filterVoices(); + }); - const utterance = new SpeechSynthesisUtterance(); - utterance.text = textToReadFormated; + // Update voices when offline filter changes + offlineOnlyCheckbox.addEventListener("change", () => { + filterVoices(); + }); - for (const voice of voices) { - if (voice.name === selectedVoice) { - utterance.voice = voice; - utterance.lang = voice.lang; - break; - } + // Update test utterance when language changes + languageSelect.addEventListener("change", () => { + if (languageSelect.value) { + updateTestUtterance(currentVoice, languageSelect.value); } - - if (!utterance.voice) { - console.error("Speech : Voice NOT FOUND"); - alert("voice not found"); - } - - console.log("Speech", utterance); - - - speechSynthesis.speak(utterance); + }); } -const filterVoices = () => { - - voicesFiltered = voices; +// Play test utterance - independent of the navigator +async function playTestUtterance() { + if (!currentVoice) { + console.error("No voice selected"); + return; + } + + try { + // Reset playback controls first + if (navigator) { + navigator.stop(); + } - if (selectedGender !== "all") { - voicesFiltered = filterOnGender(voicesFiltered, selectedGender); + // Get test utterance for the selected language + let testText = testUtteranceInput.value; + if (!testText) { + updateTestUtterance(currentVoice, languageSelect.value); + testText = testUtteranceInput.value; } - - if (checkboxOfflineChecked) { - voicesFiltered = filterOnOfflineAvailability(voicesFiltered, true); + + // Create a new SpeechSynthesisUtterance + const utterance = new SpeechSynthesisUtterance(testText); + + // Convert the ReadiumSpeechVoice to a native SpeechSynthesisVoice + const nativeVoice = voiceManager.convertToSpeechSynthesisVoice(currentVoice); + if (nativeVoice) { + utterance.voice = nativeVoice; + utterance.lang = nativeVoice.lang; } - - languagesFiltered = getLanguages(voicesFiltered); - - const voicesFilteredOnLanguage = filterOnLanguage(voicesFiltered, selectedLanguage); - const voicesGroupedByRegions = groupByRegions(voicesFilteredOnLanguage); - voicesSelectElem = listVoicesWithLanguageSelected(voicesGroupedByRegions); - - viewRender(); -} - -const setSelectVoice = (name) => { - - selectedVoice = name; - textToReadFormated = textToRead.replace("{name}", selectedVoice); + // Update UI state + testUtteranceBtn.disabled = true; + testUtteranceBtn.textContent = "Playing..."; + + // Handle when speech ends + utterance.onend = () => { + testUtteranceBtn.disabled = false; + testUtteranceBtn.textContent = "Play Test Utterance"; + }; + + // Handle errors + utterance.onerror = (event) => { + console.error("SpeechSynthesis error:", event); + testUtteranceBtn.disabled = false; + testUtteranceBtn.textContent = "Play Test Utterance"; + }; + + // Speak the utterance directly + speechSynthesis.speak(utterance); + + } catch (error) { + console.error("Error playing test utterance:", error); + testUtteranceBtn.textContent = "Play Test Utterance"; + testUtteranceBtn.disabled = false; + } } -const languageSelectOnChange = async (ev) => { - - selectedLanguage = ev.target.value; - - const jsonData = await loadJSONData("https://raw.githubusercontent.com/HadrienGardeur/web-speech-recommended-voices/main/json/" + selectedLanguage + ".json"); - - textToRead = jsonData?.testUtterance || ""; - - filterVoices(); +// Toggle sample text playback +async function togglePlayback() { + if (!currentVoice) { + console.error("No voice selected"); + return; + } + + try { + const state = navigator.getState(); + if (state === "playing") { + await navigator.pause(); + } else if (state === "paused") { + // Use play() to resume from paused state + await navigator.play(); + } else { + // Start from beginning if stopped or in an unknown state + await navigator.jumpTo(0); + await navigator.play(); + } + } catch (error) { + console.error("Error toggling playback:", error); + } + + // Update the UI to reflect the new state + updateUI(); } -const listVoicesWithLanguageSelected = (voiceMap) => { - - const elem = []; - selectedVoice = ""; - - for (const [region, voice] of voiceMap) { - const option = []; - - for (const {name, label} of voice) { - option.push(html``); - if (!selectedVoice) setSelectVoice(name); - } - elem.push(html` - - ${option} - - `) - } +// Stop sample playback +async function stopPlayback() { + try { + await navigator.stop(); + clearWordHighlighting(); + playPauseBtn.textContent = "Play Sample"; + updateUI(); + } catch (error) { + console.error("Error stopping playback:", error); + } +} - return elem; +// Go to previous utterance +async function previousUtterance() { + await navigator.previous(); + updateUI(); } -const aboutVoice = () => { - return html` -
-
${JSON.stringify(voicesFiltered.filter(({name}) => name === selectedVoice), null, 4)}
-
- `; +// Go to next utterance +async function nextUtterance() { + await navigator.next(); + updateUI(); } -const getVoicesInputForDebug = () => { - const a = window.speechSynthesis.getVoices() || []; - return a.map(({ default: def, lang, localService, name, voiceURI}) => ({default: def, lang, localService, name, voiceURI})); +// Jump to a specific utterance +function jumpToUtterance() { + const totalUtterances = navigator.getContentQueue()?.length || 0; + + // Ensure we have a valid input value + const index = Math.max(0, Math.min(parseInt(utteranceIndexInput.value) - 1, totalUtterances - 1)); + + if (!isNaN(index) && index >= 0 && index < totalUtterances) { + clearWordHighlighting(); + navigator.jumpTo(index); + + // Update UI to reflect the new position + if (utteranceIndexInput) { + utteranceIndexInput.value = index + 1; + } + + // Update total utterances display if needed + if (totalUtterancesSpan) { + totalUtterancesSpan.textContent = totalUtterances; + } + + // Clear user changed flag and update position tracking + jumpInputUserChanged = false; + lastNavigatorPosition = index + 1; + + // Update input to reflect the new position + utteranceIndexInput.value = lastNavigatorPosition; + } else { + // Invalid input, reset to current position + const currentPos = (navigator.getCurrentUtteranceIndex() || 0) + 1; + utteranceIndexInput.value = currentPos; + jumpInputUserChanged = false; + lastNavigatorPosition = currentPos; + + // Ensure total is displayed + if (totalUtterancesSpan && totalUtterances > 0) { + totalUtterancesSpan.textContent = totalUtterances; + } + } } -const content = () => html` -

ReadiumSpeech

+// Clear any previous highlighting +function clearWordHighlighting() { + if (window.CSS?.highlights) { + CSS.highlights.clear(); + } +} -

Language :

- +// Highlight current word in the sample text +function highlightCurrentWord(charIndex, charLength) { + // Clear previous highlighting + clearWordHighlighting(); + + // Get the current utterance element + const currentIndex = navigator.getCurrentUtteranceIndex(); + const utteranceElement = document.querySelector(`.utterance[data-utterance-index="${currentIndex}"] .utterance-text`); + if (!utteranceElement) return; + + const text = utteranceElement.textContent; + if (charIndex < 0 || charIndex >= text.length) return; + + // Create a range for the current word + const range = document.createRange(); + const textNode = utteranceElement.firstChild || utteranceElement; + + try { + range.setStart(textNode, charIndex); + range.setEnd(textNode, charIndex + charLength); + + // Use CSS Highlight API + const highlight = new Highlight(range); + CSS.highlights.set("current-word", highlight); + + // Update current word highlight + currentWordHighlight = { + utteranceIndex: currentIndex, + charIndex: charIndex, + charLength: charLength, + range: range + }; + } catch (e) { + console.error("Error highlighting word:", e); + } +} -

Voices :

- +// Update UI based on current state +function updateUI() { + try { + const state = navigator.getState(); + const currentIndex = navigator.getCurrentUtteranceIndex() || 0; + const totalUtterances = navigator.getContentQueue()?.length || 0; + const hasContent = totalUtterances > 0; + + // Update playback controls + if (playPauseBtn) { + playPauseBtn.disabled = !currentVoice || !hasContent; + if (state === "playing") { + playPauseBtn.innerHTML = "⏸️ Pause"; + playPauseBtn.classList.remove("play-state"); + playPauseBtn.classList.add("pause-state"); + } else { + playPauseBtn.innerHTML = "▶️ Play"; + playPauseBtn.classList.remove("pause-state"); + playPauseBtn.classList.add("play-state"); + } + } + + // Update stop button + if (stopBtn) { + stopBtn.disabled = !currentVoice || !hasContent || (state !== "playing" && state !== "paused"); + } + + // Update navigation controls + if (prevUtteranceBtn) { + prevUtteranceBtn.disabled = !currentVoice || !hasContent || currentIndex <= 0; + } + + if (nextUtteranceBtn) { + nextUtteranceBtn.disabled = !currentVoice || !hasContent || currentIndex >= totalUtterances - 1; + } + + // Update jump controls + if (utteranceIndexInput) { + utteranceIndexInput.disabled = !currentVoice || !hasContent; + if (!jumpInputUserChanged && hasContent) { + utteranceIndexInput.value = currentIndex + 1; + } + } + + if (jumpToBtn) { + jumpToBtn.disabled = !currentVoice || !hasContent; + } + + // Update test utterance button + if (testUtteranceBtn) { + testUtteranceBtn.disabled = !currentVoice; + } + + // Update utterance highlighting and scroll to current position + if (hasContent) { + const utteranceElements = document.querySelectorAll(".utterance"); + utteranceElements.forEach((el, i) => { + if (i === currentIndex) { + el.classList.add("current"); + el.classList.remove("played"); + } else if (i < currentIndex) { + el.classList.add("played"); + el.classList.remove("current"); + } else { + el.classList.remove("current", "played"); + } + }); + } + } catch (error) { + console.error("Error updating UI:", error); + } +} -

Gender :

- - -

Filter :

-
- { - checkboxOfflineChecked = e.target.checked; - filterVoices(); - }}> - -
- -

Text :

- textToReadFormated = e.target.value ? e.target.value : textToReadFormated}> - -
- -
- -
- ${selectedVoice ? aboutVoice() : undefined} -
- -
- -
- -`; -viewRender(); +// Initialize the application +init().then(() => { + // If there's a default voice selected after initialization, display its properties + if (currentVoice) { + displayVoiceProperties(currentVoice); + } +}); \ No newline at end of file diff --git a/demo/styles.css b/demo/styles.css index 55577b6..9efe1df 100644 --- a/demo/styles.css +++ b/demo/styles.css @@ -1,6 +1,10 @@ body, html { margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + line-height: 1.6; + color: #333; } html { @@ -8,9 +12,213 @@ html { } body { - height: 90%; + min-height: 100%; max-width: 800px; margin: 0 auto; + padding: 20px; + box-sizing: border-box; +} + +/* Control panel */ +.control-panel { + background: #f5f5f5; + padding: 20px; + border-radius: 8px; + margin: 0 0 20px 0; + width: 100%; + box-sizing: border-box; + text-align: left; +} + +.content-container { + background: #f5f5f5; + padding: 20px; + border-radius: 8px; + margin: 20px 0; + width: 100%; + box-sizing: border-box; +} + +/* Form elements */ +.form-group { + margin-bottom: 15px; + width: 100%; + text-align: left; +} + +label { + display: block; + margin-bottom: 5px; + font-weight: 500; +} + +select, +input[type="text"], +input[type="number"] { + width: 100%; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; + text-align: left; +} + +.checkbox-group { + margin: 10px 0; + display: flex; + align-items: center; + gap: 8px; +} + +.checkbox-group input[type="checkbox"] { + margin: 0; + width: auto; +} + +/* Test utterance section */ +.test-utterance-container { + display: flex; + align-items: center; + gap: 10px; + width: 100%; +} + +.test-utterance-input { + flex: 1; +} + +.test-utterance-button { + margin: 0; + align-self: flex-end; + margin-bottom: 1px; /* Small adjustment for visual alignment */ +} + +.test-utterance-button button { + background-color: #4CAF50; /* Green color for the test button */ + color: white; + white-space: nowrap; + padding: 8px 16px; + height: 38px; /* Match input field height */ + box-sizing: border-box; +} + +.test-utterance-button button:hover:not(:disabled) { + background-color: #45a049; /* Darker green on hover */ + opacity: 1; +} + +/* Base button styles */ +button { + padding: 8px 16px; + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + cursor: pointer; + transition: opacity 0.2s; + font-size: 14px; + margin-right: 10px; +} + +button:disabled { + background-color: #cccccc !important; + cursor: not-allowed; + opacity: 0.7; +} + +button:hover:not(:disabled) { + opacity: 0.9; +} + +/* Playback controls */ +.playback-controls { + display: flex; + gap: 10px; + margin: 20px 0; + justify-content: center; + flex-wrap: wrap; + align-items: center; +} + +/* Play state (green) */ +.play-state { + background-color: #4CAF50; /* Green for play */ +} + +.play-state:hover:not(:disabled) { + background-color: #45a049; /* Darker green on hover */ +} + +/* Pause state (orange) */ +.pause-state { + background-color: #ff9800; /* Orange for pause */ +} + +.pause-state:hover:not(:disabled) { + background-color: #e68900; /* Darker orange on hover */ +} + +/* Stop button (red) */ +.stop { + background-color: #f44336; /* Red for stop */ +} + +.stop:hover:not(:disabled) { + background-color: #d32f2f; /* Darker red on hover */ +} + +/* Navigation buttons (blue) */ +.nav { + background-color: #2196F3; /* Blue for navigation */ +} + +.nav:hover:not(:disabled) { + background-color: #1976D2; /* Darker blue on hover */ +} + +/* Jump Controls */ +.jump-controls { + display: flex; + gap: 10px; + margin: 20px 0; + justify-content: center; + align-items: center; +} + +.jump-controls input[type="number"] { + width: 60px; + padding: 5px; + border: 1px solid #ddd; + border-radius: 4px; +} + +/* Jump To button (purple) */ +.jump-btn { + background-color: #9c27b0; /* Purple for jump */ +} + +.jump-btn:hover:not(:disabled) { + background-color: #7b1fa2; /* Darker purple on hover */ +} + +/* Highlight for current utterance */ +.highlight { + background-color: #ffeb3b; + padding: 0 2px; + border-radius: 3px; + box-shadow: 0 0 3px rgba(0, 0, 0, 0.3); + transition: background-color 0.2s ease; +} + +/* Text display */ +.text-display { + line-height: 2; + min-height: 100px; + border: 1px solid #eee; + padding: 15px; + border-radius: 4px; + white-space: pre-wrap; } h1, @@ -20,7 +228,6 @@ p { padding: 10px; } -.txt, select, form > div { display: block; @@ -30,52 +237,300 @@ form > div { padding: 5px; } -.txt { - width: 82%; +form > div { + margin-bottom: 10px; + overflow: auto; } -select { - width: 83%; +/* Demo section styles */ +.demo-section { + margin: 30px 0; + padding: 20px; + border: 1px solid #ddd; + border-radius: 8px; + background: #f9f9f9; } -form > div { - width: 81%; +.demo-section h2 { + margin-top: 0; + color: #333; + font-size: 1.5em; + padding-bottom: 10px; + border-bottom: 1px solid #eee; } -.txt, -form > div { - margin-bottom: 10px; - overflow: auto; +/* Utterances list */ +.utterances-list { + margin-top: 15px; } -.clearfix { - clear: both; +.utterance { + margin: 10px 0; + padding: 8px 12px; + border-radius: 4px; + transition: all 0.2s ease; + display: flex; + align-items: flex-start; + line-height: 1.5; + position: relative; + background: white; + border: 1px solid #e0e0e0; } -.controls { - text-align: center; - margin-top: 50px; +.utterance.current { + background-color: #e6f3ff; + border-left: 3px solid #1a73e8; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } -.controls > * { - margin-bottom: 10px; - text-align: center; +.utterance.played { + opacity: 0.7; + background-color: #f8f9fa; } -.controls fieldset { + +.utterance-number { display: inline-block; - text-align: left; + min-width: 24px; + color: #666; + font-weight: 600; + margin-right: 8px; + user-select: none; + font-family: monospace; + text-align: right; + flex-shrink: 0; + padding-top: 2px; } -.controls button { - padding: 10px; - width: 100px; +.utterance-text { + flex: 1; + white-space: pre-wrap; + word-break: break-word; + padding: 2px 0; } -.checkbox { - text-align: center; +/* Playback Controls */ +.playback-controls { + display: flex; + gap: 10px; + margin: 20px 0; + justify-content: center; } -.debug { - margin-top: 100px; - text-align: center; +/* Playback control buttons - base styles */ +.playback-controls button { + padding: 8px 16px; + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + cursor: pointer; + transition: opacity 0.2s; +} + +.playback-controls button:hover { + opacity: 0.9; +} + +/* Play state (green) */ +.play-state, +.playback-controls .play-state { + background-color: #4CAF50 !important; /* Green for play */ +} + +.play-state:hover, +.playback-controls .play-state:hover { + background-color: #45a049 !important; /* Darker green on hover */ +} + +/* Pause state (orange) */ +.pause-state, +.playback-controls .pause-state { + background-color: #ff9800 !important; /* Orange for pause */ +} + +.pause-state:hover, +.playback-controls .pause-state:hover { + background-color: #e68900 !important; /* Darker orange on hover */ +} + +/* Stop button (red) */ +.stop, +.playback-controls .stop { + background-color: #f44336 !important; /* Red for stop */ +} + +.stop:hover, +.playback-controls .stop:hover { + background-color: #d32f2f !important; /* Darker red on hover */ +} + +/* Navigation buttons (blue) */ +.nav, +.playback-controls .nav { + background-color: #2196F3 !important; /* Blue for navigation */ +} + +.nav:hover, +.playback-controls .nav:hover { + background-color: #1976D2 !important; /* Darker blue on hover */ +} + +/* Jump Controls */ +.jump-controls { + display: flex; + gap: 10px; + margin: 20px 0; + justify-content: center; + align-items: center; +} + +.jump-controls input[type="number"] { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + width: 120px; +} + +/* Jump To button (purple) */ +.jump-btn { + padding: 8px 16px; + background-color: #9c27b0; /* Purple for jump */ + color: white; + border: none; + border-radius: 4px; + font-weight: bold; + cursor: pointer; + transition: background-color 0.2s; +} + +.jump-btn:hover { + background-color: #7b1fa2; /* Darker purple on hover */ +} + +/* Simple highlight style for the current word */ +::highlight(current-word) { + background-color: #ffeb3b; + color: black; +} + +/* Current utterance styling */ +.utterance.current { + background-color: #e3f2fd; + border-left: 3px solid #2196f3; + padding-left: 8px; +} + +/* Played utterances styling */ +.utterance.played { + background-color: #e8f5e9; + opacity: 0.8; +} + +.utterance.played .utterance-number { + color: #4caf50; + font-weight: bold; +} + +.utterance.played .utterance-text { + color: #2e7d32; +} + +/* Add some spacing between utterances */ +.utterance + .utterance { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #eee; +} + +/* Responsive adjustments */ +@media (max-width: 600px) { + .demo-section { + padding: 15px; + } + + .utterance { + padding: 6px 8px; + } + + .utterance-number { + min-width: 20px; + margin-right: 6px; + } +} + +/* Voice Details Section */ +.voice-details { + background: #f9f9f9; + border: 1px solid #e0e0e0; + border-radius: 8px; + margin: 20px 0; + overflow: hidden; +} + +.voice-details summary { + padding: 12px 16px; + background: #f0f0f0; + cursor: pointer; + font-weight: 600; + color: #333; + outline: none; + user-select: none; + transition: background-color 0.2s ease; +} + +.voice-details summary:hover { + background: #e8e8e8; +} + +.voice-details[open] summary { + background: #e0e0e0; +} + +.voice-properties { + padding: 16px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 14px; + line-height: 1.5; + color: #333; + background: white; +} + +.voice-property { + display: flex; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid #f0f0f0; +} + +.voice-property:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.voice-property-name { + font-weight: 600; + color: #555; + min-width: 180px; + flex-shrink: 0; +} + +.voice-property-value { + flex-grow: 1; + word-break: break-word; +} + +.voice-property-value.boolean-true { + color: #388e3c; + font-weight: 500; +} + +.voice-property-value.boolean-false { + color: #d32f2f; + font-weight: 500; +} + +.voice-property-value.undefined { + color: #9e9e9e; + font-style: italic; } \ No newline at end of file diff --git a/docs/VoicesAndFiltering.md b/docs/VoicesAndFiltering.md new file mode 100644 index 0000000..2317e4b --- /dev/null +++ b/docs/VoicesAndFiltering.md @@ -0,0 +1,351 @@ +# Voices and Filtering + +With hundreds of voices available by default across various browsers and OS, it can be tricky for developers to provide sensible defaults and a curated list of voices. + +One of the goals of this project is to document higher quality voices available on various platforms and provide an easy way to implement these recommendations using JSON configuration files. + +## Use cases + +* Providing the best possible default voice per language +* Displaying an ordered list of voices, based on quality +* Displaying user-friendly voice names +* Filtering recommended voices per gender and age (adult vs children) +* Filtering out novelty and low quality voices +* Previewing a voice with a test utterance + +## List of supported languages + +The goal of this project is to support all 43 languages available on Windows and macOS. + +In its current state, it covers 43 languages: + +* [Arabic](json/ar.json) (Algeria, Bahrain, Egypt, Iraq, Jordan, Kuwait, Lebanon, Libya, Morocco, Oman, Qatar, Saudi Arabia, Syria, Tunisia, United Arab Emirates, Yemen) +* [Basque](json/eu.json) +* [Bengali](json/bn.json) (India and Bangladesh) +* [Bhojpuri](json/bho.json) +* [Bulgarian](json/bg.json) +* [Catalan](json/ca.json) +* Chinese: + * [Mandarin Chinese](json/cmn.json) (Mainland China, Taiwan) + * [Wu Chinese](json/wuu.json) (aka "Shanghainese") + * [Yue Chinese](json/yue.json) (aka "Cantonese") +* [Croatian](json/hr.json) +* [Czech](json/cs.json) +* [Danish](json/da.json) +* [Dutch](json/nl.json) (Netherlands and Belgium) +* [English](json/en.json) (United States, United Kingdom, Australia, Canada, Hong Kong, India, Ireland, Kenya, New Zealand, Nigeria, Scotland, Singapore, South Africa and Tanzania) +* [Finnish](json/fi.json) +* [French](json/fr.json) (France, Canada, Belgium and Switzerland) +* [Galician](json/gl.json) +* [German](json/de.json) (Germany, Austria and Switzerland) +* [Greek](json/el.json) +* [Hebrew](json/he.json) +* [Hindi](json/hi.json) +* [Hungarian](json/hu.json) +* [Indonesian](json/id.json) +* [Italian](json/it.json) +* [Japanese](json/ja.json) +* [Kannada](json/kn.json) +* [Korean](json/ko.json) +* [Malay](json/ms.json) +* [Marathi](json/mr.json) +* [Norwegian](json/nb.json) +* [Persian](json/fa.json) +* [Polish](json/pl.json) +* [Portuguese](json/pt.json) (Portugal and Brazil) +* [Romanian](json/ro.json) +* [Russian](json/ru.json) +* [Slovak](json/sk.json) +* [Slovenian](json/sl.json) +* [Spanish](json/es.json) (Spain, Argentina, Bolivia, Chile, Colombia, Costa Rica, Cuba, Dominican Republic, Ecuador, El Salvador, Equatorial Guinea, Guatemala, Honduras, Mexico, Nicaragua, Panama, Paraguay, Peru, Puerto Rico, United States, Uruguay and Venezuela) +* [Swedish](json/sv.json) +* [Tamil](json/ta.json) (India, Sri Lanka, Malaysia and Singapore) +* [Telugu](json/te.json) +* [Thai](json/th.json) +* [Turkish](json/tr.json) +* [Ukrainian](json/uk.json) +* [Vietnamese](json/vi.json) + +## List of voices to filter out + +At the other end up the spectrum, this project also identifies a number of voices that should be filtered out from a voice selector component. + +Some of them are harmful to the overall reading experience, while others have a very low quality on platforms where better preloaded options are available. + +* [Novelty voices](json/filters/novelty.json) (Apple devices) +* [Very low quality voices](json/filters/veryLowQuality.json) (Apple devices and Chrome OS) + + +## Guiding principles + +* Each voice list is ordered and meant to provide an optimal listening experience on all browsers/OS/languages covered by this project. +* But each list also includes default options, to make sure that there's always something reliable to lean on. +* With these two goals in mind, higher quality voices are listed on top of the list, while lower quality voices or specialized ones are listed at the bottom. +* The number of voices can look overwhelming (110+ voices in English alone) but in practice, just a few of them will be available to users on each of their device. +* The voice names returned by the [Web Speech API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API) are hardly user-friendly, which is the reason why this list provides alternate ones that usually include a first name (or a gender) along with the region associated to the voice. +* Whenever possible, I will always try to include a good mix of high quality and default options for both genders. +* But the list has to be prioritized somehow, female voices are currently listed above their male counterparts. Since the gender associated to each voice is documented, this allows implementers to re-prioritize/filter the list based on this criteria. +* Regional variants are also grouped together in a single list rather than separated in their own files on purpose. On some devices, only two or three voices might be available and separating regional variants wouldn't make much sense. +* But regional variants have to be prioritized somehow in the list. For now, the regions with the best selections of voices are listed above, but it is highly recommended to implementers [to consider the user's regional preferences](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/languages). + + +## Syntax + +[A JSON Schema](voices.schema.json) is available for validation or potential contributors interested in opening a PR for new languages or voice additions. + +### Label + +`label` is required for each recommended voice and provides a human-friendly label for each voice. + +This string is localized for the target language and usually contains the following information: + +* First name (if available) +* Gender (when the first name is missing) +* Country/region + +**Example 1: Microsoft Natural voices** + +While the names documented by Microsoft for their natural voices are easily understandable, they tend to be very long and they're all localized in English. + +```json +{ + "label": "Isabella (Italia)", + "name": "Microsoft Isabella Online (Natural) - Italian (Italy)", + "language": "it-IT" +} +``` + +**Example 2: Chrome OS voices** + +Chrome OS provides a number of high quality voices through its Android subsystems, but they come with some of the worst names possibles for an end-user. + +```json +{ + "label": "Female voice 1 (US)", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", + "language": "en-US" +} +``` + +### Names + +`name` is required for each recommended voice and it's used as the main identifier for voices in this project. + +Names are mostly stable across browsers, which means that for most voices, a single string is sufficient. + +But there are unfortunately some outliers: Android, iOS, iPadOS and macOS voices. + +For those voices, at least a portion of the string is often localized, naming can be inconsistent across browsers and they can change depending on the number of variants installed. + +Because of this, each list can also contain the following properties: + +- `altNames` with an array of alternate strings for a given voice +- and `localizedName` that identifies the string pattern used for localizing these voices + +**Example 3: Alternate version of an Apple preloaded voice** + +```json +{ + "label": "Samantha (US)", + "name": "Samantha", + "localizedName": "apple", + "altNames": [ + "Samantha (Enhanced)", + "Samantha (English (United States))" + ], + "language": "en-US" +} +``` + +### Languages + +`language` is required for each recommended voice. + +It contains a BCP 47 language tag where a downcased two-letter language code is followed by an uppercased two-letter country code. + +The language and country codes are separated using a hyphen (-). + +Somes voices are also capable of handling another language, for example a Spanish voice for the United States might also be capable of handling English. + +For this reason, an `additionalLanguages` property is also available although it is fairly rarely used right now. + +It contains a list of languages using only two-letter codes, without a sub-tag. + +Some brand new voices from Microsoft are also capable of a multilingual output. The language switch isn't supported in the middle of a sentence, but the output seems capable of auto-detecting the language of each sentence and adopt itself accordingly. + +In order to support this, the output might automatically switch to a different voice in the process. + +These voices are identified using the `multiLingual` boolean. + +**Example 4: Voice with a multilingual output** + +```json +{ + "label": "Emma (US)", + "name": "Microsoft EmmaMultilingual Online (Natural) - English (United States)", + "language": "en-US", + "multiLingual": true +} +``` + +**Example 5: Voice capable of handling a secondary language** + +```json +{ + "label": "Sylvie (Canada)", + "name": "Microsoft Sylvie Online (Natural) - French (Canada)", + "language": "fr-CA", + "otherLanguages": [ + "en" + ] +} +``` + +### Gender and children voices + +`gender` is an optional property for each voice, that documents the gender associated to each voice. + +The following values are supported: `female`, `male` or `neutral`. + +`children` is also optional and identifies children voices using a boolean. + +**Example 6: Female children voice** + +```json +{ + "label": "Ana (US)", + "name": "Microsoft Ana Online (Natural) - English (United States)", + "language": "en-US", + "gender": "female", + "children": true +} +``` + +### Quality + +`quality` is an optional property for each voice, that documents the quality of the various variants of a voice. + +The following values are supported: +
+
veryHigh
+
Very high, almost human-indistinguishable quality of speech synthesis
+
high
+
High, human-like quality of speech synthesis
+
normal
+
Normal quality of speech synthesis
+
low
+
Low, not human-like quality of speech synthesis
+
veryLow
+
Very low, but still intelligible quality of speech synthesis
+
+ +**Example 7: An Apple voice available in three quality variants** + +```json +{ + "label": "Ava (US)", + "name": "Ava", + "note": "This voice can be installed on all Apple devices and offers three variants. Like all voices that can be installed on Apple devices, it suffers from inconsistent naming due to localization.", + "altNames": [ + "Ava (Premium)", + "Ava (Enhanced)", + "Ava (English (United States))", + ], + "language": "en-US", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] +} +``` + + +### OS and browser + +Both `os` and `browser` are optional properties. They're used to indicate in which operating systems and browsers a voice is available. + +These two properties are meant to be interpreted separately and not as a combination. + +**Example 8: A Microsoft voice available in both Edge and Windows** + +```json +{ + "label": "Denise (France)", + "name": "Microsoft Denise Online (Natural) - French (France)", + "note": "This voice is preloaded in Edge on desktop. In other browsers, it requires the user to run Windows 11 and install the voice pack.", + "language": "fr-FR", + "gender": "female", + "os": [ + "Windows" + ], + "browser": [ + "Edge" + ] +} +``` + +In addition, `preloaded` indicates if the voice is preloaded in all the OS and browsers that have been identified. + +With the current approach, it's not possible to indicate that a voice is available on Chrome and Windows, but requires a download on Windows for example. + +**Example 9: A Google voice preloaded in Chrome Desktop** + +```json +{ + "label": "Google female voice (UK)", + "name": "Google UK English Female", + "language": "en-GB", + "gender": "female", + "browser": [ + "ChromeDesktop" + ], + "preloaded": true +} +``` + +### Speech rate and pitch + +When using the [Web Speech API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API), `SpeechSynthesisUtterance` supports optional values for: + +- [`rate`](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance/rate) to control the speech rate +- and [`pitch`](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance/pitch) to control the pitch + +Each voice documented in this repo supports the following optional properties: + +- `pitchControl` is a boolean that defaults to `true` and indicates if a voice can be pitch controlled +- `rate` is an integer between 0.1 and 10 that defaults to 1 and provides a recommended default speech rate for each voice +- `pitch` is an integer between 0 and 2 that defaults to 1 and provides a recommended default pitch for each voice + +**Example 10: Microsoft voice where the pitch cannot be adjusted** + +```json +{ + "label": "Ana (US)", + "name": "Microsoft Ana Online (Natural) - English (United States)", + "language": "en-US", + "gender": "female", + "pitchControl": false +} +``` + +**Example 11: Google voice with recommended pitch and speed rates** + +```json +{ + "label": "Voix Google féminine (France)", + "name": "Google français", + "language": "fr-FR", + "gender": "female", + "rate": 1, + "pitch": 0.8 +} +``` \ No newline at end of file diff --git a/docs/WebSpeech.md b/docs/WebSpeech.md new file mode 100644 index 0000000..554d0f2 --- /dev/null +++ b/docs/WebSpeech.md @@ -0,0 +1,99 @@ +# SpeechSynthesis in browsers and OSes + +Through the work done to document a list of recommended voices, various browsers/OS have been tested to see how they behave. This section is meant to summarize some of this information. + +## General + +* The Web Speech API returns the following fields through the `getVoices()` method: `name`, `voiceURI`, `lang`, `localService` and `default`. +* While `voiceURI` should be the most consistent way of identifying a voice in theory, in practice this couldn't be further from the truth. Most browsers use the same value than `name` for `voiceURI` and do not enforce uniqueness. +* As we'll see in notes for specific browsers/OS, `name` is also inconsistently implemented and can return different values for the same voice on the same device. +* `localService` indicates if a voice is available for offline use and it seems to be working as expected, which is why the current list of recommended voices doesn't contain that information. +* `lang` seems to be mostly reliable across implementations, returning a language using BCP 47 language tags, with the main language in downcase and the subtag in uppercase (`pt-BR`). +* There are unfortunately a few outliers: + * On Android, Samsung and Chrome use an underscore as the separator instead: `en_us` ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/13)) + * While Firefox on Android gets even more creative, using three letter codes for languages and adding an extra string at the end: `eng-US-f000` ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/17)) +* `default` is meant to indicate if a voice is the default voice for the current app language. In theory this should be extremely useful, but in practice it's really hard to use due to inconsistencies across implementations, limited context (system default vs user default) and the lack of capability for setting a default voice per language. +* In addition to the use of `default`, implementers should always consider using the `Accept-Language` HTTP header as well, as it contains an ordered list of preferred language/region for a given user. + +## Android + +* For now, we've only covered testing and documentation on vanilla versions of Android, as available on Google Pixel devices. The list of voices available may vary greatly based on OEM, device and Android version. +* Due to the nature of Android, documenting all these variations will be very difficult. Further attempts will be made in future version of this project through the use of device farms ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/8)). +* In recent versions of vanilla Android, there's an excellent selection of high quality voices which cover a wide range of languages/regions (67 as of April 2024). +* To use these voices, the user needs to go fairly deep in system settings either to download them (only your system language and some of the most popular languages are preloaded by default) or select their preferred voice per language/region. +* Unfortunately, Chrome on Android doesn't return the list of voices available to the users, instead it returns an unfiltered list of languages/regions ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/12)). +* To make things worse, these voices and regions are all localized with the system locale. +* Among other things, this means that even languages and regions which require a voice pack to be installed will show up in the list returned by the Web Speech API ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/14)). +* If the user selects a language/region for which the voice pack needs to be downloaded, Chrome will default to an English voice instead ([related issue](https://github.com/HadrienGardeur/read-aloud-best-practices/issues/6)). +* Even when a voice pack has been installed, the user may need to select a default voice for each region before a language/region can be used at all. +* With this poor approach to voice selection, Chrome on Android doesn't indicate the user's preferred language/region either using `default` ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/16)). + +## Chrome Desktop + +* On desktop, Chrome comes preloaded with a limited selection of 19 high quality voices across 15 languages. +* All of these voices require online access to use them, without any fallback to a lower quality offline variant. +* Unfortunately, these voices are also plagued by a bug if any utterance read by the Web Speech API takes longer than 14 seconds ([related issue](https://github.com/HadrienGardeur/read-aloud-best-practices/issues/3)) and do not return boundary events ([related issue](https://github.com/HadrienGardeur/read-aloud-best-practices/issues/4)). +* Under the current circumstances, these Google voices have been prioritized lower than their Microsoft/Apple counterparts in the list of recommended voices. +* Overall, it's unfortunate that Chrome Desktop is lagging far behind Android and Chrome OS when it comes to the range of voices and languages supported by default ([related issue](https://github.com/HadrienGardeur/read-aloud-best-practices/issues/21)). + +## Chrome OS + +* Chrome OS comes with four sets of voices: Chrome OS voices, Android voices (50+ languages), Natural voices and eSpeak voices (38 languages). +* By default, Chrome OS downloads Chrome OS voices for your system language, while Android and eSpeak voices are available for all languages. +* Google is also gradually adding support for Natural voices, which are basically the higher quality variants of their Android voices with the added benefit of working offline. Natural voices require the user to go to their system settings to install them. +* Chrome OS has an unfortunate tendency of uninstalling voice packs whenever a new Chrome OS update is installed, which happens very often. +* Most Android voices offer offline and online variants and they're on par quality-wise with what Apple offers in terms of downloadable voices. +* These Android voices have some of the worst names on any platform/browser, making them hardly usable without the kind of re-labeling offered by this project. +* Android voices also suffer from issues with latency and/or availability. In some cases, it might take up to a minute for the first utterance to be read aloud. +* Chrome voices are one step below Android voices, but they offer a decent selection for the most common languages. +* eSpeak voices should be avoided at all cost due to their extremely low quality and have been documented separately in order to filter them out. + +## Edge + +* On desktop, Edge provides the best selection of high quality voices with over 250 preloaded voices across 75 languages (as of April 2024). +* All of these so-called "natural" voices rely on Machine Learning (ML) and therefore require online access to use them. +* A small number of those voices are also multilingual and seem to be able to detect the language of a sentence and adapt accordingly. Unfortunately, this doesn't work as well when there's a language switch in the middle of a sentence. +* On macOS at least, there's a weird bug where Edge only displays 18 natural voices initially, but this extends to 250+ once Web Speech API has been used to output an utterance. +* There are also additional issues that implementers should be aware of when using these voices: they don't support pitch adjustment ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/35)) and a number of characters need to be escaped to avoid playback issues ([related issue](https://github.com/HadrienGardeur/read-aloud-best-practices/issues/8)). +* On mobile, Edge isn't nearly as interesting: + * It's completely unusable on Android since it returns an empty list of voices, which makes it impossible to use with Web Speech API ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/20)). + * On iOS/iPadOS, all browsers are currently forced to use Safari as their engine, which means that Edge behaves exactly like Safari Mobile. + +## Firefox + +* On desktop, Firefox seems fairly straightforward when it comes to voice selection. +* Unlike Chrome and Edge, Firefox doesn't come with any preloaded voice of its own. +* Firefox has a different approach for `voiceURI` where each voice is truly identified by a unique URN. +* Since this is unique to Firefox, the current JSON files do not document these URI yet, but this could be a future addition. +* On macOS, Firefox requires a full system reboot for new voices to show up in the list. + +## iOS and iPadOS + +* Both OS come with the same set of preloaded voices and downloadable voices than macOS. [Read the macOS section](#macOS) below for additional information about the voices available. +* For an unknown reason, some preloaded voices are also listed twice but provide the same audio output. +* All browsers need to run on the system webview which means that they're just a shell on top of Safari Mobile rather than truly different browsers. +* This situation could change due to the Digital Market Act in Europe, forcing Apple to change its policy on third-party browsers and webviews. + +## macOS + +* macOS provides an extensive list of voices across 45 languages, both preloaded or downloadable. +* These voices can have up to three different variants, based on the quality of the output (and download size). +* The highest quality voices are probably the ones available for Siri, but they're unfortunately unavailable through the Web Speech API ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/22)). +* At the other end of the spectrum, Apple had the unfortunate idea of preloading a large range of low quality and weird voices such as the Eloquence (8 voices) and Effects (15 voices) voice packs. +* The existence of these voices alone is a good reason to filter voices available to macOS users and highlight the ones recommended on this repo. +* Unlike other platforms/OS, macOS decided to localize voice names. This wouldn't be an issue if `voiceURI` could be used as a reliable identifier for voices, but that's not the case ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/23)). +* In its current state, this repo only documents localizations for the languages supported officially and not the 45 languages supported by the macOS TTS engine. + +## Safari + +* For better or for worse, Safari's behaviour is mostly consistent between its desktop and mobile versions. +* Downloadable voices do not show up in the list returned by the Web Speech API ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/19)). +* Even worse than that, when installing higher quality variants of preloaded voices, these voices disappear in Safari, which means that entire languages could disappear completely. +* All voices return `true` for `default` in Safari, which makes it impossible to detect and select the system/user default ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/16)). + +## Windows + +* [Microsoft provides a very helpful page](https://support.microsoft.com/en-us/windows/appendix-a-supported-languages-and-voices-4486e345-7730-53da-fcfe-55cc64300f01), listing all voices available across Windows 10 and 11 for a total of 98 voices across 36 languages. +* Natural voices provide a far better experience but they require an up-to-date version of Windows 11 and need to be downloaded (with the added benefit that they also work offline). +* Microsoft has been slow to add these natural voices to Windows 11 overall. Until fairly recently, only US voices (3 voices) were available. The list is now a little longer (23 voices across 8 languages) but remains far behind what they offer through Edge (250+ voices across 75 languages). +* Unfortunately, these higher quality voices are not properly listed in Chrome or Firefox currently ([related issue](https://github.com/HadrienGardeur/web-speech-recommended-voices/issues/15)). They only show up in Edge, where they're preloaded anyway but strictly for an online use. diff --git a/json/ar.json b/json/ar.json new file mode 100644 index 0000000..3085259 --- /dev/null +++ b/json/ar.json @@ -0,0 +1,684 @@ +{ + "language": "ar", + "defaultRegion": "ar-SA", + "testUtterance": "مرحبًا، اسمي {name} وأنا صوت عربي.", + "voices": [ + { + "label": "Amina", + "name": "Microsoft Amina Online (Natural) - Arabic (Algeria)", + "language": "ar-DZ", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ismael", + "name": "Microsoft Ismael Online (Natural) - Arabic (Algeria)", + "language": "ar-DZ", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Laila", + "name": "Microsoft Laila Online (Natural) - Arabic (Bahrain)", + "language": "ar-BH", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ali", + "name": "Microsoft Ali Online (Natural) - Arabic (Bahrain)", + "language": "ar-BH", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Salma", + "name": "Microsoft Salma Online (Natural) - Arabic (Egypt)", + "language": "ar-EG", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Shakir", + "name": "Microsoft Shakir Online (Natural) - Arabic (Egypt)", + "language": "ar-EG", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Rana", + "name": "Microsoft Rana Online (Natural) - Arabic (Iraq)", + "language": "ar-IQ", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Bassel", + "name": "Microsoft Bassel Online (Natural) - Arabic (Iraq)", + "language": "ar-IQ", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Sana", + "name": "Microsoft Sana Online (Natural) - Arabic (Jordan)", + "language": "ar-JO", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Taim", + "name": "Microsoft Taim Online (Natural) - Arabic (Jordan)", + "language": "ar-JO", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Noura", + "name": "Microsoft Noura Online (Natural) - Arabic (Kuwait)", + "language": "ar-KW", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Fahed", + "name": "Microsoft Fahed Online (Natural) - Arabic (Kuwait)", + "language": "ar-KW", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Layla", + "name": "Microsoft Layla Online (Natural) - Arabic (Lebanon)", + "language": "ar-LB", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Rami", + "name": "Microsoft Rami Online (Natural) - Arabic (Lebanon)", + "language": "ar-LB", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Iman", + "name": "Microsoft Iman Online (Natural) - Arabic (Libya)", + "language": "ar-LY", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Omar", + "name": "Microsoft Omar Online (Natural) - Arabic (Libya)", + "language": "ar-LY", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Mouna", + "name": "Microsoft Mouna Online (Natural) - Arabic (Morocco)", + "language": "ar-MA", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Jamal", + "name": "Microsoft Jamal Online (Natural) - Arabic (Morocco)", + "language": "ar-MA", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Aysha", + "name": "Microsoft Aysha Online (Natural) - Arabic (Oman)", + "language": "ar-OM", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Abdullah", + "name": "Microsoft Abdullah Online (Natural) - Arabic (Oman)", + "language": "ar-OM", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Amal", + "name": "Microsoft Amal Online (Natural) - Arabic (Qatar)", + "language": "ar-QA", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Moaz", + "name": "Microsoft Moaz Online (Natural) - Arabic (Qatar)", + "language": "ar-QA", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Zariyah", + "name": "Microsoft Zariyah Online (Natural) - Arabic (Saudi Arabia)", + "language": "ar-SA", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Hamed", + "name": "Microsoft Hamed Online (Natural) - Arabic (Saudi Arabia)", + "language": "ar-SA", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Amany", + "name": "Microsoft Amany Online (Natural) - Arabic (Syria)", + "language": "ar-SY", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Laith", + "name": "Microsoft Laith Online (Natural) - Arabic (Syria)", + "language": "ar-SY", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Reem", + "name": "Microsoft Reem Online (Natural) - Arabic (Tunisia)", + "language": "ar-TN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Hedi", + "name": "Microsoft Hedi Online (Natural) - Arabic (Tunisia)", + "language": "ar-TN", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Fatima", + "name": "Microsoft Fatima Online (Natural) - Arabic (United Arab Emirates)", + "language": "ar-AE", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Hamdan", + "name": "Microsoft Hamdan Online (Natural) - Arabic (United Arab Emirates)", + "language": "ar-AE", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Maryam", + "name": "Microsoft Maryam Online (Natural) - Arabic (Yemen)", + "language": "ar-YE", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Saleh", + "name": "Microsoft Saleh Online (Natural) - Arabic (Yemen)", + "language": "ar-YE", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Mariam", + "name": "Mariam", + "localizedName": "apple", + "language": "ar-001", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Apple Laila", + "name": "Laila", + "localizedName": "apple", + "language": "ar-001", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Tarik", + "name": "Tarik", + "localizedName": "apple", + "language": "ar-001", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Majed", + "name": "Majed", + "localizedName": "apple", + "language": "ar-001", + "gender": "male", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Hoda", + "name": "Microsoft Hoda - Arabic (Arabic )", + "language": "ar-EG", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Naayf", + "name": "Microsoft Naayf - Arabic (Saudi Arabia)", + "language": "ar-AS", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "صوت انثوي 1", + "name": "Android Speech Recognition and Synthesis from Google ar-xa-x-arc-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ar-xa-x-arc-local", + "Android Speech Recognition and Synthesis from Google ar-language" + ], + "nativeID": [ + "ar-xa-x-arc-network", + "ar-xa-x-arc-local" + ], + "language": "ar", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "صوت انثوي 2", + "name": "Android Speech Recognition and Synthesis from Google ar-xa-x-arz-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ar-xa-x-arz-local" + ], + "nativeID": [ + "ar-xa-x-arz-network", + "ar-xa-x-arz-local" + ], + "language": "ar", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "صوت ذكر 1", + "name": "Android Speech Recognition and Synthesis from Google ar-xa-x-ard-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ar-xa-x-ard-local" + ], + "nativeID": [ + "ar-xa-x-ard-network", + "ar-xa-x-ard-local" + ], + "language": "ar", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "صوت ذكر 2", + "name": "Android Speech Recognition and Synthesis from Google ar-xa-x-are-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ar-xa-x-are-local" + ], + "nativeID": [ + "ar-xa-x-are-network", + "ar-xa-x-are-local" + ], + "language": "ar", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/bg.json b/json/bg.json new file mode 100644 index 0000000..5e6a138 --- /dev/null +++ b/json/bg.json @@ -0,0 +1,95 @@ +{ + "language": "bg", + "defaultRegion": "bg-BG", + "testUtterance": "Здравейте, казвам се {name} и съм български глас.", + "voices": [ + { + "label": "Kalina", + "name": "Microsoft Kalina Online (Natural) - Bulgarian (Bulgaria)", + "language": "bg-BG", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Borislav", + "name": "Microsoft Borislav Online (Natural) - Bulgarian (Bulgaria)", + "language": "bg-BG", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Daria", + "name": "Daria", + "localizedName": "apple", + "language": "bg-BG", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Ivan", + "name": "Microsoft Ivan - Bulgarian (Bulgaria)", + "language": "bg-BG", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Женски глас", + "name": "Android Speech Recognition and Synthesis from Google bg-bg-x-ifk-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google bg-bg-x-ifk-local", + "Android Speech Recognition and Synthesis from Google bg-bg-language" + ], + "nativeID": [ + "bg-bg-x-ifk-network", + "bg-bg-x-ifk-local" + ], + "language": "bg-BG", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/bho.json b/json/bho.json new file mode 100644 index 0000000..da3f456 --- /dev/null +++ b/json/bho.json @@ -0,0 +1,26 @@ +{ + "language": "bho", + "defaultRegion": "bho-IN", + "testUtterance": "नमस्कार, हमार नाम {name} ह आ हम भोजपुरी आवाज हईं", + "voices": [ + { + "label": "Jaya", + "name": "Jaya", + "localizedName": "apple", + "language": "bho-IN", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/bn.json b/json/bn.json new file mode 100644 index 0000000..3c52da6 --- /dev/null +++ b/json/bn.json @@ -0,0 +1,205 @@ +{ + "language": "bn", + "defaultRegion": "bn-IN", + "testUtterance": "হ্যালো, আমার নাম {name} এবং আমি একজন বাংলা ভয়েস।", + "voices": [ + { + "label": "Tanishaa", + "name": "Microsoft Tanishaa Online (Natural) - Bengali (India)", + "language": "bn-IN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Bashkar", + "name": "Microsoft Bashkar Online (Natural) - Bangla (India)", + "language": "bn-IN", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Nabanita", + "name": "Microsoft Nabanita Online (Natural) - Bangla (Bangladesh)", + "language": "bn-BD", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Pradeep", + "name": "Microsoft Pradeep Online (Natural) - Bangla (Bangladesh)", + "language": "bn-BD", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Piya", + "name": "Piya", + "localizedName": "apple", + "language": "bn-IN", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "মহিলা কণ্ঠস্বর 1", + "name": "Android Speech Recognition and Synthesis from Google bn-in-x-bnf-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google bn-in-x-bnf-local", + "Android Speech Recognition and Synthesis from Google bn-IN-language" + ], + "nativeID": [ + "bn-in-x-bnf-network", + "bn-in-x-bnf-local" + ], + "language": "bn-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "মহিলা কণ্ঠস্বর 2", + "name": "Android Speech Recognition and Synthesis from Google bn-in-x-bnx-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google bn-in-x-bnx-local" + ], + "nativeID": [ + "bn-in-x-bnx-network", + "bn-in-x-bnx-local" + ], + "language": "bn-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "পুরুষ কন্ঠ 1", + "name": "Android Speech Recognition and Synthesis from Google bn-in-x-bin-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google bn-in-x-bin-local" + ], + "nativeID": [ + "bn-in-x-bin-network", + "bn-in-x-bin-local" + ], + "language": "bn-IN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "পুরুষ কন্ঠ 2", + "name": "Android Speech Recognition and Synthesis from Google bn-in-x-bnm-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google bn-in-x-bnm-local" + ], + "nativeID": [ + "bn-in-x-bnm-network", + "bn-in-x-bnm-local" + ], + "language": "bn-IN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "পুরুষ কন্ঠ", + "name": "Google বাংলা (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google bn-bd-x-ban-network", + "Chrome OS বাংলা", + "Android Speech Recognition and Synthesis from Google bn-bd-x-ban-local", + "Android Speech Recognition and Synthesis from Google bn-BD-language" + ], + "nativeID": [ + "bn-bd-x-ban-network", + "bn-bd-x-ban-local" + ], + "language": "bn-BD", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/ca.json b/json/ca.json new file mode 100644 index 0000000..411d500 --- /dev/null +++ b/json/ca.json @@ -0,0 +1,140 @@ +{ + "language": "ca", + "defaultRegion": "ca-ES", + "testUtterance": "Hola, em dic {name} i sóc una veu catalana", + "voices": [ + { + "label": "Joana (Català)", + "name": "Microsoft Joana Online (Natural) - Catalan", + "language": "ca-ES", + "otherLanguages": [ + "es" + ], + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Enric (Català)", + "name": "Microsoft Enric Online (Natural) - Catalan", + "language": "ca-ES", + "otherLanguages": [ + "es" + ], + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Montse (Català)", + "name": "Montse", + "localizedName": "apple", + "language": "ca-ES", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Pau (Valencià)", + "name": "Pau", + "localizedName": "apple", + "language": "ca-ES-u-sd-esvc", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Jordi (Català)", + "name": "Jordi", + "localizedName": "apple", + "language": "ca-ES", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Herena (Català)", + "name": "Microsoft Herena - Catalan (Spain)", + "language": "ca-ES", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Veu femenina catalana", + "name": "Android Speech Recognition and Synthesis from Google ca-es-x-caf-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ca-es-x-caf-local", + "Android Speech Recognition and Synthesis from Google ca-ES-language" + ], + "nativeID": [ + "ca-es-x-caf-network", + "ca-es-x-caf-local" + ], + "language": "ca-ES", + "otherLanguages": [ + "es" + ], + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/cmn.json b/json/cmn.json new file mode 100644 index 0000000..82a329c --- /dev/null +++ b/json/cmn.json @@ -0,0 +1,844 @@ +{ + "language": "cmn", + "defaultRegion": "cmn-CN", + "testUtterance": "你好,我的名字是 {name},我是普通话配音。", + "voices": [ + { + "label": "Xiaoxiao", + "name": "Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland)", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Xiaoyi", + "name": "Microsoft Xiaoyi Online (Natural) - Chinese (Mainland)", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Yunxi", + "name": "Microsoft Yunxi Online (Natural) - Chinese (Mainland)", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Yunxia", + "name": "Microsoft Yunxia Online (Natural) - Chinese (Mainland)", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Xiaobei", + "name": "Microsoft Xiaobei Online (Natural) - Chinese (Northeastern Mandarin)", + "language": "cmn-CN-liaoning", + "altLanguage": "zh-CN-liaoning", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Xiaoni", + "name": "Microsoft Xiaoni Online (Natural) - Chinese (Zhongyuan Mandarin Shaanxi)", + "language": "cmn-CN-shaanxi", + "altLanguage": "zh-CN-shaanxi", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Yunjian", + "name": "Microsoft Yunjian Online (Natural) - Chinese (Mainland)", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Yunyang", + "name": "Microsoft Yunyang Online (Natural) - Chinese (Mainland)", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "HsiaoChen", + "name": "Microsoft HsiaoChen Online (Natural) - Chinese (Taiwan)", + "language": "cmn-TW", + "altLanguage": "zh-TW", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "HsiaoYu", + "name": "Microsoft HsiaoYu Online (Natural) - Chinese (Taiwanese Mandarin)", + "language": "cmn-TW", + "altLanguage": "zh-TW", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "YunJhe", + "name": "Microsoft YunJhe Online (Natural) - Chinese (Taiwan)", + "language": "cmn-TW", + "altLanguage": "zh-TW", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Lilian", + "name": "Lilian", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Tiantian", + "name": "Tiantian", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Shasha", + "name": "Shasha", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Lili", + "name": "Lili", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Lisheng", + "name": "Lisheng", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Lanlan", + "name": "Lanlan", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Shanshan", + "name": "Shanshan", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Yue", + "name": "Yue", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Tingting", + "name": "Tingting", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Yu-shu", + "name": "Yu-shu", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Dongmei", + "name": "Dongmei", + "localizedName": "apple", + "language": "cmn-CN-liaoning", + "altLanguage": "zh-CN-liaoning", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Panpan", + "name": "Panpan", + "localizedName": "apple", + "language": "cmn-CN-sichuan", + "altLanguage": "zh-CN-sichuan", + "gender": "female", + "quality": [ + "low", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Meijia", + "name": "Meijia", + "localizedName": "apple", + "language": "cmn-TW", + "altLanguage": "zh-TW", + "gender": "female", + "quality": [ + "low", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Han", + "name": "Han", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "male", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Bobo", + "name": "Bobo", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Taotao", + "name": "Taotao", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Binbin", + "name": "Binbin", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Li-Mu", + "name": "Li-Mu", + "localizedName": "apple", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Haohao", + "name": "Haohao", + "localizedName": "apple", + "language": "cmn-CN-shaanxi", + "altLanguage": "zh-CN-shaanxi", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Google 女声", + "name": "Google 普通话(中国大陆)", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Google 女聲", + "name": "Google 國語(臺灣)", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "cmn-TW", + "altLanguage": "zh-TW", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Huihui", + "name": "Microsoft Huihui - Chinese (Simplified, PRC)", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Yaoyao", + "name": "Microsoft Yaoyao - Chinese (Simplified, PRC)", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Kangkang", + "name": "Microsoft Kangkang - Chinese (Simplified, PRC)", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Yating", + "name": "Microsoft Yating - Chinese (Traditional, Taiwan)", + "language": "cmn-TW", + "altLanguage": "zh-TW", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Hanhan", + "name": "Microsoft Hanhan - Chinese (Traditional, Taiwan)", + "language": "cmn-TW", + "altLanguage": "zh-TW", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Zhiwei", + "name": "Microsoft Zhiwei - Chinese (Traditional, Taiwan)", + "language": "cmn-TW", + "altLanguage": "zh-TW", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "女声1", + "name": "Android Speech Recognition and Synthesis from Google cmn-CN-x-ccc-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google cmn-CN-x-ccc-local", + "Android Speech Recognition and Synthesis from Google zh-CN-language" + ], + "nativeID": [ + "cmn-CN-x-ccc-network", + "cmn-CN-x-ccc-local" + ], + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "女声2", + "name": "Android Speech Recognition and Synthesis from Google cmn-CN-x-ssa-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google cmn-CN-x-ssa-local" + ], + "nativeID": [ + "cmn-CN-x-ssa-network", + "cmn-CN-x-ssa-local" + ], + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "男声1", + "name": "Android Speech Recognition and Synthesis from Google cmn-CN-x-ccd-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google cmn-CN-x-ccd-local" + ], + "nativeID": [ + "cmn-CN-x-ccd-network", + "cmn-CN-x-ccd-local" + ], + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "男声2", + "name": "Android Speech Recognition and Synthesis from Google cmn-CN-x-cce-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google cmn-CN-x-cce-local" + ], + "nativeID": [ + "cmn-CN-x-cce-network", + "cmn-CN-x-cce-local" + ], + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "女聲", + "name": "Android Speech Recognition and Synthesis from Google cmn-TW-x-ctc-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google cmn-TW-x-ctc-local", + "Android Speech Recognition and Synthesis from Google zh-TW-language" + ], + "nativeID": [ + "cmn-TW-x-ctc-network", + "cmn-TW-x-ctc-local" + ], + "language": "cmn-TW", + "altLanguage": "zh-TW", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "男聲1", + "name": "Android Speech Recognition and Synthesis from Google cmn-TW-x-ctd-network", + "altNames": [ + "Chrome OS 粵語 1", + "Android Speech Recognition and Synthesis from Google cmn-TW-x-ctd-local" + ], + "nativeID": [ + "cmn-TW-x-ctd-network", + "cmn-TW-x-ctd-local" + ], + "language": "cmn-TW", + "altLanguage": "zh-TW", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "男聲2", + "name": "Android Speech Recognition and Synthesis from Google cmn-TW-x-cte-network", + "altNames": [ + "Chrome OS 粵語 1", + "Android Speech Recognition and Synthesis from Google cmn-TW-x-cte-local" + ], + "nativeID": [ + "cmn-TW-x-cte-network", + "cmn-TW-x-cte-local" + ], + "language": "cmn-CTW", + "altLanguage": "zh-TW", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/cs.json b/json/cs.json new file mode 100644 index 0000000..dcb7c19 --- /dev/null +++ b/json/cs.json @@ -0,0 +1,116 @@ +{ + "language": "cs", + "defaultRegion": "cs-CZ", + "testUtterance": "Dobrý den, jmenuji se {name} a jsem český hlas.", + "voices": [ + { + "label": "Vlasta", + "name": "Microsoft Vlasta Online (Natural) - Czech (Czech)", + "language": "cs-CZ", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Antonin", + "name": "Microsoft Antonin Online (Natural) - Czech (Czech)", + "language": "cs-CZ", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Zuzana", + "name": "Zuzana", + "localizedName": "apple", + "language": "cs-CZ", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Iveta", + "name": "Iveta", + "localizedName": "apple", + "language": "cs-CZ", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Jakub", + "name": "Microsoft Jakub - Czech (Czech)", + "language": "cs-CZ", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Ženský hlas", + "name": "Google čeština (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google cs-cz-x-jfs-network", + "Chrome OS čeština", + "Android Speech Recognition and Synthesis from Google cs-cz-x-jfs-local", + "Android Speech Recognition and Synthesis from Google cs-CZ-language" + ], + "nativeID": [ + "cs-cz-x-jfs-network", + "cs-cz-x-jfs-local" + ], + "language": "cs-CZ", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} diff --git a/json/da.json b/json/da.json new file mode 100644 index 0000000..0f19cf3 --- /dev/null +++ b/json/da.json @@ -0,0 +1,190 @@ +{ + "language": "da", + "defaultRegion": "da-DK", + "testUtterance": "Hej, mit navn er {name} og jeg er en dansk stemme.", + "voices": [ + { + "label": "Christel", + "name": "Microsoft Christel Online (Natural) - Danish (Denmark)", + "language": "da-DK", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Jeppe", + "name": "Microsoft Jeppe Online (Natural) - Danish (Denmark)", + "language": "da-DK", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Sara", + "name": "Sara", + "localizedName": "apple", + "language": "da-DK", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Magnus", + "name": "Magnus", + "localizedName": "apple", + "language": "da-DK", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Helle", + "name": "Microsoft Helle - Danish (Denmark)", + "language": "da-DK", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Kvindestemme 1", + "name": "Google Dansk 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google da-dk-x-kfm-network", + "Chrome OS Dansk 1", + "Android Speech Recognition and Synthesis from Google da-dk-x-kfm-local", + "Android Speech Recognition and Synthesis from Google da-DK-language" + ], + "nativeID": [ + "da-dk-x-kfm-network", + "da-dk-x-kfm-local" + ], + "language": "da-DK", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Kvindestemme 2", + "name": "Google Dansk 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google da-dk-x-sfp-network", + "Chrome OS Dansk 3", + "Android Speech Recognition and Synthesis from Google da-dk-x-sfp-local" + ], + "nativeID": [ + "da-dk-x-sfp-network", + "da-dk-x-sfp-local" + ], + "language": "da-DK", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Kvindestemme 3", + "name": "Google Dansk 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google da-dk-x-vfb-network", + "Chrome OS Dansk 4", + "Android Speech Recognition and Synthesis from Google da-dk-x-vfb-local" + ], + "nativeID": [ + "da-dk-x-vfb-network", + "da-dk-x-vfb-local" + ], + "language": "da-DK", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Mandsstemme", + "name": "Google Dansk 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google da-dk-x-nmm-network", + "Chrome OS Dansk 2", + "Android Speech Recognition and Synthesis from Google da-dk-x-nmm-local" + ], + "nativeID": [ + "da-dk-x-nmm-network", + "da-dk-x-nmm-local" + ], + "language": "da-DK", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/de.json b/json/de.json new file mode 100644 index 0000000..e998345 --- /dev/null +++ b/json/de.json @@ -0,0 +1,481 @@ +{ + "language": "de", + "defaultRegion": "de-DE", + "testUtterance": "Hallo, mein Name ist {name} und ich bin eine deutsche Stimme.", + "voices": [ + { + "label": "Seraphina", + "name": "Microsoft SeraphinaMultilingual Online (Natural) - German (Germany)", + "language": "de-DE", + "multiLingual": true, + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Amala", + "name": "Microsoft Amala Online (Natural) - German (Germany)", + "language": "de-DE", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Katja", + "name": "Microsoft Katja Online (Natural) - German (Germany)", + "language": "de-DE", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Florian", + "name": "Microsoft FlorianMultilingual Online (Natural) - German (Germany)", + "language": "de-DE", + "multiLingual": true, + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Conrad", + "name": "Microsoft Conrad Online (Natural) - German (Germany)", + "language": "de-DE", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Killian", + "name": "Microsoft Killian Online (Natural) - German (Germany)", + "language": "de-DE", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ingrid", + "name": "Microsoft Ingrid Online (Natural) - German (Austria)", + "language": "de-AT", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Jonas", + "name": "Microsoft Jonas Online (Natural) - German (Austria)", + "language": "de-AT", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Leni", + "name": "Microsoft Leni Online (Natural) - German (Switzerland)", + "language": "de-CH", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Jan", + "name": "Microsoft Jan Online (Natural) - German (Switzerland)", + "language": "de-CH", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Petra", + "name": "Petra", + "localizedName": "apple", + "language": "de-DE", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Anna", + "name": "Anna", + "localizedName": "apple", + "language": "de-DE", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Helena", + "name": "Helena", + "localizedName": "apple", + "note": "This is a compact version of a preloaded Siri voice on macOS.", + "language": "de-DE", + "gender": "female", + "quality": [ + "low" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Markus", + "name": "Markus", + "localizedName": "apple", + "language": "de-DE", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Viktor", + "name": "Viktor", + "localizedName": "apple", + "language": "de-DE", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Yannick", + "name": "Yannick", + "localizedName": "apple", + "language": "de-DE", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Martin", + "name": "Martin", + "localizedName": "apple", + "note": "This is a compact version of a preloaded Siri voice on macOS.", + "language": "de-DE", + "gender": "male", + "quality": [ + "low" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Google Deutsch", + "name": "Weibliche Google-Stimme (Deutschland)", + "language": "de-DE", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Hedda", + "name": "Microsoft Hedda - German (Germany)", + "language": "de-DE", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Katja", + "name": "Microsoft Katja - German (Germany)", + "language": "de-DE", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Stefan", + "name": "Microsoft Stefan - German (Germany)", + "language": "de-DE", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Michael", + "name": "Microsoft Michael - German (Austria)", + "language": "de-AT", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Karsten", + "name": "Microsoft Karsten - German (Switzerland)", + "language": "de-CH", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Weibliche Stimme 1 (Deutschland)", + "name": "Google Deutsch 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google de-de-x-dea-network", + "Chrome OS Deutsch 2", + "Android Speech Recognition and Synthesis from Google de-de-x-dea-local", + "Android Speech Recognition and Synthesis from Google de-DE-language" + ], + "nativeID": [ + "de-de-x-dea-network", + "de-de-x-dea-local" + ], + "language": "de-DE", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Weibliche Stimme 2 (Deutschland)", + "name": "Google Deutsch 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google de-de-x-nfh-network", + "Chrome OS Deutsch 1", + "Android Speech Recognition and Synthesis from Google de-de-x-nfh-local" + ], + "nativeID": [ + "de-de-x-nfh-network", + "de-de-x-nfh-local" + ], + "language": "de-DE", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Männliche Stimme 1 (Deutschland)", + "name": "Google Deutsch 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google de-de-x-deb-network", + "Chrome OS Deutsch 3", + "Android Speech Recognition and Synthesis from Google de-de-x-deb-local" + ], + "nativeID": [ + "de-de-x-deb-network", + "de-de-x-deb-local" + ], + "language": "de-DE", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Männliche Stimme 2 (Deutschland)", + "name": "Google Deutsch 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google de-de-x-deg-network", + "Chrome OS Deutsch 4", + "Android Speech Recognition and Synthesis from Google de-de-x-deg-local" + ], + "nativeID": [ + "de-de-x-deg-network", + "de-de-x-deg-local" + ], + "language": "de-DE", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/el.json b/json/el.json new file mode 100644 index 0000000..78b89f5 --- /dev/null +++ b/json/el.json @@ -0,0 +1,115 @@ +{ + "language": "el", + "defaultRegion": "el-GR", + "testUtterance": "Γεια σας, με λένε {name} και είμαι ελληνική φωνή.", + "voices": [ + { + "label": "Athina", + "name": "Microsoft Athina Online (Natural) - Greek (Greece)", + "language": "el-GR", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Nestoras", + "name": "Microsoft Nestoras Online (Natural) - Greek (Greece)", + "language": "el-GR", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Melina", + "name": "Melina", + "localizedName": "apple", + "language": "el-GR", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Nikos", + "name": "Nikos", + "localizedName": "apple", + "language": "el-GR", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Stefanos", + "name": "Microsoft Stefanos - Greek (Greece)", + "language": "el-GR", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Γυναικεία φωνή", + "name": "Google Ελληνικά (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google el-gr-x-vfz-network", + "Chrome OS Ελληνικά", + "Android Speech Recognition and Synthesis from Google el-gr-x-vfz-local", + "Android Speech Recognition and Synthesis from Google el-GR-language" + ], + "nativeID": [ + "el-gr-x-vfz-network", + "el-gr-x-vfz-local" + ], + "language": "el-GR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/en.json b/json/en.json new file mode 100644 index 0000000..7ca8768 --- /dev/null +++ b/json/en.json @@ -0,0 +1,2082 @@ +{ + "language": "en", + "defaultRegion": "en-US", + "testUtterance": "Hello, my name is {name} and I am an English voice.", + "voices": [ + { + "label": "Emma", + "name": "Microsoft EmmaMultilingual Online (Natural) - English (United States)", + "altNames": [ + "Microsoft Emma Online (Natural) - English (United States)" + ], + "language": "en-US", + "multiLingual": true, + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Microsoft Ava", + "name": "Microsoft AvaMultilingual Online (Natural) - English (United States)", + "altNames": [ + "Microsoft Ava Online (Natural) - English (United States)" + ], + "language": "en-US", + "multiLingual": true, + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Jenny", + "name": "Microsoft Jenny Online (Natural) - English (United States)", + "language": "en-US", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Aria", + "name": "Microsoft Aria Online (Natural) - English (United States)", + "language": "en-US", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Michelle", + "name": "Microsoft Michelle Online (Natural) - English (United States)", + "language": "en-US", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ana", + "name": "Microsoft Ana Online (Natural) - English (United States)", + "language": "en-US", + "gender": "female", + "children": true, + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Andrew", + "name": "Microsoft AndrewMultilingual Online (Natural) - English (United States)", + "altNames": [ + "Microsoft Andrew Online (Natural) - English (United States)" + ], + "language": "en-US", + "multiLingual": true, + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Brian", + "name": "Microsoft BrianMultilingual Online (Natural) - English (United States)", + "altNames": [ + "Microsoft Brian Online (Natural) - English (United States)" + ], + "language": "en-US", + "multiLingual": true, + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Guy", + "name": "Microsoft Guy Online (Natural) - English (United States)", + "language": "en-US", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Eric", + "name": "Microsoft Eric Online (Natural) - English (United States)", + "language": "en-US", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Steffan", + "name": "Microsoft Steffan Online (Natural) - English (United States)", + "language": "en-US", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Christopher", + "name": "Microsoft Christopher Online (Natural) - English (United States)", + "language": "en-US", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Roger", + "name": "Microsoft Roger Online (Natural) - English (United States)", + "language": "en-US", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Sonia", + "name": "Microsoft Sonia Online (Natural) - English (United Kingdom)", + "language": "en-GB", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Libby", + "name": "Microsoft Libby Online (Natural) - English (United Kingdom)", + "language": "en-GB", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Maisie", + "name": "Microsoft Maisie Online (Natural) - English (United Kingdom)", + "language": "en-GB", + "gender": "female", + "children": true, + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ryan", + "name": "Microsoft Ryan Online (Natural) - English (United Kingdom)", + "language": "en-GB", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Thomas", + "name": "Microsoft Thomas Online (Natural) - English (United Kingdom)", + "language": "en-GB", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Natasha", + "name": "Microsoft Natasha Online (Natural) - English (Australia)", + "language": "en-AU", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Hayley", + "name": "Microsoft Hayley Online - English (Australia)", + "language": "en-AU", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "William", + "name": "Microsoft William Online (Natural) - English (Australia)", + "altNames": [ + "Microsoft WilliamMultilingual Online (Natural) - English (Australia)" + ], + "language": "en-AU", + "multiLingual": true, + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Clara", + "name": "Microsoft Clara Online (Natural) - English (Canada)", + "language": "en-CA", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Heather", + "name": "Microsoft Heather Online - English (Canada)", + "language": "en-CA", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Liam", + "name": "Microsoft Liam Online (Natural) - English (Canada)", + "language": "en-CA", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Neerja", + "name": "Microsoft Neerja Online (Natural) - English (India)", + "altNames": [ + "Microsoft Neerja Online (Natural) - English (India) (Preview)" + ], + "language": "en-IN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Prabhat", + "name": "Microsoft Prabhat Online (Natural) - English (India)", + "language": "en-IN", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Emily", + "name": "Microsoft Emily Online (Natural) - English (Ireland)", + "language": "en-IE", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Connor", + "name": "Microsoft Connor Online (Natural) - English (Ireland)", + "language": "en-IE", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Leah", + "name": "Microsoft Leah Online (Natural) - English (South Africa)", + "language": "en-ZA", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Luke", + "name": "Microsoft Luke Online (Natural) - English (South Africa)", + "language": "en-ZA", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Yan", + "name": "Microsoft Yan Online (Natural) - English (Hong Kong SAR)", + "language": "en-HK", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Sam", + "name": "Microsoft Sam Online (Natural) - English (Hongkong)", + "language": "en-HK", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Asilia", + "name": "Microsoft Asilia Online (Natural) - English (Kenya)", + "language": "en-KE", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Chilemba", + "name": "Microsoft Chilemba Online (Natural) - English (Kenya)", + "language": "en-KE", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Molly", + "name": "Microsoft Molly Online (Natural) - English (New Zealand)", + "language": "en-NZ", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Mitchell", + "name": "Microsoft Mitchell Online (Natural) - English (New Zealand)", + "language": "en-NZ", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ezinne", + "name": "Microsoft Ezinne Online (Natural) - English (Nigeria)", + "language": "en-NG", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Abeo", + "name": "Microsoft Abeo Online (Natural) - English (Nigeria)", + "language": "en-NG", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Rosa", + "name": "Microsoft Rosa Online (Natural) - English (Philippines)", + "language": "en-PH", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "James", + "name": "Microsoft James Online (Natural) - English (Philippines)", + "language": "en-PH", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Luna", + "name": "Microsoft Luna Online (Natural) - English (Singapore)", + "language": "en-SG", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Wayne", + "name": "Microsoft Wayne Online (Natural) - English (Singapore)", + "language": "en-SG", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Imani", + "name": "Microsoft Imani Online (Natural) - English (Tanzania)", + "language": "en-TZ", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Elimu", + "name": "Microsoft Elimu Online (Natural) - English (Tanzania)", + "language": "en-TZ", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Apple Ava", + "name": "Ava", + "localizedName": "apple", + "language": "en-US", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Zoe", + "name": "Zoe", + "localizedName": "apple", + "language": "en-US", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Allison", + "name": "Allison", + "localizedName": "apple", + "language": "en-US", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Nicky", + "name": "Nicky", + "localizedName": "apple", + "note": "This is a compact version of a preloaded Siri voice on macOS. Unlike other Siri voices, a higher quality version can be installed and used.", + "language": "en-US", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Samantha", + "name": "Samantha", + "localizedName": "apple", + "language": "en-US", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Joelle", + "name": "Joelle", + "localizedName": "apple", + "language": "en-US", + "gender": "female", + "children": true, + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Evan", + "name": "Evan", + "localizedName": "apple", + "language": "en-US", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Nathan", + "name": "Nathan", + "localizedName": "apple", + "language": "en-US", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Tom", + "name": "Tom", + "localizedName": "apple", + "language": "en-US", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Alex", + "name": "Alex", + "localizedName": "apple", + "language": "en-US", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Aaron", + "name": "Aaron", + "localizedName": "apple", + "note": "This is a compact version of a preloaded Siri voice on macOS.", + "language": "en-US", + "gender": "male", + "quality": [ + "low" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Kate", + "name": "Kate", + "localizedName": "apple", + "language": "en-GB", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Stephanie", + "name": "Stephanie", + "localizedName": "apple", + "language": "en-GB", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Serena", + "name": "Serena", + "localizedName": "apple", + "language": "en-GB", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Martha", + "name": "Martha", + "localizedName": "apple", + "note": "This is a compact version of a preloaded Siri voice on macOS.", + "language": "en-GB", + "gender": "female", + "quality": [ + "low" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Jamie", + "name": "Jamie", + "localizedName": "apple", + "language": "en-GB", + "gender": "male", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Oliver", + "name": "Oliver", + "localizedName": "apple", + "language": "en-GB", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Daniel", + "name": "Daniel", + "localizedName": "apple", + "language": "en-GB", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Arthur", + "name": "Arthur", + "localizedName": "apple", + "note": "This is a compact version of a preloaded Siri voice on macOS.", + "language": "en-GB", + "gender": "male", + "quality": [ + "low" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Matilda", + "name": "Matilda", + "localizedName": "apple", + "language": "en-AU", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Karen", + "name": "Karen", + "localizedName": "apple", + "language": "en-AU", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Catherine", + "name": "Catherine", + "localizedName": "apple", + "note": "This is a compact version of a preloaded Siri voice on macOS.", + "language": "en-AU", + "gender": "female", + "quality": [ + "low" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Lee", + "name": "Lee", + "localizedName": "apple", + "language": "en-AU", + "gender": "male", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Gordon", + "name": "Gordon", + "localizedName": "apple", + "note": "This is a compact version of a preloaded Siri voice on macOS.", + "language": "en-AU", + "gender": "male", + "quality": [ + "low" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Isha", + "name": "Isha", + "localizedName": "apple", + "language": "en-IN", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Sangeeta", + "name": "Sangeeta", + "localizedName": "apple", + "language": "en-IN", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Rishi", + "name": "Rishi", + "localizedName": "apple", + "language": "en-IN", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Moira", + "name": "Moira", + "localizedName": "apple", + "language": "en-IE", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Tessa", + "name": "Tessa", + "localizedName": "apple", + "language": "en-ZA", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Fiona", + "name": "Fiona", + "localizedName": "apple", + "language": "en-GB-u-sd-gbsct", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Female Google voice (US)", + "name": "Google US English", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "en-US", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Female Google voice (UK)", + "name": "Google UK English Female", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "en-GB", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Male Google voice (UK)", + "name": "Google UK English Male", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "en-GB", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Zira", + "name": "Microsoft Zira - English (United States)", + "language": "en-US", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "David", + "name": "Microsoft David - English (United States)", + "language": "en-US", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Mark", + "name": "Microsoft Mark - English (United States)", + "language": "en-US", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Hazel", + "name": "Microsoft Hazel - English (Great Britain)", + "language": "en-GB", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Susan", + "name": "Microsoft Susan - English (Great Britain)", + "language": "en-GB", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "George", + "name": "Microsoft George - English (Great Britain)", + "language": "en-GB", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Catherine", + "name": "Microsoft Catherine - English (Austalia)", + "language": "en-AU", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "James", + "name": "Microsoft Richard - English (Australia)", + "language": "en-AU", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Linda", + "name": "Microsoft Linda - English (Canada)", + "language": "en-CA", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Richard", + "name": "Microsoft Richard - English (Canada)", + "language": "en-CA", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Heera", + "name": "Microsoft Heera - English (India)", + "language": "en-IN", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Ravi", + "name": "Microsoft Ravi - English (India)", + "language": "en-IN", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Sean", + "name": "Microsoft Sean - English (Ireland)", + "language": "en-IE", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Female voice 1 (US)", + "name": "Google US English 5 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", + "Chrome OS US English 5", + "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", + "Android Speech Recognition and Synthesis from Google en-US-language" + ], + "nativeID": [ + "en-us-x-tpc-network", + "en-us-x-tpc-local" + ], + "language": "en-US", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 2 (US)", + "name": "Google US English 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-us-x-iob-network", + "Chrome OS US English 1", + "Android Speech Recognition and Synthesis from Google en-us-x-iob-local" + ], + "nativeID": [ + "en-us-x-iob-network", + "en-us-x-iob-local" + ], + "language": "en-US", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 3 (US)", + "name": "Google US English 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-us-x-iog-network", + "Chrome OS US English 2", + "Android Speech Recognition and Synthesis from Google en-us-x-iog-local" + ], + "nativeID": [ + "en-us-x-iog-network", + "en-us-x-iog-local" + ], + "language": "en-US", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 4 (US)", + "name": "Google US English 7 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-us-x-tpf-network", + "Chrome OS US English 7", + "Android Speech Recognition and Synthesis from Google en-us-x-tpf-local" + ], + "nativeID": [ + "en-us-x-tpf-network", + "en-us-x-tpf-local" + ], + "language": "en-US", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 5 (US)", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-sfg-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-us-x-sfg-local" + ], + "nativeID": [ + "en-us-x-sfg-network", + "en-us-x-sfg-local" + ], + "language": "en-US", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 6 (US)", + "name": "Chrome OS US English 8", + "language": "en-US", + "gender": "female", + "quality": [ + "low" + ], + "rate": 1, + "pitch": 1, + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Male voice 1 (US)", + "name": "Google US English 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-us-x-iom-network", + "Chrome OS US English 4", + "Android Speech Recognition and Synthesis from Google en-us-x-iom-local" + ], + "nativeID": [ + "en-us-x-iom-network", + "en-us-x-iom-local" + ], + "language": "en-US", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Male voice 2 (US)", + "name": "Google US English 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-us-x-iol-network", + "Chrome OS US English 3", + "Android Speech Recognition and Synthesis from Google en-us-x-iol-local" + ], + "nativeID": [ + "en-us-x-iol-network", + "en-us-x-iol-local" + ], + "language": "en-US", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Male voice 3 (US)", + "name": "Google US English 6 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-us-x-tpd-network", + "Chrome OS US English 6", + "Android Speech Recognition and Synthesis from Google en-us-x-tpd-local" + ], + "nativeID": [ + "en-us-x-tpd-network", + "en-us-x-tpd-local" + ], + "language": "en-US", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 1 (UK)", + "name": "Google UK English 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-gb-x-gba-network", + "Chrome OS UK English 2", + "Android Speech Recognition and Synthesis from Google en-gb-x-gba-local", + "Android Speech Recognition and Synthesis from Google en-GB-language" + ], + "nativeID": [ + "en-gb-x-gba-network", + "en-gb-x-gba-local" + ], + "language": "en-GB", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 2 (UK)", + "name": "Google UK English 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-gb-x-gbc-network", + "Chrome OS UK English 4", + "Android Speech Recognition and Synthesis from Google en-gb-x-gbc-local" + ], + "nativeID": [ + "en-gb-x-gbc-network", + "en-gb-x-gbc-local" + ], + "language": "en-GB", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 3 (UK)", + "name": "Google UK English 6 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-gb-x-gbg-network", + "Chrome OS UK English 6", + "Android Speech Recognition and Synthesis from Google en-gb-x-gbg-local" + ], + "nativeID": [ + "en-gb-x-gbg-network", + "en-gb-x-gbg-local" + ], + "language": "en-GB", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 4 (UK)", + "name": "Chrome OS UK English 7", + "language": "en-GB", + "gender": "female", + "quality": [ + "low" + ], + "rate": 1, + "pitch": 1, + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Male voice 1 (UK)", + "name": "Google UK English 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-gb-x-rjs-network", + "Chrome OS UK English 1", + "Android Speech Recognition and Synthesis from Google en-gb-x-rjs-local" + ], + "nativeID": [ + "en-gb-x-rjs-network", + "en-gb-x-rjs-local" + ], + "language": "en-GB", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Male voice 2 (UK)", + "name": "Google UK English 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-gb-x-gbb-network", + "Chrome OS UK English 3", + "Android Speech Recognition and Synthesis from Google en-gb-x-gbb-local" + ], + "nativeID": [ + "en-gb-x-gbb-network", + "en-gb-x-gbb-local" + ], + "language": "en-GB", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Male voice 3 (UK)", + "name": "Google UK English 5 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-gb-x-gbd-network", + "Chrome OS UK English 5", + "Android Speech Recognition and Synthesis from Google en-gb-x-gbd-local" + ], + "nativeID": [ + "en-gb-x-gbd-network", + "en-gb-x-gbd-local" + ], + "language": "en-GB", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 1 (Australia)", + "name": "Google Australian English 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-au-x-aua-network", + "Chrome OS Australian English 1", + "Android Speech Recognition and Synthesis from Google en-au-x-aua-local", + "Android Speech Recognition and Synthesis from Google en-AU-language" + ], + "nativeID": [ + "en-au-x-aua-network", + "en-au-x-aua-local" + ], + "language": "en-AU", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 2 (Australia)", + "name": "Google Australian English 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-au-x-auc-network", + "Chrome OS Australian English 3", + "Android Speech Recognition and Synthesis from Google en-au-x-auc-local" + ], + "nativeID": [ + "en-au-x-auc-network", + "en-au-x-auc-local" + ], + "language": "en-AU", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Male voice 1 (Australia)", + "name": "Google Australian English 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-au-x-aub-network", + "Chrome OS Australian English 2", + "Android Speech Recognition and Synthesis from Google en-au-x-aub-local" + ], + "nativeID": [ + "en-au-x-aub-network", + "en-au-x-aub-local" + ], + "language": "en-AU", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Male voice 2 (Australia)", + "name": "Google Australian English 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-au-x-aud-network", + "Chrome OS Australian English 4", + "Android Speech Recognition and Synthesis from Google en-au-x-aud-local" + ], + "nativeID": [ + "en-au-x-aud-network", + "en-au-x-aud-local" + ], + "language": "en-AU", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Male voice 3 (Australia)", + "name": "Chrome OS Australian English 5", + "language": "en-AU", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 1 (India)", + "name": "Android Speech Recognition and Synthesis from Google en-in-x-ena-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-in-x-ena-local", + "Android Speech Recognition and Synthesis from Google en-IN-language" + ], + "nativeID": [ + "en-in-x-ena-network", + "en-in-x-ena-local" + ], + "language": "en-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Female voice 2 (India)", + "name": "Android Speech Recognition and Synthesis from Google en-in-x-enc-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-in-x-enc-local" + ], + "nativeID": [ + "en-in-x-enc-network", + "en-in-x-enc-local" + ], + "language": "en-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Male voice 1 (India)", + "name": "Android Speech Recognition and Synthesis from Google en-in-x-end-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-in-x-end-local" + ], + "nativeID": [ + "en-in-x-end-network", + "en-in-x-end-local" + ], + "language": "en-IN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Male voice 2 (India)", + "name": "Android Speech Recognition and Synthesis from Google en-in-x-ene-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google en-in-x-ene-local" + ], + "nativeID": [ + "en-in-x-ene-network", + "en-in-x-ene-local" + ], + "language": "en-IN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} diff --git a/json/es.json b/json/es.json new file mode 100644 index 0000000..71bd485 --- /dev/null +++ b/json/es.json @@ -0,0 +1,1233 @@ +{ + "language": "es", + "defaultRegion": "es-ES", + "testUtterance": "Hola, mi nombre es {name} y soy una voz española.", + "voices": [ + { + "label": "Elvira", + "name": "Microsoft Elvira Online (Natural) - Spanish (Spain)", + "language": "es-ES", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Alvaro", + "name": "Microsoft Alvaro Online (Natural) - Spanish (Spain)", + "language": "es-ES", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Dalia", + "name": "Microsoft Dalia Online (Natural) - Spanish (Mexico)", + "language": "es-MX", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Microsoft Jorge", + "name": "Microsoft Jorge Online (Natural) - Spanish (Mexico)", + "language": "es-MX", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Elena", + "name": "Microsoft Elena Online (Natural) - Spanish (Argentina)", + "language": "es-AR", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Tomas", + "name": "Microsoft Tomas Online (Natural) - Spanish (Argentina)", + "language": "es-AR", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Sofia", + "name": "Microsoft Sofia Online (Natural) - Spanish (Bolivia)", + "language": "es-BO", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Marcelo", + "name": "Microsoft Marcelo Online (Natural) - Spanish (Bolivia)", + "language": "es-BO", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Catalina", + "name": "Microsoft Catalina Online (Natural) - Spanish (Chile)", + "language": "es-CL", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Lorenzo", + "name": "Microsoft Lorenzo Online (Natural) - Spanish (Chile)", + "language": "es-CL", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ximena", + "name": "Microsoft Ximena Online (Natural) - Spanish (Colombia)", + "language": "es-CO", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Salome", + "name": "Microsoft Salome Online (Natural) - Spanish (Colombia)", + "language": "es-CO", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Gonzalo", + "name": "Microsoft Gonzalo Online (Natural) - Spanish (Colombia)", + "language": "es-CO", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Maria", + "name": "Microsoft Maria Online (Natural) - Spanish (Costa Rica)", + "language": "es-CR", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Juan", + "name": "Microsoft Juan Online (Natural) - Spanish (Costa Rica)", + "language": "es-CR", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Belkys", + "name": "Microsoft Belkys Online (Natural) - Spanish (Cuba)", + "language": "es-CU", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Manuel", + "name": "Microsoft Manuel Online (Natural) - Spanish (Cuba)", + "language": "es-CU", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Andrea", + "name": "Microsoft Andrea Online (Natural) - Spanish (Ecuador)", + "language": "es-EC", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Luis", + "name": "Microsoft Luis Online (Natural) - Spanish (Ecuador)", + "language": "es-EC", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Lorena", + "name": "Microsoft Lorena Online (Natural) - Spanish (El Salvador)", + "language": "es-SV", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Rodrigo", + "name": "Microsoft Rodrigo Online (Natural) - Spanish (El Salvador)", + "language": "es-SV", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Paloma", + "name": "Microsoft Paloma Online (Natural) - Spanish (United States)", + "language": "es-US", + "otherLanguages": [ + "en" + ], + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Alonso", + "name": "Microsoft Alonso Online (Natural) - Spanish (United States)", + "language": "es-US", + "otherLanguages": [ + "en" + ], + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Marta", + "name": "Microsoft Marta Online (Natural) - Spanish (Guatemala)", + "language": "es-GT", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Andres", + "name": "Microsoft Andres Online (Natural) - Spanish (Guatemala)", + "language": "es-GT", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Teresa", + "name": "Microsoft Teresa Online (Natural) - Spanish (Equatorial Guinea)", + "language": "es-GQ", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Javier", + "name": "Microsoft Javier Online (Natural) - Spanish (Equatorial Guinea)", + "language": "es-GQ", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Karla", + "name": "Microsoft Karla Online (Natural) - Spanish (Honduras)", + "language": "es-HN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Carlos", + "name": "Microsoft Carlos Online (Natural) - Spanish (Honduras)", + "language": "es-HN", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Yolanda", + "name": "Microsoft Yolanda Online (Natural) - Spanish (Nicaragua)", + "language": "es-NI", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Federico", + "name": "Microsoft Federico Online (Natural) - Spanish (Nicaragua)", + "language": "es-NI", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Margarita", + "name": "Microsoft Margarita Online (Natural) - Spanish (Panama)", + "language": "es-PA", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Roberto", + "name": "Microsoft Roberto Online (Natural) - Spanish (Panama)", + "language": "es-PA", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Tania", + "name": "Microsoft Tania Online (Natural) - Spanish (Paraguay)", + "language": "es-PY", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Mario", + "name": "Microsoft Mario Online (Natural) - Spanish (Paraguay)", + "language": "es-PY", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Camila", + "name": "Microsoft Camila Online (Natural) - Spanish (Peru)", + "language": "es-PE", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Alex", + "name": "Microsoft Alex Online (Natural) - Spanish (Peru)", + "language": "es-PE", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Karina", + "name": "Microsoft Karina Online (Natural) - Spanish (Puerto Rico)", + "language": "es-PR", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Victor", + "name": "Microsoft Victor Online (Natural) - Spanish (Puerto Rico)", + "language": "es-PR", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ramona", + "name": "Microsoft Ramona Online (Natural) - Spanish (Dominican Republic)", + "language": "es-DO", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Emilio", + "name": "Microsoft Emilio Online (Natural) - Spanish (Dominican Republic)", + "language": "es-DO", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Valentina", + "name": "Microsoft Valentina Online (Natural) - Spanish (Uruguay)", + "language": "es-UY", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Mateo", + "name": "Microsoft Mateo Online (Natural) - Spanish (Uruguay)", + "language": "es-UY", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Paola", + "name": "Microsoft Paola Online (Natural) - Spanish (Venezuela)", + "language": "es-VE", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Sebastian", + "name": "Microsoft Sebastian Online (Natural) - Spanish (Venezuela)", + "language": "es-VE", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Marisol", + "name": "Marisol", + "localizedName": "apple", + "language": "es-ES", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Mónica", + "name": "Mónica", + "localizedName": "apple", + "language": "es-ES", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Apple Jorge", + "name": "Jorge", + "localizedName": "apple", + "language": "es-ES", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Angelica", + "name": "Angelica", + "localizedName": "apple", + "language": "es-MX", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Paulina", + "name": "Paulina", + "localizedName": "apple", + "language": "es-MX", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Juan", + "name": "Juan", + "localizedName": "apple", + "language": "es-MX", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Isabela", + "name": "Isabela", + "localizedName": "apple", + "language": "es-AR", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Diego", + "name": "Diego", + "localizedName": "apple", + "language": "es-AR", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Francisca", + "name": "Francisca", + "localizedName": "apple", + "language": "es-CL", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Soledad", + "name": "Soledad", + "localizedName": "apple", + "language": "es-CO", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Jimena", + "name": "Jimena", + "localizedName": "apple", + "language": "es-CO", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Carlos", + "name": "Carlos", + "localizedName": "apple", + "language": "es-CO", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Voz Google masculina (España)", + "name": "Google español", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "es-ES", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Voz Google femenina (Estados Unidos)", + "name": "Google español de Estados Unidos", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "es-US", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Helena", + "name": "Microsoft Helena - Spanish (Spain)", + "language": "es-ES", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Laura", + "name": "Microsoft Laura - Spanish (Spain)", + "language": "es-ES", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Pablo", + "name": "Microsoft Pablo - Spanish (Spain)", + "language": "es-ES", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Sabina", + "name": "Microsoft Sabina - Spanish (Mexico)", + "language": "es-MX", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Raul", + "name": "Microsoft Raul - Spanish (Mexico)", + "language": "es-MX", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Voz femenina 1 (España)", + "name": "Google español 4 (Natural)", + "altNames": [ + "Chrome OS español 4", + "Android Speech Recognition and Synthesis from Google es-es-x-eee-local", + "Android Speech Recognition and Synthesis from Google es-ES-language" + ], + "nativeID": [ + "es-es-x-eee-local" + ], + "language": "es-ES", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz femenina 2 (España)", + "name": "Google español 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google es-es-x-eea-network", + "Chrome OS español 1", + "Android Speech Recognition and Synthesis from Google es-es-x-eea-local" + ], + "nativeID": [ + "es-es-x-eea-network", + "es-es-x-eea-local" + ], + "language": "es-ES", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz femenina 3 (España)", + "name": "Google español 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google es-es-x-eec-network", + "Chrome OS español 2", + "Android Speech Recognition and Synthesis from Google es-es-x-eec-local" + ], + "nativeID": [ + "es-es-x-eec-network", + "es-es-x-eec-local" + ], + "language": "es-ES", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz masculina 1 (España)", + "name": "Google español 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google es-es-x-eed-network", + "Chrome OS español 3", + "Android Speech Recognition and Synthesis from Google es-es-x-eed-local" + ], + "nativeID": [ + "es-es-x-eed-network", + "es-es-x-eed-local" + ], + "language": "es-ES", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz masculina 2 (España)", + "name": "Google español 5 (Natural)", + "altNames": [ + "Chrome OS español 5", + "Android Speech Recognition and Synthesis from Google es-es-x-eef-local" + ], + "nativeID": [ + "es-es-x-eef-local" + ], + "language": "es-ES", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz femenina 1 (Estados Unidos)", + "name": "Google español de Estados Unidos 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google es-us-x-esc-network", + "Chrome OS español de Estados Unidos", + "Android Speech Recognition and Synthesis from Google es-us-x-esc-local", + "Android Speech Recognition and Synthesis from Google es-US-language" + ], + "nativeID": [ + "es-us-x-esc-network", + "es-us-x-esc-local" + ], + "language": "es-US", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz femenina 2 (Estados Unidos)", + "name": "Google español de Estados Unidos 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google es-us-x-sfb-network", + "Android Speech Recognition and Synthesis from Google es-us-x-sfb-local" + ], + "nativeID": [ + "es-us-x-sfb-network", + "es-us-x-sfb-local" + ], + "language": "es-US", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz masculina 1 (Estados Unidos)", + "name": "Google español de Estados Unidos 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google es-us-x-esd-network", + "Android Speech Recognition and Synthesis from Google es-us-x-esd-local" + ], + "nativeID": [ + "es-us-x-esd-network", + "es-us-x-esd-local" + ], + "language": "es-US", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz masculina 2 (Estados Unidos)", + "name": "Google español de Estados Unidos 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google es-us-x-esf-network", + "Android Speech Recognition and Synthesis from Google es-us-x-esf-local" + ], + "nativeID": [ + "es-us-x-esf-network", + "es-us-x-esf-local" + ], + "language": "es-US", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/eu.json b/json/eu.json new file mode 100644 index 0000000..c51d67c --- /dev/null +++ b/json/eu.json @@ -0,0 +1,25 @@ +{ + "language": "eu", + "defaultRegion": "eu-ES", + "testUtterance": "Kaixo, nire izena {name} da eta euskal ahotsa naiz.", + "voices": [ + { + "label": "Miren", + "name": "Miren", + "localizedName": "apple", + "language": "eu-ES", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + } + ] +} \ No newline at end of file diff --git a/json/fa.json b/json/fa.json new file mode 100644 index 0000000..c088bb9 --- /dev/null +++ b/json/fa.json @@ -0,0 +1,56 @@ +{ + "language": "fa", + "defaultRegion": "fa-IR", + "testUtterance": "سلام اسم من {name} و صدای فارسی هستم", + "voices": [ + { + "label": "Dilara", + "name": "Microsoft Dilara Online (Natural) - Persian (Iran)", + "language": "fa-IR", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Farid", + "name": "Microsoft Farid Online (Natural) - Persian (Iran)", + "language": "fa-IR", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Dariush", + "name": "Dariush", + "localizedName": "apple", + "language": "fa-IR", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/fi.json b/json/fi.json new file mode 100644 index 0000000..2fcca35 --- /dev/null +++ b/json/fi.json @@ -0,0 +1,115 @@ +{ + "language": "fi", + "defaultRegion": "fi-FI", + "testUtterance": "Hei, nimeni on {name} ja olen suomalainen ääni.", + "voices": [ + { + "label": "Noora", + "name": "Microsoft Noora Online (Natural) - Finnish (Finland)", + "language": "fi-FI", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Harri", + "name": "Microsoft Harri Online (Natural) - Finnish (Finland)", + "language": "fi-FI", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Satu", + "name": "Satu", + "localizedName": "apple", + "language": "fi-FI", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Onni", + "name": "Onni", + "localizedName": "apple", + "language": "fi-FI", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Heidi", + "name": "Microsoft Heidi - Finnish (Finland)", + "language": "fi-FI", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Suomalainen naisääni", + "name": "Google Suomi (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google fi-fi-x-afi-network", + "Chrome OS Suomi", + "Android Speech Recognition and Synthesis from Google fi-fi-x-afi-local", + "Android Speech Recognition and Synthesis from Google fi-FI-language" + ], + "nativeID": [ + "fi-fi-x-afi-network", + "fi-fi-x-afi-local" + ], + "language": "fi-FI", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/filters/novelty.json b/json/filters/novelty.json new file mode 100644 index 0000000..b57a6f0 --- /dev/null +++ b/json/filters/novelty.json @@ -0,0 +1,245 @@ +{ + "voices": [ + { + "name": "Albert", + "nativeID": [ + "com.apple.speech.synthesis.voice.Albert" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Bad News", + "nativeID": [ + "com.apple.speech.synthesis.voice.BadNews" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "altNames": [ + "Mauvaises nouvelles", + "Malas noticias", + "Brutte notizie" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Bahh", + "nativeID": [ + "com.apple.speech.synthesis.voice.Bahh" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Bells", + "nativeID": [ + "com.apple.speech.synthesis.voice.Bells" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "altNames": [ + "Cloches", + "Campanas", + "Campane" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Boing", + "nativeID": [ + "com.apple.speech.synthesis.voice.Boing" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Bubbles", + "nativeID": [ + "com.apple.speech.synthesis.voice.Bubbles" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "altNames": [ + "Bulles", + "Burbujas", + "Bollicine" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Cellos", + "nativeID": [ + "com.apple.speech.synthesis.voice.Cellos" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "altNames": [ + "Violoncelles", + "Violonchelos", + "Violoncelli" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Good News", + "nativeID": [ + "com.apple.speech.synthesis.voice.GoodNews" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "altNames": [ + "Bonnes nouvelles", + "Buenas noticias", + "Buone notizie" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Jester", + "nativeID": [ + "com.apple.speech.synthesis.voice.Hysterical" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "altNames": [ + "Bouffon", + "Bufón", + "Giullare" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Organ", + "nativeID": [ + "com.apple.speech.synthesis.voice.Organ" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "altNames": [ + "Orgue", + "Órgano", + "Organo" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Superstar", + "nativeID": [ + "com.apple.speech.synthesis.voice.Princess" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "altNames": [ + "Superestrella" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Trinoids", + "nativeID": [ + "com.apple.speech.synthesis.voice.Trinoids" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "altNames": [ + "Trinoïdes" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Whisper", + "nativeID": [ + "com.apple.speech.synthesis.voice.Whisper" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "altNames": [ + "Murmure", + "Susurro", + "Sussurro" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Wobble", + "nativeID": [ + "com.apple.speech.synthesis.voice.Deranged" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Zarvox", + "nativeID": [ + "com.apple.speech.synthesis.voice.Zarvox" + ], + "note": "This novelty voice is part of a pack preloaded by Apple.", + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/filters/veryLowQuality.json b/json/filters/veryLowQuality.json new file mode 100644 index 0000000..4a0c3af --- /dev/null +++ b/json/filters/veryLowQuality.json @@ -0,0 +1,629 @@ +{ + "voices": [ + { + "name": "Eddy", + "localizedName": "apple", + "note": "Eloquence voices are preloaded by default on Apple devices.", + "language": "en-US", + "otherLanguages": [ + "en-GB", + "de-DE", + "fr-FR", + "fr-CA", + "es-ES", + "es-MX", + "fi-FI", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "zh-CN", + "zh-HK" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Flo", + "localizedName": "apple", + "note": "Eloquence voices are preloaded by default on Apple devices.", + "language": "en-US", + "otherLanguages": [ + "en-GB", + "de-DE", + "fr-FR", + "fr-CA", + "es-ES", + "es-MX", + "fi-FI", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "zh-CN", + "zh-HK" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Grandma", + "localizedName": "apple", + "note": "Eloquence voices are preloaded by default on Apple devices.", + "language": "en-US", + "otherLanguages": [ + "en-GB", + "de-DE", + "fr-FR", + "fr-CA", + "es-ES", + "es-MX", + "fi-FI", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "zh-CN", + "zh-HK" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Grandpa", + "localizedName": "apple", + "note": "Eloquence voices are preloaded by default on Apple devices.", + "language": "en-US", + "otherLanguages": [ + "en-GB", + "de-DE", + "fr-FR", + "fr-CA", + "es-ES", + "es-MX", + "fi-FI", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "zh-CN", + "zh-HK" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Jacques", + "localizedName": "apple", + "note": "Eloquence voices are preloaded by default on Apple devices.", + "language": "en-US", + "otherLanguages": [ + "en-GB", + "de-DE", + "fr-FR", + "fr-CA", + "es-ES", + "es-MX", + "fi-FI", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "zh-CN", + "zh-HK" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Reed", + "localizedName": "apple", + "note": "Eloquence voices are preloaded by default on Apple devices.", + "language": "en-US", + "otherLanguages": [ + "en-GB", + "de-DE", + "fr-FR", + "fr-CA", + "es-ES", + "es-MX", + "fi-FI", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "zh-CN", + "zh-HK" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Rocko", + "localizedName": "apple", + "note": "Eloquence voices are preloaded by default on Apple devices.", + "language": "en-US", + "otherLanguages": [ + "en-GB", + "de-DE", + "fr-FR", + "fr-CA", + "es-ES", + "es-MX", + "fi-FI", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "zh-CN", + "zh-HK" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Sandy", + "localizedName": "apple", + "note": "Eloquence voices are preloaded by default on Apple devices.", + "language": "en-US", + "otherLanguages": [ + "en-GB", + "de-DE", + "fr-FR", + "fr-CA", + "es-ES", + "es-MX", + "fi-FI", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "zh-CN", + "zh-HK" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Shelley", + "localizedName": "apple", + "note": "Eloquence voices are preloaded by default on Apple devices.", + "language": "en-US", + "otherLanguages": [ + "en-GB", + "de-DE", + "fr-FR", + "fr-CA", + "es-ES", + "es-MX", + "fi-FI", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "zh-CN", + "zh-HK" + ], + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Fred", + "language": "en-US", + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Junior", + "language": "en-US", + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Kathy", + "language": "en-US", + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "Ralph", + "language": "en-US", + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Arabic", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "ar", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Bulgarian", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "bg", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Bengali", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "bn", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Catalan", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "ca", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Chinese (Mandarin, latin as English)", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "cmn", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Czech", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "cs", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Danish", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "da", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak German", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "de", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Greek", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "el", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Spanish (Spain)", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "es", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Estonian", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "et", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Finnish", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "fi", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Gujarati", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "gu", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Croatian", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "hr", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Hungarian", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "hu", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Indonesian", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "id", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Italian", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "it", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Kannada", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "kn", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Korean", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "ko", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Lithuanian", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "lt", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Latvian", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "lv", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Malayalm", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "ml", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Marathi", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "mr", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Malay", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "ms", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Norwegian Bokmål", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "nb", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Polish", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "pl", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Portuguese (Brazil)", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "pt-br", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Romanian", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "ro", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Russian", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "ru", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Slovak", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "sk", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Slovenian", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "sl", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Serbian", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "sv", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Swedish", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "sv", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Swahili", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "sw", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Tamil", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "ta", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Telugu", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "te", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Turkish", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "tr", + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "name": "eSpeak Vietnamese (Northern)", + "note": "eSpeak voices are preloaded by default on Chrome OS.", + "language": "vi", + "os": [ + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/fr.json b/json/fr.json new file mode 100644 index 0000000..763811a --- /dev/null +++ b/json/fr.json @@ -0,0 +1,684 @@ +{ + "language": "fr", + "defaultRegion": "fr-FR", + "testUtterance": "Bonjour, mon nom est {name} et je suis une voix française.", + "voices": [ + { + "label": "Vivienne", + "name": "Microsoft VivienneMultilingual Online (Natural) - French (France)", + "language": "fr-FR", + "multiLingual": true, + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Denise", + "name": "Microsoft Denise Online (Natural) - French (France)", + "language": "fr-FR", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Charline", + "name": "Microsoft Charline Online (Natural) - French (Belgium)", + "language": "fr-BE", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ariane", + "name": "Microsoft Ariane Online (Natural) - French (Switzerland)", + "language": "fr-CH", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Eloise", + "name": "Microsoft Eloise Online (Natural) - French (France)", + "language": "fr-FR", + "gender": "female", + "children": true, + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Remy", + "name": "Microsoft RemyMultilingual Online (Natural) - French (France)", + "language": "fr-FR", + "multiLingual": true, + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Henri", + "name": "Microsoft Henri Online (Natural) - French (France)", + "language": "fr-FR", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Gerard", + "name": "Microsoft Gerard Online (Natural) - French (Belgium)", + "language": "fr-BE", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Fabrice", + "name": "Microsoft Fabrice Online (Natural) - French (Switzerland)", + "language": "fr-CH", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Sylvie", + "name": "Microsoft Sylvie Online (Natural) - French (Canada)", + "language": "fr-CA", + "otherLanguages": [ + "en" + ], + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Antoine", + "name": "Microsoft Antoine Online (Natural) - French (Canada)", + "language": "fr-CA", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Jean", + "name": "Microsoft Jean Online (Natural) - French (Canada)", + "language": "fr-CA", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Thierry", + "name": "Microsoft Thierry Online (Natural) - French (Canada)", + "language": "fr-CA", + "otherLanguages": [ + "en" + ], + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Audrey", + "name": "Audrey", + "localizedName": "apple", + "language": "fr-FR", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Aurélie", + "name": "Aurélie", + "localizedName": "apple", + "language": "fr-FR", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 0.9, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Marie", + "name": "Marie", + "localizedName": "apple", + "note": "This is a compact version of a preloaded Siri voice on macOS.", + "language": "fr-FR", + "gender": "female", + "quality": [ + "low" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Thomas", + "name": "Thomas", + "localizedName": "apple", + "language": "fr-FR", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Aude", + "name": "Aude", + "localizedName": "apple", + "language": "fr-BE", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Chantal", + "name": "Chantal", + "localizedName": "apple", + "language": "fr-CA", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Amélie", + "name": "Amélie", + "localizedName": "apple", + "language": "fr-CA", + "gender": "female", + "quality": [ + "low", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Nicolas", + "name": "Nicolas", + "localizedName": "apple", + "language": "fr-CA", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Voix Google féminine (France)", + "name": "Google français", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "fr-FR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Julie", + "name": "Microsoft Julie - French (France)", + "language": "fr-FR", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Hortence", + "name": "Microsoft Hortence - French (France)", + "language": "fr-FR", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Paul", + "name": "Microsoft Paul - French (France)", + "language": "fr-FR", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Caroline", + "name": "Microsoft Caroline - French (Canada)", + "language": "fr-CA", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Claude", + "name": "Microsoft Claude - French (Canada)", + "language": "fr-CA", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Guillaume", + "name": "Microsoft Claude - French (Switzerland)", + "language": "fr-CH", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Voix féminine 1 (France)", + "name": "Google français 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google fr-fr-x-frc-network", + "Chrome OS français 4", + "Android Speech Recognition and Synthesis from Google fr-fr-x-frc-local", + "Android Speech Recognition and Synthesis from Google fr-FR-language" + ], + "nativeID": [ + "fr-fr-x-frc-network", + "fr-fr-x-frc-local" + ], + "language": "fr-FR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voix féminine 2 (France)", + "name": "Google français 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google fr-fr-x-fra-network", + "Chrome OS français 2", + "Android Speech Recognition and Synthesis from Google fr-fr-x-fra-local" + ], + "nativeID": [ + "fr-fr-x-fra-network", + "fr-fr-x-fra-local" + ], + "language": "fr-FR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voix féminine 3 (France)", + "name": "Google français 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google fr-fr-x-vlf-network", + "Chrome OS français 1", + "Android Speech Recognition and Synthesis from Google fr-fr-x-vlf-local" + ], + "nativeID": [ + "fr-fr-x-vlf-network", + "fr-fr-x-vlf-local" + ], + "language": "fr-FR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voix masculine 1 (France)", + "name": "Google français 5 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google fr-fr-x-frd-network", + "Chrome OS français 5", + "Android Speech Recognition and Synthesis from Google fr-fr-x-frd-local" + ], + "nativeID": [ + "fr-fr-x-frd-network", + "fr-fr-x-frd-local" + ], + "language": "fr-FR", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voix masculine 2 (France)", + "name": "Google français 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google fr-fr-x-frb-network", + "Chrome OS français 3", + "Android Speech Recognition and Synthesis from Google fr-fr-x-frb-local" + ], + "nativeID": [ + "fr-fr-x-frb-network", + "fr-fr-x-frb-local" + ], + "language": "fr-FR", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voix féminine 1 (Canada)", + "name": "Android Speech Recognition and Synthesis from Google fr-ca-x-caa-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google fr-ca-x-caa-local", + "Android Speech Recognition and Synthesis from Google fr-CA-language" + ], + "nativeID": [ + "fr-ca-x-caa-network", + "fr-ca-x-caa-local" + ], + "language": "fr-CA", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voix féminine 2 (Canada)", + "name": "Android Speech Recognition and Synthesis from Google fr-ca-x-cac-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google fr-ca-x-cac-local" + ], + "nativeID": [ + "fr-ca-x-cac-network", + "fr-ca-x-cac-local" + ], + "language": "fr-CA", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voix masculine 1 (Canada)", + "name": "Android Speech Recognition and Synthesis from Google fr-ca-x-cab-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google fr-ca-x-cab-local" + ], + "nativeID": [ + "fr-ca-x-cab-network", + "fr-ca-x-cab-local" + ], + "language": "fr-CA", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voix masculine 2 (Canada)", + "name": "Android Speech Recognition and Synthesis from Google fr-ca-x-cad-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google fr-ca-x-cad-local" + ], + "nativeID": [ + "fr-ca-x-cad-network", + "fr-ca-x-cad-local" + ], + "language": "fr-CA", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/gl.json b/json/gl.json new file mode 100644 index 0000000..605d5ff --- /dev/null +++ b/json/gl.json @@ -0,0 +1,55 @@ +{ + "language": "gl", + "defaultRegion": "gl-ES", + "testUtterance": "Ola, chámome {name} e son unha voz galega.", + "voices": [ + { + "label": "Sabela", + "name": "Microsoft Sabela Online (Natural) - Galician", + "language": "gl-ES", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Roi", + "name": "Microsoft Roi Online (Natural) - Galician", + "language": "gl-ES", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Carmela", + "name": "Carmela", + "localizedName": "apple", + "language": "gl-ES", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + } + ] +} \ No newline at end of file diff --git a/json/he.json b/json/he.json new file mode 100644 index 0000000..11b5ff3 --- /dev/null +++ b/json/he.json @@ -0,0 +1,172 @@ +{ + "language": "he", + "defaultRegion": "he-IL", + "testUtterance": "שלום, שמי {name} ואני קול עברי.", + "voices": [ + { + "label": "Hila", + "name": "Microsoft Hila Online (Natural) - Hebrew (Israel)", + "language": "he-IL", + "altLanguage": "iw-IL", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Avri", + "name": "Microsoft Avri Online (Natural) - Hebrew (Israel)", + "language": "he-IL", + "altLanguage": "iw-IL", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Carmit", + "name": "Carmit", + "localizedName": "apple", + "language": "he-IL", + "altLanguage": "iw-IL", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Asaf", + "name": "Microsoft Asaf - Hebrew (Israel)", + "language": "he-IL", + "altLanguage": "iw-IL", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "קול גברי 1", + "name": "Android Speech Recognition and Synthesis from Google he-il-x-heb-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google he-il-x-heb-local", + "Android Speech Recognition and Synthesis from Google he-IL-language" + ], + "nativeID": [ + "he-il-x-heb-network", + "he-il-x-heb-local" + ], + "language": "he-IL", + "altLanguage": "iw-IL", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "קול נשי 1", + "name": "Android Speech Recognition and Synthesis from Google he-il-x-hec-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google he-il-x-hec-local" + ], + "nativeID": [ + "he-il-x-hec-network", + "he-il-x-hec-local" + ], + "language": "he-IL", + "altLanguage": "iw-IL", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "קול גברי 2", + "name": "Android Speech Recognition and Synthesis from Google he-il-x-hed-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google he-il-x-hed-local" + ], + "nativeID": [ + "he-il-x-hed-network", + "he-il-x-hed-local" + ], + "language": "he-IL", + "altLanguage": "iw-IL", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "קול נשי 2", + "name": "Android Speech Recognition and Synthesis from Google he-il-x-hee-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google he-il-x-hee-local" + ], + "nativeID": [ + "he-il-x-hee-network", + "he-il-x-hee-local" + ], + "language": "he-IL", + "altLanguage": "iw-IL", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} diff --git a/json/hi.json b/json/hi.json new file mode 100644 index 0000000..6c9e369 --- /dev/null +++ b/json/hi.json @@ -0,0 +1,255 @@ +{ + "language": "hi", + "defaultRegion": "hi-IN", + "testUtterance": "नमस्कार, मेरा नाम {name} है और मैं एक हिंदी आवाज़ हूँ।", + "voices": [ + { + "label": "Swara", + "name": "Microsoft Swara Online (Natural) - Hindi (India)", + "language": "hi-IN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Madhur", + "name": "Microsoft Madhur Online (Natural) - Hindi (India)", + "language": "hi-IN", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Kiyara", + "name": "Kiyara", + "localizedName": "apple", + "language": "hi-IN", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Lekha", + "name": "Lekha", + "localizedName": "apple", + "language": "hi-IN", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Neel", + "name": "Neel", + "localizedName": "apple", + "language": "hi-IN", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "महिला Google आवाज़", + "name": "Google हिन्दी", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "hi-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Kalpana", + "name": "Microsoft Kalpana - Hindi (India)", + "language": "hi-IN", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Hemant", + "name": "Microsoft Hemant - Hindi (India)", + "language": "hi-IN", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "महिला आवाज़ 1", + "name": "Google हिन्दी 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google hi-in-x-hia-network", + "Chrome OS हिन्दी 2", + "Android Speech Recognition and Synthesis from Google hi-in-x-hia-local", + "Android Speech Recognition and Synthesis from Google hi-IN-language" + ], + "nativeID": [ + "hi-in-x-hia-network", + "hi-in-x-hia-local" + ], + "language": "hi-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "महिला आवाज़ 2", + "name": "Google हिन्दी 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google hi-in-x-hic-network", + "Chrome OS हिन्दी 3", + "Android Speech Recognition and Synthesis from Google hi-in-x-hic-local" + ], + "nativeID": [ + "hi-in-x-hic-network", + "hi-in-x-hic-local" + ], + "language": "hi-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "महिला आवाज़ 3", + "name": "Chrome OS हिन्दी 1", + "language": "hi-IN", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "पुरुष आवाज 1", + "name": "Google हिन्दी 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google hi-in-x-hid-network", + "Chrome OS हिन्दी 4", + "Android Speech Recognition and Synthesis from Google hi-in-x-hid-local" + ], + "nativeID": [ + "hi-in-x-hid-network", + "hi-in-x-hid-local" + ], + "language": "hi-IN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "पुरुष आवाज 2", + "name": "Google हिन्दी 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google hi-in-x-hie-network", + "Chrome OS हिन्दी 5", + "Android Speech Recognition and Synthesis from Google hi-in-x-hie-local" + ], + "nativeID": [ + "hi-in-x-hie-network", + "hi-in-x-hie-local" + ], + "language": "hi-IN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/hr.json b/json/hr.json new file mode 100644 index 0000000..d4a2091 --- /dev/null +++ b/json/hr.json @@ -0,0 +1,122 @@ +{ + "language": "hr", + "defaultRegion": "hr-HR", + "testUtterance": "Pozdrav, ja sam {name} i hrvatski sam glas.", + "voices": [ + { + "label": "Gabrijela", + "name": "Microsoft Gabrijela Online (Natural) - Croatian (Croatia)", + "language": "hr-HR", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Srecko", + "name": "Microsoft Srecko Online (Natural) - Croatian (Croatia)", + "language": "hr-HR", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Lana", + "name": "Lana", + "localizedName": "apple", + "altNames": [ + "Lana (poboljšani)", + "Lana (hrvatski (Hrvatska))" + ], + "language": "hr-HR", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Matej", + "name": "Microsoft Matej - Croatian (Croatia)", + "language": "hr-HR", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Ženski glas", + "name": "Android Speech Recognition and Synthesis from Google hr-hr-x-hra-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google hr-hr-x-hra-local" + ], + "nativeID": [ + "hr-hr-x-hra-network", + "hr-hr-x-hra-local" + ], + "language": "hr-HR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Muški glas", + "name": "Android Speech Recognition and Synthesis from Google hr-hr-x-hrb-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google hr-hr-x-hrb-local", + "Android Speech Recognition and Synthesis from Google hr-HR-language" + ], + "nativeID": [ + "hr-hr-x-hrb-network", + "hr-hr-x-hrb-local" + ], + "language": "hr-HR", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/hu.json b/json/hu.json new file mode 100644 index 0000000..5ee6b41 --- /dev/null +++ b/json/hu.json @@ -0,0 +1,98 @@ +{ + "language": "hu", + "defaultRegion": "hu-HU", + "testUtterance": "Helló, a nevem {name} és magyar hangú vagyok.", + "voices": [ + { + "label": "Noemi", + "name": "Microsoft Noemi Online (Natural) - Hungarian (Hungary)", + "language": "hu-HU", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Tamas", + "name": "Microsoft Tamas Online (Natural) - Hungarian (Hungary)", + "language": "hu-HU", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Tünde", + "name": "Tünde", + "localizedName": "apple", + "language": "hu-HU", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Szabolcs", + "name": "Microsoft Szabolcs - Hungarian (Hungary)", + "language": "hu-HU", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Női hang", + "name": "Google Magyar (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google hu-hu-x-kfl-network", + "Chrome OS Magyar", + "Android Speech Recognition and Synthesis from Google hu-hu-x-kfl-local", + "Android Speech Recognition and Synthesis from Google hu-HU-language" + ], + "nativeID": [ + "hu-hu-x-kfl-network", + "hu-hu-x-kfl-local" + ], + "language": "hu-HU", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/id.json b/json/id.json new file mode 100644 index 0000000..23a49eb --- /dev/null +++ b/json/id.json @@ -0,0 +1,188 @@ +{ + "language": "id", + "defaultRegion": "id-ID", + "testUtterance": "Halo, nama saya {name} dan saya suara Indonesia.", + "voices": [ + { + "label": "Gadis", + "name": "Microsoft Gadis Online (Natural) - Indonesian (Indonesia)", + "language": "id-ID", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ardi", + "name": "Microsoft Ardi Online (Natural) - Indonesian (Indonesia)", + "language": "id-ID", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Damayanti", + "name": "Damayanti", + "localizedName": "apple", + "language": "id-ID", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Suara Google wanita", + "name": "Google Bahasa Indonesia", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "id-ID", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Andika", + "name": "Microsoft Andika - Indonesian (Indonesia)", + "language": "id-ID", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Suara wanita 1", + "name": "Google Bahasa Indonesia 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google id-id-x-idc-network", + "Chrome OS Bahasa Indonesia 1", + "Android Speech Recognition and Synthesis from Google id-id-x-idc-local", + "Android Speech Recognition and Synthesis from Google id-ID-language" + ], + "nativeID": [ + "id-id-x-idc-network", + "id-id-x-idc-local" + ], + "language": "id-ID", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Suara wanita 2", + "name": "Google Bahasa Indonesia 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google id-id-x-idd-network", + "Chrome OS Bahasa Indonesia 2", + "Android Speech Recognition and Synthesis from Google id-id-x-idd-local" + ], + "nativeID": [ + "id-id-x-idd-network", + "id-id-x-idd-local" + ], + "language": "id-ID", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Suara laki-laki 1", + "name": "Google Bahasa Indonesia 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google id-id-x-ide-network", + "Chrome OS Bahasa Indonesia 3", + "Android Speech Recognition and Synthesis from Google id-id-x-ide-local" + ], + "nativeID": [ + "id-id-x-ide-network", + "id-id-x-ide-local" + ], + "language": "id-ID", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Suara laki-laki 2", + "name": "Google Bahasa Indonesia 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google id-id-x-dfz-network", + "Chrome OS Bahasa Indonesia 4", + "Android Speech Recognition and Synthesis from Google id-id-x-dfz-local" + ], + "nativeID": [ + "id-id-x-dfz-network", + "id-id-x-dfz-local" + ], + "language": "id-ID", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/it.json b/json/it.json new file mode 100644 index 0000000..8026afd --- /dev/null +++ b/json/it.json @@ -0,0 +1,307 @@ +{ + "language": "it", + "defaultRegion": "it-IT", + "testUtterance": "Ciao, mi chiamo {name} e sono una voce italiana.", + "voices": [ + { + "label": "Elsa (Alta qualita)", + "name": "Microsoft Elsa Online (Natural) - Italian (Italy)", + "language": "it-IT", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Isabella", + "name": "Microsoft Isabella Online (Natural) - Italian (Italy)", + "language": "it-IT", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Giuseppe", + "name": "Microsoft GiuseppeMultilingual Online (Natural) - Italian (Italy)", + "language": "it-IT", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Diego", + "name": "Microsoft Diego Online (Natural) - Italian (Italy)", + "language": "it-IT", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Federica", + "name": "Federica", + "localizedName": "apple", + "language": "it-IT", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Emma", + "name": "Emma", + "localizedName": "apple", + "language": "it-IT", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Alice", + "name": "Alice", + "localizedName": "apple", + "language": "it-IT", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Paola", + "name": "Paola", + "localizedName": "apple", + "language": "it-IT", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Luca", + "name": "Luca", + "localizedName": "apple", + "language": "it-IT", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Voce Google femminile", + "name": "Google italiano", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "it-IT", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Elsa", + "name": "Microsoft Elsa - Italian (Italy)", + "language": "it-IT", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Cosimo", + "name": "Microsoft Cosimo - Italian (Italy)", + "language": "it-IT", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Voce femminile 1", + "name": "Google italiano 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google it-it-x-itb-network", + "Chrome OS italiano 2", + "Android Speech Recognition and Synthesis from Google it-it-x-itb-local", + "Android Speech Recognition and Synthesis from Google it-IT-language" + ], + "nativeID": [ + "it-it-x-itb-network", + "it-it-x-itb-local" + ], + "language": "it-IT", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voce femminile 2", + "name": "Google italiano 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google it-it-x-kda-network", + "Chrome OS italiano 1", + "Android Speech Recognition and Synthesis from Google it-it-x-kda-local" + ], + "nativeID": [ + "it-it-x-kda-network", + "it-it-x-kda-local" + ], + "language": "it-IT", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voce maschile 1", + "name": "Google italiano 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google it-it-x-itc-network", + "Chrome OS italiano 3", + "Android Speech Recognition and Synthesis from Google it-it-x-itc-local" + ], + "nativeID": [ + "it-it-x-itc-network", + "it-it-x-itc-local" + ], + "language": "it-IT", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voce maschile 2", + "name": "Google italiano 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google it-it-x-itd-network", + "Chrome OS italiano 4", + "Android Speech Recognition and Synthesis from Google it-it-x-itd-local" + ], + "nativeID": [ + "it-it-x-itd-network", + "it-it-x-itd-local" + ], + "language": "it-IT", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/ja.json b/json/ja.json new file mode 100644 index 0000000..8ef8ac3 --- /dev/null +++ b/json/ja.json @@ -0,0 +1,271 @@ +{ + "language": "ja", + "defaultRegion": "ja-JP", + "testUtterance": "こんにちは。私の名前は{name}で、日本語の声を担当しています。", + "voices": [ + { + "label": "Nanami", + "name": "Microsoft Nanami Online (Natural) - Japanese (Japan)", + "language": "ja-JP", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Keita", + "name": "Microsoft Keita Online (Natural) - Japanese (Japan)", + "language": "ja-JP", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "O-Ren", + "name": "O-Ren", + "localizedName": "apple", + "language": "ja-JP", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Kyoko", + "name": "Kyoko", + "localizedName": "apple", + "language": "ja-JP", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Otoya", + "name": "Otoya", + "localizedName": "apple", + "language": "ja-JP", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Hattori", + "name": "Hattori", + "localizedName": "apple", + "language": "ja-JP", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Google の女性の声", + "name": "Google 日本語", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "ja-JP", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Ayumi", + "name": "Microsoft Ayumi - Japanese (Japan)", + "language": "ja-JP", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Haruka", + "name": "Microsoft Haruka - Japanese (Japan)", + "language": "ja-JP", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Ichiro", + "name": "Microsoft Ichiro - Japanese (Japan)", + "language": "ja-JP", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "女性の声1", + "name": "Google 日本語 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ja-jp-x-htm-network", + "Chrome OS 日本語 1", + "Android Speech Recognition and Synthesis from Google ja-jp-x-htm-local", + "Android Speech Recognition and Synthesis from Google ja-JP-language" + ], + "nativeID": [ + "ja-jp-x-htm-network", + "ja-jp-x-htm-local" + ], + "language": "ja-JP", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "女性の声2", + "name": "Chrome OS 日本語 2", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ja-jp-x-jab-network", + "Android Speech Recognition and Synthesis from Google ja-jp-x-jab-local" + ], + "nativeID": [ + "ja-jp-x-jab-network", + "ja-jp-x-jab-local" + ], + "language": "ja-JP", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "男性の声1", + "name": "Google 日本語 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ja-jp-x-jac-network", + "Chrome OS 日本語 3", + "Android Speech Recognition and Synthesis from Google ja-jp-x-jac-local" + ], + "nativeID": [ + "ja-jp-x-jac-network", + "ja-jp-x-jac-local" + ], + "language": "ja-JP", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "男性の声2", + "name": "Google 日本語 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ja-jp-x-jad-network", + "Chrome OS 日本語 4", + "Android Speech Recognition and Synthesis from Google ja-jp-x-jad-local" + ], + "nativeID": [ + "ja-jp-x-jad-network", + "ja-jp-x-jad-local" + ], + "language": "ja-JP", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/kn.json b/json/kn.json new file mode 100644 index 0000000..6f25fdf --- /dev/null +++ b/json/kn.json @@ -0,0 +1,103 @@ +{ + "language": "kn", + "defaultRegion": "kn-IN", + "testUtterance": "ಹಲೋ, ನನ್ನ ಹೆಸರು {name} ಮತ್ತು ನಾನು ಕನ್ನಡ ಧ್ವನಿ.", + "voices": [ + { + "label": "Sapna", + "name": "Microsoft Sapna Online (Natural) - Kannada (India)", + "language": "kn-IN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Gagan", + "name": "Microsoft Gagan Online (Natural) - Kannada (India)", + "language": "kn-IN", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Soumya", + "name": "Soumya", + "localizedName": "apple", + "language": "kn-IN", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "ಸ್ತ್ರೀ ಧ್ವನಿ", + "name": "Android Speech Recognition and Synthesis from Google kn-in-x-knf-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google kn-in-x-knf-local", + "Android Speech Recognition and Synthesis from Google kn-IN-language" + ], + "nativeID": [ + "kn-in-x-knf-network", + "kn-in-x-knf-local" + ], + "language": "kn-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "ಪುರುಷ ಧ್ವನಿ", + "name": "Android Speech Recognition and Synthesis from Google kn-in-x-knm-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google kn-in-x-knm-local" + ], + "nativeID": [ + "kn-in-x-knm-network", + "kn-in-x-knm-local" + ], + "language": "kn-IN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} diff --git a/json/ko.json b/json/ko.json new file mode 100644 index 0000000..ffb5e21 --- /dev/null +++ b/json/ko.json @@ -0,0 +1,280 @@ +{ + "language": "ko", + "defaultRegion": "ko-KR", + "testUtterance": "안녕하세요, 저는 {name}이고 한국어 음성입니다.", + "voices": [ + { + "label": "SunHi", + "name": "Microsoft SunHi Online (Natural) - Korean (Korea)", + "language": "ko-KR", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Hyunsu", + "name": "Microsoft HyunsuMultilingual Online (Natural) - Korean (Korea)", + "altNames": [ + "Microsoft Hyunsu Online (Natural) - Korean (Korea)" + ], + "language": "ko-KR", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "InJoon", + "name": "Microsoft InJoon Online (Natural) - Korean (Korea)", + "language": "ko-KR", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Yuna", + "name": "Yuna", + "localizedName": "apple", + "language": "ko-KR", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Jian", + "name": "Jian", + "localizedName": "apple", + "language": "ko-KR", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Suhyun", + "name": "Suhyun", + "localizedName": "apple", + "language": "ko-KR", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Sora", + "name": "Sora", + "localizedName": "apple", + "language": "ko-KR", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Minsu", + "name": "Minsu", + "localizedName": "apple", + "language": "ko-KR", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Google 여성 음성", + "name": "Google 한국의", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "ko-KR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Heami", + "name": "Microsoft Heami - Korean (Korea)", + "language": "ko-KR", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "여성 목소리 1", + "name": "Google 한국어 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ko-kr-x-kob-network", + "Chrome OS 한국어 2", + "Android Speech Recognition and Synthesis from Google ko-kr-x-kob-local", + "Android Speech Recognition and Synthesis from Google ko-KR-language" + ], + "nativeID": [ + "ko-kr-x-kob-network", + "ko-kr-x-kob-local" + ], + "language": "ko-KR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "여성 목소리 2", + "name": "Google 한국어 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ko-kr-x-ism-network", + "Chrome OS 한국어 1", + "Android Speech Recognition and Synthesis from Google ko-kr-x-ism-local" + ], + "nativeID": [ + "ko-kr-x-ism-network", + "ko-kr-x-ism-local" + ], + "language": "ko-KR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "남성 1", + "name": "Google 한국어 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ko-kr-x-koc-network", + "Chrome OS 한국어 3", + "Android Speech Recognition and Synthesis from Google ko-kr-x-koc-local" + ], + "nativeID": [ + "ko-kr-x-koc-network", + "ko-kr-x-koc-local" + ], + "language": "ko-KR", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "남성 2", + "name": "Google 한국어 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ko-kr-x-kod-network", + "Chrome OS 한국어 4", + "Android Speech Recognition and Synthesis from Google ko-kr-x-kod-local" + ], + "nativeID": [ + "ko-kr-x-kod-network", + "ko-kr-x-kod-local" + ], + "language": "ko-KR", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/localizedNames/apple.json b/json/localizedNames/apple.json new file mode 100644 index 0000000..544a262 --- /dev/null +++ b/json/localizedNames/apple.json @@ -0,0 +1,140 @@ +{ + "quality": { + "ar": { + "normal": "محسن", + "high": "استثنائي" + }, + "ca": { + "normal": "millorada", + "high": "prèmium" + }, + "cmn-CN": { + "normal": "优化音质", + "high": "高音质" + }, + "cmn-TW": { + "normal": "增強音質", + "high": "高音質" + }, + "cs": { + "normal": "vylepšená verze", + "high": "prémiový" + }, + "da": { + "normal": "forbedret", + "high": "høj kvalitet" + }, + "de": { + "normal": "erweitert", + "high": "premium" + }, + "el": { + "normal": "βελτιωμένη", + "high": "υψηλής ποιότητας" + }, + "en": { + "normal": "Enhanced", + "high": "Premium" + }, + "es": { + "normal": "mejorada", + "high": "premium" + }, + "fi": { + "normal": "parannettu", + "high": "korkealaatuinen" + }, + "fr": { + "normal": "premium", + "high": "de qualité" + }, + "he": { + "normal": "משופר", + "high": "פרימיום" + }, + "hi": { + "normal": "बेहतर", + "high": "प्रीमियम" + }, + "hr": { + "normal": "poboljšani", + "high": "vrhunski" + }, + "hu": { + "normal": "továbbfejlesztett", + "high": "prémium" + }, + "id": { + "normal": "Ditingkatkan", + "high": "Premium" + }, + "it": { + "normal": "ottimizzata", + "high": "premium" + }, + "ja": { + "normal": "拡張", + "high": "プレミアム" + }, + "ko": { + "normal": "고품질", + "high": "프리미엄" + }, + "ms": { + "normal": "Dipertingkat", + "high": "Premium" + }, + "nb": { + "normal": "forbedret", + "high": "premium" + }, + "nl": { + "normal": "verbeterd", + "high": "premium" + }, + "pl": { + "normal": "rozszerzony", + "high": "premium" + }, + "pt": { + "normal": "melhorada", + "high": "premium" + }, + "ro": { + "normal": "îmbunătățită", + "high": "premium" + }, + "ru": { + "normal": "улучшенный", + "high": "высшее качество" + }, + "sk": { + "normal": "vylepšený", + "high": "prémiový" + }, + "sl": { + "normal": "izboljšano", + "high": "prvovrsten" + }, + "sv": { + "normal": "förbättrad", + "high": "premium" + }, + "th": { + "normal": "คุณภาพสูง", + "high": "คุณภาพสูง" + }, + "tr": { + "normal": "Geliştirilmiş", + "high": "Yüksek Kaliteli" + }, + "uk": { + "normal": "вдосконалений", + "high": "високої якості" + }, + "vi": { + "normal": "Nâng cao", + "high": "Cao cấp" + } + } +} \ No newline at end of file diff --git a/json/localizedNames/full/en.json b/json/localizedNames/full/en.json new file mode 100644 index 0000000..3f49124 --- /dev/null +++ b/json/localizedNames/full/en.json @@ -0,0 +1,147 @@ +{ + "quality": { + "medium": "Enhanced", + "high": "Premium" + }, + "languages": { + "ar": "Arabic", + "as": "Assamese", + "bg": "Bulgarian", + "bho": "Bhojpuri", + "bn": "Bangla", + "brx": "Bodo", + "bs": "Bosnian", + "ca": "Catalan", + "cmn": "Chinese", + "cs": "Czech", + "cy": "Welsh", + "da": "Danish", + "de": "German", + "doi": "Dogri", + "el": "Greek", + "en": "English", + "es": "Spanish", + "et": "Estonian", + "eu": "Basque", + "fa": "Persian", + "fi": "Finnish", + "fil": "Filipino", + "fr": "French", + "gl": "Galician", + "gu": "Gujarati", + "he": "Hebrew", + "hi": "Hindi", + "hr": "Croatian", + "hu": "Hungarian", + "id": "Indonesian", + "is": "Icelandic", + "it": "Italian", + "ja": "Japanese", + "jv": "Javanese", + "km": "khmer", + "kn": "Kannada", + "kok": "Konkani", + "ko": "Korean", + "lt": "Lithuanian", + "lv": "Latvia", + "mai": "Maithili", + "mal": "Malayalam", + "mni": "Manipuri", + "mr": "Marathi", + "ms": "Malay", + "nb": "Norwegian Bokmål", + "ne": "Nepali", + "nl": "Dutch", + "od": "Odia", + "pa": "Punjabi", + "pl": "Polish", + "pt": "Portuguese", + "ro": "Romanian", + "ru": "Russian", + "sa": "Sanskrit", + "sat": "Santali", + "sd": "Sindhi", + "si": "Sinhala", + "sk": "Slovak", + "sl": "Slovenian", + "sq": "Albanese", + "sr": "Serbian", + "su": "Sundanese", + "sv": "Swedish", + "sw": "Swahili", + "ta": "Tamil", + "te": "Telugu", + "th": "Thai", + "tr": "Turkish", + "uk": "Ukrainian", + "ur": "Urdu", + "vi": "Vietnamese", + "wuu": "Shanghainese" + }, + "regions": { + "0001": "World", + "al": "Albania", + "ar": "Argentina", + "at": "Austria", + "au": "Australia", + "ba": "Bosnia & Herzegovina", + "bd": "Bangladesh", + "be": "Belgium", + "bg": "Bulgaria", + "br": "Brazil", + "ca": "Canada", + "ch": "Switzerland", + "cl": "Chile", + "cn": "China Mainland", + "cz": "Czechia", + "co": "Colombia", + "da": "Denmark", + "de": "Germany", + "ee": "Estonia", + "es": "Spain", + "fi": "Finland", + "fr": "French", + "gb": "United Kingdom", + "gr": "Greece", + "hk": "Hong Kong", + "hr": "Croatia", + "hu": "Hungary", + "id": "Indonesia", + "ie": "Ireland", + "il": "Israel", + "in": "India", + "ir": "Iran", + "is": "Iceland", + "it": "Italy", + "jp": "Japan", + "ke": "Kenya", + "kh": "Cambodia", + "kr": "South Korea", + "lk": "Sri Lanka", + "lt": "Lithuania", + "lv": "Latvia", + "mx": "Mexico", + "my": "Malaysia", + "ng": "Nigeria", + "nl": "Netherlands", + "no": "Norway", + "np": "Nepal", + "pk": "Pakistan", + "pl": "Poland", + "pt": "Portugal", + "ro": "Romania", + "rs": "Serbia", + "ru": "Russia", + "se": "Sweden", + "si": "Slovenia", + "sk": "Slovakia", + "th": "Thailand", + "tr": "Türkiye", + "tw": "Taiwan", + "ua": "Ukraine", + "us": "United States", + "vn": "Viet Nam", + "yue": "Cantonese", + "za": "South Africa" + } +} \ No newline at end of file diff --git a/json/localizedNames/full/fr.json b/json/localizedNames/full/fr.json new file mode 100644 index 0000000..3822c05 --- /dev/null +++ b/json/localizedNames/full/fr.json @@ -0,0 +1,147 @@ +{ + "quality": { + "medium": "premium", + "high": "de qualité" + }, + "languages": { + "ar": "arabe", + "as": "assamais", + "bg": "bulgare", + "bho": "bhodjpouri", + "bn": "bengali", + "brx": "bodo", + "bs": "bosniaque", + "ca": "catalan", + "cs": "tchèque", + "cy": "gallois", + "da": "danois", + "de": "allemand", + "doi": "dogri", + "el": "grec", + "en": "anglais", + "es": "espagnol", + "et": "estonien", + "eu": "basque", + "fa": "persan", + "fi": "finnois", + "fil": "philippin", + "fr": "français", + "gl": "galicien", + "gu": "goudjarati", + "he": "hébreu", + "hi": "hindi", + "hr": "croate", + "hu": "hongrois", + "id": "indonésien", + "is": "islandais", + "it": "italien", + "ja": "japonais", + "jv": "javanais", + "km": "khmer", + "kn": "kannada", + "kok": "konkani", + "ko": "coréen", + "lt": "lituanien", + "lv": "letton", + "mai": "maïthili", + "mal": "malayalam", + "mni": "manipuri", + "mr": "marathi", + "ms": "malais", + "nb": "norvégien bokmål", + "ne": "népalais", + "nl": "néerlandais", + "od": "odia", + "pa": "pendjabi", + "pl": "polonais", + "pt": "portugais", + "ro": "roumain", + "ru": "russe", + "sa": "sanskrit", + "sat": "santali", + "sd": "sindhi", + "si": "singhalais", + "sk": "slovaque", + "sl": "slovène", + "sq": "albanais", + "sr": "serbe", + "su": "soudanais", + "sv": "suédois", + "sw": "swahili", + "ta": "tamoul", + "te": "télougou", + "th": "thaï", + "tr": "turc", + "uk": "ukrainien", + "ur": "ourdou", + "vi": "vietnamien", + "wuu": "shanghaïen", + "yue": "cantonais", + "zh": "chinois" + }, + "regions": { + "0001": "Monde", + "al": "Albanie", + "ar": "Argentine", + "at": "Autriche", + "au": "Australie", + "ba": "Bosnie-Herzégovine", + "bd": "Bangladesh", + "be": "Belgique", + "bg": "Bulgarie", + "br": "Brésil", + "ca": "Canada", + "ch": "Suisse", + "cl": "Chili", + "cn": "Chine continentale", + "cz": "Tchéquie", + "co": "Colombie", + "da": "Danemark", + "de": "Allemagne", + "ee": "Estonie", + "es": "Espagne", + "fi": "Finlande", + "fr": "France", + "gb": "Royaume-Uni", + "gr": "Grèce", + "hk": "Hong Kong", + "hr": "Croatie", + "hu": "Hongrie", + "id": "Indonésie", + "ie": "Irlande", + "il": "Israël", + "in": "Inde", + "ir": "Iran", + "is": "Islande", + "it": "Italie", + "jp": "Japon", + "ke": "Kenya", + "kh": "Cambodge", + "kr": "Corée du Sud", + "lk": "Sri Lanka", + "lt": "Lituanie", + "lv": "Lettonie", + "mx": "Mexique", + "my": "Malaisie", + "ng": "Nigéria", + "nl": "Pays-Bas", + "no": "Norvège", + "np": "Népal", + "pk": "Pakistan", + "pl": "Pologne", + "pt": "Portugal", + "ro": "Roumanie", + "rs": "Serbie", + "ru": "Russie", + "se": "Suède", + "si": "Slovénie", + "sk": "Slovaquie", + "th": "Thaïlande", + "tr": "Turquie", + "tw": "Taïwan", + "ua": "Ukraine", + "us": "États-Unis", + "vn": "Viêt Nam", + "za": "Afrique du Sud" + } +} \ No newline at end of file diff --git a/json/mr.json b/json/mr.json new file mode 100644 index 0000000..79ec849 --- /dev/null +++ b/json/mr.json @@ -0,0 +1,79 @@ +{ + "language": "mr", + "defaultRegion": "mr-IN", + "testUtterance": "नमस्कार, माझे नाव {name} आहे आणि मी एक मराठी आवाज आहे.", + "voices": [ + { + "label": "Aarohi", + "name": "Microsoft Aarohi Online (Natural) - Marathi (India)", + "language": "mr-IN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Manohar", + "name": "Microsoft Manohar Online (Natural) - Marathi (India)", + "language": "mr-IN", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ananya", + "name": "Ananya", + "localizedName": "apple", + "language": "mr-IN", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "स्त्री आवाज", + "name": "Android Speech Recognition and Synthesis from Google mr-in-x-mrf-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google mr-in-x-mrf-local", + "Android Speech Recognition and Synthesis from Google mr-IN-language" + ], + "nativeID": [ + "mr-in-x-mrf-network", + "mr-in-x-mrf-local" + ], + "language": "mr-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/ms.json b/json/ms.json new file mode 100644 index 0000000..b28a424 --- /dev/null +++ b/json/ms.json @@ -0,0 +1,165 @@ +{ + "language": "ms", + "defaultRegion": "ms-MY", + "testUtterance": "Hello, nama saya {name} dan saya suara Melayu.", + "voices": [ + { + "label": "Yasmin", + "name": "Microsoft Yasmin Online (Natural) - Malay (Malaysia)", + "language": "ms-MY", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Osman", + "name": "Microsoft Osman Online (Natural) - Malay (Malaysia)", + "language": "ms-MY", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Amira", + "name": "Amira", + "localizedName": "apple", + "language": "ms-MY", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Rizwan", + "name": "Microsoft Rizwan - Malay (Malaysia)", + "language": "ms-MY", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + + { + "label": "Suara perempuan 1", + "name": "Android Speech Recognition and Synthesis from Google ms-my-x-msc-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ms-my-x-msc-local", + "Android Speech Recognition and Synthesis from Google ms-MY-language" + ], + "nativeID": [ + "ms-my-x-msc-network", + "ms-my-x-msc-local" + ], + "language": "ms-MY", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Suara perempuan 2", + "name": "Android Speech Recognition and Synthesis from Google ms-my-x-mse-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ms-my-x-mse-local" + ], + "nativeID": [ + "ms-my-x-mse-network", + "ms-my-x-mse-local" + ], + "language": "ms-MY", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Suara lelaki 1", + "name": "Android Speech Recognition and Synthesis from Google ms-my-x-msd-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ms-my-x-msd-local" + ], + "nativeID": [ + "ms-my-x-msd-network", + "ms-my-x-msd-local" + ], + "language": "ms-MY", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Suara lelaki 2", + "name": "Android Speech Recognition and Synthesis from Google ms-my-x-msg-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ms-my-x-msg-local" + ], + "nativeID": [ + "ms-my-x-msg-network", + "ms-my-x-msg-local" + ], + "language": "ms-MY", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/nb.json b/json/nb.json new file mode 100644 index 0000000..47f84cc --- /dev/null +++ b/json/nb.json @@ -0,0 +1,215 @@ +{ + "language": "nb", + "defaultRegion": "nb-NO", + "testUtterance": "Hei, jeg heter {name} og er en norsk stemme.", + "voices": [ + { + "label": "Pernille", + "name": "Microsoft Pernille Online (Natural) - Norwegian (Bokmål, Norway)", + "language": "nb-NO", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Finn", + "name": "Microsoft Finn Online (Natural) - Norwegian (Bokmål Norway)", + "language": "nb-NO", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Nora", + "name": "Nora", + "localizedName": "apple", + "language": "nb-NO", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Henrik", + "name": "Henrik", + "localizedName": "apple", + "language": "nb-NO", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Jon", + "name": "Microsoft Jon - Norwegian (Bokmål Norway)", + "language": "nb-NO", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Kvinnestemme 1", + "name": "Google Norsk Bokmål 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google nb-no-x-cfl-network", + "Chrome OS Norsk Bokmål 2", + "Android Speech Recognition and Synthesis from Google nb-no-x-cfl-local", + "Android Speech Recognition and Synthesis from Google nb-NO-language" + ], + "nativeID": [ + "nb-no-x-cfl-network", + "nb-no-x-cfl-local" + ], + "language": "nb-NO", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Kvinnestemme 2", + "name": "Google Norsk Bokmål 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google nb-no-x-rfj-network", + "Chrome OS Norsk Bokmål 1", + "Android Speech Recognition and Synthesis from Google nb-no-x-rfj-local" + ], + "nativeID": [ + "nb-no-x-rfj-network", + "nb-no-x-rfj-local" + ], + "language": "nb-NO", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Kvinnestemme 3", + "name": "Google Norsk Bokmål 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google nb-no-x-tfs-network", + "Chrome OS Norsk Bokmål 4", + "Android Speech Recognition and Synthesis from Google nb-no-x-tfs-local" + ], + "nativeID": [ + "nb-no-x-tfs-network", + "nb-no-x-tfs-local" + ], + "language": "nb-NO", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Mannsstemme 1", + "name": "Google Norsk Bokmål 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google nb-no-x-cmj-network", + "Chrome OS Norsk Bokmål 3", + "Android Speech Recognition and Synthesis from Google nb-no-x-cmj-local" + ], + "nativeID": [ + "nb-no-x-cmj-network", + "nb-no-x-cmj-local" + ], + "language": "nb-NO", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Mannsstemme 2", + "name": "Google Norsk Bokmål 5 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google nb-no-x-tmg-network", + "Chrome OS Norsk Bokmål 5", + "Android Speech Recognition and Synthesis from Google nb-no-x-tmg-local" + ], + "nativeID": [ + "nb-no-x-tmg-network", + "nb-no-x-tmg-local" + ], + "language": "nb-NO", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/nl.json b/json/nl.json new file mode 100644 index 0000000..55792af --- /dev/null +++ b/json/nl.json @@ -0,0 +1,357 @@ +{ + "language": "nl", + "defaultRegion": "nl-NL", + "testUtterance": "Hallo, mijn naam is {name} en ik ben een Nederlandse stem.", + "voices": [ + { + "label": "Colette", + "name": "Microsoft Colette Online (Natural) - Dutch (Netherlands)", + "language": "nl-NL", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Fenna", + "name": "Microsoft Fenna Online (Natural) - Dutch (Netherlands)", + "language": "nl-NL", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Hanna", + "name": "Microsoft Hanna Online - Dutch (Netherlands)", + "language": "nl-NL", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Maarten", + "name": "Microsoft Maarten Online (Natural) - Dutch (Netherlands)", + "language": "nl-NL", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Dena", + "name": "Microsoft Dena Online (Natural) - Dutch (Belgium)", + "language": "nl-BE", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Arnaud", + "name": "Microsoft Arnaud Online (Natural) - Dutch (Belgium)", + "language": "nl-BE", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Claire", + "name": "Claire", + "localizedName": "apple", + "language": "nl-NL", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Xander", + "name": "Xander", + "localizedName": "apple", + "language": "nl-NL", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Ellen", + "name": "Ellen", + "localizedName": "apple", + "language": "nl-BE", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Google mannelijke stem", + "name": "Google Nederlands", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "nl-NL", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Frank", + "name": "Microsoft Frank - Dutch (Netherlands)", + "language": "nl-NL", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Vrouwelijke stem 1 (Nederland)", + "name": "Google Nederlands 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google nl-nl-x-lfc-network", + "Chrome OS Nederlands 4", + "Android Speech Recognition and Synthesis from Google nl-nl-x-lfc-local", + "Android Speech Recognition and Synthesis from Google nl-NL-language" + ], + "nativeID": [ + "nl-nl-x-lfc-network", + "nl-nl-x-lfc-local" + ], + "language": "nl-NL", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Vrouwelijke stem 2 (Nederland)", + "name": "Google Nederlands 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google nl-nl-x-tfb-network", + "Chrome OS Nederlands 1", + "Android Speech Recognition and Synthesis from Google nl-nl-x-tfb-local" + ], + "nativeID": [ + "nl-nl-x-tfb-network", + "nl-nl-x-tfb-local" + ], + "language": "nl-NL", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Vrouwelijke stem 3 (Nederland)", + "name": "Google Nederlands 5 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google nl-nl-x-yfr-network", + "Chrome OS Nederlands 5", + "Android Speech Recognition and Synthesis from Google nl-nl-x-yfr-local" + ], + "nativeID": [ + "nl-nl-x-yfr-network", + "nl-nl-x-yfr-local" + ], + "language": "nl-NL", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Mannelijke stem 1 (Nederland)", + "name": "Google Nederlands 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google nl-nl-x-bmh-network", + "Chrome OS Nederlands 2", + "Android Speech Recognition and Synthesis from Google nl-nl-x-bmh-local" + ], + "nativeID": [ + "nl-nl-x-bmh-network", + "nl-nl-x-bmh-local" + ], + "language": "nl-NL", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Mannelijke stem 2 (Nederland)", + "name": "Google Nederlands 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google nl-nl-x-dma-network", + "Chrome OS Nederlands 3", + "Android Speech Recognition and Synthesis from Google nl-nl-x-dma-local" + ], + "nativeID": [ + "nl-nl-x-dma-network", + "nl-nl-x-dma-local" + ], + "language": "nl-NL", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Vrouwelijke stem (België)", + "name": "Android Speech Recognition and Synthesis from Google nl-be-x-bec-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google nl-be-x-bec-local", + "Android Speech Recognition and Synthesis from Google nl-BE-language" + ], + "nativeID": [ + "nl-be-x-bec-network", + "nl-be-x-bec-local" + ], + "language": "nl-BE", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Mannelijke stem (België)", + "name": "Android Speech Recognition and Synthesis from Google nl-be-x-bed-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google nl-be-x-bed-local" + ], + "nativeID": [ + "nl-be-x-bed-network", + "nl-be-x-bed-local" + ], + "language": "nl-BE", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/pl.json b/json/pl.json new file mode 100644 index 0000000..9389925 --- /dev/null +++ b/json/pl.json @@ -0,0 +1,280 @@ +{ + "language": "pl", + "defaultRegion": "pl-PL", + "testUtterance": "Cześć, nazywam się {name} i mam polski głos.", + "voices": [ + { + "label": "Zofia", + "name": "Microsoft Zofia Online (Natural) - Polish (Poland)", + "language": "pl-PL", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Paulina", + "name": "Microsoft Paulina Online - Polish (Poland)", + "language": "pl-PL", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Marek", + "name": "Microsoft Marek Online (Natural) - Polish (Poland)", + "language": "pl-PL", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ewa", + "name": "Ewa", + "localizedName": "apple", + "language": "pl-PL", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Zosia", + "name": "Zosia", + "localizedName": "apple", + "language": "pl-PL", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Krzysztof", + "name": "Krzysztof", + "localizedName": "apple", + "language": "pl-PL", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Żeński głos Google’a", + "name": "Google polski", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "pl-PL", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Paulina", + "name": "Microsoft Paulina - Polish (Poland)", + "language": "pl-PL", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Adam", + "name": "Microsoft Adam - Polish (Poland)", + "language": "pl-PL", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Głos żeński 1", + "name": "Google Polski 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google pl-pl-x-afb-network", + "Chrome OS Polski 2", + "Android Speech Recognition and Synthesis from Google pl-pl-x-afb-local", + "Android Speech Recognition and Synthesis from Google pl-PL-language" + ], + "nativeID": [ + "pl-pl-x-afb-network", + "pl-pl-x-afb-local" + ], + "language": "pl-PL", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Głos żeński 2", + "name": "Google Polski 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google pl-pl-x-oda-network", + "Chrome OS Polski 1", + "Android Speech Recognition and Synthesis from Google pl-pl-x-oda-local" + ], + "nativeID": [ + "pl-pl-x-oda-network", + "pl-pl-x-oda-local" + ], + "language": "pl-PL", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Głos żeński 3", + "name": "Google Polski 5 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google pl-pl-x-zfg-network", + "Chrome OS Polski 5", + "Android Speech Recognition and Synthesis from Google pl-pl-x-zfg-local" + ], + "nativeID": [ + "pl-pl-x-zfg-network", + "pl-pl-x-zfg-local" + ], + "language": "pl-PL", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Głos męski 1", + "name": "Google Polski 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google pl-pl-x-bmg-network", + "Chrome OS Polski 3", + "Android Speech Recognition and Synthesis from Google pl-pl-x-bmg-local" + ], + "nativeID": [ + "pl-pl-x-bmg-network", + "pl-pl-x-bmg-local" + ], + "language": "pl-PL", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Głos męski 2", + "name": "Google Polski 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google pl-pl-x-jmk-network", + "Chrome OS Polski 4", + "Android Speech Recognition and Synthesis from Google pl-pl-x-jmk-local" + ], + "nativeID": [ + "pl-pl-x-jmk-network", + "pl-pl-x-jmk-local" + ], + "language": "pl-PL", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/pt.json b/json/pt.json new file mode 100644 index 0000000..46fd004 --- /dev/null +++ b/json/pt.json @@ -0,0 +1,425 @@ +{ + "language": "pt", + "defaultRegion": "pt-BR", + "testUtterance": "Olá, o meu nome é {name} e sou uma voz portuguesa.", + "voices": [ + { + "label": "Raquel", + "name": "Microsoft Raquel Online (Natural) - Portuguese (Portugal)", + "language": "pt-PT", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Duarte", + "name": "Microsoft Duarte Online (Natural) - Portuguese (Portugal)", + "language": "pt-PT", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Francisca", + "name": "Microsoft Francisca Online (Natural) - Portuguese (Brazil)", + "language": "pt-BR", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Thalita", + "name": "Microsoft ThalitaMultilingual Online (Natural) - Portuguese (Brazil)", + "language": "pt-BR", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Antonio", + "name": "Microsoft Antonio Online (Natural) - Portuguese (Brazil)", + "language": "pt-BR", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Catarina", + "name": "Catarina", + "localizedName": "apple", + "language": "pt-PT", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Joana", + "name": "Joana", + "localizedName": "apple", + "language": "pt-PT", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Joaquim", + "name": "Joaquim", + "localizedName": "apple", + "language": "pt-PT", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Fernanda", + "name": "Fernanda", + "localizedName": "apple", + "language": "pt-BR", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Luciana", + "name": "Luciana", + "localizedName": "apple", + "language": "pt-BR", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Felipe", + "name": "Felipe", + "localizedName": "apple", + "language": "pt-BR", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Voz feminina do Google", + "name": "Google português do Brasil", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "pt-BR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Helia", + "name": "Microsoft Helia - Portuguese (Portugal)", + "language": "pt-PT", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Maria", + "name": "Microsoft Maria - Portuguese (Brazil)", + "language": "pt-BR", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Daniel", + "name": "Microsoft Daniel - Portuguese (Brazil)", + "language": "pt-BR", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Voz feminina 1 (Portugal)", + "name": "Google português de Portugal 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google pt-pt-x-jfb-network", + "Android Speech Recognition and Synthesis from Google pt-pt-x-jfb-local", + "Android Speech Recognition and Synthesis from Google pt-PT-language" + ], + "nativeID": [ + "pt-pt-x-jfb-network", + "pt-pt-x-jfb-local" + ], + "language": "pt-PT", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz feminina 2 (Portugal)", + "name": "Google português de Portugal 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google pt-pt-x-sfs-network", + "Chrome OS português de Portugal", + "Android Speech Recognition and Synthesis from Google pt-pt-x-sfs-local" + ], + "nativeID": [ + "pt-pt-x-sfs-network", + "pt-pt-x-sfs-local" + ], + "language": "pt-PT", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz masculina 1 (Portugal)", + "name": "Google português de Portugal 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google pt-pt-x-jmn-network", + "Android Speech Recognition and Synthesis from Google pt-pt-x-jmn-local" + ], + "nativeID": [ + "pt-pt-x-jmn-network", + "pt-pt-x-jmn-local" + ], + "language": "pt-PT", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz masculina 2 (Portugal)", + "name": "Google português de Portugal 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google pt-pt-x-pmj-network", + "Android Speech Recognition and Synthesis from Google pt-pt-x-pmj-local" + ], + "nativeID": [ + "pt-pt-x-pmj-network", + "pt-pt-x-pmj-local" + ], + "language": "pt-PT", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz feminina 1 (Brasil)", + "name": "Google português do Brasil 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google pt-br-x-afs-network", + "Chrome OS português do Brasil", + "Android Speech Recognition and Synthesis from Google pt-br-x-afs-local", + "Android Speech Recognition and Synthesis from Google pt-BR-language" + ], + "nativeID": [ + "pt-br-x-afs-network", + "pt-br-x-afs-local" + ], + "language": "pt-BR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz feminina 2 (Brasil)", + "name": "Google português do Brasil 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google pt-br-x-pte-network", + "Android Speech Recognition and Synthesis from Google pt-br-x-pte-local" + ], + "nativeID": [ + "pt-br-x-pte-network", + "pt-br-x-pte-local" + ], + "language": "pt-BR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Voz masculina (Brasil)", + "name": "Google português do Brasil 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google pt-br-x-ptd-network", + "Android Speech Recognition and Synthesis from Google pt-br-x-ptd-local" + ], + "nativeID": [ + "pt-br-x-ptd-network", + "pt-br-x-ptd-local" + ], + "language": "pt-BR", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/ro.json b/json/ro.json new file mode 100644 index 0000000..5a4dc71 --- /dev/null +++ b/json/ro.json @@ -0,0 +1,95 @@ +{ + "language": "ro", + "defaultRegion": "ro-RO", + "testUtterance": "Buna ziua, ma numesc {name} si sunt o voce romaneasca.", + "voices": [ + { + "label": "Alina", + "name": "Microsoft Alina Online (Natural) - Romanian (Romania)", + "language": "ro-RO", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Emil", + "name": "Microsoft Emil Online (Natural) - Romanian (Romania)", + "language": "ro-RO", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ioana", + "name": "Ioana", + "localizedName": "apple", + "language": "ro-RO", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Andrei", + "name": "Microsoft Andrei - Romanian (Romania)", + "language": "ro-RO", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Voce feminină", + "name": "Android Speech Recognition and Synthesis from Google ro-ro-x-vfv-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ro-ro-x-vfv-local", + "Android Speech Recognition and Synthesis from Google ro-RO-language" + ], + "nativeID": [ + "ro-ro-x-vfv-network", + "ro-ro-x-vfv-local" + ], + "language": "ro-RO", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/ru.json b/json/ru.json new file mode 100644 index 0000000..299470c --- /dev/null +++ b/json/ru.json @@ -0,0 +1,269 @@ +{ + "language": "ru", + "defaultRegion": "ru-RU", + "testUtterance": "Здравствуйте, меня зовут {name} и я русский голос.", + "voices": [ + { + "label": "Svetlana", + "name": "Microsoft Svetlana Online (Natural) - Russian (Russia)", + "language": "ru-RU", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ekaterina", + "name": "Microsoft Ekaterina Online - Russian (Russia)", + "language": "ru-RU", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Dmitry", + "name": "Microsoft Dmitry Online (Natural) - Russian (Russia)", + "language": "ru-RU", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Katya", + "name": "Katya", + "localizedName": "apple", + "language": "ru-RU", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Milena", + "name": "Milena", + "localizedName": "apple", + "language": "ru-RU", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Yuri", + "name": "Yuri", + "localizedName": "apple", + "language": "ru-RU", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Google женский голос", + "name": "Google русский", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "ru-RU", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + + { + "label": "Irina", + "name": "Microsoft Irina - Russian (Russian)", + "language": "ru-RU", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Pavel", + "name": "Microsoft Pavel - Russian (Russian)", + "language": "ru-RU", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Женский голос 1", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-dfc-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ru-ru-x-dfc-local" + ], + "nativeID": [ + "ru-ru-x-dfc-network", + "ru-ru-x-dfc-local" + ], + "language": "ru-RU", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Женский голос 2", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-ruc-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ru-ru-x-ruc-local" + ], + "nativeID": [ + "ru-ru-x-ruc-network", + "ru-ru-x-ruc-local" + ], + "language": "ru-RU", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Женский голос 3", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-rue-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ru-ru-x-rue-local" + ], + "nativeID": [ + "ru-ru-x-rue-network", + "ru-ru-x-rue-local" + ], + "language": "ru-RU", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Мужской голос 1", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-rud-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ru-ru-x-rud-local" + ], + "nativeID": [ + "ru-ru-x-rud-network", + "ru-ru-x-rud-local" + ], + "language": "ru-RU", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Мужской голос 2", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-ruf-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ru-ru-x-ruf-local" + ], + "nativeID": [ + "ru-ru-x-ruf-network", + "ru-ru-x-ruf-local" + ], + "language": "ru-RU", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/sk.json b/json/sk.json new file mode 100644 index 0000000..56ad63e --- /dev/null +++ b/json/sk.json @@ -0,0 +1,97 @@ +{ + "language": "sk", + "defaultRegion": "sk-SK", + "testUtterance": "Dobrý deň, volám sa {name} a som slovenský hlas.", + "voices": [ + { + "label": "Viktoria", + "name": "Microsoft Viktoria Online (Natural) - Slovak (Slovakia)", + "language": "sk-SK", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Lukas", + "name": "Microsoft Lukas Online (Natural) - Slovak (Slovakia)", + "language": "sk-SK", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Laura", + "name": "Laura", + "localizedName": "apple", + "language": "sk-SK", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Filip", + "name": "Microsoft Filip - Slovak (Slovakia)", + "language": "sk-SK", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Ženský hlas", + "name": "Google Slovenčina (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google sk-sk-x-sfk-network", + "Android Speech Recognition and Synthesis from Google sk-sk-x-sfk-local", + "Chrome OS Slovenčina", + "Android Speech Recognition and Synthesis from Google sk-SK-language" + ], + "nativeID": [ + "sk-sk-x-sfk-network", + "sk-sk-x-sfk-local" + ], + "language": "sk-SK", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/sl.json b/json/sl.json new file mode 100644 index 0000000..1e5f6f9 --- /dev/null +++ b/json/sl.json @@ -0,0 +1,93 @@ +{ + "language": "sl", + "defaultRegion": "sl-SI", + "testUtterance": "Pozdravljeni, moje ime je {name} in sem slovenski glas.", + "voices": [ + { + "label": "Petra", + "name": "Microsoft Petra Online (Natural) - Slovenian (Slovenia)", + "language": "sl-SI", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Rok", + "name": "Microsoft Rok Online (Natural) - Slovenian (Slovenia)", + "language": "sl-SI", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Tina", + "name": "Tina", + "localizedName": "apple", + "language": "sl-SI", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Lado", + "name": "Microsoft Lado - Slovenian (Slovenia)", + "language": "sl-SI", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Ženski glas", + "name": "Android Speech Recognition and Synthesis from Google sl-si-x-frm-local", + "altNames": [ + "Android Speech Recognition and Synthesis from Google sl-SI-language" + ], + "nativeID": [ + "sl-si-x-frm-local" + ], + "language": "sl-SI", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/sv.json b/json/sv.json new file mode 100644 index 0000000..d99e577 --- /dev/null +++ b/json/sv.json @@ -0,0 +1,231 @@ +{ + "language": "sv", + "defaultRegion": "sv-SE", + "testUtterance": "Hej, jag heter {name} och jag är en svensk röst.", + "voices": [ + { + "label": "Sofie", + "name": "Microsoft Sofie Online (Natural) - Swedish (Sweden)", + "language": "sv-SE", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Mattias", + "name": "Microsoft Mattias Online (Natural) - Swedish (Sweden)", + "language": "sv-SE", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Klara", + "name": "Klara", + "localizedName": "apple", + "language": "sv-SE", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Alva", + "name": "Alva", + "localizedName": "apple", + "language": "sv-SE", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Oskar", + "name": "Oskar", + "note": "This voice can be installed on all Apple devices and offers two variants. Like all voices that can be installed on Apple devices, it suffers from inconsistent naming due to localization.", + "language": "sv-SE", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Bengt", + "name": "Microsoft Bengt - Swedish (Sweden)", + "language": "sv-SE", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Kvinnlig röst 1", + "name": "Google Svenska 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google sv-se-x-lfs-network", + "Chrome OS Svenska", + "Android Speech Recognition and Synthesis from Google sv-se-x-lfs-local", + "Android Speech Recognition and Synthesis from Google sv-SE-language" + ], + "nativeID": [ + "sv-se-x-lfs-network", + "sv-se-x-lfs-local" + ], + "language": "sv-SE", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Kvinnlig röst 2", + "name": "Google Svenska 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google sv-se-x-afp-network", + "Android Speech Recognition and Synthesis from Google sv-se-x-afp-local" + ], + "nativeID": [ + "sv-se-x-afp-network", + "sv-se-x-afp-local" + ], + "language": "sv-SE", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Kvinnlig röst 3", + "name": "Google Svenska 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google sv-se-x-cfg-network", + "Android Speech Recognition and Synthesis from Google sv-se-x-cfg-local" + ], + "nativeID": [ + "sv-se-x-cfg-network", + "sv-se-x-cfg-local" + ], + "language": "sv-SE", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Mansröst 1", + "name": "Google Svenska 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google sv-se-x-cmh-network", + "Android Speech Recognition and Synthesis from Google sv-se-x-cmh-local" + ], + "nativeID": [ + "sv-se-x-cmh-network", + "sv-se-x-cmh-local" + ], + "language": "sv-SE", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Mansröst 2", + "name": "Google Svenska 5 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google sv-se-x-dmc-network", + "Android Speech Recognition and Synthesis from Google sv-se-x-dmc-local" + ], + "nativeID": [ + "sv-se-x-dmc-network", + "sv-se-x-dmc-local" + ], + "language": "sv-SE", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/ta.json b/json/ta.json new file mode 100644 index 0000000..ac844dd --- /dev/null +++ b/json/ta.json @@ -0,0 +1,209 @@ +{ + "language": "ta", + "defaultRegion": "ta-IN", + "testUtterance": "வணக்கம், என் பெயர் {name} மற்றும் நான் ஒரு தமிழ் குரல்", + "voices": [ + { + "label": "Pallavi", + "name": "Microsoft Pallavi Online (Natural) - Tamil (India)", + "language": "ta-IN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Valluvar", + "name": "Microsoft Valluvar Online (Natural) - Tamil (India)", + "language": "ta-IN", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Saranya", + "name": "Microsoft Saranya Online (Natural) - Tamil (Sri Lanka)", + "language": "ta-LK", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Kumar", + "name": "Microsoft Kumar Online (Natural) - Tamil (Sri Lanka)", + "language": "ta-LK", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Kani", + "name": "Microsoft Kani Online (Natural) - Tamil (Malaysia)", + "language": "ta-MY", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Surya", + "name": "Microsoft Surya Online (Natural) - Tamil (Malaysia)", + "language": "ta-MY", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Venba", + "name": "Microsoft Venba Online (Natural) - Tamil (Singapore)", + "language": "ta-SG", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Anbu", + "name": "Microsoft Anbu Online (Natural) - Tamil (Singapore)", + "language": "ta-SG", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Vani", + "name": "Vani", + "localizedName": "apple", + "language": "ta-IN", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Valluvar", + "name": "Microsoft Valluvar - Tamil (India)", + "language": "ta-IN", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + + { + "label": "பெண் குரல்", + "name": "Android Speech Recognition and Synthesis from Google ta-in-x-tac-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ta-in-x-tac-local", + "Android Speech Recognition and Synthesis from Google ta-IN-language" + ], + "nativeID": [ + "ta-in-x-tac-network", + "ta-in-x-tac-local" + ], + "language": "ta-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "ஆண் குரல்", + "name": "Android Speech Recognition and Synthesis from Google ta-in-x-tad-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google ta-in-x-tad-local" + ], + "nativeID": [ + "ta-in-x-tad-network", + "ta-in-x-tad-local" + ], + "language": "ta-IN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/te.json b/json/te.json new file mode 100644 index 0000000..8f7b06b --- /dev/null +++ b/json/te.json @@ -0,0 +1,103 @@ +{ + "language": "te", + "defaultRegion": "te-IN", + "testUtterance": "హలో, నా పేరు {name} మరియు నేను తెలుగు వాణిని.", + "voices": [ + { + "label": "Shruti", + "name": "Microsoft Shruti Online (Natural) - Telugu (India)", + "language": "te-IN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Mohan", + "name": "Microsoft Mohan Online (Natural) - Telugu (India)", + "language": "te-IN", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Geeta", + "name": "Geeta", + "localizedName": "apple", + "language": "te-IN", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "స్త్రీ స్వరం", + "name": "Android Speech Recognition and Synthesis from Google te-in-x-tef-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google te-in-x-tef-local", + "Android Speech Recognition and Synthesis from Google te-IN-language" + ], + "nativeID": [ + "te-in-x-tef-network", + "te-in-x-tef-local" + ], + "language": "te-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "పురుష స్వరం", + "name": "Android Speech Recognition and Synthesis from Google te-in-x-tem-network", + "altNames": [ + "Android Speech Recognition and Synthesis from Google te-in-x-tem-local" + ], + "nativeID": [ + "te-in-x-tem-network", + "te-in-x-tem-local" + ], + "language": "te-IN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/th.json b/json/th.json new file mode 100644 index 0000000..399533a --- /dev/null +++ b/json/th.json @@ -0,0 +1,115 @@ +{ + "language": "th", + "defaultRegion": "th-TH", + "testUtterance": "สวัสดีค่ะ ฉันชื่อ {name} และฉันเป็นคนมีเสียงภาษาไทย", + "voices": [ + { + "label": "Premwadee", + "name": "Microsoft Premwadee Online (Natural) - Thai (Thailand)", + "language": "th-TH", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Niwat", + "name": "Microsoft Niwat Online (Natural) - Thai (Thailand)", + "language": "th-TH", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Narisa", + "name": "Narisa", + "localizedName": "apple", + "language": "th-TH", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Kanya", + "name": "Kanya", + "localizedName": "apple", + "language": "th-TH", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Pattara", + "name": "Microsoft Pattara - Thai (Thailand)", + "language": "th-TH", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "เสียงผู้หญิง", + "name": "Google ไทย (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google th-th-x-mol-network", + "Chrome OS ไทย", + "Android Speech Recognition and Synthesis from Google th-th-x-mol-local", + "Android Speech Recognition and Synthesis from Google th-TH-language" + ], + "nativeID": [ + "th-th-x-mol-network", + "th-th-x-mol-local" + ], + "language": "th-TH", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/tr.json b/json/tr.json new file mode 100644 index 0000000..c6fa47d --- /dev/null +++ b/json/tr.json @@ -0,0 +1,219 @@ +{ + "language": "tr", + "defaultRegion": "tr-TR", + "testUtterance": "Merhaba, adım {name} ve Türk sesiyim.", + "voices": [ + { + "label": "Emel", + "name": "Microsoft Emel Online (Natural) - Turkish (Turkey)", + "language": "tr-TR", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ahmet", + "name": "Microsoft Ahmet Online (Natural) - Turkish (Türkiye)", + "language": "tr-TR", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Yelda", + "name": "Yelda", + "localizedName": "apple", + "altNames": [ + "Yelda (Geliştirilmiş)", + "Yelda (Türkçe (Türkiye))" + ], + "language": "tr-TR", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Cem", + "name": "Cem", + "localizedName": "apple", + "language": "tr-TR", + "gender": "male", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Tolga", + "name": "Microsoft Tolga - Turkish (Turkey)", + "language": "tr-TR", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Kadın sesi 1", + "name": "Google Türkçe 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google tr-tr-x-cfs-network", + "Chrome OS Türkçe 3", + "Android Speech Recognition and Synthesis from Google tr-tr-x-cfs-local", + "Android Speech Recognition and Synthesis from Google tr-TR-language" + ], + "nativeID": [ + "tr-tr-x-cfs-network", + "tr-tr-x-cfs-local" + ], + "language": "tr-TR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Kadın sesi 2", + "name": "Google Türkçe 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google tr-tr-x-efu-network", + "Chrome OS Türkçe 4", + "Android Speech Recognition and Synthesis from Google tr-tr-x-efu-local" + ], + "nativeID": [ + "tr-tr-x-efu-network", + "tr-tr-x-efu-local" + ], + "language": "tr-TR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Kadın sesi 3", + "name": "Google Türkçe 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google tr-tr-x-mfm-network", + "Chrome OS Türkçe 1", + "Android Speech Recognition and Synthesis from Google tr-tr-x-mfm-local" + ], + "nativeID": [ + "tr-tr-x-mfm-network", + "tr-tr-x-mfm-local" + ], + "language": "tr-TR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Erkek sesi 1", + "name": "Google Türkçe 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google tr-tr-x-ama-network", + "Chrome OS Türkçe 2", + "Android Speech Recognition and Synthesis from Google tr-tr-x-ama-local" + ], + "nativeID": [ + "tr-tr-x-ama-network", + "tr-tr-x-ama-local" + ], + "language": "tr-TR", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Erkek sesi 2", + "name": "Google Türkçe 5 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google tr-tr-x-tmc-network", + "Chrome OS Türkçe 5", + "Android Speech Recognition and Synthesis from Google tr-tr-x-tmc-local" + ], + "nativeID": [ + "tr-tr-x-tmc-network", + "tr-tr-x-tmc-local" + ], + "language": "tr-TR", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/uk.json b/json/uk.json new file mode 100644 index 0000000..f65fc23 --- /dev/null +++ b/json/uk.json @@ -0,0 +1,82 @@ +{ + "language": "uk", + "defaultRegion": "uk-UA", + "testUtterance": "Здравствуйте, меня зовут {name} и я украинский голос.", + "voices": [ + { + "label": "Polina", + "name": "Microsoft Polina Online (Natural) - Ukrainian (Ukraine)", + "language": "uk-UA", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Ostap", + "name": "Microsoft Ostap Online (Natural) - Ukrainian (Ukraine)", + "language": "uk-UA", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Lesya", + "name": "Lesya", + "localizedName": "apple", + "language": "uk-UA", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Жіночий голос", + "name": "Google українська (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google uk-ua-x-hfd-network", + "Chrome OS українська", + "Android Speech Recognition and Synthesis from Google uk-ua-x-hfd-local", + "Android Speech Recognition and Synthesis from Google uk-UA-language" + ], + "nativeID": [ + "uk-ua-x-hfd-network", + "uk-ua-x-hfd-local" + ], + "language": "uk-UA", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/vi.json b/json/vi.json new file mode 100644 index 0000000..638646c --- /dev/null +++ b/json/vi.json @@ -0,0 +1,197 @@ +{ + "language": "vi", + "defaultRegion": "vi-VN", + "testUtterance": "Xin chào, tôi tên là {name} và tôi là giọng nói tiếng Việt.", + "voices": [ + { + "label": "HoaiMy", + "name": "Microsoft HoaiMy Online (Natural) - Vietnamese (Vietnam)", + "language": "vi-VN", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "NamMinh", + "name": "Microsoft NamMinh Online (Natural) - Vietnamese (Vietnam)", + "language": "vi-VN", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Linh", + "name": "Linh", + "localizedName": "apple", + "language": "vi-VN", + "gender": "female", + "quality": [ + "low", + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "An", + "name": "Microsoft An - Vietnamese (Vietnam)", + "language": "vi-VN", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Giọng nữ 1", + "name": "Google Tiếng Việt 1 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google vi-vn-x-vic-network", + "Chrome OS Tiếng Việt 1", + "Android Speech Recognition and Synthesis from Google vi-vn-x-vic-local", + "Android Speech Recognition and Synthesis from Google vi-VN-language" + ], + "nativeID": [ + "vi-vn-x-vic-network", + "vi-vn-x-vic-local" + ], + "language": "vi-VN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Giọng nữ 2", + "name": "Google Tiếng Việt 2 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google vi-vn-x-vid-network", + "Chrome OS Tiếng Việt 2", + "Android Speech Recognition and Synthesis from Google vi-vn-x-vid-local" + ], + "nativeID": [ + "vi-vn-x-vid-network", + "vi-vn-x-vid-local" + ], + "language": "vi-VN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Giọng nữ 3", + "name": "Google Tiếng Việt 4 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google vi-vn-x-vif-network", + "Chrome OS Tiếng Việt 4", + "Android Speech Recognition and Synthesis from Google vi-vn-x-vif-local" + ], + "nativeID": [ + "vi-vn-x-vif-network", + "vi-vn-x-vif-local" + ], + "language": "vi-VN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Giọng nam 1", + "name": "Google Tiếng Việt 3 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google vi-vn-x-vie-network", + "Chrome OS Tiếng Việt 3", + "Android Speech Recognition and Synthesis from Google vi-vn-x-vie-local" + ], + "nativeID": [ + "vi-vn-x-vie-network", + "vi-vn-x-vie-local" + ], + "language": "vi-VN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "Giọng nam 2", + "name": "Google Tiếng Việt 5 (Natural)", + "altNames": [ + "Android Speech Recognition and Synthesis from Google vi-vn-x-gft-network", + "Chrome OS Tiếng Việt 5", + "Android Speech Recognition and Synthesis from Google vi-vn-x-gft-local" + ], + "nativeID": [ + "vi-vn-x-gft-network", + "vi-vn-x-gft-local" + ], + "language": "vi-VN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/wuu.json b/json/wuu.json new file mode 100644 index 0000000..564f0b1 --- /dev/null +++ b/json/wuu.json @@ -0,0 +1,25 @@ +{ + "language": "wuu", + "defaultRegion": "wuu-CN", + "testUtterance": "你好,我的名字是 {name},我是吴语配音。", + "voices": [ + { + "label": "Nannan", + "name": "Nannan", + "localizedName": "apple", + "language": "wuu-CN", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/json/yue.json b/json/yue.json new file mode 100644 index 0000000..4d85e63 --- /dev/null +++ b/json/yue.json @@ -0,0 +1,270 @@ +{ + "language": "yue", + "defaultRegion": "yue-HK", + "testUtterance": "你好,我叫 {name},係越中文聲。", + "voices": [ + { + "label": "HiuGaai", + "name": "Microsoft HiuGaai Online (Natural) - Chinese (Cantonese Traditional)", + "language": "yue-HK", + "altLanguage": "zh-HK", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "HiuMaan", + "name": "Microsoft HiuMaan Online (Natural) - Chinese (Hong Kong SAR)", + "language": "yue-HK", + "altLanguage": "zh-HK", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "WanLung", + "name": "Microsoft WanLung Online (Natural) - Chinese (Hong Kong SAR)", + "language": "yue-HK", + "altLanguage": "zh-HK", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Sinji", + "name": "Sinji", + "localizedName": "apple", + "language": "yue-HK", + "altLanguage": "zh-HK", + "gender": "female", + "quality": [ + "low", + "normal", + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Aasing", + "name": "Aasing", + "localizedName": "apple", + "language": "yue-HK", + "altLanguage": "zh-HK", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ], + "preloaded": true + }, + { + "label": "Google 女聲", + "name": "Google 粤語(香港)", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "yue-HK", + "altLanguage": "zh-HK", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Tracy", + "name": "Microsoft Tracy - Chinese (Traditional, Hong Kong S.A.R.)", + "language": "cmn-HK", + "altLanguage": "zh-HK", + "gender": "female", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "Danny", + "name": "Microsoft Danny - Chinese (Traditional, Hong Kong S.A.R.)", + "language": "cmn-HK", + "altLanguage": "zh-HK", + "gender": "male", + "quality": [ + "normal" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Windows" + ], + "preloaded": true + }, + { + "label": "女聲1", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-jar-network", + "altNames": [ + "Chrome OS 粵語 1", + "Android Speech Recognition and Synthesis from Google yue-HK-x-jar-local", + "Android Speech Recognition and Synthesis from Google yue-HK-language" + ], + "nativeID": [ + "yue-hk-x-jar-network", + "yue-hk-x-jar-local" + ], + "language": "yue-HK", + "altLanguage": "zh-HK", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "女聲2", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-yuc-network", + "altNames": [ + "Chrome OS 粵語 2", + "Android Speech Recognition and Synthesis from Google yue-HK-x-yuc-local" + ], + "nativeID": [ + "yue-hk-x-yuc-network", + "yue-hk-x-yuc-local" + ], + "language": "yue-HK", + "altLanguage": "zh-HK", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "男聲1", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-yud-network", + "altNames": [ + "Chrome OS 粵語 3", + "Android Speech Recognition and Synthesis from Google yue-HK-x-yud-local" + ], + "nativeID": [ + "yue-hk-x-yud-network", + "yue-hk-x-yud-local" + ], + "language": "yue-HK", + "altLanguage": "zh-HK", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "男聲2", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-yue-network", + "altNames": [ + "Chrome OS 粵語 5", + "Android Speech Recognition and Synthesis from Google yue-HK-x-yue-local" + ], + "nativeID": [ + "yue-hk-x-yue-network", + "yue-hk-x-yue-local" + ], + "language": "yue-HK", + "altLanguage": "zh-HK", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + }, + { + "label": "男聲3", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-yuf-network", + "altNames": [ + "Chrome OS 粵語 5", + "Android Speech Recognition and Synthesis from Google yue-HK-x-yuf-local" + ], + "nativeID": [ + "yue-hk-x-yuf-network", + "yue-hk-x-yuf-local" + ], + "language": "yue-HK", + "altLanguage": "zh-HK", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "Android", + "ChromeOS" + ], + "preloaded": true + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ef9eaf0..ebda130 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "readium-speech", - "version": "1.0.0", + "name": "@readium/speech", + "version": "0.1.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "readium-speech", - "version": "1.0.0", + "name": "@readium/speech", + "version": "0.1.0-beta.1", "license": "BSD-3-Clause", "dependencies": { "string-strip-html": "^13.4.23" @@ -14,7 +14,6 @@ "devDependencies": { "@ava/typescript": "^6.0.0", "ava": "^6.4.0", - "cpy-cli": "^5.0.0", "http-server": "^14.1.1", "rimraf": "^6.0.1", "ts-node": "^10.9.2", @@ -48,9 +47,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -58,13 +57,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -74,14 +73,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -101,9 +100,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", - "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -118,9 +117,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", - "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -135,9 +134,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", - "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -152,9 +151,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", - "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -169,9 +168,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", - "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -186,9 +185,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", - "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -203,9 +202,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", - "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -220,9 +219,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", - "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -237,9 +236,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", - "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -254,9 +253,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", - "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -271,9 +270,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", - "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -288,9 +287,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", - "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -305,9 +304,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", - "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -322,9 +321,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", - "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -339,9 +338,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", - "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -356,9 +355,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", - "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -373,9 +372,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", - "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -390,9 +389,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", - "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -407,9 +406,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", - "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -424,9 +423,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", - "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -441,9 +440,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", - "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -458,9 +457,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", - "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ "arm64" ], @@ -475,9 +474,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", - "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -492,9 +491,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", - "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -509,9 +508,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", - "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -526,9 +525,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", - "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -608,24 +607,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -668,9 +649,9 @@ } }, "node_modules/@mapbox/node-pre-gyp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.0.tgz", - "integrity": "sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", + "integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -690,19 +671,20 @@ } }, "node_modules/@microsoft/api-extractor": { - "version": "7.53.1", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.53.1.tgz", - "integrity": "sha512-bul5eTNxijLdDBqLye74u9494sRmf+9QULtec9Od0uHnifahGeNt8CC4/xCdn7mVyEBrXIQyQ5+sc4Uc0QfBSA==", + "version": "7.55.2", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.55.2.tgz", + "integrity": "sha512-1jlWO4qmgqYoVUcyh+oXYRztZde/pAi7cSVzBz/rc+S7CoVzDasy8QE13dx6sLG4VRo8SfkkLbFORR6tBw4uGQ==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/api-extractor-model": "7.31.1", - "@microsoft/tsdoc": "~0.15.1", - "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.17.0", + "@microsoft/api-extractor-model": "7.32.2", + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.0", + "@rushstack/node-core-library": "5.19.1", "@rushstack/rig-package": "0.6.0", - "@rushstack/terminal": "0.19.1", - "@rushstack/ts-command-line": "5.1.1", + "@rushstack/terminal": "0.19.5", + "@rushstack/ts-command-line": "5.1.5", + "diff": "~8.0.2", "lodash": "~4.17.15", "minimatch": "10.0.3", "resolve": "~1.22.1", @@ -715,15 +697,25 @@ } }, "node_modules/@microsoft/api-extractor-model": { - "version": "7.31.1", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.31.1.tgz", - "integrity": "sha512-Dhnip5OFKbl85rq/ICHBFGhV4RA5UQSl8AC/P/zoGvs+CBudPkatt5kIhMGiYgVPnUWmfR6fcp38+1AFLYNtUw==", + "version": "7.32.2", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.32.2.tgz", + "integrity": "sha512-Ussc25rAalc+4JJs9HNQE7TuO9y6jpYQX9nWD1DhqUzYPBr3Lr7O9intf+ZY8kD5HnIqeIRJX7ccCT0QyBy2Ww==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "~0.15.1", - "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.17.0" + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.0", + "@rushstack/node-core-library": "5.19.1" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/diff": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" } }, "node_modules/@microsoft/api-extractor/node_modules/lru-cache": { @@ -793,20 +785,20 @@ "license": "ISC" }, "node_modules/@microsoft/tsdoc": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", - "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "dev": true, "license": "MIT" }, "node_modules/@microsoft/tsdoc-config": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.17.1.tgz", - "integrity": "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.0.tgz", + "integrity": "sha512-8N/vClYyfOH+l4fLkkr9+myAoR6M7akc8ntBJ4DJdWH2b09uVfr71+LTMpNyG19fNqWDg8KEDZhx5wxuqHyGjw==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "0.15.1", + "@microsoft/tsdoc": "0.16.0", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" @@ -862,9 +854,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", - "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -885,9 +877,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", - "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", "cpu": [ "arm" ], @@ -899,9 +891,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", - "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", "cpu": [ "arm64" ], @@ -913,9 +905,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", - "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", "cpu": [ "arm64" ], @@ -927,9 +919,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", - "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", "cpu": [ "x64" ], @@ -941,9 +933,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", - "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", "cpu": [ "arm64" ], @@ -955,9 +947,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", - "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", "cpu": [ "x64" ], @@ -969,9 +961,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", - "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", "cpu": [ "arm" ], @@ -983,9 +975,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", - "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", "cpu": [ "arm" ], @@ -997,9 +989,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", - "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", "cpu": [ "arm64" ], @@ -1011,9 +1003,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", - "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", "cpu": [ "arm64" ], @@ -1025,9 +1017,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", - "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", "cpu": [ "loong64" ], @@ -1039,9 +1031,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", - "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", "cpu": [ "ppc64" ], @@ -1053,9 +1045,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", - "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", "cpu": [ "riscv64" ], @@ -1067,9 +1059,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", - "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", "cpu": [ "riscv64" ], @@ -1081,9 +1073,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", - "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", "cpu": [ "s390x" ], @@ -1095,9 +1087,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", - "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", "cpu": [ "x64" ], @@ -1109,9 +1101,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", - "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", "cpu": [ "x64" ], @@ -1123,9 +1115,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", - "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", "cpu": [ "arm64" ], @@ -1137,9 +1129,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", - "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", "cpu": [ "arm64" ], @@ -1151,9 +1143,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", - "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", "cpu": [ "ia32" ], @@ -1165,9 +1157,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", - "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", "cpu": [ "x64" ], @@ -1179,9 +1171,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", - "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", "cpu": [ "x64" ], @@ -1193,9 +1185,9 @@ ] }, "node_modules/@rushstack/node-core-library": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.17.0.tgz", - "integrity": "sha512-24vt1GbHN6kyIglRMTVpyEiNRRRJK8uZHc1XoGAhmnTDKnrWet8OmOpImMswJIe6gM78eV8cMg1HXwuUHkSSgg==", + "version": "5.19.1", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.19.1.tgz", + "integrity": "sha512-ESpb2Tajlatgbmzzukg6zyAhH+sICqJR2CNXNhXcEbz6UGCQfrKCtkxOpJTftWc8RGouroHG0Nud1SJAszvpmA==", "dev": true, "license": "MIT", "dependencies": { @@ -1297,13 +1289,13 @@ } }, "node_modules/@rushstack/terminal": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.19.1.tgz", - "integrity": "sha512-jsBuSad67IDVMO2yp0hDfs0OdE4z3mDIjIL2pclDT3aEJboeZXE85e1HjuD0F6JoW3XgHvDwoX+WOV+AVTDQeA==", + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.19.5.tgz", + "integrity": "sha512-6k5tpdB88G0K7QrH/3yfKO84HK9ggftfUZ51p7fePyCE7+RLLHkWZbID9OFWbXuna+eeCFE7AkKnRMHMxNbz7Q==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/node-core-library": "5.17.0", + "@rushstack/node-core-library": "5.19.1", "@rushstack/problem-matcher": "0.1.1", "supports-color": "~8.1.1" }, @@ -1316,30 +1308,14 @@ } } }, - "node_modules/@rushstack/terminal/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/@rushstack/ts-command-line": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.1.1.tgz", - "integrity": "sha512-HPzFsUcr+wZ3oQI08Ec/E6cuiAVHKzrXZGHhwiwIGygAFiqN5QzX+ff30n70NU2WyE26CykgMwBZZSSyHCJrzA==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.1.5.tgz", + "integrity": "sha512-YmrFTFUdHXblYSa+Xc9OO9FsL/XFcckZy0ycQ6q7VSBsVs5P0uD9vcges5Q9vctGlVdu27w+Ct6IuJ458V0cTQ==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/terminal": "0.19.1", + "@rushstack/terminal": "0.19.5", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" @@ -1366,9 +1342,9 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "dev": true, "license": "MIT" }, @@ -1408,9 +1384,9 @@ "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", "license": "MIT" }, "node_modules/@types/lodash-es": { @@ -1423,14 +1399,14 @@ } }, "node_modules/@types/node": { - "version": "24.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", - "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.1.tgz", + "integrity": "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "undici-types": "~7.14.0" + "undici-types": "~7.16.0" } }, "node_modules/@vercel/nft": { @@ -1461,57 +1437,57 @@ } }, "node_modules/@volar/language-core": { - "version": "2.4.23", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.23.tgz", - "integrity": "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==", + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz", + "integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==", "dev": true, "license": "MIT", "dependencies": { - "@volar/source-map": "2.4.23" + "@volar/source-map": "2.4.27" } }, "node_modules/@volar/source-map": { - "version": "2.4.23", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.23.tgz", - "integrity": "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==", + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.27.tgz", + "integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==", "dev": true, "license": "MIT" }, "node_modules/@volar/typescript": { - "version": "2.4.23", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.23.tgz", - "integrity": "sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==", + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.27.tgz", + "integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.23", + "@volar/language-core": "2.4.27", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "node_modules/@vue/compiler-core": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz", - "integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", + "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.4", - "@vue/shared": "3.5.22", + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.25", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz", - "integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz", + "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.22", - "@vue/shared": "3.5.22" + "@vue/compiler-core": "3.5.25", + "@vue/shared": "3.5.25" } }, "node_modules/@vue/compiler-vue2": { @@ -1551,9 +1527,9 @@ } }, "node_modules/@vue/shared": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz", - "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz", + "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", "dev": true, "license": "MIT" }, @@ -1604,32 +1580,15 @@ } }, "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 14" } }, - "node_modules/aggregate-error": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-4.0.1.tgz", - "integrity": "sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w==", - "dev": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^4.0.0", - "indent-string": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -1688,9 +1647,9 @@ "license": "MIT" }, "node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -1701,9 +1660,9 @@ } }, "node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -1764,14 +1723,11 @@ } }, "node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "dev": true, - "license": "MIT", - "dependencies": { - "lodash": "^4.17.14" - } + "license": "MIT" }, "node_modules/async-sema": { "version": "3.1.1", @@ -1781,23 +1737,23 @@ "license": "MIT" }, "node_modules/ava": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/ava/-/ava-6.4.0.tgz", - "integrity": "sha512-aeFapuBZtaGwVMlFFf074SZJ0bPcdmAdJdsvhHMp+XaOnC2DgeMzopb7yyYAhulNGRJQfUK/SIBYo2PoX7+gtw==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/ava/-/ava-6.4.1.tgz", + "integrity": "sha512-vxmPbi1gZx9zhAjHBgw81w/iEDKcrokeRk/fqDTyA2DQygZ0o+dUGRHFOtX8RA5N0heGJTTsIk7+xYxitDb61Q==", "dev": true, "license": "MIT", "dependencies": { "@vercel/nft": "^0.29.4", - "acorn": "^8.14.1", + "acorn": "^8.15.0", "acorn-walk": "^8.3.4", "ansi-styles": "^6.2.1", "arrgv": "^1.0.2", "arrify": "^3.0.0", "callsites": "^4.2.0", - "cbor": "^10.0.3", + "cbor": "^10.0.9", "chalk": "^5.4.1", "chunkd": "^2.0.1", - "ci-info": "^4.2.0", + "ci-info": "^4.3.0", "ci-parallel-vars": "^1.0.1", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", @@ -1805,7 +1761,7 @@ "concordance": "^5.0.4", "currently-unhandled": "^0.4.1", "debug": "^4.4.1", - "emittery": "^1.1.0", + "emittery": "^1.2.0", "figures": "^6.1.0", "globby": "^14.1.0", "ignore-by-default": "^2.1.0", @@ -1863,13 +1819,6 @@ "node": ">= 0.8" } }, - "node_modules/basic-auth/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -1910,18 +1859,29 @@ "node": ">=8" } }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -1944,22 +1904,22 @@ } }, "node_modules/cbor": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/cbor/-/cbor-10.0.3.tgz", - "integrity": "sha512-72Jnj81xMsqepqdcSdf2+fflz/UDsThOHy5hj2MW5F5xzHL8Oa0KQ6I6V9CwVUPxg5pf+W9xp6W2KilaRXWWtw==", + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/cbor/-/cbor-10.0.11.tgz", + "integrity": "sha512-vIwORDd/WyB8Nc23o2zNN5RrtFGlR6Fca61TtjkUXueI3Jf2DOZDl1zsshvBntZ3wZHBM9ztjnkXSmzQDaq3WA==", "dev": true, "license": "MIT", "dependencies": { "nofilter": "^3.0.2" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", "engines": { @@ -1987,9 +1947,9 @@ "license": "MIT" }, "node_modules/ci-info": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", - "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", "dev": true, "funding": [ { @@ -2009,22 +1969,6 @@ "dev": true, "license": "MIT" }, - "node_modules/clean-stack": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-4.2.0.tgz", - "integrity": "sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-truncate": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", @@ -2067,6 +2011,22 @@ "node": ">=8" } }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/cliui/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2112,6 +2072,24 @@ "node": ">=8" } }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/code-excerpt": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", @@ -2126,9 +2104,9 @@ } }, "node_modules/codsen-utils": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/codsen-utils/-/codsen-utils-1.6.18.tgz", - "integrity": "sha512-n9F/vEGNlJrRMv+1Frjhx2hKc0u+EWaC/UcYL+uVVj27eq+fdu1cY+bKBPSTMGAcCddeox25clEfD3xaCg3AKg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/codsen-utils/-/codsen-utils-1.7.0.tgz", + "integrity": "sha512-J+fnmscIPihyeZGsMsy0wWHXDiA8+51KySw5uGqhKI+iwNSzOwe+sjU4J/BrQajMEBO6BPVx7qDq0cQHnUbrOw==", "license": "MIT", "dependencies": { "rfdc": "^1.4.1" @@ -2228,174 +2206,6 @@ "node": ">= 0.4.0" } }, - "node_modules/cp-file": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/cp-file/-/cp-file-10.0.0.tgz", - "integrity": "sha512-vy2Vi1r2epK5WqxOLnskeKeZkdZvTKfFZQCplE3XWsP+SUJyd5XAUFC9lFgTjjXJF2GMne/UML14iEmkAaDfFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.10", - "nested-error-stacks": "^2.1.1", - "p-event": "^5.0.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cp-file/node_modules/p-event": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/p-event/-/p-event-5.0.1.tgz", - "integrity": "sha512-dd589iCQ7m1L0bmC5NLlVYfy3TbBEsMUfWx9PyAgPeIcFZ/E2yaTZ4Rz4MiBmmJShviiftHVXOqfnfzJ6kyMrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-timeout": "^5.0.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cp-file/node_modules/p-timeout": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-5.1.0.tgz", - "integrity": "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cpy-cli": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cpy-cli/-/cpy-cli-5.0.0.tgz", - "integrity": "sha512-fb+DZYbL9KHc0BC4NYqGRrDIJZPXUmjjtqdw4XRRg8iV8dIfghUX/WiL+q4/B/KFTy3sK6jsbUhBaz0/Hxg7IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cpy": "^10.1.0", - "meow": "^12.0.1" - }, - "bin": { - "cpy": "cli.js" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cpy-cli/node_modules/cpy": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/cpy/-/cpy-10.1.0.tgz", - "integrity": "sha512-VC2Gs20JcTyeQob6UViBLnyP0bYHkBh6EiKzot9vi2DmeGlFT9Wd7VG3NBrkNx/jYvFBeyDOMMHdHQhbtKLgHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "arrify": "^3.0.0", - "cp-file": "^10.0.0", - "globby": "^13.1.4", - "junk": "^4.0.1", - "micromatch": "^4.0.5", - "nested-error-stacks": "^2.1.1", - "p-filter": "^3.0.0", - "p-map": "^6.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cpy-cli/node_modules/globby": { - "version": "13.2.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", - "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "dir-glob": "^3.0.1", - "fast-glob": "^3.3.0", - "ignore": "^5.2.4", - "merge2": "^1.4.1", - "slash": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cpy-cli/node_modules/p-filter": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-3.0.0.tgz", - "integrity": "sha512-QtoWLjXAW++uTX67HZQz1dbTpqBfiidsB6VtQUC9iR85S120+s0T5sO6s+B5MLzFcZkrEd/DGMmCjR+f2Qpxwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-map": "^5.1.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cpy-cli/node_modules/p-filter/node_modules/p-map": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-5.5.0.tgz", - "integrity": "sha512-VFqfGDHlx87K66yZrNdI4YGtD70IRyd+zSvgks6mzHPRNkoKy+9EKP4SFC77/vTTQYmRmti7dvqC+m5jBrBAcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cpy-cli/node_modules/p-map": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-6.0.0.tgz", - "integrity": "sha512-T8BatKGY+k5rU+Q/GTYgrEf2r4xRMevAN5mtXc2aPc4rS1j3s+vWTaO2Wag94neXuCAUAs8cxBL9EeB5EA6diw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cpy-cli/node_modules/slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -2452,9 +2262,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -2469,28 +2279,10 @@ } } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2507,27 +2299,19 @@ "node": ">=0.3.1" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", "dependencies": { - "path-type": "^4.0.0" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/dir-glob/node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": ">= 0.4" } }, "node_modules/eastasianwidth": { @@ -2551,9 +2335,9 @@ } }, "node_modules/emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, @@ -2571,14 +2355,11 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -2593,10 +2374,23 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", - "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2607,38 +2401,38 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.10", - "@esbuild/android-arm": "0.25.10", - "@esbuild/android-arm64": "0.25.10", - "@esbuild/android-x64": "0.25.10", - "@esbuild/darwin-arm64": "0.25.10", - "@esbuild/darwin-x64": "0.25.10", - "@esbuild/freebsd-arm64": "0.25.10", - "@esbuild/freebsd-x64": "0.25.10", - "@esbuild/linux-arm": "0.25.10", - "@esbuild/linux-arm64": "0.25.10", - "@esbuild/linux-ia32": "0.25.10", - "@esbuild/linux-loong64": "0.25.10", - "@esbuild/linux-mips64el": "0.25.10", - "@esbuild/linux-ppc64": "0.25.10", - "@esbuild/linux-riscv64": "0.25.10", - "@esbuild/linux-s390x": "0.25.10", - "@esbuild/linux-x64": "0.25.10", - "@esbuild/netbsd-arm64": "0.25.10", - "@esbuild/netbsd-x64": "0.25.10", - "@esbuild/openbsd-arm64": "0.25.10", - "@esbuild/openbsd-x64": "0.25.10", - "@esbuild/openharmony-arm64": "0.25.10", - "@esbuild/sunos-x64": "0.25.10", - "@esbuild/win32-arm64": "0.25.10", - "@esbuild/win32-ia32": "0.25.10", - "@esbuild/win32-x64": "0.25.10" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { @@ -2697,9 +2491,9 @@ "license": "MIT" }, "node_modules/execa": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz", - "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "dev": true, "license": "MIT", "dependencies": { @@ -2724,9 +2518,9 @@ } }, "node_modules/exsolve": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", - "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "dev": true, "license": "MIT" }, @@ -2762,9 +2556,9 @@ } }, "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2826,9 +2620,9 @@ } }, "node_modules/find-up-simple": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.0.tgz", - "integrity": "sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", "dev": true, "license": "MIT", "engines": { @@ -2839,9 +2633,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "dev": true, "funding": [ { @@ -2860,13 +2654,13 @@ } }, "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -2927,9 +2721,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", - "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", "dev": true, "license": "MIT", "engines": { @@ -2940,17 +2734,22 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -2959,6 +2758,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", @@ -2977,9 +2790,9 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -3010,46 +2823,6 @@ "node": ">= 6" } }, - "node_modules/glob/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/glob/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/glob/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globby": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", @@ -3084,24 +2857,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3124,36 +2887,10 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -3291,6 +3028,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/http-server/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -3329,9 +3079,9 @@ } }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -3497,9 +3247,9 @@ } }, "node_modules/is-unicode-supported": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.0.0.tgz", - "integrity": "sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, "license": "MIT", "engines": { @@ -3517,17 +3267,14 @@ "license": "ISC" }, "node_modules/jackspeak": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.1.tgz", - "integrity": "sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": "20 || >=22" - }, "funding": { "url": "https://github.com/sponsors/isaacs" }, @@ -3553,9 +3300,9 @@ } }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -3586,19 +3333,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/junk": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/junk/-/junk-4.0.1.tgz", - "integrity": "sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/kolorist": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", @@ -3651,19 +3385,16 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.0.tgz", - "integrity": "sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } + "license": "ISC" }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3693,6 +3424,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/md5-hex": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz", @@ -3707,9 +3448,9 @@ } }, "node_modules/memoize": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.1.0.tgz", - "integrity": "sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.2.0.tgz", + "integrity": "sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==", "dev": true, "license": "MIT", "dependencies": { @@ -3722,19 +3463,6 @@ "url": "https://github.com/sindresorhus/memoize?sponsor=1" } }, - "node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3835,9 +3563,9 @@ } }, "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, "license": "MIT", "dependencies": { @@ -3847,22 +3575,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -3928,13 +3640,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/nested-error-stacks": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz", - "integrity": "sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==", - "dev": true, - "license": "MIT" - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -4025,9 +3730,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", "engines": { @@ -4048,9 +3753,9 @@ } }, "node_modules/p-map": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", - "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "dev": true, "license": "MIT", "engines": { @@ -4078,9 +3783,9 @@ } }, "node_modules/package-json-from-dist": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, "license": "BlueOak-1.0.0" }, @@ -4122,17 +3827,17 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": "20 || >=22" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4207,41 +3912,17 @@ } }, "node_modules/portfinder": { - "version": "1.0.32", - "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.32.tgz", - "integrity": "sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==", + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", "dev": true, "license": "MIT", "dependencies": { - "async": "^2.6.4", - "debug": "^3.2.7", - "mkdirp": "^0.5.6" + "async": "^3.2.6", + "debug": "^4.3.6" }, "engines": { - "node": ">= 0.12.0" - } - }, - "node_modules/portfinder/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/portfinder/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" + "node": ">= 10.12" } }, "node_modules/postcss": { @@ -4274,9 +3955,9 @@ } }, "node_modules/pretty-ms": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", - "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4300,13 +3981,13 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -4354,12 +4035,12 @@ "license": "MIT" }, "node_modules/ranges-apply": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/ranges-apply/-/ranges-apply-7.0.30.tgz", - "integrity": "sha512-tS8uBgzBGNutgiqF/ATlLYlztDM7QtuyHt7iVXmOiSZo8ruN8l2v88hl2LQ+cYoxRbQK7+kBDkIj6LOQUMBv9w==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/ranges-apply/-/ranges-apply-7.1.0.tgz", + "integrity": "sha512-rtAdRodLlwASQlECefgqYPfyCIRKSE4CJjqIltn4UXwqNvhysR1a2db+U49nU8+5N1L6R71LlVPReCRjf3Henw==", "license": "MIT", "dependencies": { - "ranges-merge": "^9.0.29", + "ranges-merge": "^9.1.0", "tiny-invariant": "^1.3.3" }, "engines": { @@ -4367,37 +4048,37 @@ } }, "node_modules/ranges-merge": { - "version": "9.0.29", - "resolved": "https://registry.npmjs.org/ranges-merge/-/ranges-merge-9.0.29.tgz", - "integrity": "sha512-hnCj3zZm+nYUzw0HlbQITWj7t/yj9IdAkQZLnY49I1q0alDQEDm+YcabFoVngQelMlFyaujQWG/FIuO8yZwTiA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/ranges-merge/-/ranges-merge-9.1.0.tgz", + "integrity": "sha512-6jJKvNfscpCga3oEMBlZKbPz/jLwOTRdnpiyaHm/qtl57sWI99ld9qupII3YscbkNcSbt1sfePYC837M2IYf0Q==", "license": "MIT", "dependencies": { - "ranges-push": "^7.0.29", - "ranges-sort": "^6.0.24" + "ranges-push": "^7.1.0", + "ranges-sort": "^6.1.0" }, "engines": { "node": ">=14.18.0" } }, "node_modules/ranges-push": { - "version": "7.0.29", - "resolved": "https://registry.npmjs.org/ranges-push/-/ranges-push-7.0.29.tgz", - "integrity": "sha512-evoKeEy9Ai9qCwnSUAhnU2JGJ9V+x5TvSCxr8pFOXrWOIgcMSkbNERaZl3F/ArdMDlq2LECqYKP/dXMJLZbzXg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/ranges-push/-/ranges-push-7.1.0.tgz", + "integrity": "sha512-5PiLj4BHiG56CTsLGtvdaukgTRTFrzLpET2eAEx8dsJzigOh8phtzjE7zSlYhaUcnVGMmAqWkfTjcWIQhqjpJg==", "license": "MIT", "dependencies": { - "codsen-utils": "^1.6.18", - "ranges-sort": "^6.0.24", - "string-collapse-leading-whitespace": "^7.0.19", - "string-trim-spaces-only": "^5.0.23" + "codsen-utils": "^1.7.0", + "ranges-sort": "^6.1.0", + "string-collapse-leading-whitespace": "^7.1.0", + "string-trim-spaces-only": "^5.1.0" }, "engines": { "node": ">=14.18.0" } }, "node_modules/ranges-sort": { - "version": "6.0.24", - "resolved": "https://registry.npmjs.org/ranges-sort/-/ranges-sort-6.0.24.tgz", - "integrity": "sha512-giyyj0H+Lrc8qkZhvM5IB1QKnWW2/FEPqu5eTdGuAjzpvWRIFT5gVkBTC2m9JYY88OnLFJdBCKhYIwhP1pm+Cw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ranges-sort/-/ranges-sort-6.1.0.tgz", + "integrity": "sha512-esvEBNDhydnuojWhXkiZnHv4infMKaeD4NsCqce++uYxnRIAXIS6R3iAMNVLqxaPZn+4+h5dhEPXCuBgpExakg==", "license": "MIT", "engines": { "node": ">=14.18.0" @@ -4431,13 +4112,13 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -4475,9 +4156,9 @@ } }, "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -4492,14 +4173,14 @@ "license": "MIT" }, "node_modules/rimraf": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", - "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", + "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^11.0.0", - "package-json-from-dist": "^1.0.0" + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" @@ -4512,22 +4193,16 @@ } }, "node_modules/rimraf/node_modules/glob": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz", - "integrity": "sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^4.0.1", - "minimatch": "^10.0.0", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, "engines": { "node": "20 || >=22" }, @@ -4535,14 +4210,41 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/rimraf/node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { "node": "20 || >=22" @@ -4552,9 +4254,9 @@ } }, "node_modules/rollup": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", - "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", "dependencies": { @@ -4568,28 +4270,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.4", - "@rollup/rollup-android-arm64": "4.52.4", - "@rollup/rollup-darwin-arm64": "4.52.4", - "@rollup/rollup-darwin-x64": "4.52.4", - "@rollup/rollup-freebsd-arm64": "4.52.4", - "@rollup/rollup-freebsd-x64": "4.52.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", - "@rollup/rollup-linux-arm-musleabihf": "4.52.4", - "@rollup/rollup-linux-arm64-gnu": "4.52.4", - "@rollup/rollup-linux-arm64-musl": "4.52.4", - "@rollup/rollup-linux-loong64-gnu": "4.52.4", - "@rollup/rollup-linux-ppc64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-musl": "4.52.4", - "@rollup/rollup-linux-s390x-gnu": "4.52.4", - "@rollup/rollup-linux-x64-gnu": "4.52.4", - "@rollup/rollup-linux-x64-musl": "4.52.4", - "@rollup/rollup-openharmony-arm64": "4.52.4", - "@rollup/rollup-win32-arm64-msvc": "4.52.4", - "@rollup/rollup-win32-ia32-msvc": "4.52.4", - "@rollup/rollup-win32-x64-gnu": "4.52.4", - "@rollup/rollup-win32-x64-msvc": "4.52.4", + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" } }, @@ -4617,6 +4319,13 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4632,9 +4341,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -4660,24 +4369,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4702,16 +4393,73 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -4824,21 +4572,21 @@ } }, "node_modules/string-collapse-leading-whitespace": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/string-collapse-leading-whitespace/-/string-collapse-leading-whitespace-7.0.19.tgz", - "integrity": "sha512-p/xaYiDXZ6vW0TblvvKlvCujCUK03fxFoO91tCKdJfTk/0Q9bxYf6oakuE2zm0xMRT8FC/MB6UwpnDreR8sltg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/string-collapse-leading-whitespace/-/string-collapse-leading-whitespace-7.1.0.tgz", + "integrity": "sha512-VDQaY0zGeD+S36xwreMWw64C+fl31FoS4txHScuUoUw6B760P63Q00FVdcF7SU1qXD5FKG1ptMWrtV65l+kvcw==", "license": "MIT", "engines": { "node": ">=14.18.0" } }, "node_modules/string-left-right": { - "version": "6.0.31", - "resolved": "https://registry.npmjs.org/string-left-right/-/string-left-right-6.0.31.tgz", - "integrity": "sha512-UNFnpmfHigpoJ2QZcHcjyHgnKjqNFL9giZdsfssxh2lEzbBioxsv4k3Iezcq8O3HlLpS6WEKJ7PtN+SqR+yzSQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/string-left-right/-/string-left-right-6.1.0.tgz", + "integrity": "sha512-Y+QrkHzY7S8/UuArnhJkStKdHfQI4dJv9K3qWDJ2W0WVQXFkG5Zh+YbxMVssGdk84FLwhg5yxg9/y9AORaqbRA==", "license": "MIT", "dependencies": { - "codsen-utils": "^1.6.18", + "codsen-utils": "^1.7.0", "rfdc": "^1.4.1" }, "engines": { @@ -4846,27 +4594,27 @@ } }, "node_modules/string-strip-html": { - "version": "13.4.23", - "resolved": "https://registry.npmjs.org/string-strip-html/-/string-strip-html-13.4.23.tgz", - "integrity": "sha512-8ZGMRcKdicXByWet73OtqhDLfeQTBZ2k6vDx4ZNcnISkVTyhl1IOWdRS3z7exkNZs+Lpe/enJF6zU653jG9UOw==", + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/string-strip-html/-/string-strip-html-13.5.0.tgz", + "integrity": "sha512-U2ZnVRhqLuCvczaZEyk7yz4Mu91VfNHGOKtulm2Y5m8I69mp2Epr7NeoDaBxrscAQAX/gNuUQEikzaPXBWH/5g==", "license": "MIT", "dependencies": { "@types/lodash-es": "^4.17.12", - "codsen-utils": "^1.6.18", + "codsen-utils": "^1.7.0", "html-entities": "^2.6.0", "lodash-es": "^4.17.21", - "ranges-apply": "^7.0.30", - "ranges-push": "^7.0.29", - "string-left-right": "^6.0.31" + "ranges-apply": "^7.1.0", + "ranges-push": "^7.1.0", + "string-left-right": "^6.1.0" }, "engines": { "node": ">=14.18.0" } }, "node_modules/string-trim-spaces-only": { - "version": "5.0.23", - "resolved": "https://registry.npmjs.org/string-trim-spaces-only/-/string-trim-spaces-only-5.0.23.tgz", - "integrity": "sha512-KENrowiDwA0ImP1WVgIA6V2BeNqjUiOv3Zxk/46W3vpRI6VM8G7+ft8pYGY+3e6XICPxXt3PaYY9B8oYMtqS2g==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/string-trim-spaces-only/-/string-trim-spaces-only-5.1.0.tgz", + "integrity": "sha512-632znq4SGCNM7Vw7QITbx05oej+Xly2s7OtDxN9jvNbOoWcQuA5fq14CAS5TlpiiB04LDETzJj9fk851PnWLgg==", "license": "MIT", "engines": { "node": ">=14.18.0" @@ -4947,9 +4695,9 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { @@ -5029,16 +4777,19 @@ } }, "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -5055,17 +4806,16 @@ } }, "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { @@ -5193,9 +4943,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5214,9 +4964,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", - "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT", "peer": true @@ -5281,9 +5031,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.1.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", - "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5447,18 +5197,18 @@ } }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -5554,75 +5304,29 @@ "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, "license": "MIT" }, - "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" + "node": ">=12" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/write-file-atomic": { @@ -5754,9 +5458,9 @@ } }, "node_modules/yoctocolors": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", - "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 3417967..a642719 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,26 @@ "name": "@readium/speech", "version": "0.1.0-beta.1", "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.", - "main": "build/index.js", - "module": "build/index.js", + "main": "./build/index.cjs", + "module": "./build/index.js", + "types": "./build/index.d.ts", + "sideEffects": false, + "files": [ + "build", + "json" + ], + "exports": { + ".": { + "types": "./build/index.d.ts", + "import": "./build/index.js", + "require": "./build/index.cjs" + } + }, "scripts": { - "test": "ava test/**/*.test.ts", + "test": "npm run build && ava test/WebSpeechVoiceManager.test.ts", "clean": "rimraf ./build", - "types": "tsc -p tsconfig-types.json", "build": "vite build", "start": "node build/index.js", - "extract-json-data": "node script/extract-json.mjs", "serve": "http-server ./", "watch": "tsc -w" }, @@ -20,7 +31,6 @@ "devDependencies": { "@ava/typescript": "^6.0.0", "ava": "^6.4.0", - "cpy-cli": "^5.0.0", "http-server": "^14.1.1", "rimraf": "^6.0.1", "ts-node": "^10.9.2", diff --git a/script/extract-json.mjs b/script/extract-json.mjs deleted file mode 100644 index 635681d..0000000 --- a/script/extract-json.mjs +++ /dev/null @@ -1,277 +0,0 @@ - -import { spawn } from 'node:child_process'; -import { rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { writeFileSync } from 'node:fs'; - -const repoUrl = 'https://github.com/HadrienGardeur/web-speech-recommended-voices.git'; -// const repoBranch = 'locales-for-voice-names'; -const repoBranch = 'main'; -const repoPath = 'script/web-speech-recommended-voices'; - -// Clone the repository -await new Promise((resolve, reject) => { - const cloneProcess = spawn('git', ['clone', '--depth=1', '--branch', repoBranch, repoUrl, repoPath]); - cloneProcess.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`Git clone failed with code ${code}`)); - } - }); -}); - - -const jsonFiles = [ - 'ar.json', - 'bg.json', - 'bho.json', - 'bn.json', - 'ca.json', - 'cmn.json', - 'cs.json', - 'da.json', - 'de.json', - 'el.json', - 'en.json', - 'es.json', - 'eu.json', - 'fa.json', - 'fi.json', - 'fr.json', - 'gl.json', - 'he.json', - 'hi.json', - 'hr.json', - 'hu.json', - 'id.json', - 'it.json', - 'ja.json', - 'kn.json', - 'ko.json', - 'mr.json', - 'ms.json', - 'nb.json', - 'nl.json', - 'pl.json', - 'pt.json', - 'ro.json', - 'ru.json', - 'sk.json', - 'sl.json', - 'sv.json', - 'ta.json', - 'te.json', - 'th.json', - 'tr.json', - 'uk.json', - 'vi.json', - 'wuu.json', - 'yue.json', -]; - -const filters = [ - 'novelty.json', - 'veryLowQuality.json', -]; - -// const localizedNames = [ -// 'ca.json', -// 'da.json', -// 'de.json', -// 'en.json', -// 'es.json', -// 'fi.json', -// 'fr.json', -// 'it.json', -// 'nb.json', -// 'nl.json', -// 'pt.json', -// 'sv.json', -// ]; - -let novelty = []; -let veryLowQuality = []; - -// let localization = {}; - -let recommended = [] - -let quality = []; - -const defaultRegion = {}; - -// function generateLanguageRegionStrings(languages, regions) { - -// const result = {}; -// for (const languageCode in languages) { -// for (const regionCode in regions) { -// const bcp47Code = `${languageCode.toLowerCase()}-${regionCode.toLowerCase()}`; -// const translation = `(${languages[languageCode]} (${regions[regionCode]}))`; -// result[bcp47Code] = translation; -// } -// } - -// return result; -// } - -// function getAltName(languages) { - -// if (!languages.length) { -// return []; -// } - -// const result = []; -// for (const language of languages) { -// for (const langLocalization in localization) { - -// const v = localization[langLocalization][language.toLowerCase()]; -// if (v) { -// result.push(v); -// } -// } -// } - -// return result; -// } - -function filterBCP47(data) { - return data.filter((v) => /\w{2,3}-\w{2,3}/.test(v)); -} - -{ - const file = 'apple.json'; - const filePath = join(process.cwd(), repoPath, 'json', 'localizedNames', file); - try { - const { default: jsonData } = await import(filePath, { with: { type: 'json' } }); - console.log(`Imported localizedNames/${file}:` /*, jsonData*/); - - quality = jsonData.quality; - } catch (error) { - console.error(`Failed to import localizedNames/${file}: ${error.message}`); - } -} - -// for (const file of localizedNames) { -// const filePath = join(process.cwd(), repoPath, 'json', 'localizedNames', 'full', file); -// try { -// const { default: jsonData } = await import(filePath, { with: { type: 'json' } }); -// console.log(`Imported localizedNames/${file}:` /*, jsonData*/); - -// const lang = file.split(".")[0]; -// localization[lang] = generateLanguageRegionStrings(jsonData.languages, jsonData.regions); -// } catch (error) { -// console.error(`Failed to import localizedNames/${file}: ${error.message}`); -// } -// } -// // console.log(localization); - - -for (const file of jsonFiles) { - const filePath = join(process.cwd(), repoPath, 'json', file); - try { - const { default: jsonData } = await import(filePath, { with: { type: 'json' } }); - console.log(`Imported ${file}:` /*, jsonData*/); - - defaultRegion[jsonData.language] = jsonData.defaultRegion; - - const voices = jsonData.voices; - - for (const voice of voices) { - - recommended.push({ - label: voice.label, - name: voice.name || undefined, - altNames: voice.altNames || undefined, - language: voice.language || undefined, - gender: voice.gender || undefined, - age: voice.age || undefined, - quality: Array.isArray(voice.quality) ? voice.quality : [], - recommendedPitch: voice.pitchControl === false ? undefined : voice.pitch || 1, - recommendedRate: voice.pitchControl === false ? undefined : voice.rate || 1, - localizedName: voice.localizedName || "", - }); - } - - } catch (error) { - console.error(`Failed to import ${file}: ${error.message}`); - } -} - -for (const file of filters) { - const filePath = join(process.cwd(), repoPath, 'json', 'filters', file); - try { - const { default: jsonData } = await import(filePath, { with: { type: 'json' } }); - console.log(`Imported filters/${file}:` /*, jsonData*/); - - if (file.startsWith("novelty")) { - novelty = jsonData.voices.map(({ name, altNames }) => [name, ...(Array.isArray(altNames) ? altNames : [])]).flat(); - } - - if (file.startsWith("veryLow")) { - veryLowQuality = jsonData.voices.map(({ name, language, otherLanguages }) => { - // const languages = filterBCP47([language, otherLanguages].flat()); - // const altNamesGenerated = getAltName(languages); - // const altNames = altNamesGenerated.map((v) => name + " " + v); - - // return [name, altNames].flat(); - return name; - }).flat(); - } - } catch (error) { - console.error(`Failed to import filters/${file}: ${error.message}`); - } -} - - - -const content = ` -// https://github.com/readium/speech -// file script-generated by : npm run extract-json-data -// - -export const novelty = ${JSON.stringify(novelty)}; - -export const veryLowQuality = ${JSON.stringify(veryLowQuality)}; - -export type TGender = "female" | "male" | "nonbinary" -export type TQuality = "veryLow" | "low" | "normal" | "high" | "veryHigh"; - -export interface IRecommended { - label: string; - name: string; - altNames?: string[]; - language: string; - gender?: TGender | undefined; - age?: string | undefined; - quality: TQuality[]; - recommendedPitch?: number | undefined; - recommendedRate?: number | undefined; - localizedName: string; -}; - -export const recommended: Array = ${JSON.stringify(recommended)}; - -export const quality = ${JSON.stringify(quality)}; - -export const defaultRegion = ${JSON.stringify(defaultRegion)}; - -// EOF -`; - -const filePath = './src/data.gen.ts'; - -try { - writeFileSync(filePath, content); - console.log('File has been written successfully'); -} catch (err) { - console.error(err); -} - -// Delete the cloned repository -try { - await rm(repoPath, { recursive: true, force: true }); - console.log(`Deleted repository at ${repoPath}`); -} catch (error) { - console.error(`Failed to delete repository: ${error.message}`); -} diff --git a/src/WebSpeech/TmpNavigator.ts b/src/WebSpeech/TmpNavigator.ts index 1e58042..a2993a9 100644 --- a/src/WebSpeech/TmpNavigator.ts +++ b/src/WebSpeech/TmpNavigator.ts @@ -1,7 +1,7 @@ import { ReadiumSpeechPlaybackEngine } from "../engine"; import { ReadiumSpeechNavigator, ReadiumSpeechPlaybackEvent, ReadiumSpeechPlaybackState } from "../navigator"; import { ReadiumSpeechUtterance } from "../utterance"; -import { ReadiumSpeechVoice } from "../voices"; +import { ReadiumSpeechVoice } from "../voices/types"; import { WebSpeechEngine } from "./webSpeechEngine"; export class WebSpeechReadAloudNavigator implements ReadiumSpeechNavigator { @@ -99,7 +99,7 @@ export class WebSpeechReadAloudNavigator implements ReadiumSpeechNavigator { return this.engine.getAvailableVoices(); } - async setVoice(voice: ReadiumSpeechVoice | string): Promise { + setVoice(voice: ReadiumSpeechVoice | string): void { this.engine.setVoice(voice); } diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts new file mode 100644 index 0000000..885953d --- /dev/null +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -0,0 +1,764 @@ +import { ReadiumSpeechVoice, TGender, TQuality, TSource } from "../voices/types"; +import { getTestUtterance, getVoices } from "../voices/languages"; +import { + isNoveltyVoice, + isVeryLowQualityVoice, + filterOutNoveltyVoices, + filterOutVeryLowQualityVoices +} from "../voices/filters"; + +import { extractLangRegionFromBCP47 } from "../utils/language"; + +/** + * Options for filtering voices + */ +interface VoiceFilterOptions { + language?: string | string[]; + source?: TSource; + gender?: TGender; + quality?: TQuality | TQuality[]; + offlineOnly?: boolean; + provider?: string; + excludeNovelty?: boolean; + excludeVeryLowQuality?: boolean; +} + +/** + * Language/Region information with voice count + */ +interface LanguageInfo { + code: string; + label: string; + count: number; +} + +/** + * Grouped voices + */ +interface VoiceGroup { + [key: string]: ReadiumSpeechVoice[]; +} + +/** + * Sort order for voices + */ +type SortOrder = "asc" | "desc"; + +/** + * Grouping criteria for voices + */ +type GroupBy = "language" | "gender" | "quality" | "region"; + +/** + * Sort options for voices + */ +interface SortOptions { + by: GroupBy | "name"; + order?: SortOrder; + preferredLanguages?: string[]; +} + +/** + * Manages Web Speech API voices with enhanced functionality + */ +export class WebSpeechVoiceManager { + private static instance: WebSpeechVoiceManager; + private static initializationPromise: Promise | null = null; + private voices: ReadiumSpeechVoice[] = []; + private browserVoices: SpeechSynthesisVoice[] = []; + private isInitialized = false; + + private constructor() { + if (typeof window === "undefined" || !window.speechSynthesis) { + throw new Error("Web Speech API is not available in this environment"); + } + } + + /** + * Initialize the voice manager + * @param options Configuration options for voice loading + * @param options.maxTime Maximum time in milliseconds to wait for voices to load (passed to getBrowserVoices) + * @param options.interval Interval in milliseconds between voice loading checks (passed to getBrowserVoices) + * @returns Promise that resolves with the WebSpeechVoiceManager instance + */ + static async initialize( + maxTimeout?: number, + interval?: number + ): Promise { + // If we already have an initialized instance, return it + if (WebSpeechVoiceManager.instance?.isInitialized) { + return WebSpeechVoiceManager.instance; + } + + // If initialization is in progress, return the existing promise + if (WebSpeechVoiceManager.initializationPromise) { + return WebSpeechVoiceManager.initializationPromise; + } + + // Create a new instance and store the initialization promise + WebSpeechVoiceManager.initializationPromise = (async () => { + try { + const instance = new WebSpeechVoiceManager(); + WebSpeechVoiceManager.instance = instance; + + instance.browserVoices = await instance.getBrowserVoices(maxTimeout, interval); + instance.voices = await instance.parseToReadiumSpeechVoices(instance.browserVoices); + instance.isInitialized = true; + + return instance; + } catch (error) { + // On error, clear the promise so initialization can be retried + WebSpeechVoiceManager.initializationPromise = null; + console.error("Failed to initialize WebSpeechVoiceManager:", error); + throw error; + } + })(); + + return WebSpeechVoiceManager.initializationPromise; + } + + /** + * Extract language and region from BCP47 language tag + * @param lang - The BCP47 language tag (e.g., "en-US", "zh-CN") + * @returns A tuple of [language, region] where language is lowercase and region is UPPERCASE + */ + static extractLangRegionFromBCP47(lang: string): [string, string | undefined] { + return extractLangRegionFromBCP47(lang); + } + + /** + * Get display name for a language code + * @private + */ + private static getLanguageDisplayName(code: string, localization?: string): string { + try { + // Use the code as-is, let Intl handle the display name + const displayName = new Intl.DisplayNames( + localization ? [localization] : [], + { type: "language", languageDisplay: "standard" } + ).of(code); + + return displayName || code.toUpperCase(); + } catch (e) { + return code.toUpperCase(); + } + } + + /** + * Remove duplicate voices, keeping the highest quality version of each voice + * @param voices Array of voices to remove duplicates from + * @returns Filtered array with duplicates removed, keeping only the highest quality versions + */ + private removeDuplicate(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + const voiceMap = new Map(); + + for (const voice of voices) { + // Create a unique key based on voice identity (excluding quality) + const key = `${voice.voiceURI}_${voice.name}_${voice.language}`; + const existingVoice = voiceMap.get(key); + + // If we don't have this voice yet, or if the current voice is of higher quality + if (!existingVoice || this.getQualityValue(voice.quality) > this.getQualityValue(existingVoice.quality)) { + voiceMap.set(key, voice); + } + } + + return Array.from(voiceMap.values()); + } + + /** + * Get test utterance for a given language + * @param language - Language code (e.g., "en", "fr", "es") + * @returns Promise that resolves to the test utterance text + */ + getTestUtterance(language: string): string { + if (!language) return ""; + + // Try direct match + const utterance = getTestUtterance(language); + if (utterance) return utterance; + + // Try with base language as fallback + const [baseLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(language); + if (baseLang && baseLang !== language) { + const baseUtterance = getTestUtterance(baseLang); + if (baseUtterance) return baseUtterance; + } + + return ""; + } + + /** + * Get all voices matching the filter criteria + * @returns Promise that resolves to an array of filtered voices + */ + getVoices(options: VoiceFilterOptions = {}): ReadiumSpeechVoice[] { + if (!this.isInitialized) { + throw new Error("WebSpeechVoiceManager not initialized. Call initialize() first."); + } + + // Set default values for filter options + const filterOptions: VoiceFilterOptions = { + excludeNovelty: true, // Default to true to filter out novelty voices + excludeVeryLowQuality: true, // Default to true to filter out very low quality voices + ...options // Let explicit options override the defaults + }; + + return this.filterVoices([...this.voices], filterOptions); + } + + /** + * Get available languages with voice counts + * @param localization Optional BCP 47 language tag to use for language names + * @param filterOptions Optional filters to apply to voices before counting languages + */ + getLanguages(localization?: string, filterOptions?: VoiceFilterOptions): LanguageInfo[] { + if (!this.isInitialized) { + throw new Error("WebSpeechVoiceManager not initialized. Call initialize() first."); + } + + const languages = new Map(); + + // Apply filters if provided + const voicesToCount = filterOptions ? this.filterVoices([...this.voices], filterOptions) : this.voices; + + voicesToCount.forEach(voice => { + const langCode = voice.language; + const [baseLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(langCode); + + // Use the base language code for grouping (e.g., "en" for both "en-US" and "en-GB") + const key = baseLang; + const displayName = WebSpeechVoiceManager.getLanguageDisplayName(baseLang, localization); + + const entry = languages.get(key) || { count: 0, label: displayName, code: baseLang }; + languages.set(key, { ...entry, count: entry.count + 1 }); + }); + + // Convert to array and sort + return Array.from(languages.entries()) + .map(([_, { code, label, count }]) => ({ + code, + label, + count + })) + .sort((a, b) => a.label.localeCompare(b.label)); + } + + /** + * Get available regions with voice counts + */ + getRegions(localization?: string): LanguageInfo[] { + if (!this.isInitialized) { + throw new Error("WebSpeechVoiceManager not initialized. Call initialize() first."); + } + + const regions = new Map(); + + this.voices.forEach(voice => { + const [, region] = WebSpeechVoiceManager.extractLangRegionFromBCP47(voice.language); + if (region) { + const entry = regions.get(region) || { count: 0, label: voice.language }; + regions.set(region, { ...entry, count: entry.count + 1 }); + } + }); + + return Array.from(regions.entries()).map(([code, { count, label }]) => { + let displayName = label; + try { + const locale = localization || navigator.language; + displayName = new Intl.DisplayNames([locale], { type: "region" }).of(code) || label; + } catch (e) { + console.warn(`Failed to get display name for region ${code}`, e); + } + return { + code, + label: displayName, + count + }; + }); + } + + + /** + * Get the default voice for a language + * @param language The language code to get the default voice for (e.g., "en-US") + * @param voices Optional pre-filtered voices array to use instead of fetching voices + * @returns The default voice for the language, or null if no voices are available + */ + getDefaultVoice(language: string, voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice | null { + if (!language) return null; + + // Use provided voices or get filtered voices if not provided + let filteredVoices = voices || this.getVoices({ language }); + if (!filteredVoices.length) return null; + + // First sort by quality (highest first) + filteredVoices = this.sortVoices(filteredVoices, { + by: "quality", + order: "desc" + }); + + // Then sort by language to ensure we get the best match for the requested language + filteredVoices = this.sortVoices(filteredVoices, { + by: "language", + order: "asc", + preferredLanguages: [language] + }); + + // Return the best available voice (already sorted by quality and language) + return filteredVoices[0]; + } + + getBrowserVoices(maxTimeout = 10000, interval = 10): Promise { + const getVoices = () => window.speechSynthesis?.getVoices() || []; + + // Check if speechSynthesis is available + if (!window.speechSynthesis) { + return Promise.resolve([]); + } + + // Step 1: Try to load voices directly (best case scenario) + const voices = getVoices(); + if (Array.isArray(voices) && voices.length) return Promise.resolve(voices); + + return new Promise((resolve, reject) => { + // Calculate iterations from total timeout + let counter = Math.floor(maxTimeout / interval); + // Flag to ensure polling only starts once + let pollingStarted = false; + + // Polling function: Checks for voices periodically until counter expires + const startPolling = () => { + // Prevent multiple starts + if (pollingStarted) return; + pollingStarted = true; + + const tick = () => { + // Resolve with empty array if no voices found + if (counter < 1) return resolve([]); + --counter; + const voices = getVoices(); + // Resolve if voices loaded + if (Array.isArray(voices) && voices.length) return resolve(voices); + // Continue polling + setTimeout(tick, interval); + }; + // Initial start + setTimeout(tick, interval); + }; + + // Step 2: Use onvoiceschanged if available (prioritizes event over polling) + if (window.speechSynthesis.onvoiceschanged !== undefined) { + window.speechSynthesis.onvoiceschanged = () => { + const voices = getVoices(); + if (Array.isArray(voices) && voices.length) { + // Resolve immediately if voices are available + resolve(voices); + } else { + // Fallback to polling if event fires but no voices + startPolling(); + } + }; + } else { + // Step 3: No onvoiceschanged support, start polling directly + startPolling(); + } + + // Step 4: Overall safety timeout - resolve with empty array if nothing happens + setTimeout(() => resolve([]), maxTimeout); + }); + } + + /** + * Convert SpeechSynthesisVoice array to ReadiumSpeechVoice array + * @private + */ + private parseToReadiumSpeechVoices(speechVoices: SpeechSynthesisVoice[]): ReadiumSpeechVoice[] { + const parseAndFormatBCP47 = (lang: string) => { + const speechVoiceLang = lang.replace(/_/g, "-"); + if (/\w{2,3}-\w{2,3}/.test(speechVoiceLang)) { + return `${speechVoiceLang.split("-")[0].toLowerCase()}-${speechVoiceLang.split("-")[1].toUpperCase()}`; + } + return lang; + }; + + // First, map all browser voices to ReadiumSpeechVoice format + const mappedVoices = speechVoices + .filter(voice => voice && voice.name && voice.lang) + .map(voice => { + const formattedLang = parseAndFormatBCP47(voice.lang); + const [baseLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(formattedLang); + + // Get voices for the specific language + const langVoices = getVoices(baseLang); + + // Extract base name by removing anything in parentheses for matching + const baseName = voice.name.split("(")[0].trim(); + + // Try to find a matching voice by name, including base name matching + const jsonVoice = langVoices.find(v => { + // Check direct name match + if (v.name === voice.name || v.name === baseName) return true; + + // Check alt names + if (v.altNames) { + return v.altNames.some((name: string) => { + const altBaseName = name.split("(")[0].trim(); + return name === voice.name || + name === baseName || + altBaseName === voice.name || + altBaseName === baseName; + }); + } + + return false; + }); + + if (jsonVoice) { + // Found a match in JSON data, merge with browser voice + return { + ...jsonVoice, + source: "json", + // Preserve browser-specific properties + voiceURI: voice.voiceURI, + isDefault: voice.default || false, + offlineAvailability: voice.localService || false, + // Use utility functions from filters.ts + isNovelty: isNoveltyVoice(voice.name, voice.voiceURI), + isLowQuality: isVeryLowQualityVoice(voice.name, jsonVoice.quality) + } as ReadiumSpeechVoice; + } + + // No match found in JSON, create basic voice object + return { + source: "browser", + label: voice.name, + name: voice.name, + voiceURI: voice.voiceURI, + language: formattedLang, + isDefault: voice.default || false, + offlineAvailability: voice.localService || false, + isNovelty: isNoveltyVoice(voice.name, voice.voiceURI), + isLowQuality: isVeryLowQualityVoice(voice.name) + } as ReadiumSpeechVoice; + }); + + // Remove duplicates before returning + return this.removeDuplicate(mappedVoices); + } + + /** + * Convert an ReadiumSpeechVoice to a native SpeechSynthesisVoice + */ + convertToSpeechSynthesisVoice(voice: ReadiumSpeechVoice): SpeechSynthesisVoice | undefined { + if (!voice) return undefined; + + return this.browserVoices.find(v => + v.voiceURI === voice.voiceURI || + v.name === voice.name + ); + } + + /** + * Filter voices based on the provided options + */ + filterVoices(voices: ReadiumSpeechVoice[], options: VoiceFilterOptions): ReadiumSpeechVoice[] { + let result = [...voices]; + + if (options.language) { + const langs = Array.isArray(options.language) ? options.language : [options.language]; + + result = result.filter(voice => { + return langs.some(requestedLang => { + const reqLang = requestedLang.toLowerCase(); + const voiceLang = voice.language?.toLowerCase(); + const voiceAltLang = voice.altLanguage?.toLowerCase(); + + // Check direct matches first + if (voiceLang === reqLang || voiceAltLang === reqLang) { + return true; + } + + // Then check base language matches + const [reqBase] = reqLang.split("-"); + return (voiceLang && voiceLang.startsWith(reqBase)) || + (voiceAltLang && voiceAltLang.startsWith(reqBase)); + }); + }); + } + + if (options.source) { + result = result.filter(v => v.source === options.source); + } + + if (options.gender) { + result = result.filter(v => v.gender === options.gender); + } + + if (options.quality) { + const qualities = Array.isArray(options.quality) ? options.quality : [options.quality]; + result = result.filter(v => + v.quality && v.quality.some(q => qualities.includes(q as any)) + ); + } + + if (options.offlineOnly) { + result = result.filter(v => v.offlineAvailability === true); + } + + if (options.provider) { + result = result.filter(v => + v.provider?.toLowerCase() === options.provider?.toLowerCase() + ); + } + + if (options.excludeNovelty) { + result = filterOutNoveltyVoices(result); + } + + if (options.excludeVeryLowQuality) { + result = filterOutVeryLowQualityVoices(result); + } + + return result; + } + + /** + * Filter out novelty voices + * @param voices Array of voices to filter + * @returns Filtered array with novelty voices removed + */ + filterOutNoveltyVoices(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + return filterOutNoveltyVoices(voices); + } + + /** + * Filter out very low quality voices + * @param voices Array of voices to filter + * @returns Filtered array with very low quality voices removed + */ + filterOutVeryLowQualityVoices(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + return filterOutVeryLowQualityVoices(voices); + } + + /** + * Get the numeric value for a quality level + * @private + */ + private getQualityValue(quality: TQuality | TQuality[] | undefined): number { + const qualityOrder: Record = { + "veryLow": 0, + "low": 1, + "normal": 2, + "high": 3, + "veryHigh": 4 + }; + + if (!quality) return 1; // "low" as fallback + + // Handle both single quality values and arrays + if (Array.isArray(quality)) { + return Math.max(...quality.map(q => qualityOrder[q] ?? 1)); + } + + // Fallback for single quality values + return qualityOrder[quality] ?? 1; + } + + /** + * Sort voices by the specified criteria + */ + sortVoices(voices: ReadiumSpeechVoice[], options: SortOptions): ReadiumSpeechVoice[] { + if (!voices?.length) return []; + + const result = [...voices]; + + switch (options.by) { + case "name": + result.sort((a, b) => + options.order === "desc" + ? b.name.localeCompare(a.name) + : a.name.localeCompare(b.name) + ); + break; + + case "language": + result.sort((a, b) => { + const [aLang, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); + const [bLang, bRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); + + // Get display names for both languages for comparison + const aDisplayName = WebSpeechVoiceManager.getLanguageDisplayName(aLang).toLowerCase(); + const bDisplayName = WebSpeechVoiceManager.getLanguageDisplayName(bLang).toLowerCase(); + + // If preferredLanguages is provided, prioritize them + if (options.preferredLanguages?.length) { + const aIndex = options.preferredLanguages.findIndex(prefLang => { + const [prefLangBase, prefRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(prefLang); + // Match both language and region if specified in preferred language + return aLang === prefLangBase.toLowerCase() && + (!prefRegion || !aRegion || prefRegion === aRegion); + }); + + const bIndex = options.preferredLanguages.findIndex(prefLang => { + const [prefLangBase, prefRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(prefLang); + return bLang === prefLangBase.toLowerCase() && + (!prefRegion || !bRegion || prefRegion === bRegion); + }); + + // If both languages are in preferred list, sort by their position + if (aIndex !== -1 && bIndex !== -1) { + // If same preferred language but different regions, sort by region if specified + if (aIndex === bIndex && aRegion && bRegion) { + return options.order === "desc" + ? bRegion.localeCompare(aRegion) + : aRegion.localeCompare(bRegion); + } + return options.order === "desc" ? bIndex - aIndex : aIndex - bIndex; + } + // If only one language is in preferred list, it comes first + if (aIndex !== -1) return -1; + if (bIndex !== -1) return 1; + } + + // Sort by display name for all languages + const compare = aDisplayName.localeCompare(bDisplayName); + + // If same display name, sort by region if available + if (compare === 0 && aRegion && bRegion) { + return options.order === "desc" + ? bRegion.localeCompare(aRegion) + : aRegion.localeCompare(bRegion); + } + + return options.order === "desc" ? -compare : compare; + }); + break; + + case "gender": + result.sort((a, b) => { + const aGender = a.gender || ""; + const bGender = b.gender || ""; + return options.order === "desc" + ? bGender.localeCompare(aGender) + : aGender.localeCompare(bGender); + }); + break; + + case "quality": + result.sort((a, b) => { + const aQuality = this.getQualityValue(a.quality); + const bQuality = this.getQualityValue(b.quality); + + return options.order === "desc" + ? bQuality - aQuality // desc: high quality first + : aQuality - bQuality; // asc: low quality first + }); + break; + + case "region": + result.sort((a, b) => { + const [aLang, aRegion = ""] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); + const [bLang, bRegion = ""] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); + + // If preferredLanguages is provided, prioritize exact matches first + if (options.preferredLanguages?.length) { + // Check for exact language-region matches first (e.g., "en-US" matches "en-US") + const aExactMatchIndex = options.preferredLanguages.findIndex(prefLang => { + const [prefLangBase, prefRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(prefLang); + return aLang === prefLangBase.toLowerCase() && + aRegion === prefRegion?.toUpperCase(); + }); + + const bExactMatchIndex = options.preferredLanguages.findIndex(prefLang => { + const [prefLangBase, prefRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(prefLang); + return bLang === prefLangBase.toLowerCase() && + bRegion === prefRegion?.toUpperCase(); + }); + + // If one has an exact match and the other doesn't, the exact match comes first + if (aExactMatchIndex !== -1 && bExactMatchIndex === -1) return -1; + if (aExactMatchIndex === -1 && bExactMatchIndex !== -1) return 1; + + // If both have exact matches, sort by their position in preferredLanguages + if (aExactMatchIndex !== -1 && bExactMatchIndex !== -1 && aExactMatchIndex !== bExactMatchIndex) { + return aExactMatchIndex - bExactMatchIndex; + } + + // Then check for language-only matches (e.g., "en" matches "en-US") + const aLangMatchIndex = options.preferredLanguages.findIndex(prefLang => { + const [prefLangBase] = WebSpeechVoiceManager.extractLangRegionFromBCP47(prefLang); + return aLang === prefLangBase.toLowerCase(); + }); + + const bLangMatchIndex = options.preferredLanguages.findIndex(prefLang => { + const [prefLangBase] = WebSpeechVoiceManager.extractLangRegionFromBCP47(prefLang); + return bLang === prefLangBase.toLowerCase(); + }); + + // If one has a language match and the other doesn't, the language match comes first + if (aLangMatchIndex !== -1 && bLangMatchIndex === -1) return -1; + if (aLangMatchIndex === -1 && bLangMatchIndex !== -1) return 1; + + // If both have language matches, sort by their position in preferredLanguages + if (aLangMatchIndex !== -1 && bLangMatchIndex !== -1 && aLangMatchIndex !== bLangMatchIndex) { + return aLangMatchIndex - bLangMatchIndex; + } + } + + // If no preferred language matches, sort alphabetically by region + const regionCompare = options.order === "desc" + ? bRegion.localeCompare(aRegion) + : aRegion.localeCompare(bRegion); + + // If regions are the same, sort by language + return regionCompare === 0 + ? aLang.localeCompare(bLang) + : regionCompare; + }); + break; + } + + return result; + } + + /** + * Group voices by the specified criteria + * @param voices Array of voices to group + * @param options Grouping options + * @returns Object with voice groups keyed by the grouping criteria + */ + groupVoices(voices: ReadiumSpeechVoice[], by: GroupBy): VoiceGroup { + const groups: VoiceGroup = {}; + + for (const voice of voices) { + let key = "Unknown"; + + switch (by) { + case "language": + key = WebSpeechVoiceManager.extractLangRegionFromBCP47(voice.language)[0]; + break; + + case "gender": + key = voice.gender || "unknown"; + break; + + case "quality": + key = voice.quality?.[0] || "unknown"; + break; + + case "region": + const [, region] = WebSpeechVoiceManager.extractLangRegionFromBCP47(voice.language); + key = region || "unknown"; + break; + } + + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(voice); + } + + return groups; + } +} diff --git a/src/WebSpeech/index.ts b/src/WebSpeech/index.ts new file mode 100644 index 0000000..4fa73db --- /dev/null +++ b/src/WebSpeech/index.ts @@ -0,0 +1,5 @@ +export * from "./WebSpeechVoiceManager"; +export * from "./webSpeechEngine"; +export * from "./webSpeechEngineProvider"; + +export * from "./TmpNavigator"; \ No newline at end of file diff --git a/src/WebSpeech/webSpeechEngine.ts b/src/WebSpeech/webSpeechEngine.ts index 45b629e..5fd6d21 100644 --- a/src/WebSpeech/webSpeechEngine.ts +++ b/src/WebSpeech/webSpeechEngine.ts @@ -1,8 +1,8 @@ import { ReadiumSpeechPlaybackEngine } from "../engine"; import { ReadiumSpeechPlaybackEvent, ReadiumSpeechPlaybackState } from "../navigator"; import { ReadiumSpeechUtterance } from "../utterance"; -import { ReadiumSpeechVoice } from "../voices"; -import { getSpeechSynthesisVoices, parseSpeechSynthesisVoices, filterOnLanguage } from "../voices"; +import { ReadiumSpeechVoice } from "../voices/types"; +import { WebSpeechVoiceManager } from "./WebSpeechVoiceManager"; import { detectFeatures, WebSpeechFeatures } from "../utils/features"; import { detectPlatformFeatures, WebSpeechPlatformPatches } from "../utils/patches"; @@ -18,8 +18,8 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { private playbackState: ReadiumSpeechPlaybackState = "idle"; private eventListeners: Map void)[]> = new Map(); + private voiceManager: WebSpeechVoiceManager | null = null; private voices: ReadiumSpeechVoice[] = []; - private browserVoices: SpeechSynthesisVoice[] = []; private defaultVoice: ReadiumSpeechVoice | null = null; // Enhanced properties for cross-browser compatibility @@ -73,7 +73,7 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { interval?: number; maxLengthExceeded?: "error" | "none" | "warn"; } = {}): Promise { - const { maxTimeout = 10000, interval = 10, maxLengthExceeded = "warn" } = options; + const { maxTimeout, interval, maxLengthExceeded = "warn" } = options; if (this.initialized) { return false; @@ -82,14 +82,12 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { this.maxLengthExceeded = maxLengthExceeded; try { - // Get and cache the browser's native voices - this.browserVoices = await getSpeechSynthesisVoices(maxTimeout, interval); - // Parse them into our internal format - this.voices = parseSpeechSynthesisVoices(this.browserVoices); + // Initialize voice manager with provided options and get voices + this.voiceManager = await WebSpeechVoiceManager.initialize(maxTimeout, interval); + this.voices = this.voiceManager.getVoices(); - // Try to find voice matching user's language - const langVoices = filterOnLanguage(this.voices); - this.defaultVoice = langVoices.length > 0 ? langVoices[0] : this.voices[0] || null; + // Find the best matching voice for the user's language using the optimized method + this.defaultVoice = this.voiceManager.getDefaultVoice(navigator.language || "en", this.voices); this.initialized = true; return true; @@ -155,18 +153,16 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { if (typeof voice === "string") { // Find voice by name or language - this.getAvailableVoices().then(voices => { - const foundVoice = voices.find(v => v.name === voice || v.language === voice); - if (foundVoice) { - this.currentVoice = foundVoice; - // Reset position when voice changes for fresh start with new voice - if (previousVoice && previousVoice.name !== foundVoice.name) { - this.currentUtteranceIndex = 0; - } - } else { - console.warn(`Voice "${voice}" not found`); + const foundVoice = this.voices.find(v => v.name === voice || v.language === voice); + if (foundVoice) { + this.currentVoice = foundVoice; + // Reset position when voice changes for fresh start with new voice + if (previousVoice && previousVoice.name !== foundVoice.name) { + this.currentUtteranceIndex = 0; } - }); + } else { + console.warn(`Voice "${voice}" not found`); + } } else { this.currentVoice = voice; // Reset position when voice changes for fresh start with new voice @@ -266,13 +262,9 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { // Enhanced voice selection with MSNatural detection const selectedVoice = this.getCurrentVoiceForUtterance(this.currentVoice); - if (selectedVoice) { - // Find the matching voice in our cached browser voices - // as converting ReadiumSpeechVoice to SpeechSynthesisVoice is not possible - const nativeVoice = this.browserVoices.find(v => - v.name === selectedVoice.name && - v.lang === (selectedVoice.__lang || selectedVoice.language) - ); + if (selectedVoice && this.voiceManager) { + // Convert ReadiumSpeechVoice to SpeechSynthesisVoice using the initialized voiceManager + const nativeVoice = this.voiceManager.convertToSpeechSynthesisVoice(selectedVoice); if (nativeVoice) { utterance.voice = nativeVoice; // Use the real native voice from cache diff --git a/src/WebSpeech/webSpeechEngineProvider.ts b/src/WebSpeech/webSpeechEngineProvider.ts index ad82fae..c5db3eb 100644 --- a/src/WebSpeech/webSpeechEngineProvider.ts +++ b/src/WebSpeech/webSpeechEngineProvider.ts @@ -1,6 +1,6 @@ import { ReadiumSpeechEngineProvider } from "../provider"; import { ReadiumSpeechPlaybackEngine } from "../engine"; -import { ReadiumSpeechVoice } from "../voices"; +import { ReadiumSpeechVoice } from "../voices/types"; import { WebSpeechEngine } from "./webSpeechEngine"; export class WebSpeechEngineProvider implements ReadiumSpeechEngineProvider { diff --git a/src/data.gen.ts b/src/data.gen.ts deleted file mode 100644 index e747d4b..0000000 --- a/src/data.gen.ts +++ /dev/null @@ -1,32 +0,0 @@ - -// https://github.com/readium/speech -// file script-generated by : npm run extract-json-data -// - -export const novelty = ["Albert","Bad News","Bahh","Bells","Boing","Bubbles","Cellos","Good News","Jester","Organ","Superstar","Trinoids","Whisper","Wobble","Zarvox"]; - -export const veryLowQuality = ["Eddy","Flo","Grandma","Grandpa","Jacques","Reed","Rocko","Sandy","Shelley","Fred","Junior","Kathy","Ralph","eSpeak Arabic","eSpeak Bulgarian","eSpeak Bengali","eSpeak Catalan","eSpeak Chinese (Mandarin, latin as English)","eSpeak Czech","eSpeak Danish","eSpeak German","eSpeak Greek","eSpeak Spanish (Spain)","eSpeak Estonian","eSpeak Finnish","eSpeak Gujarati","eSpeak Croatian","eSpeak Hungarian","eSpeak Indonesian","eSpeak Italian","eSpeak Kannada","eSpeak Korean","eSpeak Lithuanian","eSpeak Latvian","eSpeak Malayalm","eSpeak Marathi","eSpeak Malay","eSpeak Norwegian Bokmål","eSpeak Polish","eSpeak Portuguese (Brazil)","eSpeak Romanian","eSpeak Russian","eSpeak Slovak","eSpeak Slovenian","eSpeak Serbian","eSpeak Swedish","eSpeak Swahili","eSpeak Tamil","eSpeak Telugu","eSpeak Turkish","eSpeak Vietnamese (Northern)"]; - -export type TGender = "female" | "male" | "nonbinary" -export type TQuality = "veryLow" | "low" | "normal" | "high" | "veryHigh"; - -export interface IRecommended { - label: string; - name: string; - altNames?: string[]; - language: string; - gender?: TGender | undefined; - age?: string | undefined; - quality: TQuality[]; - recommendedPitch?: number | undefined; - recommendedRate?: number | undefined; - localizedName: string; -}; - -export const recommended: Array = [{"label":"Amina","name":"Microsoft Amina Online (Natural) - Arabic (Algeria)","language":"ar-DZ","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Ismael","name":"Microsoft Ismael Online (Natural) - Arabic (Algeria)","language":"ar-DZ","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Laila","name":"Microsoft Laila Online (Natural) - Arabic (Bahrain)","language":"ar-BH","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Ali","name":"Microsoft Ali Online (Natural) - Arabic (Bahrain)","language":"ar-BH","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Salma","name":"Microsoft Salma Online (Natural) - Arabic (Egypt)","language":"ar-EG","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Shakir","name":"Microsoft Shakir Online (Natural) - Arabic (Egypt)","language":"ar-EG","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Rana","name":"Microsoft Rana Online (Natural) - Arabic (Iraq)","language":"ar-IQ","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Bassel","name":"Microsoft Bassel Online (Natural) - Arabic (Iraq)","language":"ar-IQ","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Sana","name":"Microsoft Sana Online (Natural) - Arabic (Jordan)","language":"ar-JO","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Taim","name":"Microsoft Taim Online (Natural) - Arabic (Jordan)","language":"ar-JO","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Noura","name":"Microsoft Noura Online (Natural) - Arabic (Kuwait)","language":"ar-KW","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Fahed","name":"Microsoft Fahed Online (Natural) - Arabic (Kuwait)","language":"ar-KW","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Layla","name":"Microsoft Layla Online (Natural) - Arabic (Lebanon)","language":"ar-LB","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Rami","name":"Microsoft Rami Online (Natural) - Arabic (Lebanon)","language":"ar-LB","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Iman","name":"Microsoft Iman Online (Natural) - Arabic (Libya)","language":"ar-LY","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Omar","name":"Microsoft Omar Online (Natural) - Arabic (Libya)","language":"ar-LY","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Mouna","name":"Microsoft Mouna Online (Natural) - Arabic (Morocco)","language":"ar-MA","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Jamal","name":"Microsoft Jamal Online (Natural) - Arabic (Morocco)","language":"ar-MA","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Aysha","name":"Microsoft Aysha Online (Natural) - Arabic (Oman)","language":"ar-OM","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Abdullah","name":"Microsoft Abdullah Online (Natural) - Arabic (Oman)","language":"ar-OM","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Amal","name":"Microsoft Amal Online (Natural) - Arabic (Qatar)","language":"ar-QA","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Moaz","name":"Microsoft Moaz Online (Natural) - Arabic (Qatar)","language":"ar-QA","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Zariyah","name":"Microsoft Zariyah Online (Natural) - Arabic (Saudi Arabia)","language":"ar-SA","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Hamed","name":"Microsoft Hamed Online (Natural) - Arabic (Saudi Arabia)","language":"ar-SA","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Amany","name":"Microsoft Amany Online (Natural) - Arabic (Syria)","language":"ar-SY","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Laith","name":"Microsoft Laith Online (Natural) - Arabic (Syria)","language":"ar-SY","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Reem","name":"Microsoft Reem Online (Natural) - Arabic (Tunisia)","language":"ar-TN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Hedi","name":"Microsoft Hedi Online (Natural) - Arabic (Tunisia)","language":"ar-TN","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Fatima","name":"Microsoft Fatima Online (Natural) - Arabic (United Arab Emirates)","language":"ar-AE","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Hamdan","name":"Microsoft Hamdan Online (Natural) - Arabic (United Arab Emirates)","language":"ar-AE","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Maryam","name":"Microsoft Maryam Online (Natural) - Arabic (Yemen)","language":"ar-YE","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Saleh","name":"Microsoft Saleh Online (Natural) - Arabic (Yemen)","language":"ar-YE","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Mariam","name":"Mariam","language":"ar-001","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Apple Laila","name":"Laila","language":"ar-001","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Tarik","name":"Tarik","language":"ar-001","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Majed","name":"Majed","language":"ar-001","gender":"male","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Hoda","name":"Microsoft Hoda - Arabic (Arabic )","language":"ar-EG","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Naayf","name":"Microsoft Naayf - Arabic (Saudi Arabia)","language":"ar-AS","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"صوت انثوي 1","name":"Android Speech Recognition and Synthesis from Google ar-xa-x-arc-network","altNames":["Android Speech Recognition and Synthesis from Google ar-xa-x-arc-local","Android Speech Recognition and Synthesis from Google ar-language"],"language":"ar","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"صوت انثوي 2","name":"Android Speech Recognition and Synthesis from Google ar-xa-x-arz-network","altNames":["Android Speech Recognition and Synthesis from Google ar-xa-x-arz-local"],"language":"ar","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"صوت ذكر 1","name":"Android Speech Recognition and Synthesis from Google ar-xa-x-ard-network","altNames":["Android Speech Recognition and Synthesis from Google ar-xa-x-ard-local"],"language":"ar","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"صوت ذكر 2","name":"Android Speech Recognition and Synthesis from Google ar-xa-x-are-network","altNames":["Android Speech Recognition and Synthesis from Google ar-xa-x-are-local"],"language":"ar","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kalina","name":"Microsoft Kalina Online (Natural) - Bulgarian (Bulgaria)","language":"bg-BG","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Borislav","name":"Microsoft Borislav Online (Natural) - Bulgarian (Bulgaria)","language":"bg-BG","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Daria","name":"Daria","language":"bg-BG","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Ivan","name":"Microsoft Ivan - Bulgarian (Bulgaria)","language":"bg-BG","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Женски глас","name":"Android Speech Recognition and Synthesis from Google bg-bg-x-ifk-network","altNames":["Android Speech Recognition and Synthesis from Google bg-bg-x-ifk-local","Android Speech Recognition and Synthesis from Google bg-bg-language"],"language":"bg-BG","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Jaya","name":"Jaya","language":"bho-IN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Tanishaa","name":"Microsoft Tanishaa Online (Natural) - Bengali (India)","language":"bn-IN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Bashkar","name":"Microsoft Bashkar Online (Natural) - Bangla (India)","language":"bn-IN","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Nabanita","name":"Microsoft Nabanita Online (Natural) - Bangla (Bangladesh)","language":"bn-BD","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Pradeep","name":"Microsoft Pradeep Online (Natural) - Bangla (Bangladesh)","language":"bn-BD","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Piya","name":"Piya","language":"bn-IN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"মহিলা কণ্ঠস্বর 1","name":"Android Speech Recognition and Synthesis from Google bn-in-x-bnf-network","altNames":["Android Speech Recognition and Synthesis from Google bn-in-x-bnf-local","Android Speech Recognition and Synthesis from Google bn-IN-language"],"language":"bn-IN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"মহিলা কণ্ঠস্বর 2","name":"Android Speech Recognition and Synthesis from Google bn-in-x-bnx-network","altNames":["Android Speech Recognition and Synthesis from Google bn-in-x-bnx-local"],"language":"bn-IN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"পুরুষ কন্ঠ 1","name":"Android Speech Recognition and Synthesis from Google bn-in-x-bin-network","altNames":["Android Speech Recognition and Synthesis from Google bn-in-x-bin-local"],"language":"bn-IN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"পুরুষ কন্ঠ 2","name":"Android Speech Recognition and Synthesis from Google bn-in-x-bnm-network","altNames":["Android Speech Recognition and Synthesis from Google bn-in-x-bnm-local"],"language":"bn-IN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"পুরুষ কন্ঠ","name":"Google বাংলা (Natural)","altNames":["Android Speech Recognition and Synthesis from Google bn-bd-x-ban-network","Chrome OS বাংলা","Android Speech Recognition and Synthesis from Google bn-bd-x-ban-local","Android Speech Recognition and Synthesis from Google bn-BD-language"],"language":"bn-BD","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Joana (Català)","name":"Microsoft Joana Online (Natural) - Catalan (Spain)","language":"ca-ES","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Enric (Català)","name":"Microsoft Enric Online (Natural) - Catalan (Spain)","language":"ca-ES","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Montse (Català)","name":"Montse","language":"ca-ES","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Pau (Valencià)","name":"Pau","language":"ca-ES-u-sd-esvc","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Jordi (Català)","name":"Jordi","language":"ca-ES","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Herena (Català)","name":"Microsoft Herena - Catalan (Spain)","language":"ca-ES","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Veu femenina catalana","name":"Android Speech Recognition and Synthesis from Google ca-es-x-caf-network","altNames":["Android Speech Recognition and Synthesis from Google ca-es-x-caf-local","Android Speech Recognition and Synthesis from Google ca-ES-language"],"language":"ca-ES","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Xiaoxiao","name":"Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland)","language":"cmn-CN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Xiaoyi","name":"Microsoft Xiaoyi Online (Natural) - Chinese (Mainland)","language":"cmn-CN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Yunxi","name":"Microsoft Yunxi Online (Natural) - Chinese (Mainland)","language":"cmn-CN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Yunxia","name":"Microsoft Yunxia Online (Natural) - Chinese (Mainland)","language":"cmn-CN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Xiaobei","name":"Microsoft Xiaobei Online (Natural) - Chinese (Northeastern Mandarin)","language":"cmn-CN-liaoning","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Xiaoni","name":"Microsoft Xiaoni Online (Natural) - Chinese (Zhongyuan Mandarin Shaanxi)","language":"cmn-CN-shaanxi","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Yunjian","name":"Microsoft Yunjian Online (Natural) - Chinese (Mainland)","language":"cmn-CN","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Yunyang","name":"Microsoft Yunyang Online (Natural) - Chinese (Mainland)","language":"cmn-CN","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"HsiaoChen","name":"Microsoft HsiaoChen Online (Natural) - Chinese (Taiwan)","language":"cmn-TW","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"HsiaoYu","name":"Microsoft HsiaoYu Online (Natural) - Chinese (Taiwanese Mandarin)","language":"cmn-TW","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"YunJhe","name":"Microsoft YunJhe Online (Natural) - Chinese (Taiwan)","language":"cmn-TW","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Lilian","name":"Lilian","language":"cmn-CN","gender":"female","quality":["normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Tiantian","name":"Tiantian","language":"cmn-CN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Shasha","name":"Shasha","language":"cmn-CN","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Lili","name":"Lili","language":"cmn-CN","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Lisheng","name":"Lisheng","language":"cmn-CN","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Lanlan","name":"Lanlan","language":"cmn-CN","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Shanshan","name":"Shanshan","language":"cmn-CN","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Yue","name":"Yue","language":"cmn-CN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Tingting","name":"Tingting","language":"cmn-CN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Yu-shu","name":"Yu-shu","language":"cmn-CN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Dongmei","name":"Dongmei","language":"cmn-CN-liaoning","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Panpan","name":"Panpan","language":"cmn-CN-sichuan","gender":"female","quality":["low","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Meijia","name":"Meijia","language":"cmn-TW","gender":"female","quality":["low","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Han","name":"Han","language":"cmn-CN","gender":"male","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Bobo","name":"Bobo","language":"cmn-CN","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Taotao","name":"Taotao","language":"cmn-CN","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Binbin","name":"Binbin","language":"cmn-CN","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Li-Mu","name":"Li-Mu","language":"cmn-CN","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Haohao","name":"Haohao","language":"cmn-CN-shaanxi","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Google 女声","name":"Google 普通话(中国大陆)","language":"cmn-CN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Google 女聲","name":"Google 國語(臺灣)","language":"cmn-TW","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Huihui","name":"Microsoft Huihui - Chinese (Simplified, PRC)","language":"cmn-CN","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Yaoyao","name":"Microsoft Yaoyao - Chinese (Simplified, PRC)","language":"cmn-CN","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kangkang","name":"Microsoft Kangkang - Chinese (Simplified, PRC)","language":"cmn-CN","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Yating","name":"Microsoft Yating - Chinese (Traditional, Taiwan)","language":"cmn-TW","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Hanhan","name":"Microsoft Hanhan - Chinese (Traditional, Taiwan)","language":"cmn-TW","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Zhiwei","name":"Microsoft Zhiwei - Chinese (Traditional, Taiwan)","language":"cmn-TW","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"女声1","name":"Android Speech Recognition and Synthesis from Google cmn-CN-x-ccc-network","altNames":["Android Speech Recognition and Synthesis from Google cmn-CN-x-ccc-local","Android Speech Recognition and Synthesis from Google zh-CN-language"],"language":"cmn-CN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"女声2","name":"Android Speech Recognition and Synthesis from Google cmn-CN-x-ssa-network","altNames":["Android Speech Recognition and Synthesis from Google cmn-CN-x-ssa-local"],"language":"cmn-CN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"男声1","name":"Android Speech Recognition and Synthesis from Google cmn-CN-x-ccd-network","altNames":["Android Speech Recognition and Synthesis from Google cmn-CN-x-ccd-local"],"language":"cmn-CN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"男声2","name":"Android Speech Recognition and Synthesis from Google cmn-CN-x-cce-network","altNames":["Android Speech Recognition and Synthesis from Google cmn-CN-x-cce-local"],"language":"cmn-CN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"女聲","name":"Android Speech Recognition and Synthesis from Google cmn-TW-x-ctc-network","altNames":["Android Speech Recognition and Synthesis from Google cmn-TW-x-ctc-local","Android Speech Recognition and Synthesis from Google zh-TW-language"],"language":"cmn-TW","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"男聲1","name":"Android Speech Recognition and Synthesis from Google cmn-TW-x-ctd-network","altNames":["Chrome OS 粵語 1","Android Speech Recognition and Synthesis from Google cmn-TW-x-ctd-local"],"language":"cmn-TW","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"男聲2","name":"Android Speech Recognition and Synthesis from Google cmn-TW-x-cte-network","altNames":["Chrome OS 粵語 1","Android Speech Recognition and Synthesis from Google cmn-TW-x-cte-local"],"language":"cmn-CTW","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Vlasta","name":"Microsoft Vlasta Online (Natural) - Czech (Czech)","language":"cs-CZ","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Antonin","name":"Microsoft Antonin Online (Natural) - Czech (Czech)","language":"cs-CZ","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Zuzana","name":"Zuzana","language":"cs-CZ","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Iveta","name":"Iveta","language":"cs-CZ","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Jakub","name":"Microsoft Jakub - Czech (Czech)","language":"cs-CZ","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Ženský hlas","name":"Google čeština (Natural)","altNames":["Android Speech Recognition and Synthesis from Google cs-cz-x-jfs-network","Chrome OS čeština","Android Speech Recognition and Synthesis from Google cs-cz-x-jfs-local","Android Speech Recognition and Synthesis from Google cs-CZ-language"],"language":"cs-CZ","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Christel","name":"Microsoft Christel Online (Natural) - Danish (Denmark)","language":"da-DK","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Jeppe","name":"Microsoft Jeppe Online (Natural) - Danish (Denmark)","language":"da-DK","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Sara","name":"Sara","language":"da-DK","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Magnus","name":"Magnus","language":"da-DK","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Helle","name":"Microsoft Helle - Danish (Denmark)","language":"da-DK","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kvindestemme 1","name":"Google Dansk 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google da-dk-x-kfm-network","Chrome OS Dansk 1","Android Speech Recognition and Synthesis from Google da-dk-x-kfm-local","Android Speech Recognition and Synthesis from Google da-DK-language"],"language":"da-DK","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kvindestemme 2","name":"Google Dansk 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google da-dk-x-sfp-network","Chrome OS Dansk 3","Android Speech Recognition and Synthesis from Google da-dk-x-sfp-local"],"language":"da-DK","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kvindestemme 3","name":"Google Dansk 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google da-dk-x-vfb-network","Chrome OS Dansk 4","Android Speech Recognition and Synthesis from Google da-dk-x-vfb-local"],"language":"da-DK","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Mandsstemme","name":"Google Dansk 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google da-dk-x-nmm-network","Chrome OS Dansk 2","Android Speech Recognition and Synthesis from Google da-dk-x-nmm-local"],"language":"da-DK","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Seraphina","name":"Microsoft SeraphinaMultilingual Online (Natural) - German (Germany)","language":"de-DE","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Amala","name":"Microsoft Amala Online (Natural) - German (Germany)","language":"de-DE","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Katja","name":"Microsoft Katja Online (Natural) - German (Germany)","language":"de-DE","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Florian","name":"Microsoft FlorianMultilingual Online (Natural) - German (Germany)","language":"de-DE","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Conrad","name":"Microsoft Conrad Online (Natural) - German (Germany)","language":"de-DE","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Killian","name":"Microsoft Killian Online (Natural) - German (Germany)","language":"de-DE","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Ingrid","name":"Microsoft Ingrid Online (Natural) - German (Austria)","language":"de-AT","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Jonas","name":"Microsoft Jonas Online (Natural) - German (Austria)","language":"de-AT","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Leni","name":"Microsoft Leni Online (Natural) - German (Switzerland)","language":"de-CH","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Jan","name":"Microsoft Jan Online (Natural) - German (Switzerland)","language":"de-CH","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Petra","name":"Petra","language":"de-DE","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Anna","name":"Anna","language":"de-DE","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Helena","name":"Helena","language":"de-DE","gender":"female","quality":["low"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Markus","name":"Markus","language":"de-DE","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Viktor","name":"Viktor","language":"de-DE","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Yannick","name":"Yannick","language":"de-DE","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Martin","name":"Martin","language":"de-DE","gender":"male","quality":["low"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Google Deutsch","name":"Weibliche Google-Stimme (Deutschland)","language":"de-DE","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Hedda","name":"Microsoft Hedda - German (Germany)","language":"de-DE","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Katja","name":"Microsoft Katja - German (Germany)","language":"de-DE","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Stefan","name":"Microsoft Stefan - German (Germany)","language":"de-DE","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Michael","name":"Microsoft Michael - German (Austria)","language":"de-AT","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Karsten","name":"Microsoft Karsten - German (Switzerland)","language":"de-CH","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Weibliche Stimme 1 (Deutschland)","name":"Google Deutsch 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google de-de-x-dea-network","Chrome OS Deutsch 2","Android Speech Recognition and Synthesis from Google de-de-x-dea-local","Android Speech Recognition and Synthesis from Google de-DE-language"],"language":"de-DE","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Weibliche Stimme 2 (Deutschland)","name":"Google Deutsch 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google de-de-x-nfh-network","Chrome OS Deutsch 1","Android Speech Recognition and Synthesis from Google de-de-x-nfh-local"],"language":"de-DE","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Männliche Stimme 1 (Deutschland)","name":"Google Deutsch 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google de-de-x-deb-network","Chrome OS Deutsch 3","Android Speech Recognition and Synthesis from Google de-de-x-deb-local"],"language":"de-DE","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Männliche Stimme 2 (Deutschland)","name":"Google Deutsch 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google de-de-x-deg-network","Chrome OS Deutsch 4","Android Speech Recognition and Synthesis from Google de-de-x-deg-local"],"language":"de-DE","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Athina","name":"Microsoft Athina Online (Natural) - Greek (Greece)","language":"el-GR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Nestoras","name":"Microsoft Nestoras Online (Natural) - Greek (Greece)","language":"el-GR","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Melina","name":"Melina","language":"el-GR","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Nikos","name":"Nikos","language":"el-GR","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Stefanos","name":"Microsoft Stefanos - Greek (Greece)","language":"el-GR","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Γυναικεία φωνή","name":"Google Ελληνικά (Natural)","altNames":["Android Speech Recognition and Synthesis from Google el-gr-x-vfz-network","Chrome OS Ελληνικά","Android Speech Recognition and Synthesis from Google el-gr-x-vfz-local","Android Speech Recognition and Synthesis from Google el-GR-language"],"language":"el-GR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Emma","name":"Microsoft EmmaMultilingual Online (Natural) - English (United States)","altNames":["Microsoft Emma Online (Natural) - English (United States)"],"language":"en-US","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Microsoft Ava","name":"Microsoft AvaMultilingual Online (Natural) - English (United States)","altNames":["Microsoft Ava Online (Natural) - English (United States)"],"language":"en-US","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Jenny","name":"Microsoft Jenny Online (Natural) - English (United States)","language":"en-US","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Aria","name":"Microsoft Aria Online (Natural) - English (United States)","language":"en-US","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Michelle","name":"Microsoft Michelle Online (Natural) - English (United States)","language":"en-US","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Ana","name":"Microsoft Ana Online (Natural) - English (United States)","language":"en-US","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Andrew","name":"Microsoft AndrewMultilingual Online (Natural) - English (United States)","altNames":["Microsoft Andrew Online (Natural) - English (United States)"],"language":"en-US","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Brian","name":"Microsoft BrianMultilingual Online (Natural) - English (United States)","altNames":["Microsoft Brian Online (Natural) - English (United States)"],"language":"en-US","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Guy","name":"Microsoft Guy Online (Natural) - English (United States)","language":"en-US","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Eric","name":"Microsoft Eric Online (Natural) - English (United States)","language":"en-US","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Steffan","name":"Microsoft Steffan Online (Natural) - English (United States)","language":"en-US","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Christopher","name":"Microsoft Christopher Online (Natural) - English (United States)","language":"en-US","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Roger","name":"Microsoft Roger Online (Natural) - English (United States)","language":"en-US","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Sonia","name":"Microsoft Sonia Online (Natural) - English (United Kingdom)","language":"en-GB","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Libby","name":"Microsoft Libby Online (Natural) - English (United Kingdom)","language":"en-GB","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Maisie","name":"Microsoft Maisie Online (Natural) - English (United Kingdom)","language":"en-GB","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Ryan","name":"Microsoft Ryan Online (Natural) - English (United Kingdom)","language":"en-GB","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Thomas","name":"Microsoft Thomas Online (Natural) - English (United Kingdom)","language":"en-GB","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Natasha","name":"Microsoft Natasha Online (Natural) - English (Australia)","language":"en-AU","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Hayley","name":"Microsoft Hayley Online - English (Australia)","language":"en-AU","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"William","name":"Microsoft William Online (Natural) - English (Australia)","language":"en-AU","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Clara","name":"Microsoft Clara Online (Natural) - English (Canada)","language":"en-CA","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Heather","name":"Microsoft Heather Online - English (Canada)","language":"en-CA","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Liam","name":"Microsoft Liam Online (Natural) - English (Canada)","language":"en-CA","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Neerja","name":"Microsoft Neerja Online (Natural) - English (India)","altNames":["Microsoft Neerja Online (Natural) - English (India) (Preview)"],"language":"en-IN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Prabhat","name":"Microsoft Prabhat Online (Natural) - English (India)","language":"en-IN","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Emily","name":"Microsoft Emily Online (Natural) - English (Ireland)","language":"en-IE","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Connor","name":"Microsoft Connor Online (Natural) - English (Ireland)","language":"en-IE","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Leah","name":"Microsoft Leah Online (Natural) - English (South Africa)","language":"en-ZA","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Luke","name":"Microsoft Luke Online (Natural) - English (South Africa)","language":"en-ZA","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Yan","name":"Microsoft Yan Online (Natural) - English (Hongkong)","language":"en-HK","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Sam","name":"Microsoft Sam Online (Natural) - English (Hongkong)","language":"en-HK","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Asilia","name":"Microsoft Asilia Online (Natural) - English (Kenya)","language":"en-KE","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Chilemba","name":"Microsoft Chilemba Online (Natural) - English (Kenya)","language":"en-KE","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Molly","name":"Microsoft Molly Online (Natural) - English (New Zealand)","language":"en-NZ","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Mitchell","name":"Microsoft Mitchell Online (Natural) - English (New Zealand)","language":"en-NZ","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Ezinne","name":"Microsoft Ezinne Online (Natural) - English (Nigeria)","language":"en-NG","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Abeo","name":"Microsoft Abeo Online (Natural) - English (Nigeria)","language":"en-NG","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Luna","name":"Microsoft Luna Online (Natural) - English (Singapore)","language":"en-SG","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Wayne","name":"Microsoft Wayne Online (Natural) - English (Singapore)","language":"en-SG","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Imani","name":"Microsoft Imani Online (Natural) - English (Tanzania)","language":"en-TZ","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Elimu","name":"Microsoft Elimu Online (Natural) - English (Tanzania)","language":"en-TZ","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Apple Ava","name":"Ava","language":"en-US","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Zoe","name":"Zoe","language":"en-US","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Allison","name":"Allison","language":"en-US","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Nicky","name":"Nicky","language":"en-US","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Samantha","name":"Samantha","language":"en-US","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Joelle","name":"Joelle","language":"en-US","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Evan","name":"Evan","language":"en-US","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Nathan","name":"Nathan","language":"en-US","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Tom","name":"Tom","language":"en-US","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Alex","name":"Alex","language":"en-US","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Aaron","name":"Aaron","language":"en-US","gender":"male","quality":["low"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Kate","name":"Kate","language":"en-GB","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Stephanie","name":"Stephanie","language":"en-GB","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Serena","name":"Serena","language":"en-GB","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Martha","name":"Martha","language":"en-GB","gender":"female","quality":["low"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Jamie","name":"Jamie","language":"en-GB","gender":"male","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Oliver","name":"Oliver","language":"en-GB","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Daniel","name":"Daniel","language":"en-GB","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Arthur","name":"Arthur","language":"en-GB","gender":"male","quality":["low"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Matilda","name":"Matilda","language":"en-AU","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Karen","name":"Karen","language":"en-AU","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Catherine","name":"Catherine","language":"en-AU","gender":"female","quality":["low"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Lee","name":"Lee","language":"en-AU","gender":"male","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Gordon","name":"Gordon","language":"en-AU","gender":"male","quality":["low"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Isha","name":"Isha","language":"en-IN","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Sangeeta","name":"Sangeeta","language":"en-IN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Rishi","name":"Rishi","language":"en-IN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Moira","name":"Moira","language":"en-IE","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Tessa","name":"Tessa","language":"en-ZA","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Fiona","name":"Fiona","language":"en-GB-u-sd-gbsct","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Female Google voice (US)","name":"Google US English","language":"en-US","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female Google voice (UK)","name":"Google UK English Female","language":"en-GB","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Male Google voice (UK)","name":"Google UK English Male","language":"en-GB","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Zira","name":"Microsoft Zira - English (United States)","language":"en-US","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"David","name":"Microsoft David - English (United States)","language":"en-US","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Mark","name":"Microsoft Mark - English (United States)","language":"en-US","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Hazel","name":"Microsoft Hazel - English (Great Britain)","language":"en-GB","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Susan","name":"Microsoft Susan - English (Great Britain)","language":"en-GB","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"George","name":"Microsoft George - English (Great Britain)","language":"en-GB","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Catherine","name":"Microsoft Catherine - English (Austalia)","language":"en-AU","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"James","name":"Microsoft Richard - English (Australia)","language":"en-AU","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Linda","name":"Microsoft Linda - English (Canada)","language":"en-CA","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Richard","name":"Microsoft Richard - English (Canada)","language":"en-CA","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Heera","name":"Microsoft Heera - English (India)","language":"en-IN","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Ravi","name":"Microsoft Ravi - English (India)","language":"en-IN","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Sean","name":"Microsoft Sean - English (Ireland)","language":"en-IE","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 1 (US)","name":"Google US English 5 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-us-x-tpc-network","Chrome OS US English 5","Android Speech Recognition and Synthesis from Google en-us-x-tpc-local","Android Speech Recognition and Synthesis from Google en-US-language"],"language":"en-US","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 2 (US)","name":"Google US English 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-us-x-iob-network","Chrome OS US English 1","Android Speech Recognition and Synthesis from Google en-us-x-iob-local"],"language":"en-US","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 3 (US)","name":"Google US English 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-us-x-iog-network","Chrome OS US English 2","Android Speech Recognition and Synthesis from Google en-us-x-iog-local"],"language":"en-US","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 4 (US)","name":"Google US English 7 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-us-x-tpf-network","Chrome OS US English 7","Android Speech Recognition and Synthesis from Google en-us-x-tpf-local"],"language":"en-US","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 5 (US)","name":"Android Speech Recognition and Synthesis from Google en-us-x-sfg-network","altNames":["Android Speech Recognition and Synthesis from Google en-us-x-sfg-local"],"language":"en-US","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 6 (US)","name":"Chrome OS US English 8","language":"en-US","gender":"female","quality":["low"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Male voice 1 (US)","name":"Google US English 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-us-x-iom-network","Chrome OS US English 4","Android Speech Recognition and Synthesis from Google en-us-x-iom-local"],"language":"en-US","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Male voice 2 (US)","name":"Google US English 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-us-x-iol-network","Chrome OS US English 3","Android Speech Recognition and Synthesis from Google en-us-x-iol-local"],"language":"en-US","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Male voice 3 (US)","name":"Google US English 6 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-us-x-tpd-network","Chrome OS US English 6","Android Speech Recognition and Synthesis from Google en-us-x-tpd-local"],"language":"en-US","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 1 (UK)","name":"Google UK English 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-gb-x-gba-network","Chrome OS UK English 2","Android Speech Recognition and Synthesis from Google en-gb-x-gba-local","Android Speech Recognition and Synthesis from Google en-GB-language"],"language":"en-GB","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 2 (UK)","name":"Google UK English 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-gb-x-gbc-network","Chrome OS UK English 4","Android Speech Recognition and Synthesis from Google en-gb-x-gbc-local"],"language":"en-GB","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 3 (UK)","name":"Google UK English 6 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-gb-x-gbg-network","Chrome OS UK English 6","Android Speech Recognition and Synthesis from Google en-gb-x-gbg-local"],"language":"en-GB","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 4 (UK)","name":"Chrome OS UK English 7","language":"en-GB","gender":"female","quality":["low"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Male voice 1 (UK)","name":"Google UK English 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-gb-x-rjs-network","Chrome OS UK English 1","Android Speech Recognition and Synthesis from Google en-gb-x-rjs-local"],"language":"en-GB","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Male voice 2 (UK)","name":"Google UK English 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-gb-x-gbb-network","Chrome OS UK English 3","Android Speech Recognition and Synthesis from Google en-gb-x-gbb-local"],"language":"en-GB","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Male voice 3 (UK)","name":"Google UK English 5 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-gb-x-gbd-network","Chrome OS UK English 5","Android Speech Recognition and Synthesis from Google en-gb-x-gbd-local"],"language":"en-GB","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 1 (Australia)","name":"Google Australian English 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-au-x-aua-network","Chrome OS Australian English 1","Android Speech Recognition and Synthesis from Google en-au-x-aua-local","Android Speech Recognition and Synthesis from Google en-AU-language"],"language":"en-AU","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 2 (Australia)","name":"Google Australian English 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-au-x-auc-network","Chrome OS Australian English 3","Android Speech Recognition and Synthesis from Google en-au-x-auc-local"],"language":"en-AU","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Male voice 1 (Australia)","name":"Google Australian English 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-au-x-aub-network","Chrome OS Australian English 2","Android Speech Recognition and Synthesis from Google en-au-x-aub-local"],"language":"en-AU","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Male voice 2 (Australia)","name":"Google Australian English 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google en-au-x-aud-network","Chrome OS Australian English 4","Android Speech Recognition and Synthesis from Google en-au-x-aud-local"],"language":"en-AU","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Male voice 3 (Australia)","name":"Chrome OS Australian English 5","language":"en-AU","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 1 (India)","name":"Android Speech Recognition and Synthesis from Google en-in-x-ena-network","altNames":["Android Speech Recognition and Synthesis from Google en-in-x-ena-local","Android Speech Recognition and Synthesis from Google en-IN-language"],"language":"en-IN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Female voice 2 (India)","name":"Android Speech Recognition and Synthesis from Google en-in-x-enc-network","altNames":["Android Speech Recognition and Synthesis from Google en-in-x-enc-local"],"language":"en-IN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Male voice 1 (India)","name":"Android Speech Recognition and Synthesis from Google en-in-x-end-network","altNames":["Android Speech Recognition and Synthesis from Google en-in-x-end-local"],"language":"en-IN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Male voice 2 (India)","name":"Android Speech Recognition and Synthesis from Google en-in-x-ene-network","altNames":["Android Speech Recognition and Synthesis from Google en-in-x-ene-local"],"language":"en-IN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Elvira","name":"Microsoft Elvira Online (Natural) - Spanish (Spain)","language":"es-ES","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Alvaro","name":"Microsoft Alvaro Online (Natural) - Spanish (Spain)","language":"es-ES","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Dalia","name":"Microsoft Dalia Online (Natural) - Spanish (Mexico)","language":"es-MX","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Microsoft Jorge","name":"Microsoft Jorge Online (Natural) - Spanish (Mexico)","language":"es-MX","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Elena","name":"Microsoft Elena Online (Natural) - Spanish (Argentina)","language":"es-AR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Tomas","name":"Microsoft Tomas Online (Natural) - Spanish (Argentina)","language":"es-AR","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Sofia","name":"Microsoft Sofia Online (Natural) - Spanish (Bolivia)","language":"es-BO","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Marcelo","name":"Microsoft Marcelo Online (Natural) - Spanish (Bolivia)","language":"es-BO","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Catalina","name":"Microsoft Catalina Online (Natural) - Spanish (Chile)","language":"es-CL","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Lorenzo","name":"Microsoft Lorenzo Online (Natural) - Spanish (Chile)","language":"es-CL","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Ximena","name":"Microsoft Ximena Online (Natural) - Spanish (Colombia)","language":"es-CO","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Salome","name":"Microsoft Salome Online (Natural) - Spanish (Colombia)","language":"es-CO","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Gonzalo","name":"Microsoft Gonzalo Online (Natural) - Spanish (Colombia)","language":"es-CO","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Maria","name":"Microsoft Maria Online (Natural) - Spanish (Costa Rica)","language":"es-CR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Juan","name":"Microsoft Juan Online (Natural) - Spanish (Costa Rica)","language":"es-CR","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Belkys","name":"Microsoft Belkys Online (Natural) - Spanish (Cuba)","language":"es-CU","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Manuel","name":"Microsoft Manuel Online (Natural) - Spanish (Cuba)","language":"es-CU","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Andrea","name":"Microsoft Andrea Online (Natural) - Spanish (Ecuador)","language":"es-EC","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Luis","name":"Microsoft Luis Online (Natural) - Spanish (Ecuador)","language":"es-EC","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Lorena","name":"Microsoft Lorena Online (Natural) - Spanish (El Salvador)","language":"es-SV","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Rodrigo","name":"Microsoft Rodrigo Online (Natural) - Spanish (El Salvador)","language":"es-SV","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Paloma","name":"Microsoft Paloma Online (Natural) - Spanish (United States)","language":"es-US","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Alonso","name":"Microsoft Alonso Online (Natural) - Spanish (United States)","language":"es-US","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Marta","name":"Microsoft Marta Online (Natural) - Spanish (Guatemala)","language":"es-GT","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Andres","name":"Microsoft Andres Online (Natural) - Spanish (Guatemala)","language":"es-GT","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Teresa","name":"Microsoft Teresa Online (Natural) - Spanish (Equatorial Guinea)","language":"es-GQ","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Javier","name":"Microsoft Javier Online (Natural) - Spanish (Equatorial Guinea)","language":"es-GQ","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Karla","name":"Microsoft Karla Online (Natural) - Spanish (Honduras)","language":"es-HN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Carlos","name":"Microsoft Carlos Online (Natural) - Spanish (Honduras)","language":"es-HN","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Yolanda","name":"Microsoft Yolanda Online (Natural) - Spanish (Nicaragua)","language":"es-NI","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Federico","name":"Microsoft Federico Online (Natural) - Spanish (Nicaragua)","language":"es-NI","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Margarita","name":"Microsoft Margarita Online (Natural) - Spanish (Panama)","language":"es-PA","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Roberto","name":"Microsoft Roberto Online (Natural) - Spanish (Panama)","language":"es-PA","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Tania","name":"Microsoft Tania Online (Natural) - Spanish (Paraguay)","language":"es-PY","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Mario","name":"Microsoft Mario Online (Natural) - Spanish (Paraguay)","language":"es-PY","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Camila","name":"Microsoft Camila Online (Natural) - Spanish (Peru)","language":"es-PE","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Alex","name":"Microsoft Alex Online (Natural) - Spanish (Peru)","language":"es-PE","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Karina","name":"Microsoft Karina Online (Natural) - Spanish (Puerto Rico)","language":"es-PR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Victor","name":"Microsoft Victor Online (Natural) - Spanish (Puerto Rico)","language":"es-PR","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Ramona","name":"Microsoft Ramona Online (Natural) - Spanish (Dominican Republic)","language":"es-DO","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Emilio","name":"Microsoft Emilio Online (Natural) - Spanish (Dominican Republic)","language":"es-DO","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Valentina","name":"Microsoft Valentina Online (Natural) - Spanish (Uruguay)","language":"es-UY","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Mateo","name":"Microsoft Mateo Online (Natural) - Spanish (Uruguay)","language":"es-UY","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Paola","name":"Microsoft Paola Online (Natural) - Spanish (Venezuela)","language":"es-VE","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Sebastian","name":"Microsoft Sebastian Online (Natural) - Spanish (Venezuela)","language":"es-VE","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Marisol","name":"Marisol","language":"es-ES","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Mónica","name":"Mónica","language":"es-ES","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Apple Jorge","name":"Jorge","language":"es-ES","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Angelica","name":"Angelica","language":"es-MX","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Paulina","name":"Paulina","language":"es-MX","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Juan","name":"Juan","language":"es-MX","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Isabela","name":"Isabela","language":"es-AR","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Diego","name":"Diego","language":"es-AR","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Francisca","name":"Francisca","language":"es-CL","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Soledad","name":"Soledad","language":"es-CO","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Jimena","name":"Jimena","language":"es-CO","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Carlos","name":"Carlos","language":"es-CO","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Voz Google masculina (España)","name":"Google español","language":"es-ES","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz Google femenina (Estados Unidos)","name":"Google español de Estados Unidos","language":"es-US","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Helena","name":"Microsoft Helena - Spanish (Spain)","language":"es-ES","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Laura","name":"Microsoft Laura - Spanish (Spain)","language":"es-ES","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Pablo","name":"Microsoft Pablo - Spanish (Spain)","language":"es-ES","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Sabina","name":"Microsoft Sabina - Spanish (Mexico)","language":"es-MX","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Raul","name":"Microsoft Raul - Spanish (Mexico)","language":"es-MX","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz femenina 1 (España)","name":"Google español 4 (Natural)","altNames":["Chrome OS español 4","Android Speech Recognition and Synthesis from Google es-es-x-eee-local","Android Speech Recognition and Synthesis from Google es-ES-language"],"language":"es-ES","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz femenina 2 (España)","name":"Google español 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google es-es-x-eea-network","Chrome OS español 1","Android Speech Recognition and Synthesis from Google es-es-x-eea-local"],"language":"es-ES","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz femenina 3 (España)","name":"Google español 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google es-es-x-eec-network","Chrome OS español 2","Android Speech Recognition and Synthesis from Google es-es-x-eec-local"],"language":"es-ES","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz masculina 1 (España)","name":"Google español 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google es-es-x-eed-network","Chrome OS español 3","Android Speech Recognition and Synthesis from Google es-es-x-eed-local"],"language":"es-ES","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz masculina 2 (España)","name":"Google español 5 (Natural)","altNames":["Chrome OS español 5","Android Speech Recognition and Synthesis from Google es-es-x-eef-local"],"language":"es-ES","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz femenina 1 (Estados Unidos)","name":"Google español de Estados Unidos 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google es-us-x-esc-network","Chrome OS español de Estados Unidos","Android Speech Recognition and Synthesis from Google es-us-x-esc-local","Android Speech Recognition and Synthesis from Google es-US-language"],"language":"es-US","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz femenina 2 (Estados Unidos)","name":"Google español de Estados Unidos 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google es-us-x-sfb-network","Android Speech Recognition and Synthesis from Google es-us-x-sfb-local"],"language":"es-US","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz masculina 1 (Estados Unidos)","name":"Google español de Estados Unidos 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google es-us-x-esd-network","Android Speech Recognition and Synthesis from Google es-us-x-esd-local"],"language":"es-US","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz masculina 2 (Estados Unidos)","name":"Google español de Estados Unidos 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google es-us-x-esf-network","Android Speech Recognition and Synthesis from Google es-us-x-esf-local"],"language":"es-US","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Miren","name":"Miren","language":"eu-ES","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Dilara","name":"Microsoft Dilara Online (Natural) - Persian (Iran)","language":"fa-IR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Farid","name":"Microsoft Farid Online (Natural) - Persian (Iran)","language":"fa-IR","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Dariush","name":"Dariush","language":"fa-IR","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Noora","name":"Microsoft Noora Online (Natural) - Finnish (Finland)","language":"fi-FI","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Harri","name":"Microsoft Harri Online (Natural) - Finnish (Finland)","language":"fi-FI","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Satu","name":"Satu","language":"fi-FI","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Onni","name":"Onni","language":"fi-FI","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Heidi","name":"Microsoft Heidi - Finnish (Finland)","language":"fi-FI","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Suomalainen naisääni","name":"Google Suomi (Natural)","altNames":["Android Speech Recognition and Synthesis from Google fi-fi-x-afi-network","Chrome OS Suomi","Android Speech Recognition and Synthesis from Google fi-fi-x-afi-local","Android Speech Recognition and Synthesis from Google fi-FI-language"],"language":"fi-FI","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Vivienne","name":"Microsoft VivienneMultilingual Online (Natural) - French (France)","language":"fr-FR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Denise","name":"Microsoft Denise Online (Natural) - French (France)","language":"fr-FR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Charline","name":"Microsoft Charline Online (Natural) - French (Belgium)","language":"fr-BE","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Ariane","name":"Microsoft Ariane Online (Natural) - French (Switzerland)","language":"fr-CH","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Eloise","name":"Microsoft Eloise Online (Natural) - French (France)","language":"fr-FR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Remy","name":"Microsoft RemyMultilingual Online (Natural) - French (France)","language":"fr-FR","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Henri","name":"Microsoft Henri Online (Natural) - French (France)","language":"fr-FR","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Gerard","name":"Microsoft Gerard Online (Natural) - French (Belgium)","language":"fr-BE","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Fabrice","name":"Microsoft Fabrice Online (Natural) - French (Switzerland)","language":"fr-CH","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Sylvie","name":"Microsoft Sylvie Online (Natural) - French (Canada)","language":"fr-CA","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Antoine","name":"Microsoft Antoine Online (Natural) - French (Canada)","language":"fr-CA","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Jean","name":"Microsoft Jean Online (Natural) - French (Canada)","language":"fr-CA","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Thierry","name":"Microsoft Thierry Online (Natural) - French (Canada)","language":"fr-CA","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Audrey","name":"Audrey","language":"fr-FR","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Aurélie","name":"Aurélie","language":"fr-FR","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":0.9,"localizedName":"apple"},{"label":"Marie","name":"Marie","language":"fr-FR","gender":"female","quality":["low"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Thomas","name":"Thomas","language":"fr-FR","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Aude","name":"Aude","language":"fr-BE","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Chantal","name":"Chantal","language":"fr-CA","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Amélie","name":"Amélie","language":"fr-CA","gender":"female","quality":["low","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Nicolas","name":"Nicolas","language":"fr-CA","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Voix Google féminine (France)","name":"Google français","language":"fr-FR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Julie","name":"Microsoft Julie - French (France)","language":"fr-FR","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Hortence","name":"Microsoft Hortence - French (France)","language":"fr-FR","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Paul","name":"Microsoft Paul - French (France)","language":"fr-FR","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Caroline","name":"Microsoft Caroline - French (Canada)","language":"fr-CA","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Claude","name":"Microsoft Claude - French (Canada)","language":"fr-CA","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Guillaume","name":"Microsoft Claude - French (Switzerland)","language":"fr-CH","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voix féminine 1 (France)","name":"Google français 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google fr-fr-x-frc-network","Chrome OS français 4","Android Speech Recognition and Synthesis from Google fr-fr-x-frc-local","Android Speech Recognition and Synthesis from Google fr-FR-language"],"language":"fr-FR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voix féminine 2 (France)","name":"Google français 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google fr-fr-x-fra-network","Chrome OS français 2","Android Speech Recognition and Synthesis from Google fr-fr-x-fra-local"],"language":"fr-FR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voix féminine 3 (France)","name":"Google français 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google fr-fr-x-vlf-network","Chrome OS français 1","Android Speech Recognition and Synthesis from Google fr-fr-x-vlf-local"],"language":"fr-FR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voix masculine 1 (France)","name":"Google français 5 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google fr-fr-x-frd-network","Chrome OS français 5","Android Speech Recognition and Synthesis from Google fr-fr-x-frd-local"],"language":"fr-FR","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voix masculine 2 (France)","name":"Google français 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google fr-fr-x-frb-network","Chrome OS français 3","Android Speech Recognition and Synthesis from Google fr-fr-x-frb-local"],"language":"fr-FR","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voix féminine 1 (Canada)","name":"Android Speech Recognition and Synthesis from Google fr-ca-x-caa-network","altNames":["Android Speech Recognition and Synthesis from Google fr-ca-x-caa-local","Android Speech Recognition and Synthesis from Google fr-CA-language"],"language":"fr-CA","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voix féminine 2 (Canada)","name":"Android Speech Recognition and Synthesis from Google fr-ca-x-cac-network","altNames":["Android Speech Recognition and Synthesis from Google fr-ca-x-cac-local"],"language":"fr-CA","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voix masculine 1 (Canada)","name":"Android Speech Recognition and Synthesis from Google fr-ca-x-cab-network","altNames":["Android Speech Recognition and Synthesis from Google fr-ca-x-cab-local"],"language":"fr-CA","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voix masculine 2 (Canada)","name":"Android Speech Recognition and Synthesis from Google fr-ca-x-cad-network","altNames":["Android Speech Recognition and Synthesis from Google fr-ca-x-cad-local"],"language":"fr-CA","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Sabela","name":"Microsoft Sabela Online (Natural) - Galician (Spain)","language":"gl-ES","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Roi","name":"Microsoft Roi Online (Natural) - Galician (Spain)","language":"gl-ES","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Carmela","name":"Carmela","language":"gl-ES","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Hila","name":"Microsoft Hila Online (Natural) - Hebrew (Israel)","language":"he-IL","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Avri","name":"Microsoft Avri Online (Natural) - Hebrew (Israel)","language":"he-IL","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Carmit","name":"Carmit","language":"he-IL","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Asaf","name":"Microsoft Asaf - Hebrew (Israel)","language":"he-IL","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"קול גברי 1","name":"Android Speech Recognition and Synthesis from Google he-il-x-heb-network","altNames":["Android Speech Recognition and Synthesis from Google he-il-x-heb-local","Android Speech Recognition and Synthesis from Google he-IL-language"],"language":"he-IL","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"קול גברי 2","name":"Android Speech Recognition and Synthesis from Google he-il-x-hec-network","altNames":["Android Speech Recognition and Synthesis from Google he-il-x-hec-local"],"language":"he-IL","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"קול נשי 1","name":"Android Speech Recognition and Synthesis from Google he-il-x-hed-network","altNames":["Android Speech Recognition and Synthesis from Google he-il-x-hed-local"],"language":"he-IL","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"קול נשי 2","name":"Android Speech Recognition and Synthesis from Google he-il-x-hee-network","altNames":["Android Speech Recognition and Synthesis from Google he-il-x-hee-local"],"language":"he-IL","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Swara","name":"Microsoft Swara Online (Natural) - Hindi (India)","language":"hi-IN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Madhur","name":"Microsoft Madhur Online (Natural) - Hindi (India)","language":"hi-IN","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Kiyara","name":"Kiyara","language":"hi-IN","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Lekha","name":"Lekha","language":"hi-IN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Neel","name":"Neel","language":"hi-IN","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"महिला Google आवाज़","name":"Google हिन्दी","language":"hi-IN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kalpana","name":"Microsoft Kalpana - Hindi (India)","language":"hi-IN","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Hemant","name":"Microsoft Hemant - Hindi (India)","language":"hi-IN","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"महिला आवाज़ 1","name":"Google हिन्दी 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google hi-in-x-hia-network","Chrome OS हिन्दी 2","Android Speech Recognition and Synthesis from Google hi-in-x-hia-local","Android Speech Recognition and Synthesis from Google hi-IN-language"],"language":"hi-IN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"महिला आवाज़ 2","name":"Google हिन्दी 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google hi-in-x-hic-network","Chrome OS हिन्दी 3","Android Speech Recognition and Synthesis from Google hi-in-x-hic-local"],"language":"hi-IN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"महिला आवाज़ 3","name":"Chrome OS हिन्दी 1","language":"hi-IN","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"पुरुष आवाज 1","name":"Google हिन्दी 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google hi-in-x-hid-network","Chrome OS हिन्दी 4","Android Speech Recognition and Synthesis from Google hi-in-x-hid-local"],"language":"hi-IN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"पुरुष आवाज 2","name":"Google हिन्दी 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google hi-in-x-hie-network","Chrome OS हिन्दी 5","Android Speech Recognition and Synthesis from Google hi-in-x-hie-local"],"language":"hi-IN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Gabrijela","name":"Microsoft Gabrijela Online (Natural) - Croatian (Croatia)","language":"hr-HR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Srecko","name":"Microsoft Srecko Online (Natural) - Croatian (Croatia)","language":"hr-HR","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Lana","name":"Lana","altNames":["Lana (poboljšani)","Lana (hrvatski (Hrvatska))"],"language":"hr-HR","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Matej","name":"Microsoft Matej - Croatian (Croatia)","language":"hr-HR","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Ženski glas","name":"Android Speech Recognition and Synthesis from Google hr-hr-x-hra-network","altNames":["Android Speech Recognition and Synthesis from Google hr-hr-x-hra-local"],"language":"hr-HR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Muški glas","name":"Android Speech Recognition and Synthesis from Google hr-hr-x-hrb-network","altNames":["Android Speech Recognition and Synthesis from Google hr-hr-x-hrb-local","Android Speech Recognition and Synthesis from Google hr-HR-language"],"language":"hr-HR","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Noemi","name":"Microsoft Noemi Online (Natural) - Hungarian (Hungary)","language":"hu-HU","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Tamas","name":"Microsoft Tamas Online (Natural) - Hungarian (Hungary)","language":"hu-HU","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Tünde","name":"Tünde","language":"hu-HU","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Szabolcs","name":"Microsoft Szabolcs - Hungarian (Hungary)","language":"hu-HU","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Női hang","name":"Google Magyar (Natural)","altNames":["Android Speech Recognition and Synthesis from Google hu-hu-x-kfl-network","Chrome OS Magyar","Android Speech Recognition and Synthesis from Google hu-hu-x-kfl-local","Android Speech Recognition and Synthesis from Google hu-HU-language"],"language":"hu-HU","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Gadis","name":"Microsoft Gadis Online (Natural) - Indonesian (Indonesia)","language":"id-ID","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Ardi","name":"Microsoft Ardi Online (Natural) - Indonesian (Indonesia)","language":"id-ID","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Damayanti","name":"Damayanti","language":"id-ID","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Suara Google wanita","name":"Google Bahasa Indonesia","language":"id-ID","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Andika","name":"Microsoft Andika - Indonesian (Indonesia)","language":"id-ID","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Suara wanita 1","name":"Google Bahasa Indonesia 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google id-id-x-idc-network","Chrome OS Bahasa Indonesia 1","Android Speech Recognition and Synthesis from Google id-id-x-idc-local","Android Speech Recognition and Synthesis from Google id-ID-language"],"language":"id-ID","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Suara wanita 2","name":"Google Bahasa Indonesia 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google id-id-x-idd-network","Chrome OS Bahasa Indonesia 2","Android Speech Recognition and Synthesis from Google id-id-x-idd-local"],"language":"id-ID","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Suara laki-laki 1","name":"Google Bahasa Indonesia 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google id-id-x-ide-network","Chrome OS Bahasa Indonesia 3","Android Speech Recognition and Synthesis from Google id-id-x-ide-local"],"language":"id-ID","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Suara laki-laki 2","name":"Google Bahasa Indonesia 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google id-id-x-dfz-network","Chrome OS Bahasa Indonesia 4","Android Speech Recognition and Synthesis from Google id-id-x-dfz-local"],"language":"id-ID","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Elsa (Alta qualita)","name":"Microsoft Elsa Online (Natural) - Italian (Italy)","language":"it-IT","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Isabella","name":"Microsoft Isabella Online (Natural) - Italian (Italy)","language":"it-IT","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Giuseppe","name":"Microsoft Giuseppe Online (Natural) - Italian (Italy)","language":"it-IT","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Diego","name":"Microsoft Diego Online (Natural) - Italian (Italy)","language":"it-IT","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Federica","name":"Federica","language":"it-IT","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Emma","name":"Emma","language":"it-IT","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Alice","name":"Alice","language":"it-IT","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Paola","name":"Paola","language":"it-IT","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Luca","name":"Luca","language":"it-IT","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Voce Google femminile","name":"Google italiano","language":"it-IT","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Elsa","name":"Microsoft Elsa - Italian (Italy)","language":"it-IT","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Cosimo","name":"Microsoft Cosimo - Italian (Italy)","language":"it-IT","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voce femminile 1","name":"Google italiano 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google it-it-x-itb-network","Chrome OS italiano 2","Android Speech Recognition and Synthesis from Google it-it-x-itb-local","Android Speech Recognition and Synthesis from Google it-IT-language"],"language":"it-IT","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voce femminile 2","name":"Google italiano 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google it-it-x-kda-network","Chrome OS italiano 1","Android Speech Recognition and Synthesis from Google it-it-x-kda-local"],"language":"it-IT","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voce maschile 1","name":"Google italiano 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google it-it-x-itc-network","Chrome OS italiano 3","Android Speech Recognition and Synthesis from Google it-it-x-itc-local"],"language":"it-IT","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voce maschile 2","name":"Google italiano 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google it-it-x-itd-network","Chrome OS italiano 4","Android Speech Recognition and Synthesis from Google it-it-x-itd-local"],"language":"it-IT","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Nanami","name":"Microsoft Nanami Online (Natural) - Japanese (Japan)","language":"ja-JP","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Keita","name":"Microsoft Keita Online (Natural) - Japanese (Japan)","language":"ja-JP","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"O-Ren","name":"O-Ren","language":"ja-JP","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Kyoko","name":"Kyoko","language":"ja-JP","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Otoya","name":"Otoya","language":"ja-JP","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Hattori","name":"Hattori","language":"ja-JP","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Google の女性の声","name":"Google 日本語","language":"ja-JP","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Ayumi","name":"Microsoft Ayumi - Japanese (Japan)","language":"ja-JP","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Haruka","name":"Microsoft Haruka - Japanese (Japan)","language":"ja-JP","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Ichiro","name":"Microsoft Ichiro - Japanese (Japan)","language":"ja-JP","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"女性の声1","name":"Google 日本語 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google ja-jp-x-htm-network","Chrome OS 日本語 1","Android Speech Recognition and Synthesis from Google ja-jp-x-htm-local","Android Speech Recognition and Synthesis from Google ja-JP-language"],"language":"ja-JP","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"女性の声2","name":"Chrome OS 日本語 2","altNames":["Android Speech Recognition and Synthesis from Google ja-jp-x-jab-network","Android Speech Recognition and Synthesis from Google ja-jp-x-jab-local"],"language":"ja-JP","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"男性の声1","name":"Google 日本語 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google ja-jp-x-jac-network","Chrome OS 日本語 3","Android Speech Recognition and Synthesis from Google ja-jp-x-jac-local"],"language":"ja-JP","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"男性の声2","name":"Google 日本語 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google ja-jp-x-jad-network","Chrome OS 日本語 4","Android Speech Recognition and Synthesis from Google ja-jp-x-jad-local"],"language":"ja-JP","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Sapna","name":"Microsoft Sapna Online (Natural) - Kannada (India)","language":"kn-IN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Gagan","name":"Microsoft Gagan Online (Natural) - Kannada (India)","language":"kn-IN","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Soumya","name":"Soumya","language":"kn-IN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"ಸ್ತ್ರೀ ಧ್ವನಿ","name":"Android Speech Recognition and Synthesis from Google kn-in-x-knf-network","altNames":["Android Speech Recognition and Synthesis from Google kn-in-x-knf-local","Android Speech Recognition and Synthesis from Google kn-IN-language"],"language":"kn-IN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"ಪುರುಷ ಧ್ವನಿ","name":"Android Speech Recognition and Synthesis from Google kn-in-x-knm-network","altNames":["Android Speech Recognition and Synthesis from Google kn-in-x-knm-local"],"language":"kn-IN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"SunHi","name":"Microsoft SunHi Online (Natural) - Korean (Korea)","language":"ko-KR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Hyunsu","name":"Microsoft Hyunsu Online (Natural) - Korean (Korea)","language":"ko-KR","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"InJoon","name":"Microsoft InJoon Online (Natural) - Korean (Korea)","language":"ko-KR","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Yuna","name":"Yuna","language":"ko-KR","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Jian","name":"Jian","language":"ko-KR","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Suhyun","name":"Suhyun","language":"ko-KR","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Sora","name":"Sora","language":"ko-KR","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Minsu","name":"Minsu","language":"ko-KR","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Google 여성 음성","name":"Google 한국의","language":"ko-KR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Heami","name":"Microsoft Heami - Korean (Korea)","language":"ko-KR","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"여성 목소리 1","name":"Google 한국어 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google ko-kr-x-kob-network","Chrome OS 한국어 2","Android Speech Recognition and Synthesis from Google ko-kr-x-kob-local","Android Speech Recognition and Synthesis from Google ko-KR-language"],"language":"ko-KR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"여성 목소리 2","name":"Google 한국어 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google ko-kr-x-ism-network","Chrome OS 한국어 1","Android Speech Recognition and Synthesis from Google ko-kr-x-ism-local"],"language":"ko-KR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"남성 1","name":"Google 한국어 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google ko-kr-x-koc-network","Chrome OS 한국어 3","Android Speech Recognition and Synthesis from Google ko-kr-x-koc-local"],"language":"ko-KR","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"남성 2","name":"Google 한국어 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google ko-kr-x-kod-network","Chrome OS 한국어 4","Android Speech Recognition and Synthesis from Google ko-kr-x-kod-local"],"language":"ko-KR","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Aarohi","name":"Microsoft Aarohi Online (Natural) - Marathi (India)","language":"mr-IN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Manohar","name":"Microsoft Manohar Online (Natural) - Marathi (India)","language":"mr-IN","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Ananya","name":"Ananya","language":"mr-IN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"स्त्री आवाज","name":"Android Speech Recognition and Synthesis from Google mr-in-x-mrf-network","altNames":["Android Speech Recognition and Synthesis from Google mr-in-x-mrf-local","Android Speech Recognition and Synthesis from Google mr-IN-language"],"language":"mr-IN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Yasmin","name":"Microsoft Yasmin Online (Natural) - Malay (Malaysia)","language":"ms-MY","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Osman","name":"Microsoft Osman Online (Natural) - Malay (Malaysia)","language":"ms-MY","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Amira","name":"Amira","language":"ms-MY","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Rizwan","name":"Microsoft Rizwan - Malay (Malaysia)","language":"ms-MY","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Suara perempuan 1","name":"Android Speech Recognition and Synthesis from Google ms-my-x-msc-network","altNames":["Android Speech Recognition and Synthesis from Google ms-my-x-msc-local","Android Speech Recognition and Synthesis from Google ms-MY-language"],"language":"ms-MY","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Suara perempuan 2","name":"Android Speech Recognition and Synthesis from Google ms-my-x-mse-network","altNames":["Android Speech Recognition and Synthesis from Google ms-my-x-mse-local"],"language":"ms-MY","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Suara lelaki 1","name":"Android Speech Recognition and Synthesis from Google ms-my-x-msd-network","altNames":["Android Speech Recognition and Synthesis from Google ms-my-x-msd-local"],"language":"ms-MY","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Suara lelaki 2","name":"Android Speech Recognition and Synthesis from Google ms-my-x-msg-network","altNames":["Android Speech Recognition and Synthesis from Google ms-my-x-msg-local"],"language":"ms-MY","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Pernille","name":"Microsoft Pernille Online (Natural) - Norwegian (Bokmål, Norway)","language":"nb-NO","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Finn","name":"Microsoft Finn Online (Natural) - Norwegian (Bokmål Norway)","language":"nb-NO","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Nora","name":"Nora","language":"nb-NO","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Henrik","name":"Henrik","language":"nb-NO","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Jon","name":"Microsoft Jon - Norwegian (Bokmål Norway)","language":"nb-NO","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kvinnestemme 1","name":"Google Norsk Bokmål 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google nb-no-x-cfl-network","Chrome OS Norsk Bokmål 2","Android Speech Recognition and Synthesis from Google nb-no-x-cfl-local","Android Speech Recognition and Synthesis from Google nb-NO-language"],"language":"nb-NO","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kvinnestemme 2","name":"Google Norsk Bokmål 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google nb-no-x-rfj-network","Chrome OS Norsk Bokmål 1","Android Speech Recognition and Synthesis from Google nb-no-x-rfj-local"],"language":"nb-NO","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kvinnestemme 3","name":"Google Norsk Bokmål 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google nb-no-x-tfs-network","Chrome OS Norsk Bokmål 4","Android Speech Recognition and Synthesis from Google nb-no-x-tfs-local"],"language":"nb-NO","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Mannsstemme 1","name":"Google Norsk Bokmål 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google nb-no-x-cmj-network","Chrome OS Norsk Bokmål 3","Android Speech Recognition and Synthesis from Google nb-no-x-cmj-local"],"language":"nb-NO","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Mannsstemme 2","name":"Google Norsk Bokmål 5 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google nb-no-x-tmg-network","Chrome OS Norsk Bokmål 5","Android Speech Recognition and Synthesis from Google nb-no-x-tmg-local"],"language":"nb-NO","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Colette","name":"Microsoft Colette Online (Natural) - Dutch (Netherlands)","language":"nl-NL","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Fenna","name":"Microsoft Fenna Online (Natural) - Dutch (Netherlands)","language":"nl-NL","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Hanna","name":"Microsoft Hanna Online - Dutch (Netherlands)","language":"nl-NL","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Maarten","name":"Microsoft Maarten Online (Natural) - Dutch (Netherlands)","language":"nl-NL","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Dena","name":"Microsoft Dena Online (Natural) - Dutch (Belgium)","language":"nl-BE","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Arnaud","name":"Microsoft Arnaud Online (Natural) - Dutch (Belgium)","language":"nl-BE","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Claire","name":"Claire","language":"nl-NL","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Xander","name":"Xander","language":"nl-NL","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Ellen","name":"Ellen","language":"nl-BE","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Google mannelijke stem","name":"Google Nederlands","language":"nl-NL","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Frank","name":"Microsoft Frank - Dutch (Netherlands)","language":"nl-NL","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Vrouwelijke stem 1 (Nederland)","name":"Google Nederlands 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google nl-nl-x-lfc-network","Chrome OS Nederlands 4","Android Speech Recognition and Synthesis from Google nl-nl-x-lfc-local","Android Speech Recognition and Synthesis from Google nl-NL-language"],"language":"nl-NL","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Vrouwelijke stem 2 (Nederland)","name":"Google Nederlands 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google nl-nl-x-tfb-network","Chrome OS Nederlands 1","Android Speech Recognition and Synthesis from Google nl-nl-x-tfb-local"],"language":"nl-NL","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Vrouwelijke stem 3 (Nederland)","name":"Google Nederlands 5 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google nl-nl-x-yfr-network","Chrome OS Nederlands 5","Android Speech Recognition and Synthesis from Google nl-nl-x-yfr-local"],"language":"nl-NL","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Mannelijke stem 1 (Nederland)","name":"Google Nederlands 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google nl-nl-x-bmh-network","Chrome OS Nederlands 2","Android Speech Recognition and Synthesis from Google nl-nl-x-bmh-local"],"language":"nl-NL","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Mannelijke stem 2 (Nederland)","name":"Google Nederlands 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google nl-nl-x-dma-network","Chrome OS Nederlands 3","Android Speech Recognition and Synthesis from Google nl-nl-x-dma-local"],"language":"nl-NL","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Vrouwelijke stem (België)","name":"Android Speech Recognition and Synthesis from Google nl-be-x-bec-network","altNames":["Android Speech Recognition and Synthesis from Google nl-be-x-bec-local","Android Speech Recognition and Synthesis from Google nl-BE-language"],"language":"nl-BE","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Mannelijke stem (België)","name":"Android Speech Recognition and Synthesis from Google nl-be-x-bed-network","altNames":["Android Speech Recognition and Synthesis from Google nl-be-x-bed-local"],"language":"nl-BE","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Zofia","name":"Microsoft Zofia Online (Natural) - Polish (Poland)","language":"pl-PL","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Paulina","name":"Microsoft Paulina Online - Polish (Poland)","language":"pl-PL","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Marek","name":"Microsoft Marek Online (Natural) - Polish (Poland)","language":"pl-PL","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Ewa","name":"Ewa","language":"pl-PL","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Zosia","name":"Zosia","language":"pl-PL","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Krzysztof","name":"Krzysztof","language":"pl-PL","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Żeński głos Google’a","name":"Google polski","language":"pl-PL","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Paulina","name":"Microsoft Paulina - Polish (Poland)","language":"pl-PL","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Adam","name":"Microsoft Adam - Polish (Poland)","language":"pl-PL","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Głos żeński 1","name":"Google Polski 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google pl-pl-x-afb-network","Chrome OS Polski 2","Android Speech Recognition and Synthesis from Google pl-pl-x-afb-local","Android Speech Recognition and Synthesis from Google pl-PL-language"],"language":"pl-PL","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Głos żeński 2","name":"Google Polski 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google pl-pl-x-oda-network","Chrome OS Polski 1","Android Speech Recognition and Synthesis from Google pl-pl-x-oda-local"],"language":"pl-PL","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Głos żeński 3","name":"Google Polski 5 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google pl-pl-x-zfg-network","Chrome OS Polski 5","Android Speech Recognition and Synthesis from Google pl-pl-x-zfg-local"],"language":"pl-PL","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Głos męski 1","name":"Google Polski 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google pl-pl-x-bmg-network","Chrome OS Polski 3","Android Speech Recognition and Synthesis from Google pl-pl-x-bmg-local"],"language":"pl-PL","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Głos męski 2","name":"Google Polski 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google pl-pl-x-jmk-network","Chrome OS Polski 4","Android Speech Recognition and Synthesis from Google pl-pl-x-jmk-local"],"language":"pl-PL","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Raquel","name":"Microsoft Raquel Online (Natural) - Portuguese (Portugal)","language":"pt-PT","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Duarte","name":"Microsoft Duarte Online (Natural) - Portuguese (Portugal)","language":"pt-PT","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Francisca","name":"Microsoft Francisca Online (Natural) - Portuguese (Brazil)","language":"pt-BR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Thalita","name":"Microsoft Thalita Online (Natural) - Portuguese (Brazil)","language":"pt-BR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Antonio","name":"Microsoft Antonio Online (Natural) - Portuguese (Brazil)","language":"pt-BR","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Catarina","name":"Catarina","language":"pt-PT","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Joana","name":"Joana","language":"pt-PT","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Joaquim","name":"Joaquim","language":"pt-PT","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Fernanda","name":"Fernanda","language":"pt-BR","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Luciana","name":"Luciana","language":"pt-BR","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Felipe","name":"Felipe","language":"pt-BR","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Voz feminina do Google","name":"Google português do Brasil","language":"pt-BR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Helia","name":"Microsoft Helia - Portuguese (Portugal)","language":"pt-PT","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Maria","name":"Microsoft Maria - Portuguese (Brazil)","language":"pt-BR","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Daniel","name":"Microsoft Daniel - Portuguese (Brazil)","language":"pt-BR","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz feminina 1 (Portugal)","name":"Google português de Portugal 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google pt-pt-x-jfb-network","Android Speech Recognition and Synthesis from Google pt-pt-x-jfb-local","Android Speech Recognition and Synthesis from Google pt-PT-language"],"language":"pt-PT","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz feminina 2 (Portugal)","name":"Google português de Portugal 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google pt-pt-x-sfs-network","Chrome OS português de Portugal","Android Speech Recognition and Synthesis from Google pt-pt-x-sfs-local"],"language":"pt-PT","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz masculina 1 (Portugal)","name":"Google português de Portugal 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google pt-pt-x-jmn-network","Android Speech Recognition and Synthesis from Google pt-pt-x-jmn-local"],"language":"pt-PT","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz masculina 2 (Portugal)","name":"Google português de Portugal 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google pt-pt-x-pmj-network","Android Speech Recognition and Synthesis from Google pt-pt-x-pmj-local"],"language":"pt-PT","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz feminina 1 (Brasil)","name":"Google português do Brasil 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google pt-br-x-afs-network","Chrome OS português do Brasil","Android Speech Recognition and Synthesis from Google pt-br-x-afs-local","Android Speech Recognition and Synthesis from Google pt-BR-language"],"language":"pt-BR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz feminina 2 (Brasil)","name":"Google português do Brasil 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google pt-br-x-pte-network","Android Speech Recognition and Synthesis from Google pt-br-x-pte-local"],"language":"pt-BR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voz masculina (Brasil)","name":"Google português do Brasil 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google pt-br-x-ptd-network","Android Speech Recognition and Synthesis from Google pt-br-x-ptd-local"],"language":"pt-BR","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Alina","name":"Microsoft Alina Online (Natural) - Romanian (Romania)","language":"ro-RO","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Emil","name":"Microsoft Emil Online (Natural) - Romanian (Romania)","language":"ro-RO","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Ioana","name":"Ioana","language":"ro-RO","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Andrei","name":"Microsoft Andrei - Romanian (Romania)","language":"ro-RO","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Voce feminină","name":"Android Speech Recognition and Synthesis from Google ro-ro-x-vfv-network","altNames":["Android Speech Recognition and Synthesis from Google ro-ro-x-vfv-local","Android Speech Recognition and Synthesis from Google ro-RO-language"],"language":"ro-RO","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Svetlana","name":"Microsoft Svetlana Online (Natural) - Russian (Russia)","language":"ru-RU","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Ekaterina","name":"Microsoft Ekaterina Online - Russian (Russia)","language":"ru-RU","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Dmitry","name":"Microsoft Dmitry Online (Natural) - Russian (Russia)","language":"ru-RU","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Katya","name":"Katya","language":"ru-RU","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Milena","name":"Milena","language":"ru-RU","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Yuri","name":"Yuri","language":"ru-RU","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Google женский голос","name":"Google русский","language":"ru-RU","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Irina","name":"Microsoft Irina - Russian (Russian)","language":"ru-RU","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Pavel","name":"Microsoft Pavel - Russian (Russian)","language":"ru-RU","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Женский голос 1","name":"Android Speech Recognition and Synthesis from Google ru-ru-x-dfc-network","altNames":["Android Speech Recognition and Synthesis from Google ru-ru-x-dfc-local"],"language":"ru-RU","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Женский голос 2","name":"Android Speech Recognition and Synthesis from Google ru-ru-x-ruc-network","altNames":["Android Speech Recognition and Synthesis from Google ru-ru-x-ruc-local"],"language":"ru-RU","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Женский голос 3","name":"Android Speech Recognition and Synthesis from Google ru-ru-x-rue-network","altNames":["Android Speech Recognition and Synthesis from Google ru-ru-x-rue-local"],"language":"ru-RU","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Мужской голос 1","name":"Android Speech Recognition and Synthesis from Google ru-ru-x-rud-network","altNames":["Android Speech Recognition and Synthesis from Google ru-ru-x-rud-local"],"language":"ru-RU","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Мужской голос 2","name":"Android Speech Recognition and Synthesis from Google ru-ru-x-ruf-network","altNames":["Android Speech Recognition and Synthesis from Google ru-ru-x-ruf-local"],"language":"ru-RU","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Viktoria","name":"Microsoft Viktoria Online (Natural) - Slovak (Slovakia)","language":"sk-SK","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Lukas","name":"Microsoft Lukas Online (Natural) - Slovak (Slovakia)","language":"sk-SK","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Laura","name":"Laura","language":"sk-SK","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Filip","name":"Microsoft Filip - Slovak (Slovakia)","language":"sk-SK","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Ženský hlas","name":"Google Slovenčina (Natural)","altNames":["Android Speech Recognition and Synthesis from Google sk-sk-x-sfk-network","Android Speech Recognition and Synthesis from Google sk-sk-x-sfk-local","Chrome OS Slovenčina","Android Speech Recognition and Synthesis from Google sk-SK-language"],"language":"sk-SK","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Petra","name":"Microsoft Petra Online (Natural) - Slovenian (Slovenia)","language":"sl-SI","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Rok","name":"Microsoft Rok Online (Natural) - Slovenian (Slovenia)","language":"sl-SI","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Tina","name":"Tina","language":"sl-SI","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Lado","name":"Microsoft Lado - Slovenian (Slovenia)","language":"sl-SI","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Ženski glas","name":"Android Speech Recognition and Synthesis from Google sl-si-x-frm-local","altNames":["Android Speech Recognition and Synthesis from Google sl-SI-language"],"language":"sl-SI","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Sofie","name":"Microsoft Sofie Online (Natural) - Swedish (Sweden)","language":"sv-SE","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Mattias","name":"Microsoft Mattias Online (Natural) - Swedish (Sweden)","language":"sv-SE","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Klara","name":"Klara","language":"sv-SE","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Alva","name":"Alva","language":"sv-SE","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Oskar","name":"Oskar","language":"sv-SE","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Bengt","name":"Microsoft Bengt - Swedish (Sweden)","language":"sv-SE","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kvinnlig röst 1","name":"Google Svenska 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google sv-se-x-lfs-network","Chrome OS Svenska","Android Speech Recognition and Synthesis from Google sv-se-x-lfs-local","Android Speech Recognition and Synthesis from Google sv-SE-language"],"language":"sv-SE","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kvinnlig röst 2","name":"Google Svenska 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google sv-se-x-afp-network","Android Speech Recognition and Synthesis from Google sv-se-x-afp-local"],"language":"sv-SE","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kvinnlig röst 3","name":"Google Svenska 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google sv-se-x-cfg-network","Android Speech Recognition and Synthesis from Google sv-se-x-cfg-local"],"language":"sv-SE","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Mansröst 1","name":"Google Svenska 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google sv-se-x-cmh-network","Android Speech Recognition and Synthesis from Google sv-se-x-cmh-local"],"language":"sv-SE","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Mansröst 2","name":"Google Svenska 5 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google sv-se-x-dmc-network","Android Speech Recognition and Synthesis from Google sv-se-x-dmc-local"],"language":"sv-SE","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Pallavi","name":"Microsoft Pallavi Online (Natural) - Tamil (India)","language":"ta-IN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Valluvar","name":"Microsoft Valluvar Online (Natural) - Tamil (India)","language":"ta-IN","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Saranya","name":"Microsoft Saranya Online (Natural) - Tamil (Sri Lanka)","language":"ta-LK","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Kumar","name":"Microsoft Kumar Online (Natural) - Tamil (Sri Lanka)","language":"ta-LK","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Kani","name":"Microsoft Kani Online (Natural) - Tamil (Malaysia)","language":"ta-MY","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Surya","name":"Microsoft Surya Online (Natural) - Tamil (Malaysia)","language":"ta-MY","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Venba","name":"Microsoft Venba Online (Natural) - Tamil (Singapore)","language":"ta-SG","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Anbu","name":"Microsoft Anbu Online (Natural) - Tamil (Singapore)","language":"ta-SG","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Vani","name":"Vani","language":"ta-IN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Valluvar","name":"Microsoft Valluvar - Tamil (India)","language":"ta-IN","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"பெண் குரல்","name":"Android Speech Recognition and Synthesis from Google ta-in-x-tac-network","altNames":["Android Speech Recognition and Synthesis from Google ta-in-x-tac-local","Android Speech Recognition and Synthesis from Google ta-IN-language"],"language":"ta-IN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"ஆண் குரல்","name":"Android Speech Recognition and Synthesis from Google ta-in-x-tad-network","altNames":["Android Speech Recognition and Synthesis from Google ta-in-x-tad-local"],"language":"ta-IN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Shruti","name":"Microsoft Shruti Online (Natural) - Telugu (India)","language":"te-IN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Mohan","name":"Microsoft Mohan Online (Natural) - Telugu (India)","language":"te-IN","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Geeta","name":"Geeta","language":"te-IN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"స్త్రీ స్వరం","name":"Android Speech Recognition and Synthesis from Google te-in-x-tef-network","altNames":["Android Speech Recognition and Synthesis from Google te-in-x-tef-local","Android Speech Recognition and Synthesis from Google te-IN-language"],"language":"te-IN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"పురుష స్వరం","name":"Android Speech Recognition and Synthesis from Google te-in-x-tem-network","altNames":["Android Speech Recognition and Synthesis from Google te-in-x-tem-local"],"language":"te-IN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Premwadee","name":"Microsoft Premwadee Online (Natural) - Thai (Thailand)","language":"th-TH","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Niwat","name":"Microsoft Niwat Online (Natural) - Thai (Thailand)","language":"th-TH","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Narisa","name":"Narisa","language":"th-TH","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Kanya","name":"Kanya","language":"th-TH","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Pattara","name":"Microsoft Pattara - Thai (Thailand)","language":"th-TH","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"เสียงผู้หญิง","name":"Google ไทย (Natural)","altNames":["Android Speech Recognition and Synthesis from Google th-th-x-mol-network","Chrome OS ไทย","Android Speech Recognition and Synthesis from Google th-th-x-mol-local","Android Speech Recognition and Synthesis from Google th-TH-language"],"language":"th-TH","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Emel","name":"Microsoft Emel Online (Natural) - Turkish (Turkey)","language":"tr-TR","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Ahmet","name":"Microsoft Ahmet Online (Natural) - Turkish (Turkey)","language":"tr-TR","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Yelda","name":"Yelda","altNames":["Yelda (Geliştirilmiş)","Yelda (Türkçe (Türkiye))"],"language":"tr-TR","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Cem","name":"Cem","language":"tr-TR","gender":"male","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Tolga","name":"Microsoft Tolga - Turkish (Turkey)","language":"tr-TR","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kadın sesi 1","name":"Google Türkçe 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google tr-tr-x-cfs-network","Chrome OS Türkçe 3","Android Speech Recognition and Synthesis from Google tr-tr-x-cfs-local","Android Speech Recognition and Synthesis from Google tr-TR-language"],"language":"tr-TR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kadın sesi 2","name":"Google Türkçe 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google tr-tr-x-efu-network","Chrome OS Türkçe 4","Android Speech Recognition and Synthesis from Google tr-tr-x-efu-local"],"language":"tr-TR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Kadın sesi 3","name":"Google Türkçe 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google tr-tr-x-mfm-network","Chrome OS Türkçe 1","Android Speech Recognition and Synthesis from Google tr-tr-x-mfm-local"],"language":"tr-TR","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Erkek sesi 1","name":"Google Türkçe 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google tr-tr-x-ama-network","Chrome OS Türkçe 2","Android Speech Recognition and Synthesis from Google tr-tr-x-ama-local"],"language":"tr-TR","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Erkek sesi 2","name":"Google Türkçe 5 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google tr-tr-x-tmc-network","Chrome OS Türkçe 5","Android Speech Recognition and Synthesis from Google tr-tr-x-tmc-local"],"language":"tr-TR","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Polina","name":"Microsoft Polina Online (Natural) - Ukrainian (Ukraine)","language":"uk-UA","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"Ostap","name":"Microsoft Ostap Online (Natural) - Ukrainian (Ukraine)","language":"uk-UA","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Lesya","name":"Lesya","language":"uk-UA","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Жіночий голос","name":"Google українська (Natural)","altNames":["Android Speech Recognition and Synthesis from Google uk-ua-x-hfd-network","Chrome OS українська","Android Speech Recognition and Synthesis from Google uk-ua-x-hfd-local","Android Speech Recognition and Synthesis from Google uk-UA-language"],"language":"uk-UA","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"HoaiMy","name":"Microsoft HoaiMy Online (Natural) - Vietnamese (Vietnam)","language":"vi-VN","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"NamMinh","name":"Microsoft NamMinh Online (Natural) - Vietnamese (Vietnam)","language":"vi-VN","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Linh","name":"Linh","language":"vi-VN","gender":"female","quality":["low","normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"An","name":"Microsoft An - Vietnamese (Vietnam)","language":"vi-VN","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Giọng nữ 1","name":"Google Tiếng Việt 1 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google vi-vn-x-vic-network","Chrome OS Tiếng Việt 1","Android Speech Recognition and Synthesis from Google vi-vn-x-vic-local","Android Speech Recognition and Synthesis from Google vi-VN-language"],"language":"vi-VN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Giọng nữ 2","name":"Google Tiếng Việt 2 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google vi-vn-x-vid-network","Chrome OS Tiếng Việt 2","Android Speech Recognition and Synthesis from Google vi-vn-x-vid-local"],"language":"vi-VN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Giọng nữ 3","name":"Google Tiếng Việt 4 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google vi-vn-x-vif-network","Chrome OS Tiếng Việt 4","Android Speech Recognition and Synthesis from Google vi-vn-x-vif-local"],"language":"vi-VN","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Giọng nam 1","name":"Google Tiếng Việt 3 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google vi-vn-x-vie-network","Chrome OS Tiếng Việt 3","Android Speech Recognition and Synthesis from Google vi-vn-x-vie-local"],"language":"vi-VN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Giọng nam 2","name":"Google Tiếng Việt 5 (Natural)","altNames":["Android Speech Recognition and Synthesis from Google vi-vn-x-gft-network","Chrome OS Tiếng Việt 5","Android Speech Recognition and Synthesis from Google vi-vn-x-gft-local"],"language":"vi-VN","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Nannan","name":"Nannan","language":"wuu-CN","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"HiuGaai","name":"Microsoft HiuGaai Online (Natural) - Chinese (Cantonese Traditional)","language":"yue-HK","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"HiuMaan","name":"Microsoft HiuMaan Online (Natural) - Chinese (Hong Kong)","language":"yue-HK","gender":"female","quality":["veryHigh"],"localizedName":""},{"label":"WanLung","name":"Microsoft WanLung Online (Natural) - Chinese (Hong Kong)","language":"yue-HK","gender":"male","quality":["veryHigh"],"localizedName":""},{"label":"Sinji","name":"Sinji","language":"yue-HK","gender":"female","quality":["low","normal","high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Aasing","name":"Aasing","language":"yue-HK","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":"apple"},{"label":"Google 女聲","name":"Google 粤語(香港)","language":"yue-HK","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Tracy","name":"Microsoft Tracy - Chinese (Traditional, Hong Kong S.A.R.)","language":"cmn-HK","gender":"female","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"Danny","name":"Microsoft Danny - Chinese (Traditional, Hong Kong S.A.R.)","language":"cmn-HK","gender":"male","quality":["normal"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"女聲1","name":"Android Speech Recognition and Synthesis from Google yue-hk-x-jar-network","altNames":["Chrome OS 粵語 1","Android Speech Recognition and Synthesis from Google yue-HK-x-jar-local","Android Speech Recognition and Synthesis from Google yue-HK-language"],"language":"yue-HK","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"女聲2","name":"Android Speech Recognition and Synthesis from Google yue-hk-x-yuc-network","altNames":["Chrome OS 粵語 2","Android Speech Recognition and Synthesis from Google yue-HK-x-yuc-local"],"language":"yue-HK","gender":"female","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"男聲1","name":"Android Speech Recognition and Synthesis from Google yue-hk-x-yud-network","altNames":["Chrome OS 粵語 3","Android Speech Recognition and Synthesis from Google yue-HK-x-yud-local"],"language":"yue-HK","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"男聲2","name":"Android Speech Recognition and Synthesis from Google yue-hk-x-yue-network","altNames":["Chrome OS 粵語 5","Android Speech Recognition and Synthesis from Google yue-HK-x-yue-local"],"language":"yue-HK","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""},{"label":"男聲3","name":"Android Speech Recognition and Synthesis from Google yue-hk-x-yuf-network","altNames":["Chrome OS 粵語 5","Android Speech Recognition and Synthesis from Google yue-HK-x-yuf-local"],"language":"yue-HK","gender":"male","quality":["high"],"recommendedPitch":1,"recommendedRate":1,"localizedName":""}]; - -export const quality = {"ar":{"normal":"محسن","high":"استثنائي"},"ca":{"normal":"millorada","high":"prèmium"},"cmn-CN":{"normal":"优化音质","high":"高音质"},"cmn-TW":{"normal":"增強音質","high":"高音質"},"cs":{"normal":"vylepšená verze","high":"prémiový"},"da":{"normal":"forbedret","high":"høj kvalitet"},"de":{"normal":"erweitert","high":"premium"},"el":{"normal":"βελτιωμένη","high":"υψηλής ποιότητας"},"en":{"normal":"Enhanced","high":"Premium"},"es":{"normal":"mejorada","high":"premium"},"fi":{"normal":"parannettu","high":"korkealaatuinen"},"fr":{"normal":"premium","high":"de qualité"},"he":{"normal":"משופר","high":"פרימיום"},"hi":{"normal":"बेहतर","high":"प्रीमियम"},"hr":{"normal":"poboljšani","high":"vrhunski"},"hu":{"normal":"továbbfejlesztett","high":"prémium"},"id":{"normal":"Ditingkatkan","high":"Premium"},"it":{"normal":"ottimizzata","high":"premium"},"ja":{"normal":"拡張","high":"プレミアム"},"ko":{"normal":"고품질","high":"프리미엄"},"ms":{"normal":"Dipertingkat","high":"Premium"},"nb":{"normal":"forbedret","high":"premium"},"nl":{"normal":"verbeterd","high":"premium"},"pl":{"normal":"rozszerzony","high":"premium"},"pt":{"normal":"melhorada","high":"premium"},"ro":{"normal":"îmbunătățită","high":"premium"},"ru":{"normal":"улучшенный","high":"высшее качество"},"sk":{"normal":"vylepšený","high":"prémiový"},"sl":{"normal":"izboljšano","high":"prvovrsten"},"sv":{"normal":"förbättrad","high":"premium"},"th":{"normal":"คุณภาพสูง","high":"คุณภาพสูง"},"tr":{"normal":"Geliştirilmiş","high":"Yüksek Kaliteli"},"uk":{"normal":"вдосконалений","high":"високої якості"},"vi":{"normal":"Nâng cao","high":"Cao cấp"}}; - -export const defaultRegion = {"ar":"ar-SA","bg":"bg-BG","bho":"bho-IN","bn":"bn-IN","ca":"ca-ES","cmn":"cmn-CN","cs":"cs-CZ","da":"da-DK","de":"de-DE","el":"el-GR","en":"en-US","es":"es-ES","eu":"eu-ES","fa":"fa-IR","fi":"fi-FI","fr":"fr-FR","gl":"gl-ES","he":"he-IL","hi":"hi-IN","hr":"hr-HR","hu":"hu-HU","id":"id-ID","it":"it-IT","ja":"ja-JP","kn":"kn-IN","ko":"ko-KR","mr":"mr-IN","ms":"ms-MY","nb":"nb-NO","nl":"nl-NL","pl":"pl-PL","pt":"pt-BR","ro":"ro-RO","ru":"ru-RU","sk":"sk-SK","sl":"sl-SI","sv":"sv-SE","ta":"ta-IN","te":"te-IN","th":"th-TH","tr":"tr-TR","uk":"uk-UA","vi":"vi-VN","wuu":"wuu-CN","yue":"yue-HK"}; - -// EOF diff --git a/src/engine.ts b/src/engine.ts index 79ff6b7..9d742e8 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,6 +1,6 @@ import { ReadiumSpeechPlaybackEvent, ReadiumSpeechPlaybackState } from "./navigator"; import { ReadiumSpeechUtterance } from "./utterance"; -import { ReadiumSpeechVoice } from "./voices"; +import { ReadiumSpeechVoice } from "./voices/types"; export interface ReadiumSpeechPlaybackEngine { // Queue Management diff --git a/src/index.ts b/src/index.ts index 7096f9b..8e342c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,11 @@ -export * from "./voices"; +// Core exports +export * from "./WebSpeech"; + +// Data exports +export * from "./voices/languages"; + +// Other exports export * from "./engine"; export * from "./navigator"; export * from "./provider"; -export * from "./utterance"; -export * from "./WebSpeech/webSpeechEngine"; -export * from "./WebSpeech/webSpeechEngineProvider"; -export * from "./WebSpeech/TmpNavigator"; \ No newline at end of file +export * from "./utterance"; \ No newline at end of file diff --git a/src/navigator.ts b/src/navigator.ts index b8e96a1..1ab3163 100644 --- a/src/navigator.ts +++ b/src/navigator.ts @@ -1,4 +1,4 @@ -import { ReadiumSpeechVoice } from "./voices"; +import { ReadiumSpeechVoice } from "./voices/types"; import { ReadiumSpeechUtterance } from "./utterance"; export type ReadiumSpeechPlaybackState = "playing" | "paused" | "idle" | "loading" | "ready"; @@ -26,7 +26,7 @@ export interface ReadiumSpeechPlaybackEvent { export interface ReadiumSpeechNavigator { // Voice Management getVoices(): Promise; - setVoice(voice: ReadiumSpeechVoice | string): Promise; + setVoice(voice: ReadiumSpeechVoice | string): void; getCurrentVoice(): ReadiumSpeechVoice | null; // Content Management diff --git a/src/provider.ts b/src/provider.ts index 5a4defb..4d9dcd4 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,5 +1,5 @@ import { ReadiumSpeechPlaybackEngine } from "./engine"; -import { ReadiumSpeechVoice } from "./voices"; +import { ReadiumSpeechVoice } from "./voices/types"; export interface ReadiumSpeechEngineProvider { readonly id: string; diff --git a/src/types/json.d.ts b/src/types/json.d.ts new file mode 100644 index 0000000..a3932b0 --- /dev/null +++ b/src/types/json.d.ts @@ -0,0 +1,5 @@ +declare module "@json/*.json" { + import { VoiceData } from "../voices/types"; + const value: VoiceData; + export default value; +} diff --git a/src/utils/language.ts b/src/utils/language.ts new file mode 100644 index 0000000..6d8aa6c --- /dev/null +++ b/src/utils/language.ts @@ -0,0 +1,26 @@ +/** + * Extracts language and region from a BCP 47 language tag. + * @param lang - The BCP 47 language tag (e.g., "en-US", "fr-CA") + * @returns A tuple containing [language, region] where region is optional + */ +export const extractLangRegionFromBCP47 = (lang: string): [string, string | undefined] => { + if (!lang) return ["", undefined]; + + // Normalize language code by replacing underscores with dashes + const normalizedLang = lang.replace(/_/g, "-"); + + try { + const locale = new Intl.Locale(normalizedLang); + return [ + locale.language.toLowerCase(), + locale.region?.toUpperCase() + ]; + } catch { + // Fallback to simple parsing if Intl.Locale fails + const parts = normalizedLang.split("-"); + return [ + parts[0].toLowerCase(), + parts[1]?.toUpperCase() + ]; + } +} diff --git a/src/voices.ts b/src/voices.ts deleted file mode 100644 index faa96e9..0000000 --- a/src/voices.ts +++ /dev/null @@ -1,571 +0,0 @@ - -import { novelty, quality, recommended, veryLowQuality, TGender, TQuality, IRecommended, defaultRegion } from "./data.gen.js"; - -// export type TOS = 'Android' | 'ChromeOS' | 'iOS' | 'iPadOS' | 'macOS' | 'Windows'; -// export type TBrowser = 'ChromeDesktop' | 'Edge' | 'Firefox' | 'Safari'; - -const navigatorLanguages = () => window?.navigator?.languages || []; -const navigatorLang = () => (navigator?.language || "").split("-")[0].toLowerCase(); - -export interface ReadiumSpeechVoice { - label: string; - voiceURI: string; - name: string; - __lang?: string | undefined; - language: string; - gender?: TGender | undefined; - age?: string | undefined; - offlineAvailability: boolean; - quality?: TQuality | undefined; - pitchControl: boolean; - recommendedPitch?: number | undefined; - recommendedRate?: number | undefined; -} - -const normalQuality = Object.values(quality).map(({ normal }) => normal); -const highQuality = Object.values(quality).map(({ high }) => high); - -function compareQuality(a?: TQuality, b?: TQuality): number { - const qualityToNumber = (quality: TQuality) => { - switch (quality) { - case "veryLow": {return 0;} - case "low": {return 1;} - case "normal": {return 2;} - case "high": {return 3;} - case "veryHigh": {return 4;} - default: {return -1}; - } - } - - return qualityToNumber(b || "low") - qualityToNumber(a || "low"); -}; - -export async function getSpeechSynthesisVoices(maxTimeout = 10000, interval = 10): Promise { - const a = () => speechSynthesis.getVoices(); - - // Step 1: Try to load voices directly (best case scenario) - const voices = a(); - if (Array.isArray(voices) && voices.length) return voices; - - return new Promise((resolve, reject) => { - // Calculate iterations from total timeout - let counter = Math.floor(maxTimeout / interval); - // Flag to ensure polling only starts once - let pollingStarted = false; - - // Polling function: Checks for voices periodically until counter expires - const startPolling = () => { - // Prevent multiple starts - if (pollingStarted) return; - pollingStarted = true; - - const tick = () => { - // Resolve with empty array if no voices found - if (counter < 1) return resolve([]); - --counter; - const voices = a(); - // Resolve if voices loaded - if (Array.isArray(voices) && voices.length) return resolve(voices); - // Continue polling - setTimeout(tick, interval); - }; - // Initial start - setTimeout(tick, interval); - }; - - // Step 2: Use onvoiceschanged if available (prioritizes event over polling) - if (speechSynthesis.onvoiceschanged) { - speechSynthesis.onvoiceschanged = () => { - const voices = a(); - if (Array.isArray(voices) && voices.length) { - // Resolve immediately if voices are available - resolve(voices); - } else { - // Fallback to polling if event fires but no voices - startPolling(); - } - }; - } else { - // Step 3: No onvoiceschanged support, start polling directly - startPolling(); - } - - // Step 4: Overall safety timeout - fail if nothing happens after maxTimeout - setTimeout(() => reject(new Error("No voices available after timeout")), maxTimeout); - }); -} - -const _strHash = ({voiceURI, name, language, offlineAvailability}: ReadiumSpeechVoice) => `${voiceURI}_${name}_${language}_${offlineAvailability}`; - -function removeDuplicate(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { - - const voicesStrMap = [...new Set(voices.map((v) => _strHash(v)))]; - - const voicesFiltered = voicesStrMap - .map((s) => voices.find((v) => _strHash(v) === s)) - .filter((v) => !!v); - - return voicesFiltered; -} - -export function parseSpeechSynthesisVoices(speechSynthesisVoices: SpeechSynthesisVoice[]): ReadiumSpeechVoice[] { - - const parseAndFormatBCP47 = (lang: string) => { - const speechVoiceLang = lang.replace("_", "-"); - if (/\w{2,3}-\w{2,3}/.test(speechVoiceLang)) { - return `${speechVoiceLang.split("-")[0].toLowerCase()}-${speechVoiceLang.split("-")[1].toUpperCase()}`; - } - - // bad formated !? - return lang; - }; - return speechSynthesisVoices.map((speechVoice) => ({ - label: speechVoice.name, - voiceURI: speechVoice.voiceURI , - name: speechVoice.name, - __lang: speechVoice.lang, - language: parseAndFormatBCP47(speechVoice.lang) , - gender: undefined, - age: undefined, - offlineAvailability: speechVoice.localService, - quality: undefined, - pitchControl: true, - recommendedPitch: undefined, - recomendedRate: undefined, - })); -} - -// Note: This does not work as browsers expect an actual SpeechSynthesisVoice -// Here it is just an object with the same-ish properties -export function convertToSpeechSynthesisVoices(voices: ReadiumSpeechVoice[]): SpeechSynthesisVoice[] { - return voices.map((voice) => ({ - default: false, - lang: voice.__lang || voice.language, - localService: voice.offlineAvailability, - name: voice.name, - voiceURI: voice.voiceURI, - })); -} - -export function filterOnOfflineAvailability(voices: ReadiumSpeechVoice[], offline = true): ReadiumSpeechVoice[] { - return voices.filter(({offlineAvailability}) => { - return offlineAvailability === offline; - }); -} - -export function filterOnGender(voices: ReadiumSpeechVoice[], gender: TGender): ReadiumSpeechVoice[] { - return voices.filter(({gender: voiceGender}) => { - return voiceGender === gender; - }) -} - -export function filterOnLanguage(voices: ReadiumSpeechVoice[], language: string | string[] = navigatorLang()): ReadiumSpeechVoice[] { - language = Array.isArray(language) ? language : [language]; - language = language.map((l) => extractLangRegionFromBCP47(l)[0]); - return voices.filter(({language: voiceLanguage}) => { - const [lang] = extractLangRegionFromBCP47(voiceLanguage); - return language.includes(lang); - }) -} - -export function filterOnQuality(voices: ReadiumSpeechVoice[], quality: TQuality | TQuality[]): ReadiumSpeechVoice[] { - quality = Array.isArray(quality) ? quality : [quality]; - return voices.filter(({quality: voiceQuality}) => { - return quality.some((qual) => qual === voiceQuality); - }); -} - -export function filterOnNovelty(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { - return voices.filter(({ name }) => { - return !novelty.includes(name); - }); -} - -export function filterOnVeryLowQuality(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { - return voices.filter(({ name }) => { - return !veryLowQuality.find((v) => name.startsWith(v)); - }); -} - -function updateVoiceInfo(recommendedVoice: IRecommended, voice: ReadiumSpeechVoice) { - voice.label = recommendedVoice.label; - voice.gender = recommendedVoice.gender; - voice.recommendedPitch = recommendedVoice.recommendedPitch; - voice.recommendedRate = recommendedVoice.recommendedRate; - - return voice; -} -export type TReturnFilterOnRecommended = [voicesRecommended: ReadiumSpeechVoice[], voicesLowerQuality: ReadiumSpeechVoice[]]; -export function filterOnRecommended(voices: ReadiumSpeechVoice[], _recommended: IRecommended[] = recommended): TReturnFilterOnRecommended { - - const voicesRecommended: ReadiumSpeechVoice[] = []; - const voicesLowerQuality: ReadiumSpeechVoice[] = []; - - recommendedVoiceLoop: - for (const recommendedVoice of _recommended) { - if (Array.isArray(recommendedVoice.quality) && recommendedVoice.quality.length > 1) { - - const voicesFound = voices.filter(({ name }) => name.startsWith(recommendedVoice.name)); - if (voicesFound.length) { - - for (const qualityTested of ["high", "normal"] as TQuality[]) { - for (let i = 0; i < voicesFound.length; i++) { - const voice = voicesFound[i]; - - const rxp = /^.*\((.*)\)$/; - if (rxp.test(voice.name)) { - const res = rxp.exec(voice.name); - const maybeQualityString = res ? res[1] || "" : ""; - const qualityDataArray = qualityTested === "high" ? highQuality : normalQuality; - - if (recommendedVoice.quality.includes(qualityTested) && qualityDataArray.includes(maybeQualityString)) { - voice.quality = qualityTested; - voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice)); - - voicesFound.splice(i, 1); - voicesLowerQuality.push(...(voicesFound.map((v) => { - v.quality = "low"; // Todo need to be more precise for 'normal' quality voices - return updateVoiceInfo(recommendedVoice, v); - }))); - - continue recommendedVoiceLoop; - } - } - } - } - const voice = voicesFound[0]; - for (let i = 1; i < voicesFound.length; i++) { - voicesLowerQuality.push(voicesFound[i]); - } - - voice.quality = voicesFound.length > 3 ? "veryHigh" : voicesFound.length > 2 ? "high" : "normal"; - voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice)); - - } - } else if (Array.isArray(recommendedVoice.altNames) && recommendedVoice.altNames.length) { - - const voiceFound = voices.find(({ name }) => name === recommendedVoice.name); - if (voiceFound) { - const voice = voiceFound; - - voice.quality = Array.isArray(recommendedVoice.quality) ? recommendedVoice.quality[0] : undefined; - voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice)); - - // voice Name found so altNames array must be filter and push to voicesLowerQuality - const altNamesVoicesFound = voices.filter(({name}) => recommendedVoice.altNames!.includes(name)); - // TODO: Typescript bug type assertion doesn't work, need to force the compiler with the Non-null Assertion Operator - - voicesLowerQuality.push(...(altNamesVoicesFound.map((v) => { - v.quality = recommendedVoice.quality[0]; - return updateVoiceInfo(recommendedVoice, v); - }))); - } else { - - // filter voices on altNames, keep the first and push the remaining to voicesLowerQuality - const altNamesVoicesFound = voices.filter(({name}) => recommendedVoice.altNames!.includes(name)); - if (altNamesVoicesFound.length) { - - const voice = altNamesVoicesFound.shift() as ReadiumSpeechVoice; - - voice.quality = Array.isArray(recommendedVoice.quality) ? recommendedVoice.quality[0] : undefined; - voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice)); - - - voicesLowerQuality.push(...(altNamesVoicesFound.map((v) => { - v.quality = recommendedVoice.quality[0]; - return updateVoiceInfo(recommendedVoice, v); - }))); - } - } - } else { - - const voiceFound = voices.find(({ name }) => name === recommendedVoice.name); - if (voiceFound) { - - const voice = voiceFound; - - voice.quality = Array.isArray(recommendedVoice.quality) ? recommendedVoice.quality[0] : undefined; - voicesRecommended.push(updateVoiceInfo(recommendedVoice, voice)); - - } - } - } - - return [removeDuplicate(voicesRecommended), removeDuplicate(voicesLowerQuality)]; -} - -const extractLangRegionFromBCP47 = (l: string) => [l.split("-")[0].toLowerCase(), l.split("-")[1]?.toUpperCase()]; - -export function sortByQuality(voices: ReadiumSpeechVoice[]) { - return voices.sort(({quality: qa}, {quality: qb}) => { - return compareQuality(qa, qb); - }); -} - -export function sortByName(voices: ReadiumSpeechVoice[]) { - return voices.sort(({name: na}, {name: nb}) => { - return na.localeCompare(nb); - }) -} - -export function sortByGender(voices: ReadiumSpeechVoice[], genderFirst: TGender) { - return voices.sort(({gender: ga}, {gender: gb}) => { - return ga === gb ? 0 : ga === genderFirst ? -1 : gb === genderFirst ? -1 : 1; - }) -} - -function orderByPreferredLanguage(preferredLanguage?: string[] | string): string[] { - preferredLanguage = Array.isArray(preferredLanguage) ? preferredLanguage : - preferredLanguage ? [preferredLanguage] : []; - - return [...(new Set([...preferredLanguage, ...navigatorLanguages()]))]; -} -function orderByPreferredRegion(preferredLanguage?: string[] | string): string[] { - preferredLanguage = Array.isArray(preferredLanguage) ? preferredLanguage : - preferredLanguage ? [preferredLanguage] : []; - - const regionByDefaultArray = Object.values(defaultRegion); - - return [...(new Set([...preferredLanguage, ...navigatorLanguages(), ...regionByDefaultArray]))]; -} - -const getLangFromBCP47Array = (a: string[]) => { - return [...(new Set(a.map((v) => extractLangRegionFromBCP47(v)[0]).filter((v) => !!v)))]; -} -const getRegionFromBCP47Array = (a: string[]) => { - return [...(new Set(a.map((v) => (extractLangRegionFromBCP47(v)[1] || "").toUpperCase()).filter((v) => !!v)))]; -} - -export function sortByLanguage(voices: ReadiumSpeechVoice[], preferredLanguage: string[] | string = [], localization: string | undefined = navigatorLang()): ReadiumSpeechVoice[] { - - const languages = getLangFromBCP47Array(orderByPreferredLanguage(preferredLanguage)); - - const voicesSorted: ReadiumSpeechVoice[] = []; - for (const lang of languages) { - voicesSorted.push(...voices.filter(({language: voiceLanguage}) => lang === extractLangRegionFromBCP47(voiceLanguage)[0])); - } - - let langueName: Intl.DisplayNames | undefined = undefined; - if (localization) { - try { - langueName = new Intl.DisplayNames([localization], { type: "language" }); - } catch (e) { - console.error("Intl.DisplayNames throw an exception with ", localization, e); - } - } - - const remainingVoices = voices.filter((v) => !voicesSorted.includes(v)); - remainingVoices.sort(({ language: a }, { language: b }) => { - - let nameA = a, nameB = b; - try { - if (langueName) { - nameA = langueName.of(extractLangRegionFromBCP47(a)[0]) || a; - nameB = langueName.of(extractLangRegionFromBCP47(b)[0]) || b; - } - } catch (e) { - // ignore - } - return nameA.localeCompare(nameB); - }); - - return [...voicesSorted, ...remainingVoices]; -} - -export function sortByRegion(voices: ReadiumSpeechVoice[], preferredRegions: string[] | string = [], localization: string | undefined = navigatorLang()): ReadiumSpeechVoice[] { - - const regions = getRegionFromBCP47Array(orderByPreferredRegion(preferredRegions)); - - const voicesSorted: ReadiumSpeechVoice[] = []; - for (const reg of regions) { - voicesSorted.push(...voices.filter(({language: voiceLanguage}) => reg === extractLangRegionFromBCP47(voiceLanguage)[1])); - } - - let regionName: Intl.DisplayNames | undefined = undefined; - if (localization) { - try { - regionName = new Intl.DisplayNames([localization], { type: "region" }); - } catch (e) { - console.error("Intl.DisplayNames throw an exception with ", localization, e); - } - } - - const remainingVoices = voices.filter((v) => !voicesSorted.includes(v)); - remainingVoices.sort(({ language: a }, { language: b }) => { - - let nameA = a, nameB = b; - try { - if (regionName) { - nameA = regionName.of(extractLangRegionFromBCP47(a)[1]) || a; - nameB = regionName.of(extractLangRegionFromBCP47(b)[1]) || b; - } - } catch (e) { - // ignore - } - return nameA.localeCompare(nameB); - }); - - return [...voicesSorted, ...remainingVoices]; -} - -export interface ILanguages { - label: string; - code: string; - count: number; -} -export function listLanguages(voices: ReadiumSpeechVoice[], localization: string | undefined = navigatorLang()): ILanguages[] { - let langueName: Intl.DisplayNames | undefined = undefined; - if (localization) { - try { - langueName = new Intl.DisplayNames([localization], { type: "language" }); - } catch (e) { - console.error("Intl.DisplayNames throw an exception with ", localization, e); - } - } - return voices.reduce((acc, cv) => { - const [lang] = extractLangRegionFromBCP47(cv.language); - let name = lang; - try { - if (langueName) { - name = langueName.of(lang) || lang; - } - } catch (e) { - console.error("langueName.of throw an error with ", lang, e); - } - const found = acc.find(({code}) => code === lang) - if (found) { - found.count++; - } else { - acc.push({code: lang, count: 1, label: name}); - } - return acc; - }, []); -} -export function listRegions(voices: ReadiumSpeechVoice[], localization: string | undefined = navigatorLang()): ILanguages[] { - let regionName: Intl.DisplayNames | undefined = undefined; - if (localization) { - try { - regionName = new Intl.DisplayNames([localization], { type: "region" }); - } catch (e) { - console.error("Intl.DisplayNames throw an exception with ", localization, e); - } - } - return voices.reduce((acc, cv) => { - const [,region] = extractLangRegionFromBCP47(cv.language); - let name = region; - try { - if (regionName) { - name = regionName.of(region) || region; - } - } catch (e) { - console.error("regionName.of throw an error with ", region, e); - } - const found = acc.find(({code}) => code === region); - if (found) { - found.count++; - } else { - acc.push({code: region, count: 1, label: name}); - } - return acc; - }, []); -} - -export type TGroupVoices = Map; -export function groupByLanguages(voices: ReadiumSpeechVoice[], preferredLanguage: string[] | string = [], localization: string | undefined = navigatorLang()): TGroupVoices { - - const voicesSorted = sortByLanguage(voices, preferredLanguage, localization); - - const languagesStructure = listLanguages(voicesSorted, localization); - const res: TGroupVoices = new Map(); - for (const { code, label } of languagesStructure) { - res.set(label, voicesSorted - .filter(({ language: voiceLang }) => { - const [l] = extractLangRegionFromBCP47(voiceLang); - return l === code; - })); - } - return res; -} - -export function groupByRegions(voices: ReadiumSpeechVoice[], preferredRegions: string[] | string = [], localization: string | undefined = navigatorLang()): TGroupVoices { - - const voicesSorted = sortByRegion(voices, preferredRegions, localization); - - const languagesStructure = listRegions(voicesSorted, localization); - const res: TGroupVoices = new Map(); - for (const { code, label } of languagesStructure) { - res.set(label, voicesSorted - .filter(({ language: voiceLang }) => { - const [, r] = extractLangRegionFromBCP47(voiceLang); - return r === code; - })); - } - return res; -} - -export function groupByKindOfVoices(allVoices: ReadiumSpeechVoice[]): TGroupVoices { - - const [recommendedVoices, lowQualityVoices] = filterOnRecommended(allVoices); - const remainingVoice = allVoices.filter((v) => !recommendedVoices.includes(v) && !lowQualityVoices.includes(v)); - const noveltyFiltered = filterOnNovelty(remainingVoice); - const noveltyVoices = remainingVoice.filter((v) => !noveltyFiltered.includes(v)); - const veryLowQualityFiltered = filterOnVeryLowQuality(remainingVoice); - const veryLowQualityVoices = remainingVoice.filter((v) => !veryLowQualityFiltered.includes(v)); - const remainingVoiceFiltered = filterOnNovelty(filterOnVeryLowQuality(remainingVoice)); - - const res: TGroupVoices = new Map(); - res.set("recommendedVoices", recommendedVoices); - res.set("lowerQuality", lowQualityVoices); - res.set("novelty", noveltyVoices); - res.set("veryLowQuality", veryLowQualityVoices); - res.set("remaining", remainingVoiceFiltered); - - return res; -} - -export function getLanguages(voices: ReadiumSpeechVoice[], preferredLanguage: string[] | string = [], localization: string | undefined = navigatorLang()): ILanguages[] { - - const group = groupByLanguages(voices, preferredLanguage, localization); - - return Array.from(group.entries()).map(([label, _voices]) => { - return {label, count: _voices.length, code: extractLangRegionFromBCP47(_voices[0]?.language || "")[0]} - }); -} - -/** - * Parse and extract SpeechSynthesisVoices, - * @returns ReadiumSpeechVoice[] - */ -export async function getVoices(preferredLanguage?: string[] | string, localization?: string) { - - const speechVoices = await getSpeechSynthesisVoices(); - const allVoices = removeDuplicate(parseSpeechSynthesisVoices(speechVoices)); - const recommendedTuple = filterOnRecommended(allVoices); - const [recommendedVoices, lowQualityVoices] = recommendedTuple; - const recommendedTupleFlatten = recommendedTuple.flat(); - const remainingVoices = allVoices - .map((allVoicesItem) => _strHash(allVoicesItem)) - .filter((str) => !recommendedTupleFlatten.find((recommendedVoicesPtr) => _strHash(recommendedVoicesPtr) === str)) - .map((str) => allVoices.find((allVoicesPtr) => _strHash(allVoicesPtr) === str)) - .filter((v) => !!v); - const remainingVoiceFiltered = filterOnNovelty(filterOnVeryLowQuality(remainingVoices)); - - - // console.log("PRE_recommendedVoices_GET_VOICES", recommendedVoices.filter(({label}) => label === "Paulina"), recommendedVoices.length); - - // console.log("PRE_lowQualityVoices_GET_VOICES", lowQualityVoices.filter(({label}) => label === "Paulina"), lowQualityVoices.length); - - // console.log("PRE_remainingVoiceFiltered_GET_VOICES", remainingVoiceFiltered.filter(({label}) => label === "Paulina"), remainingVoiceFiltered.length); - - // console.log("PRE_allVoices_GET_VOICES", allVoices.filter(({label}) => label === "Paulina"), allVoices.length); - - const voices = [recommendedVoices, lowQualityVoices, remainingVoiceFiltered].flat(); - - // console.log("MID_GET_VOICES", voices.filter(({label}) => label === "Paulina"), voices.length); - - const voicesSorted = sortByLanguage(sortByQuality(voices), preferredLanguage, localization || navigatorLang()); - - // console.log("POST_GET_VOICES", voicesSorted.filter(({ label }) => label === "Paulina"), voicesSorted.length); - - return voicesSorted; -} \ No newline at end of file diff --git a/src/voices/filters.ts b/src/voices/filters.ts new file mode 100644 index 0000000..93a2e0c --- /dev/null +++ b/src/voices/filters.ts @@ -0,0 +1,36 @@ +import type { ReadiumSpeechVoice } from "./types"; +import noveltyFilterData from "@json/filters/novelty.json"; +import veryLowQualityFilterData from "@json/filters/veryLowQuality.json"; + +interface FilterVoice { + name: string; + nativeID?: string[]; + altNames?: string[]; +} + +const noveltyFilter = noveltyFilterData as { voices: FilterVoice[] }; +const veryLowQualityFilter = veryLowQualityFilterData as { voices: FilterVoice[] }; + +export const isNoveltyVoice = (voiceName: string, voiceId?: string): boolean => { + return noveltyFilter.voices.some(filter => + voiceName.includes(filter.name) || + (voiceId && filter.nativeID?.some(id => voiceId.includes(id))) || + (filter.altNames?.some(name => voiceName.includes(name))) + ); +} + +export const isVeryLowQualityVoice = (voiceName: string, quality?: string[]): boolean => { + return veryLowQualityFilter.voices.some(filter => + voiceName.includes(filter.name) + ) || (Array.isArray(quality) && quality.includes("veryLow")); +} + +export const filterOutNoveltyVoices = (voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] => { + if (!voices?.length) return []; + return voices.filter(voice => !(voice.isNovelty || isNoveltyVoice(voice.name, voice.voiceURI))); +} + +export const filterOutVeryLowQualityVoices = (voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] => { + if (!voices?.length) return []; + return voices.filter(voice => !isVeryLowQualityVoice(voice.name, voice.quality)); +} \ No newline at end of file diff --git a/src/voices/languages.ts b/src/voices/languages.ts new file mode 100644 index 0000000..f747b83 --- /dev/null +++ b/src/voices/languages.ts @@ -0,0 +1,199 @@ +import { extractLangRegionFromBCP47 } from "../utils/language"; +import type { ReadiumSpeechVoice, VoiceData, TGender, TQuality, TLocalizedName } from "./types"; + +// Import all language JSON files statically +import ar from "@json/ar.json"; +import bg from "@json/bg.json"; +import bho from "@json/bho.json"; +import bn from "@json/bn.json"; +import ca from "@json/ca.json"; +import cmn from "@json/cmn.json"; +import cs from "@json/cs.json"; +import da from "@json/da.json"; +import de from "@json/de.json"; +import el from "@json/el.json"; +import en from "@json/en.json"; +import es from "@json/es.json"; +import eu from "@json/eu.json"; +import fa from "@json/fa.json"; +import fi from "@json/fi.json"; +import fr from "@json/fr.json"; +import gl from "@json/gl.json"; +import he from "@json/he.json"; +import hi from "@json/hi.json"; +import hr from "@json/hr.json"; +import hu from "@json/hu.json"; +import id from "@json/id.json"; +import it from "@json/it.json"; +import ja from "@json/ja.json"; +import kn from "@json/kn.json"; +import ko from "@json/ko.json"; +import mr from "@json/mr.json"; +import ms from "@json/ms.json"; +import nb from "@json/nb.json"; +import nl from "@json/nl.json"; +import pl from "@json/pl.json"; +import pt from "@json/pt.json"; +import ro from "@json/ro.json"; +import ru from "@json/ru.json"; +import sk from "@json/sk.json"; +import sl from "@json/sl.json"; +import sv from "@json/sv.json"; +import ta from "@json/ta.json"; +import te from "@json/te.json"; +import th from "@json/th.json"; +import tr from "@json/tr.json"; +import uk from "@json/uk.json"; +import vi from "@json/vi.json"; +import wuu from "@json/wuu.json"; +import yue from "@json/yue.json"; + +// Helper function to cast voice data to the correct type +const castVoice = (voice: any): ReadiumSpeechVoice => ({ + ...voice, + gender: voice.gender as TGender | undefined, + quality: voice.quality ? (Array.isArray(voice.quality) + ? voice.quality.filter((q: any) => + ["veryLow", "low", "normal", "high", "veryHigh"].includes(q) + ) as TQuality[] + : [voice.quality].filter((q: any) => + ["veryLow", "low", "normal", "high", "veryHigh"].includes(q) + ) as TQuality[] + ) : undefined, + localizedName: voice.localizedName && ["android", "apple"].includes(voice.localizedName) + ? voice.localizedName as TLocalizedName + : undefined +}); + +// Map of language codes to their respective voice data with proper casting +const voiceDataMap: Record = Object.fromEntries( + Object.entries({ + ar, bg, bho, bn, ca, cmn, cs, da, de, el, en, es, eu, fa, fi, fr, gl, he, hi, + hr, hu, id, it, ja, kn, ko, mr, ms, nb, nl, pl, pt, ro, ru, sk, sl, sv, ta, + te, th, tr, uk, vi, wuu, yue + }).map(([lang, data]) => [ + lang, + { + ...data, + voices: data.voices.map(castVoice) + } + ]) +); + +// Helper function to get voice data synchronously +const getVoiceData = (lang: string): VoiceData | undefined => voiceDataMap[lang]; + +// Chinese variant mapping for special handling +export const chineseVariantMap: {[key: string]: string} = { + "cmn": "cmn", + "cmn-cn": "cmn", + "cmn-tw": "cmn", + "zh": "cmn", + "zh-cn": "cmn", + "zh-tw": "cmn", + "yue": "yue", + "yue-hk": "yue", + "zh-hk": "yue", + "wuu": "wuu", + "wuu-cn": "wuu" +}; + +/** + * Normalizes language code with special handling for Chinese variants + * @param lang - Input language code + * @returns Normalized language code + */ +const normalizeLanguageCode = (lang: string): string => { + if (!lang) return ""; + + const normalized = lang.toLowerCase().replace(/_/g, "-"); + return chineseVariantMap[normalized] || normalized; +}; + +/** + * Get all voices for a specific language + * @param {string} lang - Language code (e.g., "en", "fr", "zh-CN") + * @returns {ReadiumSpeechVoice[]} Array of voices for the specified language + */ +export const getVoices = (lang: string): ReadiumSpeechVoice[] => { + if (!lang) return []; + + try { + // Normalize the language code first + const normalizedLang = normalizeLanguageCode(lang); + + // Try with the normalized language code + let voiceData = getVoiceData(normalizedLang); + + // If no voices found and it's a Chinese variant, try with the base Chinese code + if ((!voiceData || !voiceData.voices?.length) && normalizedLang in chineseVariantMap) { + voiceData = getVoiceData("zh"); + } + + // If still no voices, try with the base language code + if (!voiceData || !voiceData.voices?.length) { + const [baseLang] = extractLangRegionFromBCP47(normalizedLang); + if (baseLang !== normalizedLang) { + voiceData = getVoiceData(baseLang); + } + } + + return voiceData?.voices || []; + } catch (error) { + console.error(`Failed to load voices for ${lang}:`, error); + return []; + } +}; + +/** + * Get all available language codes + * @returns {string[]} Array of available language codes + */ +export const getAvailableLanguages = (): string[] => Object.keys(voiceDataMap); + +/** + * Get the test utterance for a language + * @param {string} lang - Language code (e.g., "en", "fr", "zh-CN") + * @returns {string} The test utterance or empty string if not found + */ +export const getTestUtterance = (lang: string): string => { + if (!lang) return ""; + + try { + // Normalize the language code first + const normalizedLang = normalizeLanguageCode(lang); + + // Try with the normalized language code + let voiceData = getVoiceData(normalizedLang); + + // If no test utterance found and it's a Chinese variant, try with the mapped variant code + if ((!voiceData?.testUtterance) && normalizedLang in chineseVariantMap) { + const variantCode = chineseVariantMap[normalizedLang]; + if (variantCode) { + const variantData = getVoiceData(variantCode); + if (variantData?.testUtterance) { + return variantData.testUtterance; + } + } + } + + // If still no test utterance, try with the base language code + if (!voiceData?.testUtterance) { + const [baseLang] = extractLangRegionFromBCP47(normalizedLang); + if (baseLang !== normalizedLang) { + const baseLangData = getVoiceData(baseLang); + if (baseLangData?.testUtterance) { + return baseLangData.testUtterance; + } + } + } + + return voiceData?.testUtterance ?? ""; + } catch (error) { + console.error(`Failed to get test utterance for ${lang}:`, error); + return ""; + } +}; + +// Re-export types for backward compatibility +export * from "./types"; diff --git a/src/voices/types.ts b/src/voices/types.ts new file mode 100644 index 0000000..468338f --- /dev/null +++ b/src/voices/types.ts @@ -0,0 +1,78 @@ +// Auto-generated file - DO NOT EDIT + +/** + * Voice gender as defined in the schema + */ +export type TGender = "neutral" | "female" | "male"; + +/** + * Voice quality levels as defined in the schema + */ +export type TQuality = "veryLow" | "low" | "normal" | "high" | "veryHigh"; + +/** + * Localization type for voice names + */ +export type TLocalizedName = "android" | "apple"; + +/** + * Source of the voice data + */ +export type TSource = "json" | "browser"; + +export interface VoiceFilterData { + voices: Array<{ + name: string; + altNames?: string[]; + [key: string]: any; + }>; +} + +export interface ReadiumSpeechVoice { + source: TSource; // Source of the voice data + + // Core identification (required) + label: string; // Human-friendly label for the voice + name: string; // System/technical name (matches Web Speech API voiceURI) + voiceURI?: string; // For Web Speech API compatibility + + // Localization + language: string; // BCP-47 language tag + localizedName?: TLocalizedName; // Localization pattern (android/apple) + altNames?: string[]; // Alternative names (mostly for Apple voices) + altLanguage?: string; // Alternative BCP-47 language tag + otherLanguages?: string[]; // Other languages this voice can speak + multiLingual?: boolean; // If voice can handle multiple languages + + // Voice characteristics + gender?: TGender; // Voice gender + children?: boolean; // If this is a children's voice + + // Quality and capabilities + quality?: TQuality[]; // Available quality levels for this voice + pitchControl?: boolean; // Whether pitch can be controlled + + // Performance settings + pitch?: number; // Current pitch (0-2, where 1 is normal) + rate?: number; // Speech rate (0.1-10, where 1 is normal) + + // Platform and compatibility + browser?: string[]; // Supported browsers + os?: string[]; // Supported operating systems + preloaded?: boolean; // If the voice is preloaded on the system + nativeID?: string | string[]; // Platform-specific voice ID(s) + + // Additional metadata + note?: string; // Additional notes about the voice + provider?: string; // Voice provider (e.g., "Microsoft", "Google") + + // Allow any additional properties that might be in the JSON + [key: string]: any; +} + +export interface VoiceData { + language: string; // BCP-47 language tag + defaultRegion: string; // Default region for this language + testUtterance: string; // Sample text for testing the voice + voices: ReadiumSpeechVoice[]; // Array of available voices +} \ No newline at end of file diff --git a/test/WebSpeechVoiceManager.test.ts b/test/WebSpeechVoiceManager.test.ts new file mode 100644 index 0000000..bed1462 --- /dev/null +++ b/test/WebSpeechVoiceManager.test.ts @@ -0,0 +1,1384 @@ +import test, { type ExecutionContext } from "ava"; +import { WebSpeechVoiceManager, ReadiumSpeechVoice } from "../build/index.js"; + +// ============================================= +// Mock Data and Helpers +// ============================================= + +// Mock DisplayNames for testing +class MockDisplayNames { + options: any; + constructor(_: any, options: any) { + this.options = options; + } + + of(code: string): string { + if (this.options.type === "language") { + return `${code.toUpperCase()}_LANG`; + } + if (this.options.type === "region") { + return `${code.toUpperCase()}_REGION`; + } + return code; + } + + static supportedLocalesOf(locales: string[]): string[] { + return locales; + } +} + +// Mock Intl.DisplayNames +if (typeof (globalThis as any).Intl === "undefined") { + (globalThis as any).Intl = {}; +} +(globalThis as any).Intl.DisplayNames = MockDisplayNames as any; + +interface TestContext { + manager: WebSpeechVoiceManager; +} + + +// ============================================= +// Test Data +// ============================================= + +// Mock voices for testing +const mockVoices = [ + { + voiceURI: "voice1", + name: "Voice 1", + lang: "en-US", + localService: true, + default: true + }, + { + voiceURI: "voice2", + name: "Voice 2", + lang: "fr-FR", + localService: true, + default: false + }, + { + voiceURI: "voice3", + name: "Voice 3", + lang: "es-ES", + localService: true, + default: false + }, + { + voiceURI: "voice4", + name: "Voice 4", + lang: "de-DE", + localService: true, + default: false + }, + { + voiceURI: "voice5", + name: "Voice 5", + lang: "it-IT", + localService: true, + default: false + } +]; + +// Store original globals +const originalNavigator = globalThis.navigator; +const originalSpeechSynthesis = globalThis.speechSynthesis; + +// ============================================= +// Test Setup +// ============================================= + +// Test context type and setup +type TestFn = (t: ExecutionContext) => void | Promise; +const testWithContext = test as unknown as { + (name: string, fn: TestFn): void; + afterEach: { + always: (fn: (t: ExecutionContext) => void | Promise) => void; + }; + beforeEach: (fn: (t: ExecutionContext) => void | Promise) => void; +}; + +// Helper function to create test voice objects that match ReadiumSpeechVoice interface +function createTestVoice(overrides: Partial = {}): ReadiumSpeechVoice { + return { + source: "json", + label: overrides.name || "Test Voice", + name: overrides.name || "Test Voice", + voiceURI: `voice-${overrides.name || "test"}`, + language: "en-US", + ...overrides + }; +} + +// Set up global mocks before any tests run +if (typeof globalThis.window === "undefined") { + (globalThis as any).window = globalThis; +} + +// Mock the global objects +Object.defineProperty(globalThis, "navigator", { + value: { + ...originalNavigator, + languages: ["en-US", "fr-FR"] + }, + configurable: true, + writable: true +}); + +// Create a mock speechSynthesis object that matches the browser's API +const mockSpeechSynthesis = { + getVoices: () => mockVoices, + onvoiceschanged: null as (() => void) | null, + addEventListener: function(event: string, callback: () => void) { + if (event === "voiceschanged") { + this.onvoiceschanged = callback; + } + }, + removeEventListener: function(event: string) { + }, + _triggerVoicesChanged: function() { + if (this.onvoiceschanged) { + this.onvoiceschanged(); + } + } +}; + +// Mock the window.speechSynthesis to return our mock voices +Object.defineProperty(globalThis.window, "speechSynthesis", { + value: mockSpeechSynthesis, + configurable: true, + writable: true +}); + +// ============================================= +// Test Hooks +// ============================================= + +testWithContext.beforeEach(async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); +}); + +testWithContext.afterEach.always((t: ExecutionContext) => { + // Clean up + (WebSpeechVoiceManager as any).instance = undefined; + + // Restore original globals + Object.defineProperty(globalThis, "navigator", { + value: originalNavigator, + configurable: true, + writable: true + }); + + Object.defineProperty(globalThis, "speechSynthesis", { + value: originalSpeechSynthesis, + configurable: true, + writable: true + }); +}); + +// ============================================= +// 1. Initialization Tests +// ============================================= + +testWithContext("initialize: returns singleton instance", async (t) => { + const instance1 = await WebSpeechVoiceManager.initialize(); + const instance2 = await WebSpeechVoiceManager.initialize(); + t.is(instance1, instance2); +}); + +testWithContext("initialize: loads voices and gets voices successfully", (t) => { + const manager = t.context.manager; + const voices = manager.getVoices(); + t.true(Array.isArray(voices)); + t.true(voices.length > 0); +}); + +// ============================================= +// 2. Voice Retrieval Tests +// ============================================= + +testWithContext("getVoices: returns all voices when no filters are provided", (t) => { + const voices = t.context.manager.getVoices(); + t.is(voices.length, mockVoices.length); +}); + +testWithContext("getVoices: throws if not initialized", (t) => { + // Create a new instance without initializing + const manager = new (WebSpeechVoiceManager as any)(); + t.throws(() => manager.getVoices(), { + message: 'WebSpeechVoiceManager not initialized. Call initialize() first.' + }); +}); + +testWithContext("getVoices: combines all filters", async (t: ExecutionContext) => { + const manager = t.context.manager; + + (manager as any).voices = [ + createTestVoice({ name: "English Male High", language: "en-US", gender: "male", quality: ["high"], provider: "Google", offlineAvailability: true }), + createTestVoice({ name: "English Female Normal", language: "en-US", gender: "female", quality: ["normal"], provider: "Microsoft", offlineAvailability: false }), + createTestVoice({ name: "French Male Low", language: "fr-FR", gender: "male", quality: ["low"], provider: "Google", offlineAvailability: true }), + createTestVoice({ name: "French Female High", language: "fr-FR", gender: "female", quality: ["high"], provider: "Amazon", offlineAvailability: false }), + createTestVoice({ name: "Spanish Male Normal", language: "es-ES", gender: "male", quality: ["normal"], provider: "Microsoft", offlineAvailability: true }) + ]; + + // Test with all filters combined + const filtered = await manager.getVoices({ + language: ["en", "fr"], + gender: "male", + quality: ["high", "normal"], + provider: "Google", + offlineOnly: true, + excludeNovelty: true, + excludeVeryLowQuality: true + }); + + t.is(filtered.length, 1); + t.true(filtered.every(v => + (v.language.startsWith("en") || v.language.startsWith("fr")) && + v.gender === "male" && + (v.quality?.includes("high") || v.quality?.includes("normal")) && + v.provider === "Google" && + v.offlineAvailability === true + )); +}); + +testWithContext("getVoices: handles empty navigator.languages", async (t) => { + const manager = t.context.manager; + + // Create test voices + const testVoices = [ + { voiceURI: "voice1", name: "Voice 1", language: "en-US" }, + { voiceURI: "voice2", name: "Voice 2", language: "fr-FR" } + ]; + + // Replace the voices in the manager + (manager as any).voices = testVoices; + + // Mock empty navigator.languages + const originalLanguages = [...(globalThis.navigator as any).languages]; + (globalThis.navigator as any).languages = []; + + try { + const voices = await manager.getVoices(); + + // Should still return all voices even with empty languages + t.is(voices.length, 2); + } finally { + // Restore original languages + (globalThis.navigator as any).languages = originalLanguages; + } +}); + +testWithContext("getVoices: handles undefined navigator.languages", async (t) => { + const manager = t.context.manager; + + // Create test voices + const testVoices = [ + { voiceURI: "voice1", name: "Voice 1", language: "en-US" }, + { voiceURI: "voice2", name: "Voice 2", language: "fr-FR" } + ]; + + // Replace the voices in the manager + (manager as any).voices = testVoices; + + // Mock undefined navigator.languages + const originalLanguages = (globalThis.navigator as any).languages; + delete (globalThis.navigator as any).languages; + + try { + const voices = await manager.getVoices(); + + // Should still return all voices even with undefined languages + t.is(voices.length, 2); + } finally { + // Restore original languages + (globalThis.navigator as any).languages = originalLanguages; + } +}); + + +testWithContext("getVoices: returns empty array when no voices are available", async (t) => { + // Save the original getVoices implementation + const originalGetVoices = mockSpeechSynthesis.getVoices; + + try { + // Override getVoices to return empty array + mockSpeechSynthesis.getVoices = () => []; + + // Create a fresh instance to avoid interference from other tests + (WebSpeechVoiceManager as any).instance = undefined; + const manager = await WebSpeechVoiceManager.initialize(); + + // Reset initialization to force re-initialization with empty voices + (manager as any).initializationPromise = null; + (manager as any).voices = []; + (manager as any).browserVoices = []; + + // Should return empty array when no voices are available + const voices = manager.getVoices(); + t.deepEqual(voices, []); + } finally { + // Restore original getVoices implementation + mockSpeechSynthesis.getVoices = originalGetVoices; + } +}); + +testWithContext("getVoices: filters by language", async (t: ExecutionContext) => { + const manager = t.context.manager; + + // Single language + let voices = await manager.getVoices({ language: "en" }); + t.true(voices.length > 0); + t.true(voices.every((v: ReadiumSpeechVoice) => v.language.startsWith("en"))); + + // Multiple languages + voices = await manager.getVoices({ language: ["en", "fr"] }); + t.true(voices.length > 1); + t.true(voices.some((v: ReadiumSpeechVoice) => v.language.startsWith("en"))); + t.true(voices.some((v: ReadiumSpeechVoice) => v.language.startsWith("fr"))); +}); + +testWithContext("getVoices: filters by quality", async (t: ExecutionContext) => { + const manager = t.context.manager; + + // Mock quality property on voices + const voices = await manager.getVoices(); + const voicesWithQuality = voices.map((v: ReadiumSpeechVoice, i: number) => ({ + ...v, + quality: i % 2 === 0 ? ["high"] : ["low"] + })); + + // Replace the voices in the manager + (manager as any).voices = voicesWithQuality; + + const highQualityVoices = await manager.getVoices({ quality: "high" }); + t.true(highQualityVoices.length > 0); + t.true(highQualityVoices.every((v: ReadiumSpeechVoice) => v.quality?.includes("high") ?? false)); +}); + +testWithContext("getVoices: returns empty array when speechSynthesis is not available", async (t) => { + // Save original + const originalSpeechSynthesis = globalThis.speechSynthesis; + + try { + // Mock speechSynthesis to be undefined + Object.defineProperty(globalThis, "speechSynthesis", { + value: undefined, + configurable: true, + writable: true + }); + + // Create a new instance + (WebSpeechVoiceManager as any).instance = undefined; + const manager = await WebSpeechVoiceManager.initialize(); + + // Should return empty array when speechSynthesis is not available + const voices = manager.getVoices(); + t.deepEqual(voices, []); + } finally { + // Restore + Object.defineProperty(globalThis, "speechSynthesis", { + value: originalSpeechSynthesis, + configurable: true, + writable: true + }); + } +}); + +// ============================================= +// 3. Language Retrieval Tests +// ============================================= + +testWithContext("getLanguages: returns available languages with counts", async (t: ExecutionContext) => { + const languages = await t.context.manager.getLanguages(); + t.true(Array.isArray(languages)); + + // Check that we have at least one language + t.true(languages.length > 0); + + // Check structure of language entries + for (const lang of languages) { + t.truthy(lang.code); + t.truthy(lang.label); + t.true(typeof lang.count === "number"); + } +}); + +testWithContext("getLanguages: handles empty voices array", async (t: ExecutionContext) => { + // Save the original getVoices implementation + const originalGetVoices = mockSpeechSynthesis.getVoices; + + try { + // Override getVoices to return empty array + mockSpeechSynthesis.getVoices = () => []; + + // Create a fresh instance to avoid interference + (WebSpeechVoiceManager as any).instance = undefined; + const manager = await WebSpeechVoiceManager.initialize(); + + // Reset initialization to force re-initialization with empty voices + (manager as any).initializationPromise = null; + (manager as any).voices = []; + (manager as any).browserVoices = []; + + const languages = manager.getLanguages(); + t.deepEqual(languages, []); + } finally { + // Restore original getVoices implementation + mockSpeechSynthesis.getVoices = originalGetVoices; + } +}); + +// ============================================= +// 4. Region Retrieval Tests +// ============================================= + +testWithContext("getRegions: returns available regions with counts", async (t: ExecutionContext) => { + const regions = await t.context.manager.getRegions(); + t.true(Array.isArray(regions)); + + // Check that we have at least one region + t.true(regions.length > 0); + + // Check structure of region entries + for (const region of regions) { + t.truthy(region.code); + t.truthy(region.label); + t.true(typeof region.count === "number"); + } +}); + +testWithContext("getRegions: handles empty voices array", async (t: ExecutionContext) => { + // Create a fresh instance to avoid interference + (WebSpeechVoiceManager as any).instance = undefined; + const manager = await WebSpeechVoiceManager.initialize(); + + // Mock empty voices array + const emptyMockVoices: any[] = []; + const mockSpeechSynthesis = { + getVoices: () => emptyMockVoices, + onvoiceschanged: null as (() => void) | null, + addEventListener: function(event: string, callback: () => void) { + if (event === "voiceschanged") { + this.onvoiceschanged = callback; + } + }, + removeEventListener: function(event: string) { + }, + _triggerVoicesChanged: function() { + if (this.onvoiceschanged) { + this.onvoiceschanged(); + } + } + }; + + Object.defineProperty(globalThis.window, "speechSynthesis", { + value: mockSpeechSynthesis, + configurable: true, + writable: true + }); + + try { + // Reset initialization + (manager as any).initializationPromise = null; + (manager as any).voices = []; + (manager as any).browserVoices = []; + + const regions = manager.getRegions(); + t.deepEqual(regions, []); + } finally { + // Restore for other tests + Object.defineProperty(globalThis.window, "speechSynthesis", { + value: { + getVoices: () => mockVoices, + onvoiceschanged: null as (() => void) | null, + addEventListener: function(event: string, callback: () => void) { + if (event === "voiceschanged") { + this.onvoiceschanged = callback; + } + }, + removeEventListener: function(event: string) { + }, + _triggerVoicesChanged: function() { + if (this.onvoiceschanged) { + this.onvoiceschanged(); + } + } + }, + configurable: true, + writable: true + }); + } +}); + +// ============================================= +// 5. Default Voice Retrieval Tests +// ============================================= + +testWithContext("getDefaultVoice: selects highest quality voice regardless of isDefault flag", async (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with quality as the distinguishing factor + const testVoices = [ + { + voiceURI: "voice1", + name: "High Quality", + language: "en-US", + isDefault: false, // Not default but higher quality + quality: ["high"] + }, + { + voiceURI: "voice2", + name: "Normal Quality", + language: "en-US", + isDefault: true, // Default but lower quality + quality: ["normal"] + } + ]; + + (manager as any).voices = testVoices; + + const defaultVoice = await manager.getDefaultVoice("en-US"); + t.truthy(defaultVoice); + t.is(defaultVoice?.voiceURI, "voice1", "Should select highest quality voice regardless of isDefault flag"); +}); + +testWithContext("getDefaultVoice: falls back to base language", async (t: ExecutionContext) => { + const manager = t.context.manager; + + const testVoices = [ + { + voiceURI: "voice1", + name: "English Generic", + language: "en", // Base language + isDefault: false, + quality: ["high"] + }, + { + voiceURI: "voice2", + name: "US English", + language: "en-US", + isDefault: false, + quality: ["high"] + } + ]; + + (manager as any).voices = testVoices; + + // Request en-GB which isn't available, should fall back to en + const defaultVoice = await manager.getDefaultVoice("en-GB"); + t.truthy(defaultVoice); + t.is(defaultVoice?.language, "en", "Should fall back to base language when exact match not found"); +}); + +testWithContext("getDefaultVoice: respects quality sorting", async (t: ExecutionContext) => { + const manager = t.context.manager; + + const testVoices = [ + { + voiceURI: "voice1", + name: "High Quality", + language: "en-US", + isDefault: false, + quality: ["high"] + }, + { + voiceURI: "voice2", + name: "Very High Quality", + language: "en-US", + isDefault: false, + quality: ["veryHigh"] // Higher quality + }, + { + voiceURI: "voice3", + name: "Normal Quality", + language: "en-US", + isDefault: false, + quality: ["normal"] // Lower quality + } + ]; + + (manager as any).voices = testVoices; + + const defaultVoice = await manager.getDefaultVoice("en-US"); + t.truthy(defaultVoice); + t.is(defaultVoice?.voiceURI, "voice2", "Should select highest quality voice available"); +}); + +testWithContext("getDefaultVoice: returns undefined when no voices available", async (t) => { + // Create a fresh instance to avoid interference + (WebSpeechVoiceManager as any).instance = undefined; + const manager = await WebSpeechVoiceManager.initialize(); + + // Mock empty voices array + const emptyMockVoices: any[] = []; + const mockSpeechSynthesis = { + getVoices: () => emptyMockVoices, + onvoiceschanged: null as (() => void) | null, + addEventListener: function(event: string, callback: () => void) { + if (event === "voiceschanged") { + this.onvoiceschanged = callback; + } + }, + removeEventListener: function(event: string) { + }, + _triggerVoicesChanged: function() { + if (this.onvoiceschanged) { + this.onvoiceschanged(); + } + } + }; + + Object.defineProperty(globalThis.window, "speechSynthesis", { + value: mockSpeechSynthesis, + configurable: true, + writable: true + }); + + try { + // Reset initialization + (manager as any).initializationPromise = null; + (manager as any).voices = []; + (manager as any).browserVoices = []; + + const defaultVoice = manager.getDefaultVoice("en-US"); + t.is(defaultVoice, null); + } finally { + // Restore for other tests + Object.defineProperty(globalThis.window, "speechSynthesis", { + value: { + getVoices: () => mockVoices, + onvoiceschanged: null as (() => void) | null, + addEventListener: function(event: string, callback: () => void) { + if (event === "voiceschanged") { + this.onvoiceschanged = callback; + } + }, + removeEventListener: function(event: string) { + }, + _triggerVoicesChanged: function() { + if (this.onvoiceschanged) { + this.onvoiceschanged(); + } + } + }, + configurable: true, + writable: true + }); + } +}); + +testWithContext("getDefaultVoice: returns null when no matching language", async (t: ExecutionContext) => { + const manager = t.context.manager; + + // Test with language that has no voices + const result = await manager.getDefaultVoice("xx-XX"); + t.is(result, null); +}); + +// ============================================= +// 6. Test Utterance Retrieval Tests +// ============================================= + +testWithContext("getTestUtterance: returns test utterance for supported language", (t) => { + const manager = t.context.manager; + + // Test with a base language + const utterance1 = manager.getTestUtterance("en"); + t.is(typeof utterance1, "string"); + t.true(utterance1 && utterance1.length > 0); + + // Test with a locale variant (should fall back to base language) + const utterance2 = manager.getTestUtterance("en-US"); + t.is(typeof utterance2, "string"); + t.true(utterance2 && utterance2.length > 0); + t.is(utterance1, utterance2); // Should be the same +}); + +testWithContext("getTestUtterance: returns empty string for unsupported language", (t) => { + const manager = t.context.manager; + + // Test with an unsupported language + const utterance = manager.getTestUtterance("xx-XX"); + t.is(utterance, ""); +}); + +// ============================================= +// 7. Voice Filtering Tests +// ============================================= + +testWithContext("filterVoices: filters by language", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices + const testVoices = [ + createTestVoice({ name: "English Voice 1", language: "en-US" }), + createTestVoice({ name: "English Voice 2", language: "en-GB" }), + createTestVoice({ name: "French Voice", language: "fr-FR" }), + createTestVoice({ name: "Spanish Voice", language: "es-ES" }) + ]; + + const englishVoices = manager.filterVoices(testVoices, { language: "en" }); + t.is(englishVoices.length, 2); + t.true(englishVoices.every(v => v.language.startsWith("en"))); + + const multiLangVoices = manager.filterVoices(testVoices, { language: ["en", "fr"] }); + t.is(multiLangVoices.length, 3); + t.true(multiLangVoices.every(v => v.language.startsWith("en") || v.language.startsWith("fr"))); +}); + +testWithContext("filterVoices: filters by source", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different sources + const testVoices = [ + createTestVoice({ name: "JSON Voice 1", source: "json" }), + createTestVoice({ name: "JSON Voice 2", source: "json" }), + createTestVoice({ name: "Browser Voice 1", source: "browser" }), + ]; + + const jsonVoices = manager.filterVoices(testVoices, { source: "json" }); + t.is(jsonVoices.length, 2); + t.true(jsonVoices.every(v => v.source === "json")); + + const browserVoices = manager.filterVoices(testVoices, { source: "browser"}); + t.is(browserVoices.length, 1); + t.true(browserVoices.every(v => v.source === "browser")); +}); + +testWithContext("filterVoices: filters by gender", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different genders + const testVoices = [ + createTestVoice({ name: "Male Voice 1", language: "en-US", gender: "male" }), + createTestVoice({ name: "Female Voice 1", language: "en-US", gender: "female" }), + createTestVoice({ name: "Male Voice 2", language: "en-US", gender: "male" }), + createTestVoice({ name: "Unknown Gender Voice", language: "en-US" }) + ]; + + const maleVoices = manager.filterVoices(testVoices, { gender: "male" }); + t.is(maleVoices.length, 2); + t.true(maleVoices.every(v => v.gender === "male")); + + const femaleVoices = manager.filterVoices(testVoices, { gender: "female" }); + t.is(femaleVoices.length, 1); + t.is(femaleVoices[0].gender, "female"); +}); + +testWithContext("filterVoices: filters by quality array", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different quality levels + const testVoices = [ + createTestVoice({ name: "High Quality Voice", language: "en-US", quality: ["high"] }), + createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: ["low"] }), + createTestVoice({ name: "Normal Quality Voice", language: "en-US", quality: ["normal"] }), + createTestVoice({ name: "Very High Quality Voice", language: "en-US", quality: ["veryHigh"] }), + createTestVoice({ name: "Multi Quality Voice", language: "en-US", quality: ["high", "normal"] }), + createTestVoice({ name: "No Quality Voice", language: "en-US", quality: undefined }) + ]; + + // Test single quality filter + const highQualityVoices = manager.filterVoices(testVoices, { quality: "high" }); + t.is(highQualityVoices.length, 2); // high and multi quality voices + + // Test multiple quality filter + const multiQualityVoices = manager.filterVoices(testVoices, { quality: ["high", "normal"] }); + t.is(multiQualityVoices.length, 3); // high, normal, and multi quality voices + + // Test that undefined quality voices are filtered out + const filteredVoices = manager.filterVoices(testVoices, { quality: "high" }); + t.false(filteredVoices.some(v => v.quality === undefined)); +}); + +testWithContext("filterVoices: filters out novelty and low quality voices", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices using the createTestVoice helper + const testVoices = [ + createTestVoice({ + voiceURI: "com.apple.speech.synthesis.voice.Albert", + name: "Albert", + language: "en-US", + isNovelty: true + }), + createTestVoice({ + voiceURI: "com.appk.it.speech.synthesis.voice.Eddy", + name: "Eddy", + language: "en-US", + quality: ["veryLow"] + }) + ]; + + // Test filtering with default options (should filter out both voices) + const filteredVoices = manager.filterVoices(testVoices, { + excludeNovelty: true, + excludeVeryLowQuality: true + }); + t.is(filteredVoices.length, 0, "Should filter out all test voices by default"); + + // Test including them by disabling the filters + const allVoices = manager.filterVoices(testVoices, { + excludeNovelty: false, + excludeVeryLowQuality: false + }); + t.is(allVoices.length, 2, "Should include all voices when not filtered"); +}); + +testWithContext("filterVoices: filters by offline availability", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different offline availability + const testVoices = [ + createTestVoice({ name: "Offline Voice 1", language: "en-US", offlineAvailability: true }), + createTestVoice({ name: "Online Voice 1", language: "en-US", offlineAvailability: false }), + createTestVoice({ name: "Offline Voice 2", language: "en-US", offlineAvailability: true }), + createTestVoice({ name: "Undefined Availability Voice", language: "en-US" }) + ]; + + const offlineVoices = manager.filterVoices(testVoices, { offlineOnly: true }); + t.is(offlineVoices.length, 2); + t.true(offlineVoices.every(v => v.offlineAvailability === true)); + + // Test that undefined and false values are filtered out + t.false(offlineVoices.some(v => v.offlineAvailability === false)); + t.false(offlineVoices.some(v => v.offlineAvailability === undefined)); +}); + +testWithContext("filterVoices: filters by provider", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different providers + const testVoices = [ + createTestVoice({ name: "Google Voice", language: "en-US", provider: "Google" }), + createTestVoice({ name: "Microsoft Voice", language: "en-US", provider: "Microsoft" }), + createTestVoice({ name: "Amazon Voice", language: "en-US", provider: "Amazon" }), + createTestVoice({ name: "Another Google Voice", language: "en-US", provider: "Google" }) + ]; + + const googleVoices = manager.filterVoices(testVoices, { provider: "Google" }); + t.is(googleVoices.length, 2); + t.true(googleVoices.every(v => v.provider === "Google")); + + // Test case insensitive matching + const caseInsensitiveVoices = manager.filterVoices(testVoices, { provider: "google" }); + t.is(caseInsensitiveVoices.length, 2); +}); + +testWithContext("filterVoices: combines multiple filters", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with various properties + const testVoices = [ + createTestVoice({ name: "Male High Quality English", language: "en-US", gender: "male", quality: ["high"], provider: "Google" }), + createTestVoice({ name: "Female Low Quality English", language: "en-US", gender: "female", quality: ["low"], provider: "Google" }), + createTestVoice({ name: "Male High Quality French", language: "fr-FR", gender: "male", quality: ["high"], provider: "Microsoft" }), + createTestVoice({ name: "Female Normal Quality English", language: "en-US", gender: "female", quality: ["normal"], provider: "Google" }) + ]; + + // Filter by language and gender + const englishFemaleVoices = manager.filterVoices(testVoices, { + language: "en", + gender: "female" + }); + t.is(englishFemaleVoices.length, 2); + t.true(englishFemaleVoices.every(v => + v.language.startsWith("en") && v.gender === "female" + )); + + // Filter by quality and provider + const highQualityGoogleVoices = manager.filterVoices(testVoices, { + quality: "high", + provider: "Google" + }); + t.is(highQualityGoogleVoices.length, 1); + t.is(highQualityGoogleVoices[0].name, "Male High Quality English"); +}); + +testWithContext("filterVoices: handles edge cases", (t: ExecutionContext) => { + const manager = t.context.manager; + + const testVoices = [ + createTestVoice({ name: "Voice 1", language: "en-US", gender: "male", quality: ["high"] }), + createTestVoice({ name: "Voice 2", language: "fr-FR", gender: "female", quality: ["low"] }), + createTestVoice({ name: "Voice 3", language: "de-DE", gender: "male", quality: ["normal"] }) + ]; + + // Test empty filter arrays + const emptyLanguageFilter = manager.filterVoices(testVoices, { language: [] }); + t.is(emptyLanguageFilter.length, 0); + + const emptyQualityFilter = manager.filterVoices(testVoices, { quality: [] }); + t.is(emptyQualityFilter.length, 0); + + // Test case sensitivity for language + const caseSensitiveLanguage = manager.filterVoices(testVoices, { language: "EN-us" }); + t.is(caseSensitiveLanguage.length, 1); // Should match due to toLowerCase() + + // Test invalid quality values - cast to any for testing invalid input + const invalidQualityFilter = manager.filterVoices(testVoices, { quality: "invalid" as any }); + t.is(invalidQualityFilter.length, 0); +}); + +testWithContext("filterVoices: uses array values for multiple filters", (t: ExecutionContext) => { + const manager = t.context.manager; + + const testVoices = [ + createTestVoice({ name: "English Male", language: "en-US", gender: "male", quality: ["high"] }), + createTestVoice({ name: "English Female", language: "en-US", gender: "female", quality: ["normal"] }), + createTestVoice({ name: "French Male", language: "fr-FR", gender: "male", quality: ["low"] }), + createTestVoice({ name: "French Female", language: "fr-FR", gender: "female", quality: ["high"] }), + createTestVoice({ name: "Spanish Male", language: "es-ES", gender: "male", quality: ["normal"] }) + ]; + + // Test with array of languages and array of qualities + const filtered = manager.filterVoices(testVoices, { + language: ["en", "fr"], + quality: ["high", "normal"] + }); + t.is(filtered.length, 3); + t.true(filtered.every(v => + (v.language.startsWith("en") || v.language.startsWith("fr")) && + (v.quality?.includes("high") || v.quality?.includes("normal")) + )); +}); + +testWithContext("filterOutNoveltyVoices: removes novelty voices", (t: ExecutionContext) => { + const manager = t.context.manager; + + const testVoices = [ + createTestVoice({ name: "Regular Voice 1", language: "en-US" }), + createTestVoice({ name: "Novelty Voice 1", language: "en-US", isNovelty: true }), + createTestVoice({ name: "Regular Voice 2", language: "en-US" }), + createTestVoice({ name: "Novelty Voice 2", language: "en-US", isNovelty: true }) + ]; + + const filtered = manager.filterOutNoveltyVoices(testVoices); + t.is(filtered.length, 2); + t.false(filtered.some((v: ReadiumSpeechVoice) => v.isNovelty)); +}); + +testWithContext("filterOutVeryLowQualityVoices: removes very low quality voices", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with one very low quality voice + const testVoices = [ + createTestVoice({ name: "Voice 1", language: "en-US", quality: ["normal"] }), + createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: ["veryLow"] }), + createTestVoice({ name: "Voice 2", language: "fr-FR", quality: ["normal"] }) + ]; + + const filtered = manager.filterOutVeryLowQualityVoices(testVoices); + t.is(filtered.length, testVoices.length - 1); + t.false(filtered.some((v: ReadiumSpeechVoice) => v.quality?.includes("veryLow"))); +}); + +// ============================================= +// 8. Voice Sorting Tests +// ============================================= + +testWithContext("sortVoices: sorts by name", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices + const testVoices = [ + createTestVoice({ name: "Zeta Voice", language: "en-US" }), + createTestVoice({ name: "Alpha Voice", language: "en-US" }), + createTestVoice({ name: "Beta Voice", language: "en-US" }) + ]; + + // Test ascending order + const sortedAsc = manager.sortVoices(testVoices, { by: "name", order: "asc" }); + t.is(sortedAsc[0].name, "Alpha Voice"); + t.is(sortedAsc[1].name, "Beta Voice"); + t.is(sortedAsc[2].name, "Zeta Voice"); + + // Test descending order + const sortedDesc = manager.sortVoices(testVoices, { by: "name", order: "desc" }); + t.is(sortedDesc[0].name, "Zeta Voice"); + t.is(sortedDesc[1].name, "Beta Voice"); + t.is(sortedDesc[2].name, "Alpha Voice"); +}); + +testWithContext("sortVoices: sorts by quality with proper direction", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different quality levels + const testVoices = [ + createTestVoice({ name: "High Quality Voice", language: "en-US", quality: ["high"] }), + createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: ["low"] }), + createTestVoice({ name: "Normal Quality Voice", language: "en-US", quality: ["normal"] }), + createTestVoice({ name: "Very High Quality Voice", language: "en-US", quality: ["veryHigh"] }), + createTestVoice({ name: "Very Low Quality Voice", language: "en-US", quality: ["veryLow"] }) + ]; + + // Test ascending order (low to high quality) + const sortedAsc = manager.sortVoices(testVoices, { by: "quality", order: "asc" }); + t.is(sortedAsc[0].quality?.[0], "veryLow"); + t.is(sortedAsc[1].quality?.[0], "low"); + t.is(sortedAsc[2].quality?.[0], "normal"); + t.is(sortedAsc[3].quality?.[0], "high"); + t.is(sortedAsc[4].quality?.[0], "veryHigh"); + + // Test descending order (high to low quality) + const sortedDesc = manager.sortVoices(testVoices, { by: "quality", order: "desc" }); + t.is(sortedDesc[0].quality?.[0], "veryHigh"); + t.is(sortedDesc[1].quality?.[0], "high"); + t.is(sortedDesc[2].quality?.[0], "normal"); + t.is(sortedDesc[3].quality?.[0], "low"); + t.is(sortedDesc[4].quality?.[0], "veryLow"); +}); + +testWithContext("sortVoices: sorts by language", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different languages + const testVoices = [ + createTestVoice({ name: "French Voice", language: "fr-FR" }), + createTestVoice({ name: "English Voice", language: "en-US" }), + createTestVoice({ name: "Spanish Voice", language: "es-ES" }), + createTestVoice({ name: "German Voice", language: "de-DE" }) + ]; + + // Test ascending order + const sortedAsc = manager.sortVoices(testVoices, { by: "language", order: "asc" }); + t.is(sortedAsc[0].language, "de-DE"); + t.is(sortedAsc[1].language, "en-US"); + t.is(sortedAsc[2].language, "es-ES"); + t.is(sortedAsc[3].language, "fr-FR"); + + // Test descending order + const sortedDesc = manager.sortVoices(testVoices, { by: "language", order: "desc" }); + t.is(sortedDesc[0].language, "fr-FR"); + t.is(sortedDesc[1].language, "es-ES"); + t.is(sortedDesc[2].language, "en-US"); + t.is(sortedDesc[3].language, "de-DE"); +}); + +testWithContext("sortVoices: sorts by gender", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different genders + const testVoices = [ + createTestVoice({ name: "Female Voice 1", language: "en-US", gender: "female" }), + createTestVoice({ name: "Male Voice 1", language: "en-US", gender: "male" }), + createTestVoice({ name: "Unknown Voice", language: "en-US" }), + createTestVoice({ name: "Female Voice 2", language: "en-US", gender: "female" }) + ]; + + // Test ascending order (undefined should come first, then female, then male) + const sortedAsc = manager.sortVoices(testVoices, { by: "gender", order: "asc" }); + t.is(sortedAsc[0].gender, undefined); + t.is(sortedAsc[1].gender, "female"); + t.is(sortedAsc[2].gender, "female"); + t.is(sortedAsc[3].gender, "male"); + + // Test descending order (male should come first, then female, then undefined) + const sortedDesc = manager.sortVoices(testVoices, { by: "gender", order: "desc" }); + t.is(sortedDesc[0].gender, "male"); + t.is(sortedDesc[1].gender, "female"); + t.is(sortedDesc[2].gender, "female"); + t.is(sortedDesc[3].gender, undefined); +}); + +testWithContext("sortVoices: sorts by region", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different regions + const testVoices = [ + createTestVoice({ name: "US Voice", language: "en-US" }), + createTestVoice({ name: "UK Voice", language: "en-GB" }), + createTestVoice({ name: "Canada Voice", language: "en-CA" }), + createTestVoice({ name: "Australia Voice", language: "en-AU" }) + ]; + + // Test ascending order + const sortedAsc = manager.sortVoices(testVoices, { by: "region", order: "asc" }); + t.is(sortedAsc[0].language, "en-AU"); + t.is(sortedAsc[1].language, "en-CA"); + t.is(sortedAsc[2].language, "en-GB"); + t.is(sortedAsc[3].language, "en-US"); + + // Test descending order + const sortedDesc = manager.sortVoices(testVoices, { by: "region", order: "desc" }); + t.is(sortedDesc[0].language, "en-US"); + t.is(sortedDesc[1].language, "en-GB"); + t.is(sortedDesc[2].language, "en-CA"); + t.is(sortedDesc[3].language, "en-AU"); +}); + +testWithContext("sortVoices: sorts by preferred languages", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different languages and regions + const testVoices = [ + createTestVoice({ name: "French Voice", language: "fr-FR" }), + createTestVoice({ name: "US English Voice", language: "en-US" }), + createTestVoice({ name: "UK English Voice", language: "en-GB" }), + createTestVoice({ name: "German Voice", language: "de-DE" }), + createTestVoice({ name: "Spanish Voice", language: "es-ES" }), + createTestVoice({ name: "French Canadian Voice", language: "fr-CA" }) + ]; + + // Test with preferred languages (exact matches first, then partial matches) + const preferredLangs = ["en-US", "fr", "es-ES"]; + const sorted = manager.sortVoices(testVoices, { + by: "language", + preferredLanguages: preferredLangs + }); + + // Exact matches should come first in the order of preferredLanguages + t.is(sorted[0].language, "en-US"); // Exact match + t.is(sorted[1].language, "fr-CA"); // Partial match for "fr" - sorts by region code + t.is(sorted[2].language, "fr-FR"); // Also partial match for "fr" - sorts by region code + t.is(sorted[3].language, "es-ES"); // Exact match + + // Non-preferred languages should come after, sorted alphabetically + t.is(sorted[4].language, "de-DE"); + t.is(sorted[5].language, "en-GB"); + + // Test with region-specific preferences + const regionSpecific = manager.sortVoices(testVoices, { + by: "language", + preferredLanguages: ["fr-CA", "en-GB"] + }); + + t.is(regionSpecific[0].language, "fr-CA"); // Exact match + t.is(regionSpecific[1].language, "en-GB"); // Exact match + // Others should be sorted alphabetically + t.is(regionSpecific[2].language, "de-DE"); + t.is(regionSpecific[3].language, "en-US"); + t.is(regionSpecific[4].language, "es-ES"); + t.is(regionSpecific[5].language, "fr-FR"); + + // Test with empty preferred languages (should sort alphabetically) + const emptyPreferred = manager.sortVoices(testVoices, { + by: "language", + preferredLanguages: [] + }); + t.is(emptyPreferred[0].language, "de-DE"); + t.is(emptyPreferred[1].language, "en-GB"); + t.is(emptyPreferred[2].language, "en-US"); + t.is(emptyPreferred[3].language, "es-ES"); + t.is(emptyPreferred[4].language, "fr-CA"); + t.is(emptyPreferred[5].language, "fr-FR"); + + // Test with undefined preferred languages (should sort alphabetically) + const undefinedPreferred = manager.sortVoices(testVoices, { + by: "language" + }); + t.is(undefinedPreferred[0].language, "de-DE"); + t.is(undefinedPreferred[1].language, "en-GB"); + t.is(undefinedPreferred[2].language, "en-US"); + t.is(undefinedPreferred[3].language, "es-ES"); + t.is(undefinedPreferred[4].language, "fr-CA"); + t.is(undefinedPreferred[5].language, "fr-FR"); + + // Test with case-insensitive matching + const caseInsensitive = manager.sortVoices(testVoices, { + by: "language", + preferredLanguages: ["EN-us", "FR"] // Mixed case and partial + }); + t.is(caseInsensitive[0].language, "en-US"); // Matches despite case difference + t.is(caseInsensitive[1].language, "fr-CA"); // Partial match, sorted by region + t.is(caseInsensitive[2].language, "fr-FR"); // Also partial match +}); + +testWithContext("sortVoices: sorts by region with preferred languages", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different regions + const testVoices = [ + createTestVoice({ name: "US English", language: "en-US" }), + createTestVoice({ name: "UK English", language: "en-GB" }), + createTestVoice({ name: "Australian English", language: "en-AU" }), + createTestVoice({ name: "Canadian French", language: "fr-CA" }), + createTestVoice({ name: "French", language: "fr-FR" }), + createTestVoice({ name: "Canadian English", language: "en-CA" }) + ]; + + // Test with preferred languages that include regions + const sorted = manager.sortVoices(testVoices, { + by: "region", + preferredLanguages: ["en-CA", "fr-CA", "en"] // Prefer Canadian English, then Canadian French, then any English + }); + + // Verify order: + // 1. en-CA (exact match for first preferred) + // 2. fr-CA (exact match for second preferred) + // 3. en-US (language match for third preferred) + // 4. en-GB (language match for third preferred) + // 5. en-AU (language match for third preferred) + // 6. fr-FR (no match, should come last) + t.is(sorted[0].language, "en-CA", "en-CA should be first (exact match)"); + t.is(sorted[1].language, "fr-CA", "fr-CA should be second (exact match)"); + + // The remaining English variants should be in their natural order + const remainingEnglish = sorted.slice(2, 5).map(v => v.language); + t.true( + ["en-US", "en-GB", "en-AU"].every(lang => remainingEnglish.includes(lang)), + "Should include all English variants after exact matches" + ); + + t.is(sorted[5].language, "fr-FR", "fr-FR should be last (no match)"); + + // Test with preferred languages that don't match any regions + const noMatches = manager.sortVoices(testVoices, { + by: "region", + preferredLanguages: ["es-ES", "de-DE"] // No matches in test data + }); + + // Should sort alphabetically by region + const regions = noMatches.map(v => v.language.split("-")[1]); + const sortedRegions = [...regions].sort(); + t.deepEqual(regions, sortedRegions, "Should sort alphabetically by region when no preferred matches"); +}); + +// ============================================= +// 9. Voice Grouping Tests +// ============================================= + +testWithContext("groupVoices: groups by language", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different languages + const testVoices = [ + { voiceURI: "voice1", name: "Voice 1", language: "en-US" }, + { voiceURI: "voice2", name: "Voice 2", language: "fr-FR" }, + { voiceURI: "voice3", name: "Voice 3", language: "en-US" } + ]; + + const groups = (manager as any).groupVoices(testVoices, "language"); + + // Check that groups were created for each language + t.truthy(groups["en"]); + t.truthy(groups["fr"]); + + // Check the number of voices in each group + t.is(groups["en"].length, 2); + t.is(groups["fr"].length, 1); +}); + +testWithContext("groupVoices: groups by gender", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different genders + const testVoices = [ + createTestVoice({ name: "Male Voice 1", language: "en-US", gender: "male" }), + createTestVoice({ name: "Female Voice 1", language: "en-US", gender: "female" }), + createTestVoice({ name: "Male Voice 2", language: "fr-FR", gender: "male" }), + createTestVoice({ name: "Unknown Voice", language: "es-ES" }) + ]; + + const groups = manager.groupVoices(testVoices, "gender"); + t.true(groups.hasOwnProperty("male")); + t.true(groups.hasOwnProperty("female")); + t.true(groups.hasOwnProperty("unknown")); + t.is(groups.male.length, 2); + t.is(groups.female.length, 1); + t.is(groups.unknown.length, 1); +}); + +testWithContext("groupVoices: groups by quality", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different qualities + const testVoices = [ + createTestVoice({ name: "High Quality 1", language: "en-US", quality: ["high"] }), + createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: ["low"] }), + createTestVoice({ name: "High Quality 2", language: "fr-FR", quality: ["high"] }) + ]; + + const groups = manager.groupVoices(testVoices, "quality"); + t.is(Object.keys(groups).length, 2); + t.is(groups.high.length, 2); + t.is(groups.low.length, 1); +}); + +testWithContext("groupVoices: groups by region", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different regions + const testVoices = [ + createTestVoice({ name: "US Voice", language: "en-US" }), + createTestVoice({ name: "UK Voice", language: "en-GB" }), + createTestVoice({ name: "Canada Voice", language: "en-CA" }), + createTestVoice({ name: "Australia Voice", language: "en-AU" }) + ]; + + const groups = manager.groupVoices(testVoices, "region"); + t.is(Object.keys(groups).length, 4); + t.is(groups.US.length, 1); + t.is(groups.GB.length, 1); + t.is(groups.CA.length, 1); + t.is(groups.AU.length, 1); +}); + +testWithContext("groupVoices: handles empty voices array", (t: ExecutionContext) => { + const manager = t.context.manager; + + const groups = manager.groupVoices([], "language"); + t.deepEqual(groups, {}); +}); + +testWithContext("groupVoices: handles voices with missing properties", (t: ExecutionContext) => { + const manager = t.context.manager; + + const testVoices = [ + createTestVoice({ name: "Voice 1", language: "en-US" }), + createTestVoice({ name: "Voice 2", language: undefined as any }), + createTestVoice({ name: "Voice 3", language: "fr-FR", gender: undefined as any }), + createTestVoice({ name: "Voice 4", language: "es-ES", quality: undefined as any }) + ]; + + // Should handle missing properties gracefully + const groupsByLanguage = manager.groupVoices(testVoices, "language"); + t.true(groupsByLanguage.hasOwnProperty("en")); + t.true(groupsByLanguage.hasOwnProperty("fr")); + t.true(groupsByLanguage.hasOwnProperty("es")); + + const groupsByGender = manager.groupVoices(testVoices, "gender"); + // Should have an "undefined" group for voices without gender + + const groupsByQuality = manager.groupVoices(testVoices, "quality"); + // Should have an "undefined" group for voices without quality +}); + +// ============================================= +// 10. Conversion Tests +// ============================================= + +testWithContext("convertToSpeechSynthesisVoice: converts ReadiumSpeechVoice to SpeechSynthesisVoice", async (t: ExecutionContext) => { + const manager = t.context.manager; + const voices = await manager.getVoices(); + t.plan(3); + + if (voices.length > 0) { + const speechVoice = manager.convertToSpeechSynthesisVoice(voices[0]); + t.truthy(speechVoice); + t.is(speechVoice?.name, voices[0].name); + t.is(speechVoice?.voiceURI, voices[0].voiceURI); + } else { + t.pass("No voices available to test"); + } +}); + +testWithContext("convertToSpeechSynthesisVoice: handles invalid voice", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Test with undefined voice + const result1 = manager.convertToSpeechSynthesisVoice(undefined as any); + t.is(result1, undefined); + + // Test with voice that doesn't match any browser voice + const invalidVoice = createTestVoice({ name: "Non-existent Voice", language: "xx-XX" }); + const result2 = manager.convertToSpeechSynthesisVoice(invalidVoice); + t.is(result2, undefined); +}); \ No newline at end of file diff --git a/test/voices.test.ts b/test/voices.test.ts deleted file mode 100644 index c61b0c3..0000000 --- a/test/voices.test.ts +++ /dev/null @@ -1,553 +0,0 @@ -import test from "ava"; -import { filterOnRecommended, groupByLanguages, ReadiumSpeechVoice, sortByLanguage, groupByRegions } from "../src/voices.js"; -import { IRecommended } from "../src/data.gen.js"; - -test('dumb test', t => { - t.deepEqual([], []); -}); - -test.before(t => { - // This runs before all tests - globalThis.window = { navigator: { languages: [] } as any } as any; -}); - -test('sortByLanguage: Empty preferred language list', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, - ]; - - const result = sortByLanguage(voices, [], ""); - t.true(result.length === voices.length); - t.true(result[0].language === 'en-US'); - t.true(result[1].language === 'en-US'); - t.true(result[2].language === 'fr-FR'); -}); - -test('sortByLanguage: Preferred language list with one language', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, - ]; - - const result = sortByLanguage(voices, ['fr-FR'], ""); - t.true(result.length === voices.length); - t.true(result[0].language === 'fr-FR'); - t.true(result[1].language === 'en-US'); - t.true(result[2].language === 'en-US'); -}); - -test('sortByLanguage: Preferred language list with multiple languages', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 4', voiceURI: 'uri4', name: 'Name 4', language: 'es-ES', offlineAvailability: true, pitchControl: true }, - ]; - - const result = sortByLanguage(voices, ['fr-FR', 'es-ES'], ""); - t.true(result.length === voices.length); - t.true(result[0].language === 'fr-FR'); - t.true(result[1].language === 'es-ES'); - t.true(result[2].language === 'en-US'); - t.true(result[3].language === 'en-US'); -}); - -test('sortByLanguage: No matching languages', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, - ]; - - const result = sortByLanguage(voices, ['de-DE'], ""); - t.true(result.length === voices.length); - t.true(result[0].language === 'en-US'); - t.true(result[1].language === 'en-US'); - t.true(result[2].language === 'fr-FR'); -}); - -test('sortByLanguage: Preferred language list is not an array', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, - ]; - - const result = sortByLanguage(voices, 'en-US', ""); - t.true(result.length === voices.length); - t.true(result[0].language === 'en-US'); - t.true(result[1].language === 'en-US'); - t.true(result[2].language === 'fr-FR'); -}); - -test('sortByLanguage: Preferred language undefined and navigator langua', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, - ]; - - const result = sortByLanguage(voices, 'en-US', ""); - t.true(result.length === voices.length); - t.true(result[0].language === 'en-US'); - t.true(result[1].language === 'en-US'); - t.true(result[2].language === 'fr-FR'); -}); - -test('sortByLanguage: Preferred language list with one language and navigator.languages', t => { - (globalThis.window.navigator as any).languages = ['fr-FR', 'en-US']; - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, - ]; - - const result = sortByLanguage(voices, ['fr-FR'], ""); - t.true(result.length === voices.length); - t.true(result[0].language === 'fr-FR'); - t.true(result[1].language === 'en-US'); - t.true(result[2].language === 'en-US'); -}); - -test('sortByLanguage: Preferred language list with multiple languages and navigator.languages', t => { - (globalThis.window.navigator as any).languages = ['fr-FR', 'en-US']; - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 4', voiceURI: 'uri4', name: 'Name 4', language: 'es-ES', offlineAvailability: true, pitchControl: true }, - ]; - - const result = sortByLanguage(voices, ['fr-FR', 'es-ES'], ""); - t.true(result.length === voices.length); - t.true(result[0].language === 'fr-FR'); - t.true(result[1].language === 'es-ES'); - t.true(result[2].language === 'en-US'); - t.true(result[3].language === 'en-US'); -}); - -test('sortByLanguage: No matching languages and navigator.languages', t => { - (globalThis.window.navigator as any).languages = ['de-DE', 'en-US']; - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, - ]; - - const result = sortByLanguage(voices, ['de-DE'], ""); - t.true(result.length === voices.length); - t.true(result[0].language === 'en-US'); - t.true(result[1].language === 'en-US'); - t.true(result[2].language === 'fr-FR'); -}); - -test('sortByLanguage: Preferred language list is not an array and navigator.languages', t => { - (globalThis.window.navigator as any).languages = ['fr-FR', 'en-US']; - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, - ]; - - const result = sortByLanguage(voices, 'en-US', ""); - t.true(result.length === voices.length); - t.true(result[0].language === 'en-US'); - t.true(result[1].language === 'en-US'); - t.true(result[2].language === 'fr-FR'); -}); - -test('filterOnRecommended: Empty input', t => { - const voices: ReadiumSpeechVoice[] = []; - const result = filterOnRecommended(voices); - t.deepEqual(result, [[], []]); -}); - -test('filterOnRecommended: No recommended voices', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, - ]; - const result = filterOnRecommended(voices, []); - t.deepEqual(result, [[], []]); -}); - -test('filterOnRecommended: Single recommended voice with single quality', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, - ]; - const recommended: IRecommended[] = [ - { name: 'Name 1', label: 'Voice 1', quality: ['high'], language: 'en-US', localizedName: "" }, - ]; - const result = filterOnRecommended(voices, recommended); - t.deepEqual(result, [ - [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'high', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, - ], - [], - ]); -}); - -test('filterOnRecommended: Single recommended voice with multiple qualities', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, - ]; - const recommended: IRecommended[] = [ - { name: 'Name 1', label: 'Voice 1', quality: ['high', 'normal'], language: 'en-US', localizedName: "" }, - ]; - const result = filterOnRecommended(voices, recommended); - t.deepEqual(result, [ - [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'normal', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, - ], - [], - ]); -}); - -test('filterOnRecommended: Single recommended voice with multiple qualities and remaining lowQuality', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 1 (Premium)', language: 'en-US', offlineAvailability: true, pitchControl: true }, - ]; - const recommended: IRecommended[] = [ - { name: 'Name 1', label: 'Voice 1', quality: ['high', 'normal'], language: 'en-US', localizedName: "" }, - ]; - const result = filterOnRecommended(voices, recommended); - t.deepEqual(result, [ - [ - { label: 'Voice 1', voiceURI: 'uri3', name: 'Name 1 (Premium)', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'high', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, - ], - [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'low', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, - ], - ]); -}); - -test('filterOnRecommended: Multiple recommended voices', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - ]; - const recommended: IRecommended[] = [ - { name: 'Name 1', label: 'Voice 1', quality: ['high'], language: 'en-US', localizedName: "" }, - { name: 'Name 2', label: 'Voice 2', quality: ['normal'], language: 'es-ES', localizedName: "" }, - ]; - const result = filterOnRecommended(voices, recommended); - t.deepEqual(result, [ - [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'high', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false, quality: 'normal', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, - ], - [], - ]); -}); -test('filterOnRecommended: Recommended voices with altNames', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 1-1', voiceURI: 'uri1-1', name: 'Name 1 with an altNames', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - ]; - const recommended: IRecommended[] = [ - { name: 'Name 1', label: 'Voice 1', quality: ['high'], altNames: ['Name 1 with an altNames'], language: 'en-US', localizedName: "" }, - { name: 'Name 2', label: 'Voice 2', quality: ['normal'], language: 'es-ES', localizedName: "" }, - ]; - const result = filterOnRecommended(voices, recommended); - t.deepEqual(result, [ - [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true, quality: 'high', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false, quality: 'normal', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, - ], - [ - { 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 }, - ], - ]); -}); -test('filterOnRecommended: Recommended voices with altNames only and voices not in name', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1 with an altNames', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - ]; - const recommended: IRecommended[] = [ - { name: 'Name 1', label: 'Voice 1', quality: ['high'], altNames: ['Name 1 with an altNames'], language: 'en-US', localizedName: "" }, - { name: 'Name 2', label: 'Voice 2', quality: ['normal'], language: 'es-ES', localizedName: "" }, - ]; - const result = filterOnRecommended(voices, recommended); - t.deepEqual(result, [ - [ - { 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 }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false, quality: 'normal', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, - ], - [ - ], - ]); -}); -test('filterOnRecommended: Recommended voices with multiple altNames and voices not in name', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1 with an altNames', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 1-1', voiceURI: 'uri1-1', name: 'Name 1 with a second altNames', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - ]; - const recommended: IRecommended[] = [ - { name: 'Name 1', label: 'Voice 1', quality: ['high'], altNames: ['Name 1 with an altNames', 'Name 1 with a second altNames'], language: 'en-US', localizedName: "" }, - { name: 'Name 2', label: 'Voice 2', quality: ['normal'], language: 'es-ES', localizedName: "" }, - ]; - const result = filterOnRecommended(voices, recommended); - t.deepEqual(result, [ - [ - { 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 }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'es-ES', offlineAvailability: false, pitchControl: false, quality: 'normal', recommendedRate: undefined, recommendedPitch: undefined, gender: undefined }, - ], - [ - { 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 }, - ], - ]); -}); -test('groupByLanguage: ', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 4', voiceURI: 'uri4', name: 'Name 4', language: 'es-ES', offlineAvailability: true, pitchControl: true }, - ]; - const result = groupByLanguages(voices, ['fr-FR', 'es-ES'], ""); - t.deepEqual(result, new Map([ - ['fr', [ - { - label: 'Voice 2', - language: 'fr-FR', - name: 'Name 2', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri2', - }, - ]], - ['es', [ - { - label: 'Voice 4', - language: 'es-ES', - name: 'Name 4', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri4', - }, - ]], - ['en', [ - { - label: 'Voice 1', - language: 'en-US', - name: 'Name 1', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri1', - }, - { - label: 'Voice 3', - language: 'en-US', - name: 'Name 3', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri3', - }, - ]], - ])); -}); -test('groupByLanguage: localized en', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 4', voiceURI: 'uri4', name: 'Name 4', language: 'es-ES', offlineAvailability: true, pitchControl: true }, - ]; - const result = groupByLanguages(voices, ['fr-FR', 'es-ES'], "en"); - t.deepEqual(result, new Map([ - ['French', [ - { - label: 'Voice 2', - language: 'fr-FR', - name: 'Name 2', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri2', - }, - ]], - ['Spanish', [ - { - label: 'Voice 4', - language: 'es-ES', - name: 'Name 4', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri4', - }, - ]], - ['English', [ - { - label: 'Voice 1', - language: 'en-US', - name: 'Name 1', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri1', - }, - { - label: 'Voice 3', - language: 'en-US', - name: 'Name 3', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri3', - }, - ]], - ])); -}); -test('groupByRegion: ', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-GB', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 4', voiceURI: 'uri4', name: 'Name 4', language: 'es-ES', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 5', voiceURI: 'uri5', name: 'Name 5', language: 'en-CA', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 6', voiceURI: 'uri6', name: 'Name 6', language: 'fr-CA', offlineAvailability: true, pitchControl: true }, - ]; - const result = groupByRegions(voices, ['fr-FR', 'es-ES'], ""); - t.deepEqual(result, new Map([ - ['FR', [ - { - label: 'Voice 2', - language: 'fr-FR', - name: 'Name 2', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri2', - }, - ]], - ['ES', [ - { - label: 'Voice 4', - language: 'es-ES', - name: 'Name 4', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri4', - }, - ]], - ['US', [ - { - label: 'Voice 1', - language: 'en-US', - name: 'Name 1', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri1', - }, - ]], - ['CA', [ - { - label: 'Voice 5', - language: 'en-CA', - name: 'Name 5', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri5', - }, - { - label: 'Voice 6', - language: 'fr-CA', - name: 'Name 6', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri6', - }, - ]], - ['GB', [ - { - label: 'Voice 3', - language: 'en-GB', - name: 'Name 3', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri3', - }, - ]], - ])); -}); -test('groupByRegion: localized fr', t => { - const voices = [ - { label: 'Voice 1', voiceURI: 'uri1', name: 'Name 1', language: 'en-US', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 2', voiceURI: 'uri2', name: 'Name 2', language: 'fr-FR', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 3', voiceURI: 'uri3', name: 'Name 3', language: 'en-GB', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 4', voiceURI: 'uri4', name: 'Name 4', language: 'es-ES', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 5', voiceURI: 'uri5', name: 'Name 5', language: 'en-CA', offlineAvailability: true, pitchControl: true }, - { label: 'Voice 6', voiceURI: 'uri6', name: 'Name 6', language: 'fr-CA', offlineAvailability: true, pitchControl: true }, - ]; - const result = groupByRegions(voices, ['fr-FR', 'es-ES'], "fr"); - t.deepEqual(result, new Map([ - ['France', [ - { - label: 'Voice 2', - language: 'fr-FR', - name: 'Name 2', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri2', - }, - ]], - ['Espagne', [ - { - label: 'Voice 4', - language: 'es-ES', - name: 'Name 4', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri4', - }, - ]], - ['États-Unis', [ - { - label: 'Voice 1', - language: 'en-US', - name: 'Name 1', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri1', - }, - ]], - ['Canada', [ - { - label: 'Voice 5', - language: 'en-CA', - name: 'Name 5', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri5', - }, - { - label: 'Voice 6', - language: 'fr-CA', - name: 'Name 6', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri6', - }, - ]], - ['Royaume-Uni', [ - { - label: 'Voice 3', - language: 'en-GB', - name: 'Name 3', - offlineAvailability: true, - pitchControl: true, - voiceURI: 'uri3', - }, - ]], - ])); -}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 2604862..165e1bf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,16 @@ { "extends": "./tsconfig-base.json", - "include": ["src/**/*"], + "compilerOptions": { + "resolveJsonModule": true, + "esModuleInterop": true, + "outDir": "build", + "rootDir": "src", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@json/*": ["json/*"] + } + }, + "include": ["src/**/*", "json"], "exclude": ["node_modules", "dist", "test", "demo", "build"] } diff --git a/vite.config.js b/vite.config.js index ff4cac7..9b63d1b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,6 @@ -import { defineConfig } from "vite" -import dts from "vite-plugin-dts" +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; +import { resolve } from "path"; export default defineConfig({ build: { @@ -7,22 +8,29 @@ export default defineConfig({ lib: { entry: "src/index.ts", name: "ReadiumSpeech", - fileName: "index", - formats: ["es"] + fileName: (format) => format === "es" ? "index.js" : "index.cjs", + formats: ["es", "cjs"] }, rollupOptions: { external: [], output: { - format: "es" + inlineDynamicImports: true, + exports: "named", + preserveModules: false } } }, define: { - global: 'globalThis', - 'process.env': {}, - 'process.version': '""', - 'process.platform': '"browser"', - 'process.browser': true, + global: "globalThis", + "process.env": {}, + "process.version": '""', + "process.platform": '"browser"', + "process.browser": true, + }, + resolve: { + alias: { + "@json": resolve(__dirname, "./json") + } }, plugins: [ dts({ @@ -31,4 +39,4 @@ export default defineConfig({ include: ["src/**/*"] }) ] -}) +}) \ No newline at end of file diff --git a/voices.schema.json b/voices.schema.json new file mode 100644 index 0000000..5e5b5e7 --- /dev/null +++ b/voices.schema.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://readium.org/speech/voices.schema.json", + "title": "Recommended voices for TTS", + "type": "object", + "additionalProperties": false, + "properties": { + "language": { + "type": "string" + }, + "defaultRegion": { + "type": "string" + }, + "testUtterance": { + "type": "string" + }, + "voices": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "description": "Provides a human-friendly label for each voice." + }, + "name": { + "type": "string", + "description": "Identifies voices, as returned by the Web Speech API." + }, + "localizedName": { + "type": "string", + "description": "Identifies the string pattern used to localized a given voice.", + "enum": [ + "android", + "apple" + ] + }, + "note": { + "type": "string" + }, + "altNames": { + "type": "array", + "items": { + "type": "string", + "description": "Alternate names for a given voice. Only useful for Apple voices." + } + }, + "nativeID": { + "type": "array", + "items": { + "type": "string", + "description": "Identifiers used by the native API of the platform for a specific voice." + } + }, + "language": { + "type": "string", + "description": "BCP-47 language tag that identifies the language of a voice." + }, + "altLanguage": { + "type": "string", + "description": "Alternative BCP-47 language tag, mostly used for deprecated values." + }, + "otherLanguages": { + "type": "array", + "items": { + "type": "string" + } + }, + "multiLingual": { + "type": "boolean", + "description": "Identifies voices that are capable of handling multiple languages, even if it means that the voice itself will change. Only available on Microsoft Natural voices for now.", + "default": false + }, + "gender": { + "type": "string", + "description": "Identifies the gender of a voice.", + "enum": [ + "neutral", + "female", + "male" + ] + }, + "children": { + "type": "boolean", + "description": "Indicates if the voice is a children voice.", + "default": false + }, + "quality": { + "type": "array", + "description": "Quality available for the variants of a given voice", + "items": { + "type": "string", + "enum": [ + "veryLow", + "low", + "normal", + "high", + "veryHigh" + ] + } + }, + "rate": { + "type": "number", + "description": "Default recommended speed rate for a voice.", + "minimum": 0.1, + "maximum": 10, + "default": 1 + }, + "pitch": { + "type": "number", + "description": "Default recommended pitch rate for a voice.", + "minimum": 0, + "maximum": 2, + "default": 1 + }, + "pitchControl": { + "type": "boolean", + "description": "Indicates if the pitch of a voice can be controlled.", + "default": true + }, + "os": { + "type": "array", + "description": "List of operating systems in which a voice is available.", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "Android", + "ChromeOS", + "iOS", + "iPadOS", + "macOS", + "Windows" + ] + } + }, + "browser": { + "type": "array", + "description": "List of Web browsers in which a voice is available.", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "ChromeDesktop", + "Edge", + "Firefox", + "Safari" + ] + } + }, + "preloaded": { + "type": "boolean", + "description": "Indicates that a voice is preloaded in all OS and browsers that have been identified.", + "default": false + } + }, + "required": [ + "name" + ] + } + } + }, + "required": [ + "voices" + ] +} \ No newline at end of file From 9c9da8cca693bbbb282b631abfad8280ed00cf5d Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 16 Dec 2025 08:28:48 +0100 Subject: [PATCH 06/32] Deploy docs (#23) * Expose docs in gh-pages * Correct internal links * Add npmrc file --- .github/workflows/gh-pages.yml | 4 +- .npmrc | 4 ++ README.md | 7 +++ docs/VoicesAndFiltering.md | 96 +++++++++++++++++----------------- 4 files changed, 62 insertions(+), 49 deletions(-) create mode 100644 .npmrc diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index c343a7d..e380ebc 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -41,11 +41,13 @@ jobs: ls -laR ./build ls -laR ./demo ls -laR ./json + ls -laR ./docs mkdir build-demo cp README.md ./build-demo/ cp -r ./build ./build-demo/ cp -r ./demo ./build-demo/ cp -r ./json ./build-demo/ + cp -r ./docs ./build-demo/ ls -laR ./build-demo @@ -60,4 +62,4 @@ jobs: publish_dir: ./build-demo publish_branch: gh-pages # default: gh-pages destination_dir: ./ - enable_jekyll: true # yes for README.md \ No newline at end of file + enable_jekyll: true # yes for markdown files \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..89e9db2 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +# Disable running npm scripts during package installation +# This is a security best practice to prevent arbitrary code execution +# when installing packages +ignore-scripts=true \ No newline at end of file diff --git a/README.md b/README.md index 4069bad..5b109cf 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,13 @@ async function setupVoices() { await setupVoices(); ``` +## Docs + +Documentation provides guide for: + +- [SpeechSynthesis in browsers and OSes](docs/WebSpeech.md) +- [Voices and Filtering](docs/VoicesAndFiltering.md) + ## API Reference ### Class: WebSpeechVoiceManager diff --git a/docs/VoicesAndFiltering.md b/docs/VoicesAndFiltering.md index 2317e4b..9485147 100644 --- a/docs/VoicesAndFiltering.md +++ b/docs/VoicesAndFiltering.md @@ -19,52 +19,52 @@ The goal of this project is to support all 43 languages available on Windows and In its current state, it covers 43 languages: -* [Arabic](json/ar.json) (Algeria, Bahrain, Egypt, Iraq, Jordan, Kuwait, Lebanon, Libya, Morocco, Oman, Qatar, Saudi Arabia, Syria, Tunisia, United Arab Emirates, Yemen) -* [Basque](json/eu.json) -* [Bengali](json/bn.json) (India and Bangladesh) -* [Bhojpuri](json/bho.json) -* [Bulgarian](json/bg.json) -* [Catalan](json/ca.json) +* [Arabic](../json/ar.json) (Algeria, Bahrain, Egypt, Iraq, Jordan, Kuwait, Lebanon, Libya, Morocco, Oman, Qatar, Saudi Arabia, Syria, Tunisia, United Arab Emirates, Yemen) +* [Basque](../json/eu.json) +* [Bengali](../json/bn.json) (India and Bangladesh) +* [Bhojpuri](../json/bho.json) +* [Bulgarian](../json/bg.json) +* [Catalan](../json/ca.json) * Chinese: - * [Mandarin Chinese](json/cmn.json) (Mainland China, Taiwan) - * [Wu Chinese](json/wuu.json) (aka "Shanghainese") - * [Yue Chinese](json/yue.json) (aka "Cantonese") -* [Croatian](json/hr.json) -* [Czech](json/cs.json) -* [Danish](json/da.json) -* [Dutch](json/nl.json) (Netherlands and Belgium) -* [English](json/en.json) (United States, United Kingdom, Australia, Canada, Hong Kong, India, Ireland, Kenya, New Zealand, Nigeria, Scotland, Singapore, South Africa and Tanzania) -* [Finnish](json/fi.json) -* [French](json/fr.json) (France, Canada, Belgium and Switzerland) -* [Galician](json/gl.json) -* [German](json/de.json) (Germany, Austria and Switzerland) -* [Greek](json/el.json) -* [Hebrew](json/he.json) -* [Hindi](json/hi.json) -* [Hungarian](json/hu.json) -* [Indonesian](json/id.json) -* [Italian](json/it.json) -* [Japanese](json/ja.json) -* [Kannada](json/kn.json) -* [Korean](json/ko.json) -* [Malay](json/ms.json) -* [Marathi](json/mr.json) -* [Norwegian](json/nb.json) -* [Persian](json/fa.json) -* [Polish](json/pl.json) -* [Portuguese](json/pt.json) (Portugal and Brazil) -* [Romanian](json/ro.json) -* [Russian](json/ru.json) -* [Slovak](json/sk.json) -* [Slovenian](json/sl.json) -* [Spanish](json/es.json) (Spain, Argentina, Bolivia, Chile, Colombia, Costa Rica, Cuba, Dominican Republic, Ecuador, El Salvador, Equatorial Guinea, Guatemala, Honduras, Mexico, Nicaragua, Panama, Paraguay, Peru, Puerto Rico, United States, Uruguay and Venezuela) -* [Swedish](json/sv.json) -* [Tamil](json/ta.json) (India, Sri Lanka, Malaysia and Singapore) -* [Telugu](json/te.json) -* [Thai](json/th.json) -* [Turkish](json/tr.json) -* [Ukrainian](json/uk.json) -* [Vietnamese](json/vi.json) + * [Mandarin Chinese](../json/cmn.json) (Mainland China, Taiwan) + * [Wu Chinese](../json/wuu.json) (aka "Shanghainese") + * [Yue Chinese](../json/yue.json) (aka "Cantonese") +* [Croatian](../json/hr.json) +* [Czech](../json/cs.json) +* [Danish](../json/da.json) +* [Dutch](../json/nl.json) (Netherlands and Belgium) +* [English](../json/en.json) (United States, United Kingdom, Australia, Canada, Hong Kong, India, Ireland, Kenya, New Zealand, Nigeria, Scotland, Singapore, South Africa and Tanzania) +* [Finnish](../json/fi.json) +* [French](../json/fr.json) (France, Canada, Belgium and Switzerland) +* [Galician](../json/gl.json) +* [German](../json/de.json) (Germany, Austria and Switzerland) +* [Greek](../json/el.json) +* [Hebrew](../json/he.json) +* [Hindi](../json/hi.json) +* [Hungarian](../json/hu.json) +* [Indonesian](../json/id.json) +* [Italian](../json/it.json) +* [Japanese](../json/ja.json) +* [Kannada](../json/kn.json) +* [Korean](../json/ko.json) +* [Malay](../json/ms.json) +* [Marathi](../json/mr.json) +* [Norwegian](../json/nb.json) +* [Persian](../json/fa.json) +* [Polish](../json/pl.json) +* [Portuguese](../json/pt.json) (Portugal and Brazil) +* [Romanian](../json/ro.json) +* [Russian](../json/ru.json) +* [Slovak](../json/sk.json) +* [Slovenian](../json/sl.json) +* [Spanish](../json/es.json) (Spain, Argentina, Bolivia, Chile, Colombia, Costa Rica, Cuba, Dominican Republic, Ecuador, El Salvador, Equatorial Guinea, Guatemala, Honduras, Mexico, Nicaragua, Panama, Paraguay, Peru, Puerto Rico, United States, Uruguay and Venezuela) +* [Swedish](../json/sv.json) +* [Tamil](../json/ta.json) (India, Sri Lanka, Malaysia and Singapore) +* [Telugu](../json/te.json) +* [Thai](../json/th.json) +* [Turkish](../json/tr.json) +* [Ukrainian](../json/uk.json) +* [Vietnamese](../json/vi.json) ## List of voices to filter out @@ -72,8 +72,8 @@ At the other end up the spectrum, this project also identifies a number of voice Some of them are harmful to the overall reading experience, while others have a very low quality on platforms where better preloaded options are available. -* [Novelty voices](json/filters/novelty.json) (Apple devices) -* [Very low quality voices](json/filters/veryLowQuality.json) (Apple devices and Chrome OS) +* [Novelty voices](../json/filters/novelty.json) (Apple devices) +* [Very low quality voices](../json/filters/veryLowQuality.json) (Apple devices and Chrome OS) ## Guiding principles @@ -91,7 +91,7 @@ Some of them are harmful to the overall reading experience, while others have a ## Syntax -[A JSON Schema](voices.schema.json) is available for validation or potential contributors interested in opening a PR for new languages or voice additions. +[A JSON Schema](../voices.schema.json) is available for validation or potential contributors interested in opening a PR for new languages or voice additions. ### Label From a220f3210f519db9f02ae4e96e16b7c094453cde Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 16 Dec 2025 08:35:04 +0100 Subject: [PATCH 07/32] Copy schema (#24) --- .github/workflows/gh-pages.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index e380ebc..bc1dd1a 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -48,6 +48,7 @@ jobs: cp -r ./demo ./build-demo/ cp -r ./json ./build-demo/ cp -r ./docs ./build-demo/ + cp ./voices.schema.json ./build-demo/ ls -laR ./build-demo From 8d37e674e948763673c7fe1afd76d45afed96ee9 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 16 Dec 2025 09:15:34 +0100 Subject: [PATCH 08/32] Localized names fixes (#22) Infer quality using new heuristics: from package name in voiceURI, then string, then number of duplicates. --- .github/workflows/gh-pages.yml | 4 +- ava.config.js | 3 +- demo/index.html | 51 +++--- demo/script.js | 4 +- demo/styles.css | 31 ++++ package.json | 2 +- src/WebSpeech/WebSpeechVoiceManager.ts | 192 ++++++++++++++------- src/voices/filters.ts | 5 +- src/voices/localized.ts | 53 ++++++ src/voices/packages.ts | 20 +++ src/voices/types.ts | 45 ++++- test/WebSpeechVoiceManager.test.ts | 221 ++++++++++++++++++------- 12 files changed, 471 insertions(+), 160 deletions(-) create mode 100644 src/voices/localized.ts create mode 100644 src/voices/packages.ts diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index bc1dd1a..614b921 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -54,8 +54,8 @@ jobs: - name: Deploy uses: peaceiris/actions-gh-pages@v4 - # If you're changing the branch from main, - # also change the `main` in `refs/heads/main` + # If you're changing the branch from develop, + # also change the `main` in `refs/heads/develop` # below accordingly. if: github.ref == 'refs/heads/develop' with: diff --git a/ava.config.js b/ava.config.js index 0129c8a..68a7770 100644 --- a/ava.config.js +++ b/ava.config.js @@ -3,6 +3,7 @@ export default { ts: "module" }, nodeArguments: [ - "--loader=ts-node/esm" + "--import", + 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("ts-node/esm", pathToFileURL("./"));' ] } diff --git a/demo/index.html b/demo/index.html index 879420b..1bcc390 100644 --- a/demo/index.html +++ b/demo/index.html @@ -17,30 +17,6 @@

Readium Speech Demo

-
- - -
- -
- - -
- -
- - -
-
+ + + + + +
+ +
+ + +
+ +
+ + +
+
diff --git a/demo/script.js b/demo/script.js index 321c802..6ea07c3 100644 --- a/demo/script.js +++ b/demo/script.js @@ -465,7 +465,7 @@ function setupEventListeners() { displayVoiceProperties(currentVoice); // Update the test utterance with the new voice - updateTestUtterance(currentVoice, languageCode); + updateTestUtterance(currentVoice, baseLanguage); } catch (error) { console.error("Error setting default voice:", error); @@ -474,7 +474,7 @@ function setupEventListeners() { } // Load sample text using the voice's language code if available, otherwise use the selector's value - const languageToUse = currentVoice?.language || languageCode; + const languageToUse = currentVoice?.language || baseLanguage; loadSampleText(languageToUse); updateUI(); diff --git a/demo/styles.css b/demo/styles.css index 9efe1df..a715995 100644 --- a/demo/styles.css +++ b/demo/styles.css @@ -242,6 +242,37 @@ form > div { overflow: auto; } +/* Filter section */ +.filter-section { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 15px 20px 5px; + margin-bottom: 20px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.filter-section legend { + font-weight: 600; + color: #555; + font-size: 0.9em; + padding: 0 8px; + width: auto; + margin-left: -4px; +} + +.filter-section .form-group { + margin-bottom: 12px; +} + +.filter-section .form-group:last-child { + margin-bottom: 8px; +} + +.filter-section .checkbox-group { + margin: 12px 0 4px; +} + /* Demo section styles */ .demo-section { margin: 30px 0; diff --git a/package.json b/package.json index a642719..4081291 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@readium/speech", - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.2", "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.", "main": "./build/index.cjs", "module": "./build/index.js", diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index 885953d..65d7172 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -1,4 +1,4 @@ -import { ReadiumSpeechVoice, TGender, TQuality, TSource } from "../voices/types"; +import { JSONVoice, ReadiumSpeechVoice, TGender, TQuality, TSource } from "../voices/types"; import { getTestUtterance, getVoices } from "../voices/languages"; import { isNoveltyVoice, @@ -6,7 +6,8 @@ import { filterOutNoveltyVoices, filterOutVeryLowQualityVoices } from "../voices/filters"; - +import { getInferredQualityFromPlatform } from "../voices/localized"; +import { getInferredQualityFromPackageName } from "../voices/packages"; import { extractLangRegionFromBCP47 } from "../utils/language"; /** @@ -144,6 +145,85 @@ export class WebSpeechVoiceManager { } } + /** + * Normalize voice name for comparison by removing common variations + * @private + */ + + private normalizeVoiceName(name: string): string { + if (!name) return ""; + + // Convert to lowercase and remove only the specific formatting we don't want + return name + .toLowerCase() + .replace(/\s*\([^)]*\)/g, "") // Remove anything in parentheses + .replace(/[^\p{L}\p{N}\s-]/gu, "") // Keep letters, numbers, spaces, and hyphens + .replace(/\s+/g, " ") // Normalize spaces + .trim(); + } + + /** + * Count occurrences of each voice based on language and normalized name + * @private + */ + private countVoiceDuplicates(voices: SpeechSynthesisVoice[]): Map { + const counts = new Map(); + for (const voice of voices) { + if (!voice?.name || !voice?.lang) continue; + const key = `${voice.lang.toLowerCase()}_${this.normalizeVoiceName(voice.name)}`; + counts.set(key, (counts.get(key) || 0) + 1); + } + return counts; + } + + /** + * Infer voice quality based on package, platform, JSON, or duplicate count + * Returns null if quality cannot be determined + * @private + */ + private inferVoiceQuality( + voice: SpeechSynthesisVoice, + jsonVoice: JSONVoice | undefined, + duplicatesCount: number + ): TQuality { + // 1. Try package name + const packageQuality = voice.voiceURI ? getInferredQualityFromPackageName(voice.voiceURI) : undefined; + if (packageQuality) return packageQuality; + + // 2. Try platform (localized names) - only if jsonVoice is defined + if (jsonVoice?.localizedName && voice.voiceURI && voice.lang) { + const platformQuality = getInferredQualityFromPlatform( + voice.voiceURI, + voice.lang, + jsonVoice.localizedName + ); + if (platformQuality) return platformQuality; + } + + // 3. Use the jsonVoice.quality array if available + if (jsonVoice?.quality && jsonVoice.quality.length > 0) { + const qualityIndex = Math.min(duplicatesCount - 1, jsonVoice.quality.length - 1); + const quality = jsonVoice.quality[qualityIndex]; + if (quality) { + return quality; + } + } + + // 4. If we can't determine the quality, return null + return null; + } + + /** + * Find matching JSON voice by name or alternative names + * @private + */ + private findMatchingJsonVoice(langVoices: any[], normalizedName: string) { + return langVoices.find(v => + this.normalizeVoiceName(v.name) === normalizedName || + v.altNames?.some((alt: string) => this.normalizeVoiceName(alt) === normalizedName) + ); + } + /** * Remove duplicate voices, keeping the highest quality version of each voice * @param voices Array of voices to remove duplicates from @@ -151,18 +231,24 @@ export class WebSpeechVoiceManager { */ private removeDuplicate(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { const voiceMap = new Map(); - + for (const voice of voices) { - // Create a unique key based on voice identity (excluding quality) - const key = `${voice.voiceURI}_${voice.name}_${voice.language}`; - const existingVoice = voiceMap.get(key); + const key = `${voice.language.toLowerCase()}_${this.normalizeVoiceName(voice.name)}`; + const existing = voiceMap.get(key); - // If we don't have this voice yet, or if the current voice is of higher quality - if (!existingVoice || this.getQualityValue(voice.quality) > this.getQualityValue(existingVoice.quality)) { + if (!existing) { voiceMap.set(key, voice); + } else { + const existingQuality = this.getQualityValue(existing.quality); + const newQuality = this.getQualityValue(voice.quality); + + // If new voice has higher or equal quality, use it (preferring the newer one) + if (newQuality >= existingQuality) { + voiceMap.set(key, voice); + } } } - + return Array.from(voiceMap.values()); } @@ -310,7 +396,7 @@ export class WebSpeechVoiceManager { } getBrowserVoices(maxTimeout = 10000, interval = 10): Promise { - const getVoices = () => window.speechSynthesis?.getVoices() || []; + const getAvailableVoices = () => window.speechSynthesis?.getVoices() || []; // Check if speechSynthesis is available if (!window.speechSynthesis) { @@ -318,7 +404,7 @@ export class WebSpeechVoiceManager { } // Step 1: Try to load voices directly (best case scenario) - const voices = getVoices(); + const voices = getAvailableVoices(); if (Array.isArray(voices) && voices.length) return Promise.resolve(voices); return new Promise((resolve, reject) => { @@ -337,7 +423,7 @@ export class WebSpeechVoiceManager { // Resolve with empty array if no voices found if (counter < 1) return resolve([]); --counter; - const voices = getVoices(); + const voices = getAvailableVoices(); // Resolve if voices loaded if (Array.isArray(voices) && voices.length) return resolve(voices); // Continue polling @@ -350,7 +436,7 @@ export class WebSpeechVoiceManager { // Step 2: Use onvoiceschanged if available (prioritizes event over polling) if (window.speechSynthesis.onvoiceschanged !== undefined) { window.speechSynthesis.onvoiceschanged = () => { - const voices = getVoices(); + const voices = getAvailableVoices(); if (Array.isArray(voices) && voices.length) { // Resolve immediately if voices are available resolve(voices); @@ -382,50 +468,39 @@ export class WebSpeechVoiceManager { return lang; }; - // First, map all browser voices to ReadiumSpeechVoice format + // Count duplicates first + const duplicateCounts = this.countVoiceDuplicates(speechVoices); + + // Map all browser voices to ReadiumSpeechVoice format const mappedVoices = speechVoices - .filter(voice => voice && voice.name && voice.lang) + .filter(voice => voice?.name && voice?.lang) .map(voice => { const formattedLang = parseAndFormatBCP47(voice.lang); const [baseLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(formattedLang); + const normalizedName = this.normalizeVoiceName(voice.name); + const voiceKey = `${voice.lang.toLowerCase()}_${normalizedName}`; + const duplicatesCount = duplicateCounts.get(voiceKey) || 1; // Get voices for the specific language const langVoices = getVoices(baseLang); - // Extract base name by removing anything in parentheses for matching - const baseName = voice.name.split("(")[0].trim(); + // Find matching JSON voice + const jsonVoice = this.findMatchingJsonVoice(langVoices, normalizedName); + + // Infer quality using the helper method + const quality = this.inferVoiceQuality(voice, jsonVoice, duplicatesCount); - // Try to find a matching voice by name, including base name matching - const jsonVoice = langVoices.find(v => { - // Check direct name match - if (v.name === voice.name || v.name === baseName) return true; - - // Check alt names - if (v.altNames) { - return v.altNames.some((name: string) => { - const altBaseName = name.split("(")[0].trim(); - return name === voice.name || - name === baseName || - altBaseName === voice.name || - altBaseName === baseName; - }); - } - - return false; - }); - if (jsonVoice) { - // Found a match in JSON data, merge with browser voice + // Create the voice object with the determined quality return { ...jsonVoice, source: "json", - // Preserve browser-specific properties + quality, voiceURI: voice.voiceURI, isDefault: voice.default || false, offlineAvailability: voice.localService || false, - // Use utility functions from filters.ts isNovelty: isNoveltyVoice(voice.name, voice.voiceURI), - isLowQuality: isVeryLowQualityVoice(voice.name, jsonVoice.quality) + isLowQuality: isVeryLowQualityVoice(voice.name, quality) } as ReadiumSpeechVoice; } @@ -434,12 +509,13 @@ export class WebSpeechVoiceManager { source: "browser", label: voice.name, name: voice.name, - voiceURI: voice.voiceURI, language: formattedLang, + quality, + voiceURI: voice.voiceURI, isDefault: voice.default || false, offlineAvailability: voice.localService || false, isNovelty: isNoveltyVoice(voice.name, voice.voiceURI), - isLowQuality: isVeryLowQualityVoice(voice.name) + isLowQuality: isVeryLowQualityVoice(voice.name, quality) } as ReadiumSpeechVoice; }); @@ -453,9 +529,10 @@ export class WebSpeechVoiceManager { convertToSpeechSynthesisVoice(voice: ReadiumSpeechVoice): SpeechSynthesisVoice | undefined { if (!voice) return undefined; + const normalizedVoiceName = this.normalizeVoiceName(voice.name); return this.browserVoices.find(v => v.voiceURI === voice.voiceURI || - v.name === voice.name + this.normalizeVoiceName(v.name) === normalizedVoiceName ); } @@ -497,9 +574,7 @@ export class WebSpeechVoiceManager { if (options.quality) { const qualities = Array.isArray(options.quality) ? options.quality : [options.quality]; - result = result.filter(v => - v.quality && v.quality.some(q => qualities.includes(q as any)) - ); + result = result.filter(v => v.quality && qualities.includes(v.quality)); } if (options.offlineOnly) { @@ -545,24 +620,17 @@ export class WebSpeechVoiceManager { * Get the numeric value for a quality level * @private */ - private getQualityValue(quality: TQuality | TQuality[] | undefined): number { + private getQualityValue(quality: TQuality | undefined): number { const qualityOrder: Record = { - "veryLow": 0, - "low": 1, - "normal": 2, - "high": 3, - "veryHigh": 4 + "veryLow": 1, + "low": 2, + "normal": 3, + "high": 4, + "veryHigh": 5 }; - if (!quality) return 1; // "low" as fallback - - // Handle both single quality values and arrays - if (Array.isArray(quality)) { - return Math.max(...quality.map(q => qualityOrder[q] ?? 1)); - } - - // Fallback for single quality values - return qualityOrder[quality] ?? 1; + // Return 0 for null/undefined, otherwise the quality value or 0 if not found + return quality ? (qualityOrder[quality] ?? 0) : 0; } /** @@ -744,7 +812,7 @@ export class WebSpeechVoiceManager { break; case "quality": - key = voice.quality?.[0] || "unknown"; + key = voice.quality || "unknown"; break; case "region": diff --git a/src/voices/filters.ts b/src/voices/filters.ts index 93a2e0c..9d0d1aa 100644 --- a/src/voices/filters.ts +++ b/src/voices/filters.ts @@ -1,4 +1,5 @@ import type { ReadiumSpeechVoice } from "./types"; +import { TQuality } from "./types"; import noveltyFilterData from "@json/filters/novelty.json"; import veryLowQualityFilterData from "@json/filters/veryLowQuality.json"; @@ -19,10 +20,10 @@ export const isNoveltyVoice = (voiceName: string, voiceId?: string): boolean => ); } -export const isVeryLowQualityVoice = (voiceName: string, quality?: string[]): boolean => { +export const isVeryLowQualityVoice = (voiceName: string, quality?: TQuality): boolean => { return veryLowQualityFilter.voices.some(filter => voiceName.includes(filter.name) - ) || (Array.isArray(quality) && quality.includes("veryLow")); + ) || quality === "veryLow"; } export const filterOutNoveltyVoices = (voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] => { diff --git a/src/voices/localized.ts b/src/voices/localized.ts new file mode 100644 index 0000000..457705f --- /dev/null +++ b/src/voices/localized.ts @@ -0,0 +1,53 @@ +import type { TQuality, TLocalizedName } from "./types"; +import { extractLangRegionFromBCP47 } from "../utils/language"; + +// Import platform-specific configurations +import appleQualities from "@json/localizedNames/apple.json"; + +interface LocalizedQuality { + normal: string; + high: string; +} + +interface PlatformQualities { + [platform: string]: { + [lang: string]: LocalizedQuality; + }; +} + +const platformQualities: PlatformQualities = { + apple: appleQualities.quality, + // android: androidQualities.quality +}; + +export const getInferredQualityFromPlatform = ( + voiceURI: string, + language: string, + platform?: TLocalizedName | TLocalizedName[] +): TQuality | undefined => { + if (!voiceURI) return undefined; + + // Convert single platform to array for consistent handling + const platforms = Array.isArray(platform) ? platform : platform ? [platform] : []; + + for (const p of platforms) { + if (p && platformQualities[p]) { + const qualities = platformQualities[p]; + const langCode = extractLangRegionFromBCP47(language)[0]; + const qualityIndicators = qualities[language] || qualities[langCode]; + + if (qualityIndicators) { + const lowerName = voiceURI.toLowerCase(); + const { normal, high } = qualityIndicators; + + if (high && lowerName.includes(high.toLowerCase())) { + return "high"; + } else if (normal && lowerName.includes(normal.toLowerCase())) { + return "normal"; + } + } + } + } + + return undefined; +} \ No newline at end of file diff --git a/src/voices/packages.ts b/src/voices/packages.ts new file mode 100644 index 0000000..44880b3 --- /dev/null +++ b/src/voices/packages.ts @@ -0,0 +1,20 @@ +export enum PackageQuality { + VeryLow = "super-compact", + Low = "compact", + Normal = "enhanced", + High = "premium" +} + +import { TQuality } from "./types"; + +export const getInferredQualityFromPackageName = (voiceName: string): TQuality | undefined => { + if (!voiceName) return undefined; + + const lowerName = voiceName.toLowerCase(); + if (lowerName.includes(`.${PackageQuality.VeryLow}.`)) return "veryLow"; + if (lowerName.includes(`.${PackageQuality.Low}.`)) return "low"; + if (lowerName.includes(`.${PackageQuality.Normal}.`)) return "normal"; + if (lowerName.includes(`.${PackageQuality.High}.`)) return "high"; + + return undefined; +} \ No newline at end of file diff --git a/src/voices/types.ts b/src/voices/types.ts index 468338f..8ffdbb0 100644 --- a/src/voices/types.ts +++ b/src/voices/types.ts @@ -1,5 +1,3 @@ -// Auto-generated file - DO NOT EDIT - /** * Voice gender as defined in the schema */ @@ -8,7 +6,7 @@ export type TGender = "neutral" | "female" | "male"; /** * Voice quality levels as defined in the schema */ -export type TQuality = "veryLow" | "low" | "normal" | "high" | "veryHigh"; +export type TQuality = null | "veryLow" | "low" | "normal" | "high" | "veryHigh"; /** * Localization type for voice names @@ -20,6 +18,41 @@ export type TLocalizedName = "android" | "apple"; */ export type TSource = "json" | "browser"; +/** + * Supported operating systems for voices + */ +export type TOperatingSystem = "Android" | "ChromeOS" | "iOS" | "iPadOS" | "macOS" | "Windows"; + +/** + * Supported browsers for voices + */ +export type TBrowser = "ChromeDesktop" | "Edge" | "Firefox" | "Safari"; + +/** + * Represents a voice from the JSON data file + */ +export interface JSONVoice { + label?: string; + name: string; + localizedName?: "android" | "apple"; + note?: string; + altNames?: string[]; + nativeID?: string[]; + language?: string; + altLanguage?: string; + otherLanguages?: string[]; + multiLingual?: boolean; + gender?: TGender; + children?: boolean; + quality?: TQuality[]; + rate?: number; + pitch?: number; + pitchControl?: boolean; + os?: TOperatingSystem[]; + browser?: TBrowser[]; + preloaded?: boolean; +} + export interface VoiceFilterData { voices: Array<{ name: string; @@ -49,7 +82,7 @@ export interface ReadiumSpeechVoice { children?: boolean; // If this is a children's voice // Quality and capabilities - quality?: TQuality[]; // Available quality levels for this voice + quality?: TQuality; // Voice quality level pitchControl?: boolean; // Whether pitch can be controlled // Performance settings @@ -57,8 +90,8 @@ export interface ReadiumSpeechVoice { rate?: number; // Speech rate (0.1-10, where 1 is normal) // Platform and compatibility - browser?: string[]; // Supported browsers - os?: string[]; // Supported operating systems + browser?: TBrowser[]; // Supported browsers + os?: TOperatingSystem[]; // Supported operating systems preloaded?: boolean; // If the voice is preloaded on the system nativeID?: string | string[]; // Platform-specific voice ID(s) diff --git a/test/WebSpeechVoiceManager.test.ts b/test/WebSpeechVoiceManager.test.ts index bed1462..5670232 100644 --- a/test/WebSpeechVoiceManager.test.ts +++ b/test/WebSpeechVoiceManager.test.ts @@ -199,6 +199,108 @@ testWithContext("initialize: loads voices and gets voices successfully", (t) => t.true(voices.length > 0); }); +testWithContext("deduplication: keeps higher quality voice from voiceURI package name", (t) => { + const manager = t.context.manager; + + // Define test voices once + const veryLowVoice = { + voiceURI: "com.apple.speech.synthesis.voice.super-compact.samantha", + name: "Samantha (very low)", + lang: "en-US", + localService: true, + default: false + }; + + const lowVoice = { + voiceURI: "com.apple.speech.synthesis.voice.compact.samantha", + name: "Samantha", + lang: "en-US", + localService: true, + default: false + }; + + // 1. First parse separately to verify individual qualities + const veryLowQualityVoice = (manager as any).parseToReadiumSpeechVoices([veryLowVoice])[0]; + const lowQualityVoice = (manager as any).parseToReadiumSpeechVoices([lowVoice])[0]; + + // Verify individual qualities + t.is(veryLowQualityVoice.quality, "veryLow", "Very low quality voice should have very low quality"); + t.is(lowQualityVoice.quality, "low", "Low quality voice should have low quality"); + + // 2. Now parse both together to test deduplication + const [resultVoice] = (manager as any).parseToReadiumSpeechVoices([veryLowVoice, lowVoice]); + + // Verify the result + t.is(resultVoice.name, "Samantha", "Should keep the json name of the voice"); + t.deepEqual(resultVoice.quality, "low", "Should keep the voice with low quality"); +}); + +testWithContext("deduplication: keeps higher quality voice from voiceURI string", (t) => { + const manager = t.context.manager; + + // Define test voices once + const basicVoice = { + voiceURI: "Samantha", + name: "Samantha", + lang: "en-US", + localService: true, + default: false + }; + + const enhancedVoice = { + voiceURI: "Samantha (Premium)", + name: "Samantha (Premium)", + lang: "en-US", + localService: true, + default: false + }; + + // 1. First parse separately to verify individual qualities + const basicVoiceParsed = (manager as any).parseToReadiumSpeechVoices([basicVoice])[0]; + const enhancedVoiceParsed = (manager as any).parseToReadiumSpeechVoices([enhancedVoice])[0]; + + // Verify individual qualities + t.is(basicVoiceParsed.quality, "low", "Basic voice should have low quality"); + t.is(enhancedVoiceParsed.quality, "high", "Premium voice should have high quality"); + + // 2. Now parse both together to test deduplication + const [resultVoice] = (manager as any).parseToReadiumSpeechVoices([basicVoice, enhancedVoice]); + + // Verify the result + t.is(resultVoice.name, "Samantha", "Should keep the json name of the voice"); + t.deepEqual(resultVoice.quality, "high", "Should keep the voice with high quality"); +}); + +testWithContext("deduplication: keeps higher quality voice from json quality array", (t) => { + const manager = t.context.manager; + + // Parse both voices together to get correct duplicate counts + const voices = (manager as any).parseToReadiumSpeechVoices([ + { + voiceURI: "Samantha", + name: "Samantha", + lang: "en-US", + localService: true, + default: false + }, + { + voiceURI: "Samantha superior", + name: "Samantha (Superior)", + lang: "en-US", + localService: true, + default: false + } + ]); + // Now test deduplication with both voices + const deduped = (manager as any).removeDuplicate(voices); + + // Verify only the higher quality voice remains with its original name + t.is(deduped.length, 1, "Should only keep one voice after deduplication"); + t.is(deduped[0].name, "Samantha", "Should keep the json name of the voice"); + t.is(deduped[0].voiceURI, "Samantha superior", "Should keep the voice with superior quality"); + t.deepEqual(deduped[0].quality, "normal", "Should find the voice with normal quality from the array"); +}); + // ============================================= // 2. Voice Retrieval Tests // ============================================= @@ -212,7 +314,7 @@ testWithContext("getVoices: throws if not initialized", (t) => { // Create a new instance without initializing const manager = new (WebSpeechVoiceManager as any)(); t.throws(() => manager.getVoices(), { - message: 'WebSpeechVoiceManager not initialized. Call initialize() first.' + message: "WebSpeechVoiceManager not initialized. Call initialize() first." }); }); @@ -220,11 +322,11 @@ testWithContext("getVoices: combines all filters", async (t: ExecutionContext ({ ...v, - quality: i % 2 === 0 ? ["high"] : ["low"] + quality: i % 2 === 0 ? "high" : "low" })); // Replace the voices in the manager @@ -359,7 +461,7 @@ testWithContext("getVoices: filters by quality", async (t: ExecutionContext 0); - t.true(highQualityVoices.every((v: ReadiumSpeechVoice) => v.quality?.includes("high") ?? false)); + t.true(highQualityVoices.every((v: ReadiumSpeechVoice) => v.quality === "high")); }); testWithContext("getVoices: returns empty array when speechSynthesis is not available", async (t) => { @@ -531,14 +633,14 @@ testWithContext("getDefaultVoice: selects highest quality voice regardless of is name: "High Quality", language: "en-US", isDefault: false, // Not default but higher quality - quality: ["high"] + quality: "high" }, { voiceURI: "voice2", name: "Normal Quality", language: "en-US", isDefault: true, // Default but lower quality - quality: ["normal"] + quality: "normal" } ]; @@ -558,14 +660,14 @@ testWithContext("getDefaultVoice: falls back to base language", async (t: Execut name: "English Generic", language: "en", // Base language isDefault: false, - quality: ["high"] + quality: "high" }, { voiceURI: "voice2", name: "US English", language: "en-US", isDefault: false, - quality: ["high"] + quality: "high" } ]; @@ -586,21 +688,21 @@ testWithContext("getDefaultVoice: respects quality sorting", async (t: Execution name: "High Quality", language: "en-US", isDefault: false, - quality: ["high"] + quality: "high" }, { voiceURI: "voice2", name: "Very High Quality", language: "en-US", isDefault: false, - quality: ["veryHigh"] // Higher quality + quality: "veryHigh" // Higher quality }, { voiceURI: "voice3", name: "Normal Quality", language: "en-US", isDefault: false, - quality: ["normal"] // Lower quality + quality: "normal" // Lower quality } ]; @@ -777,21 +879,20 @@ testWithContext("filterVoices: filters by quality array", (t: ExecutionContext (v.language.startsWith("en") || v.language.startsWith("fr")) && - (v.quality?.includes("high") || v.quality?.includes("normal")) + (v.quality === "high" || v.quality === "normal") )); }); @@ -970,14 +1071,14 @@ testWithContext("filterOutVeryLowQualityVoices: removes very low quality voices" // Create test voices with one very low quality voice const testVoices = [ - createTestVoice({ name: "Voice 1", language: "en-US", quality: ["normal"] }), - createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: ["veryLow"] }), - createTestVoice({ name: "Voice 2", language: "fr-FR", quality: ["normal"] }) + createTestVoice({ name: "Voice 1", language: "en-US", quality: "normal" }), + createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: "veryLow" }), + createTestVoice({ name: "Voice 2", language: "fr-FR", quality: "normal" }) ]; const filtered = manager.filterOutVeryLowQualityVoices(testVoices); t.is(filtered.length, testVoices.length - 1); - t.false(filtered.some((v: ReadiumSpeechVoice) => v.quality?.includes("veryLow"))); + t.false(filtered.some((v: ReadiumSpeechVoice) => v.quality === "veryLow")); }); // ============================================= @@ -1012,28 +1113,28 @@ testWithContext("sortVoices: sorts by quality with proper direction", (t: Execut // Create test voices with different quality levels const testVoices = [ - createTestVoice({ name: "High Quality Voice", language: "en-US", quality: ["high"] }), - createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: ["low"] }), - createTestVoice({ name: "Normal Quality Voice", language: "en-US", quality: ["normal"] }), - createTestVoice({ name: "Very High Quality Voice", language: "en-US", quality: ["veryHigh"] }), - createTestVoice({ name: "Very Low Quality Voice", language: "en-US", quality: ["veryLow"] }) + createTestVoice({ name: "High Quality Voice", language: "en-US", quality: "high" }), + createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: "low" }), + createTestVoice({ name: "Normal Quality Voice", language: "en-US", quality: "normal" }), + createTestVoice({ name: "Very High Quality Voice", language: "en-US", quality: "veryHigh" }), + createTestVoice({ name: "Very Low Quality Voice", language: "en-US", quality: "veryLow" }) ]; // Test ascending order (low to high quality) const sortedAsc = manager.sortVoices(testVoices, { by: "quality", order: "asc" }); - t.is(sortedAsc[0].quality?.[0], "veryLow"); - t.is(sortedAsc[1].quality?.[0], "low"); - t.is(sortedAsc[2].quality?.[0], "normal"); - t.is(sortedAsc[3].quality?.[0], "high"); - t.is(sortedAsc[4].quality?.[0], "veryHigh"); + t.is(sortedAsc[0].quality, "veryLow"); + t.is(sortedAsc[1].quality, "low"); + t.is(sortedAsc[2].quality, "normal"); + t.is(sortedAsc[3].quality, "high"); + t.is(sortedAsc[4].quality, "veryHigh"); // Test descending order (high to low quality) const sortedDesc = manager.sortVoices(testVoices, { by: "quality", order: "desc" }); - t.is(sortedDesc[0].quality?.[0], "veryHigh"); - t.is(sortedDesc[1].quality?.[0], "high"); - t.is(sortedDesc[2].quality?.[0], "normal"); - t.is(sortedDesc[3].quality?.[0], "low"); - t.is(sortedDesc[4].quality?.[0], "veryLow"); + t.is(sortedDesc[0].quality, "veryHigh"); + t.is(sortedDesc[1].quality, "high"); + t.is(sortedDesc[2].quality, "normal"); + t.is(sortedDesc[3].quality, "low"); + t.is(sortedDesc[4].quality, "veryLow"); }); testWithContext("sortVoices: sorts by language", (t: ExecutionContext) => { @@ -1291,9 +1392,9 @@ testWithContext("groupVoices: groups by quality", (t: ExecutionContext Date: Thu, 18 Dec 2025 17:56:41 +0100 Subject: [PATCH 09/32] Demo fixes (#25) * Update package name inference so that super-compact does not return veryLow and filter a lot of voices * Add download all voices as dev tool * Make sure to set lang on utterance * Fix zh-HK parsing * Update defaultVoice on setVoice (when language changes) * Add originalName to ReadiumSpeechVoice interface --- README.md | 3 +- demo/index.html | 15 +++- demo/script.js | 49 +++++++++++ demo/styles.css | 40 +++++++-- src/WebSpeech/WebSpeechVoiceManager.ts | 28 ++++--- src/WebSpeech/webSpeechEngine.ts | 21 +++-- src/voices/packages.ts | 40 ++++++--- src/voices/types.ts | 5 +- test/WebSpeechVoiceManager.test.ts | 108 ++++++++++++++++++++++--- 9 files changed, 257 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 5b109cf..7298357 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,8 @@ interface ReadiumSpeechVoice { // Core identification (required) label: string; // Human-friendly label for the voice - name: string; // System/technical name (matches Web Speech API voiceURI) + name: string; // JSON Name (or Web Speech API name if not found) + originalName: string; // Original name of the voice voiceURI?: string; // For Web Speech API compatibility // Localization diff --git a/demo/index.html b/demo/index.html index 1bcc390..cfc47a0 100644 --- a/demo/index.html +++ b/demo/index.html @@ -39,9 +39,9 @@

Readium Speech Demo

Voice Details -
-

Select a voice to see its properties

-
+
+

Select a voice to see its properties

+
@@ -96,6 +96,15 @@

Readium Speech Demo

+
+
+ Developer Tools +
+ +
+
+
+ diff --git a/demo/script.js b/demo/script.js index 6ea07c3..97aaba7 100644 --- a/demo/script.js +++ b/demo/script.js @@ -22,6 +22,7 @@ const jumpToBtn = document.getElementById("jump-to-btn"); const utteranceIndexInput = document.getElementById("utterance-index"); const totalUtterancesSpan = document.getElementById("total-utterances"); const sampleTextDisplay = document.getElementById("sample-text"); +const downloadVoicesBtn = document.getElementById("download-voices-btn"); // Track if user has manually changed the jump input let jumpInputUserChanged = false; @@ -628,6 +629,9 @@ function displayVoiceProperties(voice) { updateTestUtterance(currentVoice, languageSelect.value); } }); + + // Download voices button + downloadVoicesBtn.addEventListener("click", downloadVoicesAsJson); } // Play test utterance - independent of the navigator @@ -896,6 +900,51 @@ function updateUI() { } } +// Simple function to get current date for filenames +function getCurrentDate() { + return new Date().toISOString().split("T")[0]; +} + +// Function to download voices as JSON +function downloadVoicesAsJson() { + try { + const voices = window.speechSynthesis.getVoices(); + + const metadata = { + timestamp: new Date().toISOString(), + voicesCount: voices.length + }; + + const voicesData = voices.map(voice => ({ + ...voice, + // Known properties + voiceURI: voice.voiceURI, + name: voice.name, + lang: voice.lang, + localService: voice.localService, + default: voice.default + })); + + const exportData = { + metadata, + voices: voicesData + }; + + const dataStr = JSON.stringify(exportData, null, 2); + const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr); + + const exportFileDefaultName = `speech-voices-${getCurrentDate()}.json`.replace(/[^a-z0-9.-]+/gi, "-").toLowerCase(); + + const linkElement = document.createElement("a"); + linkElement.setAttribute("href", dataUri); + linkElement.setAttribute("download", exportFileDefaultName); + linkElement.click(); + } catch (error) { + console.error("Error downloading voices data:", error); + alert("Error downloading voices data. Please check console for details."); + } +} + // Initialize the application init().then(() => { // If there's a default voice selected after initialization, display its properties diff --git a/demo/styles.css b/demo/styles.css index a715995..97f12a3 100644 --- a/demo/styles.css +++ b/demo/styles.css @@ -489,8 +489,10 @@ form > div { } } -/* Voice Details Section */ -.voice-details { +/* Expandable Details */ + +.voice-details, +.dev-tools { background: #f9f9f9; border: 1px solid #e0e0e0; border-radius: 8px; @@ -498,7 +500,8 @@ form > div { overflow: hidden; } -.voice-details summary { +.voice-details summary, +.dev-tools summary { padding: 12px 16px; background: #f0f0f0; cursor: pointer; @@ -509,15 +512,18 @@ form > div { transition: background-color 0.2s ease; } -.voice-details summary:hover { +.voice-details summary:hover, +.dev-tools summary:hover { background: #e8e8e8; } -.voice-details[open] summary { +.voice-details[open] summary, +.dev-tools summary:hover { background: #e0e0e0; } -.voice-properties { +.voice-properties, +.dev-tools-items { padding: 16px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 14px; @@ -526,6 +532,8 @@ form > div { background: white; } +/* Voice Details Section */ + .voice-property { display: flex; margin-bottom: 8px; @@ -564,4 +572,24 @@ form > div { .voice-property-value.undefined { color: #9e9e9e; font-style: italic; +} + +/* Dev tools items */ + +.download-btn { + background-color: #2196F3; /* Blue to match navigation buttons */ + color: white; + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s; + display: inline-block; + margin-top: 10px; +} + +.download-btn:hover:not(:disabled) { + background-color: #1976D2; /* Slightly darker blue on hover */ + opacity: 1; } \ No newline at end of file diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index 65d7172..c7fe3d0 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -1,4 +1,4 @@ -import { JSONVoice, ReadiumSpeechVoice, TGender, TQuality, TSource } from "../voices/types"; +import { ReadiumSpeechJSONVoice, ReadiumSpeechVoice, TGender, TQuality, TSource } from "../voices/types"; import { getTestUtterance, getVoices } from "../voices/languages"; import { isNoveltyVoice, @@ -183,7 +183,7 @@ export class WebSpeechVoiceManager { */ private inferVoiceQuality( voice: SpeechSynthesisVoice, - jsonVoice: JSONVoice | undefined, + jsonVoice: ReadiumSpeechJSONVoice | undefined, duplicatesCount: number ): TQuality { // 1. Try package name @@ -217,7 +217,7 @@ export class WebSpeechVoiceManager { * Find matching JSON voice by name or alternative names * @private */ - private findMatchingJsonVoice(langVoices: any[], normalizedName: string) { + private findMatchingJsonVoice(langVoices: any[], normalizedName: string): ReadiumSpeechJSONVoice | undefined { return langVoices.find(v => this.normalizeVoiceName(v.name) === normalizedName || v.altNames?.some((alt: string) => this.normalizeVoiceName(alt) === normalizedName) @@ -481,8 +481,13 @@ export class WebSpeechVoiceManager { const voiceKey = `${voice.lang.toLowerCase()}_${normalizedName}`; const duplicatesCount = duplicateCounts.get(voiceKey) || 1; - // Get voices for the specific language - const langVoices = getVoices(baseLang); + // First try with the full language code to handle variants like zh-HK + let langVoices = getVoices(formattedLang); + + // If no voices found, try with the base language code + if (!langVoices || langVoices.length === 0) { + langVoices = getVoices(baseLang); + } // Find matching JSON voice const jsonVoice = this.findMatchingJsonVoice(langVoices, normalizedName); @@ -495,8 +500,10 @@ export class WebSpeechVoiceManager { return { ...jsonVoice, source: "json", - quality, + originalName: voice.name, + language: voice.lang, voiceURI: voice.voiceURI, + quality, isDefault: voice.default || false, offlineAvailability: voice.localService || false, isNovelty: isNoveltyVoice(voice.name, voice.voiceURI), @@ -507,11 +514,12 @@ export class WebSpeechVoiceManager { // No match found in JSON, create basic voice object return { source: "browser", - label: voice.name, + label: this.normalizeVoiceName(voice.name), name: voice.name, + originalName: voice.name, language: formattedLang, - quality, voiceURI: voice.voiceURI, + quality, isDefault: voice.default || false, offlineAvailability: voice.localService || false, isNovelty: isNoveltyVoice(voice.name, voice.voiceURI), @@ -529,10 +537,10 @@ export class WebSpeechVoiceManager { convertToSpeechSynthesisVoice(voice: ReadiumSpeechVoice): SpeechSynthesisVoice | undefined { if (!voice) return undefined; - const normalizedVoiceName = this.normalizeVoiceName(voice.name); return this.browserVoices.find(v => v.voiceURI === voice.voiceURI || - this.normalizeVoiceName(v.name) === normalizedVoiceName + v.name === voice.originalName || + this.normalizeVoiceName(v.name) === this.normalizeVoiceName(voice.name) ); } diff --git a/src/WebSpeech/webSpeechEngine.ts b/src/WebSpeech/webSpeechEngine.ts index 5fd6d21..2445caf 100644 --- a/src/WebSpeech/webSpeechEngine.ts +++ b/src/WebSpeech/webSpeechEngine.ts @@ -87,7 +87,7 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { this.voices = this.voiceManager.getVoices(); // Find the best matching voice for the user's language using the optimized method - this.defaultVoice = this.voiceManager.getDefaultVoice(navigator.language || "en", this.voices); + this.defaultVoice = this.voiceManager.getDefaultVoice(navigator.languages[0] || "en", this.voices); this.initialized = true; return true; @@ -170,6 +170,15 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { this.currentUtteranceIndex = 0; } } + + // Update default voice if language changed + if ( + this.voiceManager && + this.defaultVoice && this.currentVoice && + this.currentVoice.language !== this.defaultVoice.language + ) { + this.defaultVoice = this.voiceManager.getDefaultVoice(this.currentVoice.language, this.voices); + } } getAvailableVoices(): Promise { @@ -254,11 +263,6 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { const utterance = this.createUtterance(text); - // Configure utterance - if (content.language) { - utterance.lang = content.language; - } - // Enhanced voice selection with MSNatural detection const selectedVoice = this.getCurrentVoiceForUtterance(this.currentVoice); @@ -268,9 +272,14 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { if (nativeVoice) { utterance.voice = nativeVoice; // Use the real native voice from cache + utterance.lang = nativeVoice.lang; } } + if (content.language) { + utterance.lang = content.language; + } + utterance.rate = this.rate; utterance.pitch = this.pitch; utterance.volume = this.volume; diff --git a/src/voices/packages.ts b/src/voices/packages.ts index 44880b3..d5460bc 100644 --- a/src/voices/packages.ts +++ b/src/voices/packages.ts @@ -1,20 +1,38 @@ -export enum PackageQuality { - VeryLow = "super-compact", - Low = "compact", - Normal = "enhanced", - High = "premium" -} - import { TQuality } from "./types"; +type PackageQuality = { + [key: string]: { + values: string[]; + quality: TQuality; + }; +}; + +const packageQualities: PackageQuality = { + low: { + values: ["super-compact", "compact"], + quality: "low" + }, + normal: { + values: ["enhanced"], + quality: "normal" + }, + high: { + values: ["premium"], + quality: "high" + } +}; + export const getInferredQualityFromPackageName = (voiceName: string): TQuality | undefined => { if (!voiceName) return undefined; const lowerName = voiceName.toLowerCase(); - if (lowerName.includes(`.${PackageQuality.VeryLow}.`)) return "veryLow"; - if (lowerName.includes(`.${PackageQuality.Low}.`)) return "low"; - if (lowerName.includes(`.${PackageQuality.Normal}.`)) return "normal"; - if (lowerName.includes(`.${PackageQuality.High}.`)) return "high"; + + // Check each quality level + for (const quality of Object.values(packageQualities)) { + if (quality.values.some(value => lowerName.includes(`.${value}.`))) { + return quality.quality; + } + } return undefined; } \ No newline at end of file diff --git a/src/voices/types.ts b/src/voices/types.ts index 8ffdbb0..e16f50c 100644 --- a/src/voices/types.ts +++ b/src/voices/types.ts @@ -31,7 +31,7 @@ export type TBrowser = "ChromeDesktop" | "Edge" | "Firefox" | "Safari"; /** * Represents a voice from the JSON data file */ -export interface JSONVoice { +export interface ReadiumSpeechJSONVoice { label?: string; name: string; localizedName?: "android" | "apple"; @@ -66,7 +66,8 @@ export interface ReadiumSpeechVoice { // Core identification (required) label: string; // Human-friendly label for the voice - name: string; // System/technical name (matches Web Speech API voiceURI) + name: string; // JSON Name (or Web Speech API name if not found) + originalName: string; // Web Speech API name voiceURI?: string; // For Web Speech API compatibility // Localization diff --git a/test/WebSpeechVoiceManager.test.ts b/test/WebSpeechVoiceManager.test.ts index 5670232..709bef8 100644 --- a/test/WebSpeechVoiceManager.test.ts +++ b/test/WebSpeechVoiceManager.test.ts @@ -105,6 +105,7 @@ function createTestVoice(overrides: Partial = {}): ReadiumSp source: "json", label: overrides.name || "Test Voice", name: overrides.name || "Test Voice", + originalName: overrides.originalName || "Test Voice", voiceURI: `voice-${overrides.name || "test"}`, language: "en-US", ...overrides @@ -203,36 +204,37 @@ testWithContext("deduplication: keeps higher quality voice from voiceURI package const manager = t.context.manager; // Define test voices once - const veryLowVoice = { - voiceURI: "com.apple.speech.synthesis.voice.super-compact.samantha", - name: "Samantha (very low)", + const lowVoice = { + voiceURI: "com.apple.speech.synthesis.voice.compact.samantha", + name: "Samantha", lang: "en-US", localService: true, default: false }; - const lowVoice = { - voiceURI: "com.apple.speech.synthesis.voice.compact.samantha", - name: "Samantha", + const normalVoice = { + voiceURI: "com.apple.speech.synthesis.voice.enhanced.samantha", + name: "Samantha (enhanced)", lang: "en-US", localService: true, default: false }; // 1. First parse separately to verify individual qualities - const veryLowQualityVoice = (manager as any).parseToReadiumSpeechVoices([veryLowVoice])[0]; const lowQualityVoice = (manager as any).parseToReadiumSpeechVoices([lowVoice])[0]; + const normalQualityVoice = (manager as any).parseToReadiumSpeechVoices([normalVoice])[0]; // Verify individual qualities - t.is(veryLowQualityVoice.quality, "veryLow", "Very low quality voice should have very low quality"); t.is(lowQualityVoice.quality, "low", "Low quality voice should have low quality"); + t.is(normalQualityVoice.quality, "normal", "Normal quality voice should have normal quality"); // 2. Now parse both together to test deduplication - const [resultVoice] = (manager as any).parseToReadiumSpeechVoices([veryLowVoice, lowVoice]); + const [resultVoice] = (manager as any).parseToReadiumSpeechVoices([lowVoice, normalVoice]); // Verify the result - t.is(resultVoice.name, "Samantha", "Should keep the json name of the voice"); - t.deepEqual(resultVoice.quality, "low", "Should keep the voice with low quality"); + t.is(resultVoice.name, "Samantha", "Should use the JSON name of the voice"); + t.is(resultVoice.originalName, "Samantha (enhanced)", "Should keep the original name of the voice"); + t.deepEqual(resultVoice.quality, "normal", "Should keep the voice with normal quality"); }); testWithContext("deduplication: keeps higher quality voice from voiceURI string", (t) => { @@ -267,7 +269,8 @@ testWithContext("deduplication: keeps higher quality voice from voiceURI string" const [resultVoice] = (manager as any).parseToReadiumSpeechVoices([basicVoice, enhancedVoice]); // Verify the result - t.is(resultVoice.name, "Samantha", "Should keep the json name of the voice"); + t.is(resultVoice.name, "Samantha", "Should use the JSON name of the voice"); + t.is(resultVoice.originalName, "Samantha (Premium)", "Should keep the original name of the voice"); t.deepEqual(resultVoice.quality, "high", "Should keep the voice with high quality"); }); @@ -296,7 +299,8 @@ testWithContext("deduplication: keeps higher quality voice from json quality arr // Verify only the higher quality voice remains with its original name t.is(deduped.length, 1, "Should only keep one voice after deduplication"); - t.is(deduped[0].name, "Samantha", "Should keep the json name of the voice"); + t.is(deduped[0].name, "Samantha", "Should use the JSON name of the voice"); + t.is(deduped[0].originalName, "Samantha (Superior)", "Should keep the original name of the voice"); t.is(deduped[0].voiceURI, "Samantha superior", "Should keep the voice with superior quality"); t.deepEqual(deduped[0].quality, "normal", "Should find the voice with normal quality from the array"); }); @@ -1471,6 +1475,84 @@ testWithContext("convertToSpeechSynthesisVoice: converts ReadiumSpeechVoice to S } }); +testWithContext("convertToSpeechSynthesisVoice: handles undefined voiceURI", async (t: ExecutionContext) => { + const manager = t.context.manager; + const voices = await manager.getVoices(); + + if (voices.length > 0) { + // Create a test voice with undefined voiceURI but with name and originalName + const testVoice = { + ...voices[0], + voiceURI: undefined, + name: "Test Voice", + originalName: "Test Voice (Original)" + }; + + // Mock browserVoices to include a matching voice by name + const mockBrowserVoice = { + voiceURI: "mock-voice-uri", + name: "Test Voice (Original)", + lang: "en-US", + localService: true, + default: false + }; + + // Save original browserVoices and replace with our mock + const originalBrowserVoices = (manager as any).browserVoices; + (manager as any).browserVoices = [mockBrowserVoice]; + + try { + const result = manager.convertToSpeechSynthesisVoice(testVoice); + t.truthy(result, "Should return a voice when matching by original name"); + t.is(result?.name, "Test Voice (Original)", "Should match by original name when voiceURI is undefined"); + } finally { + // Restore original browserVoices + (manager as any).browserVoices = originalBrowserVoices; + } + } else { + t.pass("No voices available to test"); + } +}); + +testWithContext("convertToSpeechSynthesisVoice: handles undefined voiceURI and originalName", async (t: ExecutionContext) => { + const manager = t.context.manager; + const voices = await manager.getVoices(); + + if (voices.length > 0) { + // Create a test voice with undefined voiceURI and originalName + const testVoice = { + ...voices[0], + voiceURI: undefined, + name: "Test Voice", + originalName: undefined + }; + + // Mock browserVoices to include a matching voice by name + const mockBrowserVoice = { + voiceURI: "mock-voice-uri", + name: "Test Voice (Original)", + lang: "en-US", + localService: true, + default: false + }; + + // Save original browserVoices and replace with our mock + const originalBrowserVoices = (manager as any).browserVoices; + (manager as any).browserVoices = [mockBrowserVoice]; + + try { + const result = manager.convertToSpeechSynthesisVoice(testVoice as any); + t.truthy(result, "Should return a voice when matching by original name"); + t.is(result?.name, "Test Voice (Original)", "Should match by name when voiceURI and Original name are undefined"); + } finally { + // Restore original browserVoices + (manager as any).browserVoices = originalBrowserVoices; + } + } else { + t.pass("No voices available to test"); + } +}); + testWithContext("convertToSpeechSynthesisVoice: handles invalid voice", (t: ExecutionContext) => { const manager = t.context.manager; From c2a71f03235d9ea052e7732938e01427c24ff2f2 Mon Sep 17 00:00:00 2001 From: Hadrien Gardeur Date: Fri, 19 Dec 2025 10:53:50 +0100 Subject: [PATCH 10/32] Bumped up Chrome voices Now that we can handle playback issues for them, it's worth moving up all Chrome voices since they're high quality and pre-loaded --- json/cmn.json | 68 ++++++++++++++++++------------------ json/de.json | 30 ++++++++-------- json/en.json | 96 +++++++++++++++++++++++++-------------------------- json/es.json | 64 +++++++++++++++++----------------- json/fr.json | 32 ++++++++--------- json/hi.json | 32 ++++++++--------- json/id.json | 30 ++++++++-------- json/it.json | 32 ++++++++--------- json/ja.json | 32 ++++++++--------- json/ko.json | 32 ++++++++--------- json/nl.json | 32 ++++++++--------- json/pl.json | 32 ++++++++--------- json/pt.json | 32 ++++++++--------- json/ru.json | 33 +++++++++--------- json/yue.json | 34 +++++++++--------- 15 files changed, 305 insertions(+), 306 deletions(-) diff --git a/json/cmn.json b/json/cmn.json index 82a329c..2c5e010 100644 --- a/json/cmn.json +++ b/json/cmn.json @@ -179,6 +179,40 @@ ], "preloaded": true }, + { + "label": "Google 女声", + "name": "Google 普通话(中国大陆)", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "cmn-CN", + "altLanguage": "zh-CN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Google 女聲", + "name": "Google 國語(臺灣)", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "cmn-TW", + "altLanguage": "zh-TW", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Lilian", "name": "Lilian", @@ -538,40 +572,6 @@ ], "preloaded": true }, - { - "label": "Google 女声", - "name": "Google 普通话(中国大陆)", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "cmn-CN", - "altLanguage": "zh-CN", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, - { - "label": "Google 女聲", - "name": "Google 國語(臺灣)", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "cmn-TW", - "altLanguage": "zh-TW", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Huihui", "name": "Microsoft Huihui - Chinese (Simplified, PRC)", diff --git a/json/de.json b/json/de.json index e998345..4eb4e36 100644 --- a/json/de.json +++ b/json/de.json @@ -155,6 +155,21 @@ ], "preloaded": true }, + { + "label": "Google Deutsch", + "name": "Weibliche Google-Stimme (Deutschland)", + "language": "de-DE", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Petra", "name": "Petra", @@ -286,21 +301,6 @@ ], "preloaded": true }, - { - "label": "Google Deutsch", - "name": "Weibliche Google-Stimme (Deutschland)", - "language": "de-DE", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Hedda", "name": "Microsoft Hedda - German (Germany)", diff --git a/json/en.json b/json/en.json index 7ca8768..42b8931 100644 --- a/json/en.json +++ b/json/en.json @@ -688,6 +688,54 @@ ], "preloaded": true }, + { + "label": "Female Google voice (US)", + "name": "Google US English", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "en-US", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Female Google voice (UK)", + "name": "Google UK English Female", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "en-GB", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Male Google voice (UK)", + "name": "Google UK English Male", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "en-GB", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Apple Ava", "name": "Ava", @@ -1246,54 +1294,6 @@ "iPadOS" ] }, - { - "label": "Female Google voice (US)", - "name": "Google US English", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "en-US", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, - { - "label": "Female Google voice (UK)", - "name": "Google UK English Female", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "en-GB", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, - { - "label": "Male Google voice (UK)", - "name": "Google UK English Male", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "en-GB", - "gender": "male", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Zira", "name": "Microsoft Zira - English (United States)", diff --git a/json/es.json b/json/es.json index 71bd485..cb032cb 100644 --- a/json/es.json +++ b/json/es.json @@ -684,6 +684,38 @@ ], "preloaded": true }, + { + "label": "Voz Google masculina (España)", + "name": "Google español", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "es-ES", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, + { + "label": "Voz Google femenina (Estados Unidos)", + "name": "Google español de Estados Unidos", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "es-US", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Marisol", "name": "Marisol", @@ -902,38 +934,6 @@ "iPadOS" ] }, - { - "label": "Voz Google masculina (España)", - "name": "Google español", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "es-ES", - "gender": "male", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, - { - "label": "Voz Google femenina (Estados Unidos)", - "name": "Google español de Estados Unidos", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "es-US", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Helena", "name": "Microsoft Helena - Spanish (Spain)", diff --git a/json/fr.json b/json/fr.json index 763811a..117491c 100644 --- a/json/fr.json +++ b/json/fr.json @@ -207,6 +207,22 @@ ], "preloaded": true }, + { + "label": "Voix Google féminine (France)", + "name": "Google français", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "fr-FR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Audrey", "name": "Audrey", @@ -355,22 +371,6 @@ "iPadOS" ] }, - { - "label": "Voix Google féminine (France)", - "name": "Google français", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "fr-FR", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Julie", "name": "Microsoft Julie - French (France)", diff --git a/json/hi.json b/json/hi.json index 6c9e369..1216bcc 100644 --- a/json/hi.json +++ b/json/hi.json @@ -33,6 +33,22 @@ ], "preloaded": true }, + { + "label": "महिला Google आवाज़", + "name": "Google हिन्दी", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "hi-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Kiyara", "name": "Kiyara", @@ -89,22 +105,6 @@ "iPadOS" ] }, - { - "label": "महिला Google आवाज़", - "name": "Google हिन्दी", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "hi-IN", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Kalpana", "name": "Microsoft Kalpana - Hindi (India)", diff --git a/json/id.json b/json/id.json index 23a49eb..3286983 100644 --- a/json/id.json +++ b/json/id.json @@ -34,37 +34,37 @@ "preloaded": true }, { - "label": "Damayanti", - "name": "Damayanti", - "localizedName": "apple", + "label": "Suara Google wanita", + "name": "Google Bahasa Indonesia", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", "language": "id-ID", "gender": "female", "quality": [ - "low", - "normal" + "high" ], "rate": 1, "pitch": 1, - "os": [ - "macOS", - "iOS", - "iPadOS" + "browser": [ + "ChromeDesktop" ], "preloaded": true }, { - "label": "Suara Google wanita", - "name": "Google Bahasa Indonesia", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "label": "Damayanti", + "name": "Damayanti", + "localizedName": "apple", "language": "id-ID", "gender": "female", "quality": [ - "high" + "low", + "normal" ], "rate": 1, "pitch": 1, - "browser": [ - "ChromeDesktop" + "os": [ + "macOS", + "iOS", + "iPadOS" ], "preloaded": true }, diff --git a/json/it.json b/json/it.json index 8026afd..7e9ef52 100644 --- a/json/it.json +++ b/json/it.json @@ -63,6 +63,22 @@ ], "preloaded": true }, + { + "label": "Voce Google femminile", + "name": "Google italiano", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "it-IT", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Federica", "name": "Federica", @@ -156,22 +172,6 @@ "iPadOS" ] }, - { - "label": "Voce Google femminile", - "name": "Google italiano", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "it-IT", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Elsa", "name": "Microsoft Elsa - Italian (Italy)", diff --git a/json/ja.json b/json/ja.json index 8ef8ac3..45bf3b7 100644 --- a/json/ja.json +++ b/json/ja.json @@ -33,6 +33,22 @@ ], "preloaded": true }, + { + "label": "Google の女性の声", + "name": "Google 日本語", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "ja-JP", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "O-Ren", "name": "O-Ren", @@ -107,22 +123,6 @@ "iPadOS" ] }, - { - "label": "Google の女性の声", - "name": "Google 日本語", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "ja-JP", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Ayumi", "name": "Microsoft Ayumi - Japanese (Japan)", diff --git a/json/ko.json b/json/ko.json index ffb5e21..d8656f8 100644 --- a/json/ko.json +++ b/json/ko.json @@ -51,6 +51,22 @@ ], "preloaded": true }, + { + "label": "Google 여성 음성", + "name": "Google 한국의", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "ko-KR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Yuna", "name": "Yuna", @@ -144,22 +160,6 @@ "iPadOS" ] }, - { - "label": "Google 여성 음성", - "name": "Google 한국의", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "ko-KR", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Heami", "name": "Microsoft Heami - Korean (Korea)", diff --git a/json/nl.json b/json/nl.json index 55792af..5802ca6 100644 --- a/json/nl.json +++ b/json/nl.json @@ -93,6 +93,22 @@ ], "preloaded": true }, + { + "label": "Google mannelijke stem", + "name": "Google Nederlands", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "nl-NL", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Claire", "name": "Claire", @@ -149,22 +165,6 @@ ], "preloaded": true }, - { - "label": "Google mannelijke stem", - "name": "Google Nederlands", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "nl-NL", - "gender": "male", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Frank", "name": "Microsoft Frank - Dutch (Netherlands)", diff --git a/json/pl.json b/json/pl.json index 9389925..10d7dcc 100644 --- a/json/pl.json +++ b/json/pl.json @@ -48,6 +48,22 @@ ], "preloaded": true }, + { + "label": "Żeński głos Google’a", + "name": "Google polski", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "pl-PL", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Ewa", "name": "Ewa", @@ -104,22 +120,6 @@ "iPadOS" ] }, - { - "label": "Żeński głos Google’a", - "name": "Google polski", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "pl-PL", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Paulina", "name": "Microsoft Paulina - Polish (Poland)", diff --git a/json/pt.json b/json/pt.json index 46fd004..3012209 100644 --- a/json/pt.json +++ b/json/pt.json @@ -78,6 +78,22 @@ ], "preloaded": true }, + { + "label": "Voz feminina do Google", + "name": "Google português do Brasil", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "pt-BR", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Catarina", "name": "Catarina", @@ -188,22 +204,6 @@ "iPadOS" ] }, - { - "label": "Voz feminina do Google", - "name": "Google português do Brasil", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "pt-BR", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Helia", "name": "Microsoft Helia - Portuguese (Portugal)", diff --git a/json/ru.json b/json/ru.json index 299470c..c0cd490 100644 --- a/json/ru.json +++ b/json/ru.json @@ -48,6 +48,22 @@ ], "preloaded": true }, + { + "label": "Google женский голос", + "name": "Google русский", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "ru-RU", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Katya", "name": "Katya", @@ -103,23 +119,6 @@ "iPadOS" ] }, - { - "label": "Google женский голос", - "name": "Google русский", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "ru-RU", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, - { "label": "Irina", "name": "Microsoft Irina - Russian (Russian)", diff --git a/json/yue.json b/json/yue.json index 4d85e63..81edc40 100644 --- a/json/yue.json +++ b/json/yue.json @@ -51,6 +51,23 @@ ], "preloaded": true }, + { + "label": "Google 女聲", + "name": "Google 粤語(香港)", + "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", + "language": "yue-HK", + "altLanguage": "zh-HK", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "browser": [ + "ChromeDesktop" + ], + "preloaded": true + }, { "label": "Sinji", "name": "Sinji", @@ -91,23 +108,6 @@ ], "preloaded": true }, - { - "label": "Google 女聲", - "name": "Google 粤語(香港)", - "note": "This voice is pre-loaded in Chrome on desktop. Utterances that are longer than 14 seconds long can trigger a bug with this voice, check the notes in the project's README for more information.", - "language": "yue-HK", - "altLanguage": "zh-HK", - "gender": "female", - "quality": [ - "high" - ], - "rate": 1, - "pitch": 1, - "browser": [ - "ChromeDesktop" - ], - "preloaded": true - }, { "label": "Tracy", "name": "Microsoft Tracy - Chinese (Traditional, Hong Kong S.A.R.)", From 9192962c1eff5c82b32086bd0d6018aaba132128 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Fri, 19 Dec 2025 15:34:42 +0100 Subject: [PATCH 11/32] Lang count (#31) * Update voice count on filter * Apply default voice on filtering --- demo/script.js | 273 +++++++++++++++++++++++++++++++------------------ 1 file changed, 174 insertions(+), 99 deletions(-) diff --git a/demo/script.js b/demo/script.js index 97aaba7..b0dab47 100644 --- a/demo/script.js +++ b/demo/script.js @@ -36,29 +36,29 @@ let currentVoice = null; let testUtterance = ""; let lastNavigatorPosition = 1; -const navigator = new WebSpeechReadAloudNavigator(); +const speechNavigator = new WebSpeechReadAloudNavigator(); // Set up event listeners for the navigator -navigator.on("boundary", (event) => { +speechNavigator.on("boundary", (event) => { if (event.detail && event.detail.name === "word") { highlightCurrentWord(event.detail.charIndex, event.detail.charLength); } }); -navigator.on("start", () => { +speechNavigator.on("start", () => { clearWordHighlighting(); updateUI(); }); -navigator.on("pause", updateUI); -navigator.on("resume", updateUI); -navigator.on("stop", () => { +speechNavigator.on("pause", updateUI); +speechNavigator.on("resume", updateUI); +speechNavigator.on("stop", () => { clearWordHighlighting(); updateUI(); }); -navigator.on("end", updateUI); -navigator.on("error", (event) => { +speechNavigator.on("end", updateUI); +speechNavigator.on("error", (event) => { console.error("Navigator error:", event.detail); updateUI(); }); @@ -128,6 +128,134 @@ function populateLanguageDropdown() { }); } +// Update language counts based on filtered voices +function updateLanguageCounts(voices) { + // Create a map to count voices per language from the provided voices + const languageCounts = new Map(); + + voices.forEach(voice => { + const langCode = voice.language.split("-")[0]; // Get base language code + languageCounts.set(langCode, (languageCounts.get(langCode) || 0) + 1); + }); + + // Update the languages array with new counts + languages = languages.map(lang => ({ + ...lang, + count: languageCounts.get(lang.code) || 0 + })); + + // Update the dropdown text without losing selection + const options = languageSelect.querySelectorAll("option"); + + options.forEach(option => { + if (option.value) { + const lang = languages.find(l => l.code === option.value); + if (lang) { + option.textContent = `${lang.label} (${lang.count})`; + } + } + }); +} + +/** + * Format a value for display in the voice properties + */ +function formatValue(value) { + if (value === undefined || value === null) { + return { display: "undefined", className: "undefined" }; + } + + if (typeof value === "boolean") { + return { + display: value ? "true" : "false", + className: `boolean-${value}` + }; + } + + if (Array.isArray(value)) { + return { + display: value.length > 0 ? value.join(", ") : "[]", + className: "" + }; + } + + if (typeof value === "object") { + return { + display: JSON.stringify(value, null, 2).replace(/"/g, ""), + className: "object-value" + }; + } + + return { display: String(value), className: "" }; +} + +/** + * Display voice properties in the UI + */ +function displayVoiceProperties(voice) { + const propertiesContainer = document.getElementById("voice-properties"); + + if (!voice) { + propertiesContainer.innerHTML = "

No voice selected

"; + return; + } + + // Sort properties alphabetically + const sortedProps = Object.keys(voice).sort(); + + // Create HTML for each property + const propertiesHtml = sortedProps.map(prop => { + // Skip internal/private properties that start with underscore + if (prop.startsWith("_")) return ""; + + const value = voice[prop]; + const { display, className } = formatValue(value); + + return ` +
+
${prop}
+
${display}
+
+ `; + }).join(""); + + propertiesContainer.innerHTML = propertiesHtml || "

No properties available

"; +} + +// Replace current voice with a new default voice if it gets filtered out +function replaceCurrentVoiceIfFilteredOut(language) { + const currentVoiceFilteredOut = currentVoice && !filteredVoices.some(voice => voice.voiceURI === currentVoice.voiceURI); + const needNewVoice = !currentVoice && filteredVoices.length > 0; + + if (currentVoiceFilteredOut || needNewVoice) { + // Current voice was filtered out or no voice selected, pick a new default voice based on language + if (filteredVoices.length > 0) { + currentVoice = voiceManager.getDefaultVoice(language, filteredVoices); + + if (currentVoice) { + try { + speechNavigator.setVoice(currentVoice); + displayVoiceProperties(currentVoice); + updateTestUtterance(currentVoice, language); + + // Update dropdown to select the new voice + const voiceOption = voiceSelect.querySelector(`option[value="${currentVoice.name}"]`); + if (voiceOption) { + voiceOption.selected = true; + } + } catch (error) { + console.error("Error setting new default voice:", error); + } + } + } else { + // No voices available after filtering + currentVoice = null; + displayVoiceProperties(null); + updateTestUtterance(null, language); + } + } +} + // Filter voices based on current filters function filterVoices() { const language = languageSelect.value; @@ -137,10 +265,6 @@ function filterVoices() { const filterOptions = {}; - if (language) { - filterOptions.language = language; - } - if (gender !== "all") { filterOptions.gender = gender; } @@ -153,15 +277,31 @@ function filterVoices() { filterOptions.offlineOnly = true; } - // Apply filters - filteredVoices = voiceManager.filterVoices(allVoices, filterOptions); + // Filter voices once with all filters except language + let voicesFilteredExceptLanguage = voiceManager.filterVoices(allVoices, filterOptions); + + // Update language counts using the filtered voices + updateLanguageCounts(voicesFilteredExceptLanguage); + + // Now apply language filter if needed + if (language) { + filterOptions.language = language; + filteredVoices = voiceManager.filterVoices(voicesFilteredExceptLanguage, { language }); + } else { + filteredVoices = voicesFilteredExceptLanguage; + } // Sort voices by quality (highest first) filteredVoices = voiceManager.sortVoices(filteredVoices, { by: "quality", order: "desc" }); + populateVoiceDropdown(language); + + // Replace current voice if it was filtered out + replaceCurrentVoiceIfFilteredOut(language); + updateUI(); } @@ -361,7 +501,7 @@ async function loadSampleText(languageCode) { sampleTextDisplay.appendChild(demoSection); // Load utterances into the navigator - await navigator.loadContent(utterances); + await speechNavigator.loadContent(utterances); // Update total utterances display const totalUtterancesSpan = document.getElementById("total-utterances"); @@ -454,7 +594,7 @@ function setupEventListeners() { if (currentVoice) { try { // Set the voice for the navigator - navigator.setVoice(currentVoice); + speechNavigator.setVoice(currentVoice); // Update the voice dropdown to reflect the selected voice const voiceOption = voiceSelect.querySelector(`option[value="${currentVoice.name}"]`); @@ -481,71 +621,6 @@ function setupEventListeners() { updateUI(); }); - /** - * Format a value for display in the voice properties - */ -function formatValue(value) { - if (value === undefined || value === null) { - return { display: "undefined", className: "undefined" }; - } - - if (typeof value === "boolean") { - return { - display: value ? "true" : "false", - className: `boolean-${value}` - }; - } - - if (Array.isArray(value)) { - return { - display: value.length > 0 ? value.join(", ") : "[]", - className: "" - }; - } - - if (typeof value === "object") { - return { - display: JSON.stringify(value, null, 2).replace(/"/g, ""), - className: "object-value" - }; - } - - return { display: String(value), className: "" }; -} - -/** - * Display voice properties in the UI - */ -function displayVoiceProperties(voice) { - const propertiesContainer = document.getElementById("voice-properties"); - - if (!voice) { - propertiesContainer.innerHTML = "

No voice selected

"; - return; - } - - // Sort properties alphabetically - const sortedProps = Object.keys(voice).sort(); - - // Create HTML for each property - const propertiesHtml = sortedProps.map(prop => { - // Skip internal/private properties that start with underscore - if (prop.startsWith("_")) return ""; - - const value = voice[prop]; - const { display, className } = formatValue(value); - - return ` -
-
${prop}
-
${display}
-
- `; - }).join(""); - - propertiesContainer.innerHTML = propertiesHtml || "

No properties available

"; -} - // Voice selection voiceSelect.addEventListener("change", async () => { const selectedVoiceName = voiceSelect.value; @@ -554,7 +629,7 @@ function displayVoiceProperties(voice) { if (currentVoice) { try { // Set the voice for the navigator - navigator.setVoice(currentVoice); + speechNavigator.setVoice(currentVoice); // Display voice properties displayVoiceProperties(currentVoice); @@ -643,8 +718,8 @@ async function playTestUtterance() { try { // Reset playback controls first - if (navigator) { - navigator.stop(); + if (speechNavigator) { + speechNavigator.stop(); } // Get test utterance for the selected language @@ -699,16 +774,16 @@ async function togglePlayback() { } try { - const state = navigator.getState(); + const state = speechNavigator.getState(); if (state === "playing") { - await navigator.pause(); + await speechNavigator.pause(); } else if (state === "paused") { // Use play() to resume from paused state - await navigator.play(); + await speechNavigator.play(); } else { // Start from beginning if stopped or in an unknown state - await navigator.jumpTo(0); - await navigator.play(); + await speechNavigator.jumpTo(0); + await speechNavigator.play(); } } catch (error) { console.error("Error toggling playback:", error); @@ -721,7 +796,7 @@ async function togglePlayback() { // Stop sample playback async function stopPlayback() { try { - await navigator.stop(); + await speechNavigator.stop(); clearWordHighlighting(); playPauseBtn.textContent = "Play Sample"; updateUI(); @@ -732,26 +807,26 @@ async function stopPlayback() { // Go to previous utterance async function previousUtterance() { - await navigator.previous(); + await speechNavigator.previous(); updateUI(); } // Go to next utterance async function nextUtterance() { - await navigator.next(); + await speechNavigator.next(); updateUI(); } // Jump to a specific utterance function jumpToUtterance() { - const totalUtterances = navigator.getContentQueue()?.length || 0; + const totalUtterances = speechNavigator.getContentQueue()?.length || 0; // Ensure we have a valid input value const index = Math.max(0, Math.min(parseInt(utteranceIndexInput.value) - 1, totalUtterances - 1)); if (!isNaN(index) && index >= 0 && index < totalUtterances) { clearWordHighlighting(); - navigator.jumpTo(index); + speechNavigator.jumpTo(index); // Update UI to reflect the new position if (utteranceIndexInput) { @@ -771,7 +846,7 @@ function jumpToUtterance() { utteranceIndexInput.value = lastNavigatorPosition; } else { // Invalid input, reset to current position - const currentPos = (navigator.getCurrentUtteranceIndex() || 0) + 1; + const currentPos = (speechNavigator.getCurrentUtteranceIndex() || 0) + 1; utteranceIndexInput.value = currentPos; jumpInputUserChanged = false; lastNavigatorPosition = currentPos; @@ -796,7 +871,7 @@ function highlightCurrentWord(charIndex, charLength) { clearWordHighlighting(); // Get the current utterance element - const currentIndex = navigator.getCurrentUtteranceIndex(); + const currentIndex = speechNavigator.getCurrentUtteranceIndex(); const utteranceElement = document.querySelector(`.utterance[data-utterance-index="${currentIndex}"] .utterance-text`); if (!utteranceElement) return; @@ -830,9 +905,9 @@ function highlightCurrentWord(charIndex, charLength) { // Update UI based on current state function updateUI() { try { - const state = navigator.getState(); - const currentIndex = navigator.getCurrentUtteranceIndex() || 0; - const totalUtterances = navigator.getContentQueue()?.length || 0; + const state = speechNavigator.getState(); + const currentIndex = speechNavigator.getCurrentUtteranceIndex() || 0; + const totalUtterances = speechNavigator.getContentQueue()?.length || 0; const hasContent = totalUtterances > 0; // Update playback controls From c9589e18ea16ed40f234126d5d429115aae00ed0 Mon Sep 17 00:00:00 2001 From: Hadrien Gardeur Date: Fri, 19 Dec 2025 16:32:19 +0100 Subject: [PATCH 12/32] New voices for iOS/iPadOS/macOS 26 (#29) * Added new Siri voices detected in Firefox * Added Microsoft Edge voices for Kazakh --- docs/VoicesAndFiltering.md | 1 + json/de.json | 4 +-- json/en.json | 40 ++++++++++++++++++++++++++ json/es.json | 7 +++-- json/ja.json | 38 +++++++++++++------------ json/kk.json | 57 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 22 deletions(-) create mode 100644 json/kk.json diff --git a/docs/VoicesAndFiltering.md b/docs/VoicesAndFiltering.md index 9485147..4c1604b 100644 --- a/docs/VoicesAndFiltering.md +++ b/docs/VoicesAndFiltering.md @@ -46,6 +46,7 @@ In its current state, it covers 43 languages: * [Italian](../json/it.json) * [Japanese](../json/ja.json) * [Kannada](../json/kn.json) +* [Kazakh](../json/kk.json) * [Korean](../json/ko.json) * [Malay](../json/ms.json) * [Marathi](../json/mr.json) diff --git a/json/de.json b/json/de.json index 4eb4e36..f77ff4d 100644 --- a/json/de.json +++ b/json/de.json @@ -156,8 +156,8 @@ "preloaded": true }, { - "label": "Google Deutsch", - "name": "Weibliche Google-Stimme (Deutschland)", + "label": "Weibliche Google-Stimme", + "name": "Google Deutsch", "language": "de-DE", "gender": "female", "quality": [ diff --git a/json/en.json b/json/en.json index 42b8931..0c76525 100644 --- a/json/en.json +++ b/json/en.json @@ -1184,6 +1184,46 @@ ], "preloaded": true }, + { + "label": "Aman", + "name": "Aman", + "nativeID": [ + "com.apple.voice.Aman" + ], + "localizedName": "apple", + "language": "en-IN", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, + { + "label": "Tara", + "name": "Tara", + "nativeID": [ + "com.apple.voice.Tara" + ], + "localizedName": "apple", + "language": "en-IN", + "gender": "female", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, { "label": "Isha", "name": "Isha", diff --git a/json/es.json b/json/es.json index cb032cb..2460e59 100644 --- a/json/es.json +++ b/json/es.json @@ -863,8 +863,11 @@ ] }, { - "label": "Francisca", - "name": "Francisca", + "label": "Francesca", + "name": "Francesca", + "nativeID": [ + "com.apple.voice.enhanced.es-CL.Francisca" + ], "localizedName": "apple", "language": "es-CL", "gender": "female", diff --git a/json/ja.json b/json/ja.json index 45bf3b7..c453e87 100644 --- a/json/ja.json +++ b/json/ja.json @@ -33,6 +33,26 @@ ], "preloaded": true }, + { + "label": "Hattori", + "name": "Hattori", + "nativeID": [ + "com.apple.ttsbundle.siri_Hattori_ja-JP_premium" + ], + "localizedName": "apple", + "language": "ja-JP", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + }, { "label": "Google の女性の声", "name": "Google 日本語", @@ -105,24 +125,6 @@ "iPadOS" ] }, - { - "label": "Hattori", - "name": "Hattori", - "localizedName": "apple", - "language": "ja-JP", - "gender": "male", - "quality": [ - "low", - "normal" - ], - "rate": 1, - "pitch": 1, - "os": [ - "macOS", - "iOS", - "iPadOS" - ] - }, { "label": "Ayumi", "name": "Microsoft Ayumi - Japanese (Japan)", diff --git a/json/kk.json b/json/kk.json new file mode 100644 index 0000000..3a66682 --- /dev/null +++ b/json/kk.json @@ -0,0 +1,57 @@ +{ + "language": "kk", + "defaultRegion": "kk-KZ", + "testUtterance": "Sälemetsiz be, meniñ atım {name} jäne men qazaq dawısımın.", + "voices": [ + { + "label": "Aigul", + "name": "Microsoft Aigul Online (Natural) - Kazakh (Kazakhstan)", + "language": "kk-KZ", + "gender": "female", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Daulet", + "name": "Microsoft Daulet Online (Natural) - Kazakh (Kazakhstan)", + "language": "kk-KZ", + "gender": "male", + "quality": [ + "veryHigh" + ], + "rate": 1, + "pitchControl": false, + "browser": [ + "Edge" + ], + "preloaded": true + }, + { + "label": "Aru", + "name": "Aru", + "nativeID": [ + "com.apple.voice.Aru" + ], + "localizedName": "apple", + "language": "kk-KZ", + "gender": "male", + "quality": [ + "high" + ], + "rate": 1, + "pitch": 1, + "os": [ + "macOS", + "iOS", + "iPadOS" + ] + } + ] +} \ No newline at end of file From 8b1af2c2da5367e502c6f5aef673cbb89c1e9179 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Fri, 19 Dec 2025 16:51:35 +0100 Subject: [PATCH 13/32] Enable native id kazakh (#32) * Enable kazakh language * Add Kazakh language sample * Infer Quality from nativeID * Add tests for nativeID --- demo/sampleText.json | 4 +++ src/WebSpeech/WebSpeechVoiceManager.ts | 14 +++++++-- src/voices/languages.ts | 3 +- test/WebSpeechVoiceManager.test.ts | 39 ++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/demo/sampleText.json b/demo/sampleText.json index bc84135..ff84765 100644 --- a/demo/sampleText.json +++ b/demo/sampleText.json @@ -95,6 +95,10 @@ "language": "Japanese", "text": "アリスは川岸で姉のそばに座っているのにとても疲れ始めていた。何度か姉が読んでいる本を覗き込んだが、そこには絵も会話もなかった。『絵も会話もない本なんて、何の役に立つのだろう』とアリスは思った。そこで彼女は、デイジーチェーンを作る楽しみが立ち上がって花を摘む手間に見合うかどうか、自分の心の中で考えていた。突然、ピンクの目をした白ウサギが彼女のそばを走り抜けた。それに特に驚くことはなかった;アリスもウサギが自分自身に『ああ、しまった!ああ、しまった!遅れる!』と言うのを聞いても特に変だとは思わなかった。しかし、ウサギが実際にチョッキのポケットから時計を取り出して見たとき、アリスは飛び上がった。" }, + "kk": { + "language": "Kazakh", + "text": "Міне, Алисa әпкесімен бірге өзен жағасында отырып, қатты жалыға бастады. Бір-екі рет әпкесінің оқып отырған кітабына көз жүгіртіп еді, бірақ онда суреттер де, диалогтар да жоқ екен. «Суреттері де, әңгімелері де жоқ кітаптың қандай пайдасы бар?» — деп ойлады Алиса. Сөйтіп ол орнынан тұрып, гүл теріп, маржан гүлдерінен тізбек жасаудың ләззаты оған тұру мен әуре-сарсаңға тұрарлық па, жоқ па — соны іштей ой елегінен өткізіп отырды. Дәл сол сәтте кенет қасынан қызғылт көзді Ақ Қоян жүгіріп өтті. Мұның өзі аса таңғаларлық емес еді; қоянның өз-өзіне: «Ой, тоба-ай! Ой, тоба-ай! Кешігіп қаламын!» — деп сөйлегенін есту де Алисаға тым оғаш көрінбеді. Бірақ Қоян шынымен-ақ жилетінің қалтасынан сағат шығарып, оған қарағанда, Алиса орнынан атып тұрды. Ол бұрын-соңды қалтасы бар жилет киген, не қалтасынан сағат алып қарайтын қоянды көрмеген еді." + }, "kn": { "language": "Kannada", "text": "ಆಲಿಸ್ ತನ್ನ ಸಹೋದರಿಯ ಬಳಿಯಲ್ಲಿ ತೀರದಲ್ಲಿ ಕೂತಿರುವುದರಿಂದ ತುಂಬಾ ಥಕತಿಹೋಗುತ್ತಿದ್ದುದು ಪ್ರಾರಂಭಿಸಿತು. ಒಮ್ಮೆ ಅಥವಾ ಎರಡು ಬಾರಿ ಅವಳು ತನ್ನ ಸಹೋದರಿ ಓದುತ್ತಿದ್ದ ಪುಸ್ತಕವನ್ನು ನೋಡಿದಳು, ಆದರೆ ಅದರಲ್ಲಿ ಚಿತ್ರಗಳು ಅಥವಾ ಸಂಭಾಷಣೆಗಳೇ ಇರಲಿಲ್ಲ. 'ಚಿತ್ರಗಳು ಅಥವಾ ಸಂಭಾಷಣೆ ಇಲ್ಲದ ಪುಸ್ತಕದ ಉಪಯೋಗವೇನು,' ಎಂದು ಆಲಿಸ್ ಚಿಂತನೆ ಮಾಡಿದರು. ಆದ್ದರಿಂದ ದೇಸಿ-ಚೈನ್ ಮಾಡುವ ಸಂತೋಷವು ಎದ್ದುಕೊಂಡು ಹೂವುಗಳನ್ನು ತೂಗುವ ಕಷ್ಟಕ್ಕೆ ಸಮರ್ಥವಾಗುವುದೇ ಎಂದು ಅವಳು ತನ್ನ ಮನಸ್ಸಿನಲ್ಲಿ ಪರಿಗಣಿಸುತ್ತಿದ್ದಳು. ಹಠಾತ್ತಾಗಿ, ಪಿಂಕ್ ಕಣ್ಣುಳ್ಳ ಬಿಳಿ ಮೊಲ ಅವಳ ಹತ್ತಿರ ಓಡಿತು. ಅದರಲ್ಲಿ ಯಾವುದೇ ವಿಶೇಷವಾದ ಅಚ್ಚರಿ ಏನೂ ಇರಲಿಲ್ಲ; ಆಲಿಸಿಗೂ ಮೊಲವು ತನ್ನನ್ನೇ 'ಅಯ್ಯೋ! ಅಯ್ಯೋ! ನಾನು ತಡವಾಗಿ ಆಗುತ್ತೇನೆ!' ಎಂದು ಹೇಳುತ್ತಿರುವುದು ಅನ್ಯಾಯವೆಂದು ಕಂಡಿಲ್ಲ. ಆದರೆ ಮೊಲವು ವಾಸ್ಟ್ಕೋಟ್ ಜೇಬಿನಿಂದ ಘಡಿಯನ್ನು ತೆಗೆದು ಅದನ್ನು ನೋಡಿದಾಗ, ಆಲಿಸ್ ತನ್ನ ಪಾದಗಳ ಮೇಲೆ ಜಿಗಿಯಿತು." diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index c7fe3d0..3b1b4bf 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -190,7 +190,15 @@ export class WebSpeechVoiceManager { const packageQuality = voice.voiceURI ? getInferredQualityFromPackageName(voice.voiceURI) : undefined; if (packageQuality) return packageQuality; - // 2. Try platform (localized names) - only if jsonVoice is defined + // 2. Try jsonVoice nativeID against package names + if (jsonVoice?.nativeID && Array.isArray(jsonVoice.nativeID)) { + for (const nativeId of jsonVoice.nativeID) { + const nativeIdQuality = getInferredQualityFromPackageName(nativeId); + if (nativeIdQuality) return nativeIdQuality; + } + } + + // 3. Try platform (localized names) - only if jsonVoice is defined if (jsonVoice?.localizedName && voice.voiceURI && voice.lang) { const platformQuality = getInferredQualityFromPlatform( voice.voiceURI, @@ -200,7 +208,7 @@ export class WebSpeechVoiceManager { if (platformQuality) return platformQuality; } - // 3. Use the jsonVoice.quality array if available + // 4. Use the jsonVoice.quality array if available if (jsonVoice?.quality && jsonVoice.quality.length > 0) { const qualityIndex = Math.min(duplicatesCount - 1, jsonVoice.quality.length - 1); const quality = jsonVoice.quality[qualityIndex]; @@ -209,7 +217,7 @@ export class WebSpeechVoiceManager { } } - // 4. If we can't determine the quality, return null + // 5. If we can't determine the quality, return null return null; } diff --git a/src/voices/languages.ts b/src/voices/languages.ts index f747b83..4b72a9e 100644 --- a/src/voices/languages.ts +++ b/src/voices/languages.ts @@ -26,6 +26,7 @@ import hu from "@json/hu.json"; import id from "@json/id.json"; import it from "@json/it.json"; import ja from "@json/ja.json"; +import kk from "@json/kk.json"; import kn from "@json/kn.json"; import ko from "@json/ko.json"; import mr from "@json/mr.json"; @@ -69,7 +70,7 @@ const castVoice = (voice: any): ReadiumSpeechVoice => ({ const voiceDataMap: Record = Object.fromEntries( Object.entries({ ar, bg, bho, bn, ca, cmn, cs, da, de, el, en, es, eu, fa, fi, fr, gl, he, hi, - hr, hu, id, it, ja, kn, ko, mr, ms, nb, nl, pl, pt, ro, ru, sk, sl, sv, ta, + hr, hu, id, it, ja, kk, kn, ko, mr, ms, nb, nl, pl, pt, ro, ru, sk, sl, sv, ta, te, th, tr, uk, vi, wuu, yue }).map(([lang, data]) => [ lang, diff --git a/test/WebSpeechVoiceManager.test.ts b/test/WebSpeechVoiceManager.test.ts index 709bef8..e3d5ace 100644 --- a/test/WebSpeechVoiceManager.test.ts +++ b/test/WebSpeechVoiceManager.test.ts @@ -305,6 +305,45 @@ testWithContext("deduplication: keeps higher quality voice from json quality arr t.deepEqual(deduped[0].quality, "normal", "Should find the voice with normal quality from the array"); }); +testWithContext("quality inference: infers quality from nativeID when voiceURI has no indicators", (t) => { + const manager = t.context.manager; + + // Test Francesca voice from es.json which has nativeID with "enhanced" + // Use plain voiceURI to force nativeID quality inference + const testVoice = { + voiceURI: "plain.voice.uri", // No package indicators + name: "Francesca", // Must match the JSON voice name exactly + lang: "es-CL", // Must match the JSON voice language + localService: true, + default: false + }; + + // Parse the voice - it should find Francesca in es.json and infer quality from nativeID + const voices = (manager as any).parseToReadiumSpeechVoices([testVoice]); + + // Should infer "normal" quality from "enhanced" in nativeID array + t.is(voices[0].quality, "normal", "Should infer 'normal' quality from 'enhanced' in Francesca's nativeID"); +}); + +testWithContext("quality inference: voiceURI takes precedence over nativeID", (t) => { + const manager = t.context.manager; + + // Test Francesca voice with compact in voiceURI (should take precedence over nativeID enhanced) + const testVoice = { + voiceURI: "com.apple.speech.synthesis.voice.compact.Francesca", // compact should infer "low" + name: "Francesca", // Must match the JSON voice name exactly + lang: "es-CL", // Must match the JSON voice language + localService: true, + default: false + }; + + // Parse the voice - it should find Francesca but use voiceURI quality (takes precedence) + const voices = (manager as any).parseToReadiumSpeechVoices([testVoice]); + + // Should infer "low" from voiceURI, not "normal" from nativeID (voiceURI takes precedence) + t.is(voices[0].quality, "low", "Should infer 'low' quality from voiceURI, not 'normal' from nativeID"); +}); + // ============================================= // 2. Voice Retrieval Tests // ============================================= From abebd5209951abc9b3e8b013046a912ad8f7e1cd Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Fri, 19 Dec 2025 17:02:00 +0100 Subject: [PATCH 14/32] Localisation heuristics (#30) * Init heuristics for inferring system locale * Revamp tests --- package.json | 4 +- src/WebSpeech/WebSpeechVoiceManager.ts | 28 +- src/voices/localized.ts | 27 +- test/WebSpeechVoiceManager.test.ts | 1606 ----------------- .../convertToSpeechSynthesisVoice.test.ts | 132 ++ .../filterVoices.test.ts | 292 +++ .../getDefaultVoice.test.ts | 187 ++ .../getLanguages.test.ts | 148 ++ test/WebSpeechVoiceManager/getRegions.test.ts | 67 + .../getTestUtterance.test.ts | 49 + test/WebSpeechVoiceManager/getVoices.test.ts | 214 +++ .../WebSpeechVoiceManager/groupVoices.test.ts | 136 ++ .../initialization.test.ts | 187 ++ test/WebSpeechVoiceManager/setup.ts | 184 ++ test/WebSpeechVoiceManager/sortVoices.test.ts | 283 +++ .../systemLocale.test.ts | 129 ++ 16 files changed, 2062 insertions(+), 1611 deletions(-) delete mode 100644 test/WebSpeechVoiceManager.test.ts create mode 100644 test/WebSpeechVoiceManager/convertToSpeechSynthesisVoice.test.ts create mode 100644 test/WebSpeechVoiceManager/filterVoices.test.ts create mode 100644 test/WebSpeechVoiceManager/getDefaultVoice.test.ts create mode 100644 test/WebSpeechVoiceManager/getLanguages.test.ts create mode 100644 test/WebSpeechVoiceManager/getRegions.test.ts create mode 100644 test/WebSpeechVoiceManager/getTestUtterance.test.ts create mode 100644 test/WebSpeechVoiceManager/getVoices.test.ts create mode 100644 test/WebSpeechVoiceManager/groupVoices.test.ts create mode 100644 test/WebSpeechVoiceManager/initialization.test.ts create mode 100644 test/WebSpeechVoiceManager/setup.ts create mode 100644 test/WebSpeechVoiceManager/sortVoices.test.ts create mode 100644 test/WebSpeechVoiceManager/systemLocale.test.ts diff --git a/package.json b/package.json index 4081291..00e0d1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@readium/speech", - "version": "0.1.0-beta.2", + "version": "0.1.0-beta.3", "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.", "main": "./build/index.cjs", "module": "./build/index.js", @@ -18,7 +18,7 @@ } }, "scripts": { - "test": "npm run build && ava test/WebSpeechVoiceManager.test.ts", + "test": "npm run build && NODE_NO_WARNINGS=1 ava test/**/*.test.ts", "clean": "rimraf ./build", "build": "vite build", "start": "node build/index.js", diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index 3b1b4bf..22bc118 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -6,7 +6,7 @@ import { filterOutNoveltyVoices, filterOutVeryLowQualityVoices } from "../voices/filters"; -import { getInferredQualityFromPlatform } from "../voices/localized"; +import { findLocaleWithQualityIndicators, getInferredQualityFromPlatform } from "../voices/localized"; import { getInferredQualityFromPackageName } from "../voices/packages"; import { extractLangRegionFromBCP47 } from "../utils/language"; @@ -65,6 +65,7 @@ interface SortOptions { export class WebSpeechVoiceManager { private static instance: WebSpeechVoiceManager; private static initializationPromise: Promise | null = null; + private systemLocale: string; private voices: ReadiumSpeechVoice[] = []; private browserVoices: SpeechSynthesisVoice[] = []; private isInitialized = false; @@ -73,6 +74,7 @@ export class WebSpeechVoiceManager { if (typeof window === "undefined" || !window.speechSynthesis) { throw new Error("Web Speech API is not available in this environment"); } + this.systemLocale = navigator.languages?.[0]?.split("-")[0] || "en"; } /** @@ -103,6 +105,7 @@ export class WebSpeechVoiceManager { WebSpeechVoiceManager.instance = instance; instance.browserVoices = await instance.getBrowserVoices(maxTimeout, interval); + instance.updateSystemLocale(instance.browserVoices); instance.voices = await instance.parseToReadiumSpeechVoices(instance.browserVoices); instance.isInitialized = true; @@ -176,6 +179,27 @@ export class WebSpeechVoiceManager { return counts; } + /** + * Updates the system locale based on available voices by detecting quality indicators. + * The method extracts voice names and attempts to find a matching locale with both + * high and normal quality indicators. If found, updates the systemLocale property. + * + * @param voices - Array of SpeechSynthesisVoice objects to analyze for locale detection + * @returns void - Updates the systemLocale property if a matching locale is found + */ + private updateSystemLocale(voices: SpeechSynthesisVoice[]): void { + if (!voices?.length) return; + + // Get voice names for locale detection + const voiceNames = voices.map(v => v.name); + + // Try to find a locale with quality indicators + const detectedLocale = findLocaleWithQualityIndicators(voiceNames, "apple"); + if (detectedLocale) { + this.systemLocale = detectedLocale; + } + } + /** * Infer voice quality based on package, platform, JSON, or duplicate count * Returns null if quality cannot be determined @@ -202,7 +226,7 @@ export class WebSpeechVoiceManager { if (jsonVoice?.localizedName && voice.voiceURI && voice.lang) { const platformQuality = getInferredQualityFromPlatform( voice.voiceURI, - voice.lang, + this.systemLocale, jsonVoice.localizedName ); if (platformQuality) return platformQuality; diff --git a/src/voices/localized.ts b/src/voices/localized.ts index 457705f..c8660a9 100644 --- a/src/voices/localized.ts +++ b/src/voices/localized.ts @@ -50,4 +50,29 @@ export const getInferredQualityFromPlatform = ( } return undefined; -} \ No newline at end of file +} + +/** + * Finds a locale where both high and normal quality indicators are present in the voice names + * @param voiceNames Array of voice names to check against quality indicators + * @param platform The platform to check quality indicators for (e.g., "apple") + * @returns The first matching locale code or undefined if none found or platform not found + */ +export const findLocaleWithQualityIndicators = ( + voiceNames: string[], + platform: keyof typeof platformQualities +): string | undefined => { + const qualityMap = platformQualities[platform]; + if (!qualityMap) return undefined; + + for (const [lang, { high, normal }] of Object.entries(qualityMap)) { + const hasHigh = high && voiceNames.some(name => name.includes(high)); + const hasNormal = normal && voiceNames.some(name => name.includes(normal)); + + if (hasHigh && hasNormal) { + return lang; + } + } + + return undefined; +}; \ No newline at end of file diff --git a/test/WebSpeechVoiceManager.test.ts b/test/WebSpeechVoiceManager.test.ts deleted file mode 100644 index e3d5ace..0000000 --- a/test/WebSpeechVoiceManager.test.ts +++ /dev/null @@ -1,1606 +0,0 @@ -import test, { type ExecutionContext } from "ava"; -import { WebSpeechVoiceManager, ReadiumSpeechVoice } from "../build/index.js"; - -// ============================================= -// Mock Data and Helpers -// ============================================= - -// Mock DisplayNames for testing -class MockDisplayNames { - options: any; - constructor(_: any, options: any) { - this.options = options; - } - - of(code: string): string { - if (this.options.type === "language") { - return `${code.toUpperCase()}_LANG`; - } - if (this.options.type === "region") { - return `${code.toUpperCase()}_REGION`; - } - return code; - } - - static supportedLocalesOf(locales: string[]): string[] { - return locales; - } -} - -// Mock Intl.DisplayNames -if (typeof (globalThis as any).Intl === "undefined") { - (globalThis as any).Intl = {}; -} -(globalThis as any).Intl.DisplayNames = MockDisplayNames as any; - -interface TestContext { - manager: WebSpeechVoiceManager; -} - - -// ============================================= -// Test Data -// ============================================= - -// Mock voices for testing -const mockVoices = [ - { - voiceURI: "voice1", - name: "Voice 1", - lang: "en-US", - localService: true, - default: true - }, - { - voiceURI: "voice2", - name: "Voice 2", - lang: "fr-FR", - localService: true, - default: false - }, - { - voiceURI: "voice3", - name: "Voice 3", - lang: "es-ES", - localService: true, - default: false - }, - { - voiceURI: "voice4", - name: "Voice 4", - lang: "de-DE", - localService: true, - default: false - }, - { - voiceURI: "voice5", - name: "Voice 5", - lang: "it-IT", - localService: true, - default: false - } -]; - -// Store original globals -const originalNavigator = globalThis.navigator; -const originalSpeechSynthesis = globalThis.speechSynthesis; - -// ============================================= -// Test Setup -// ============================================= - -// Test context type and setup -type TestFn = (t: ExecutionContext) => void | Promise; -const testWithContext = test as unknown as { - (name: string, fn: TestFn): void; - afterEach: { - always: (fn: (t: ExecutionContext) => void | Promise) => void; - }; - beforeEach: (fn: (t: ExecutionContext) => void | Promise) => void; -}; - -// Helper function to create test voice objects that match ReadiumSpeechVoice interface -function createTestVoice(overrides: Partial = {}): ReadiumSpeechVoice { - return { - source: "json", - label: overrides.name || "Test Voice", - name: overrides.name || "Test Voice", - originalName: overrides.originalName || "Test Voice", - voiceURI: `voice-${overrides.name || "test"}`, - language: "en-US", - ...overrides - }; -} - -// Set up global mocks before any tests run -if (typeof globalThis.window === "undefined") { - (globalThis as any).window = globalThis; -} - -// Mock the global objects -Object.defineProperty(globalThis, "navigator", { - value: { - ...originalNavigator, - languages: ["en-US", "fr-FR"] - }, - configurable: true, - writable: true -}); - -// Create a mock speechSynthesis object that matches the browser's API -const mockSpeechSynthesis = { - getVoices: () => mockVoices, - onvoiceschanged: null as (() => void) | null, - addEventListener: function(event: string, callback: () => void) { - if (event === "voiceschanged") { - this.onvoiceschanged = callback; - } - }, - removeEventListener: function(event: string) { - }, - _triggerVoicesChanged: function() { - if (this.onvoiceschanged) { - this.onvoiceschanged(); - } - } -}; - -// Mock the window.speechSynthesis to return our mock voices -Object.defineProperty(globalThis.window, "speechSynthesis", { - value: mockSpeechSynthesis, - configurable: true, - writable: true -}); - -// ============================================= -// Test Hooks -// ============================================= - -testWithContext.beforeEach(async (t) => { - // Reset singleton instance - (WebSpeechVoiceManager as any).instance = undefined; - (WebSpeechVoiceManager as any).initializationPromise = null; - - // Initialize and store the manager - t.context.manager = await WebSpeechVoiceManager.initialize(); -}); - -testWithContext.afterEach.always((t: ExecutionContext) => { - // Clean up - (WebSpeechVoiceManager as any).instance = undefined; - - // Restore original globals - Object.defineProperty(globalThis, "navigator", { - value: originalNavigator, - configurable: true, - writable: true - }); - - Object.defineProperty(globalThis, "speechSynthesis", { - value: originalSpeechSynthesis, - configurable: true, - writable: true - }); -}); - -// ============================================= -// 1. Initialization Tests -// ============================================= - -testWithContext("initialize: returns singleton instance", async (t) => { - const instance1 = await WebSpeechVoiceManager.initialize(); - const instance2 = await WebSpeechVoiceManager.initialize(); - t.is(instance1, instance2); -}); - -testWithContext("initialize: loads voices and gets voices successfully", (t) => { - const manager = t.context.manager; - const voices = manager.getVoices(); - t.true(Array.isArray(voices)); - t.true(voices.length > 0); -}); - -testWithContext("deduplication: keeps higher quality voice from voiceURI package name", (t) => { - const manager = t.context.manager; - - // Define test voices once - const lowVoice = { - voiceURI: "com.apple.speech.synthesis.voice.compact.samantha", - name: "Samantha", - lang: "en-US", - localService: true, - default: false - }; - - const normalVoice = { - voiceURI: "com.apple.speech.synthesis.voice.enhanced.samantha", - name: "Samantha (enhanced)", - lang: "en-US", - localService: true, - default: false - }; - - // 1. First parse separately to verify individual qualities - const lowQualityVoice = (manager as any).parseToReadiumSpeechVoices([lowVoice])[0]; - const normalQualityVoice = (manager as any).parseToReadiumSpeechVoices([normalVoice])[0]; - - // Verify individual qualities - t.is(lowQualityVoice.quality, "low", "Low quality voice should have low quality"); - t.is(normalQualityVoice.quality, "normal", "Normal quality voice should have normal quality"); - - // 2. Now parse both together to test deduplication - const [resultVoice] = (manager as any).parseToReadiumSpeechVoices([lowVoice, normalVoice]); - - // Verify the result - t.is(resultVoice.name, "Samantha", "Should use the JSON name of the voice"); - t.is(resultVoice.originalName, "Samantha (enhanced)", "Should keep the original name of the voice"); - t.deepEqual(resultVoice.quality, "normal", "Should keep the voice with normal quality"); -}); - -testWithContext("deduplication: keeps higher quality voice from voiceURI string", (t) => { - const manager = t.context.manager; - - // Define test voices once - const basicVoice = { - voiceURI: "Samantha", - name: "Samantha", - lang: "en-US", - localService: true, - default: false - }; - - const enhancedVoice = { - voiceURI: "Samantha (Premium)", - name: "Samantha (Premium)", - lang: "en-US", - localService: true, - default: false - }; - - // 1. First parse separately to verify individual qualities - const basicVoiceParsed = (manager as any).parseToReadiumSpeechVoices([basicVoice])[0]; - const enhancedVoiceParsed = (manager as any).parseToReadiumSpeechVoices([enhancedVoice])[0]; - - // Verify individual qualities - t.is(basicVoiceParsed.quality, "low", "Basic voice should have low quality"); - t.is(enhancedVoiceParsed.quality, "high", "Premium voice should have high quality"); - - // 2. Now parse both together to test deduplication - const [resultVoice] = (manager as any).parseToReadiumSpeechVoices([basicVoice, enhancedVoice]); - - // Verify the result - t.is(resultVoice.name, "Samantha", "Should use the JSON name of the voice"); - t.is(resultVoice.originalName, "Samantha (Premium)", "Should keep the original name of the voice"); - t.deepEqual(resultVoice.quality, "high", "Should keep the voice with high quality"); -}); - -testWithContext("deduplication: keeps higher quality voice from json quality array", (t) => { - const manager = t.context.manager; - - // Parse both voices together to get correct duplicate counts - const voices = (manager as any).parseToReadiumSpeechVoices([ - { - voiceURI: "Samantha", - name: "Samantha", - lang: "en-US", - localService: true, - default: false - }, - { - voiceURI: "Samantha superior", - name: "Samantha (Superior)", - lang: "en-US", - localService: true, - default: false - } - ]); - // Now test deduplication with both voices - const deduped = (manager as any).removeDuplicate(voices); - - // Verify only the higher quality voice remains with its original name - t.is(deduped.length, 1, "Should only keep one voice after deduplication"); - t.is(deduped[0].name, "Samantha", "Should use the JSON name of the voice"); - t.is(deduped[0].originalName, "Samantha (Superior)", "Should keep the original name of the voice"); - t.is(deduped[0].voiceURI, "Samantha superior", "Should keep the voice with superior quality"); - t.deepEqual(deduped[0].quality, "normal", "Should find the voice with normal quality from the array"); -}); - -testWithContext("quality inference: infers quality from nativeID when voiceURI has no indicators", (t) => { - const manager = t.context.manager; - - // Test Francesca voice from es.json which has nativeID with "enhanced" - // Use plain voiceURI to force nativeID quality inference - const testVoice = { - voiceURI: "plain.voice.uri", // No package indicators - name: "Francesca", // Must match the JSON voice name exactly - lang: "es-CL", // Must match the JSON voice language - localService: true, - default: false - }; - - // Parse the voice - it should find Francesca in es.json and infer quality from nativeID - const voices = (manager as any).parseToReadiumSpeechVoices([testVoice]); - - // Should infer "normal" quality from "enhanced" in nativeID array - t.is(voices[0].quality, "normal", "Should infer 'normal' quality from 'enhanced' in Francesca's nativeID"); -}); - -testWithContext("quality inference: voiceURI takes precedence over nativeID", (t) => { - const manager = t.context.manager; - - // Test Francesca voice with compact in voiceURI (should take precedence over nativeID enhanced) - const testVoice = { - voiceURI: "com.apple.speech.synthesis.voice.compact.Francesca", // compact should infer "low" - name: "Francesca", // Must match the JSON voice name exactly - lang: "es-CL", // Must match the JSON voice language - localService: true, - default: false - }; - - // Parse the voice - it should find Francesca but use voiceURI quality (takes precedence) - const voices = (manager as any).parseToReadiumSpeechVoices([testVoice]); - - // Should infer "low" from voiceURI, not "normal" from nativeID (voiceURI takes precedence) - t.is(voices[0].quality, "low", "Should infer 'low' quality from voiceURI, not 'normal' from nativeID"); -}); - -// ============================================= -// 2. Voice Retrieval Tests -// ============================================= - -testWithContext("getVoices: returns all voices when no filters are provided", (t) => { - const voices = t.context.manager.getVoices(); - t.is(voices.length, mockVoices.length); -}); - -testWithContext("getVoices: throws if not initialized", (t) => { - // Create a new instance without initializing - const manager = new (WebSpeechVoiceManager as any)(); - t.throws(() => manager.getVoices(), { - message: "WebSpeechVoiceManager not initialized. Call initialize() first." - }); -}); - -testWithContext("getVoices: combines all filters", async (t: ExecutionContext) => { - const manager = t.context.manager; - - (manager as any).voices = [ - createTestVoice({ name: "Male High Quality English", language: "en-US", gender: "male", quality: "high", provider: "Google", offlineAvailability: true }), - createTestVoice({ name: "English Female Normal", language: "en-US", gender: "female", quality: "normal", provider: "Microsoft", offlineAvailability: false }), - createTestVoice({ name: "French Male Low", language: "fr-FR", gender: "male", quality: "low", provider: "Google", offlineAvailability: true }), - createTestVoice({ name: "French Female High", language: "fr-FR", gender: "female", quality: "high", provider: "Amazon", offlineAvailability: false }), - createTestVoice({ name: "Spanish Male Normal", language: "es-ES", gender: "male", quality: "normal", provider: "Microsoft", offlineAvailability: true }) - ]; - - // Test with all filters combined - const filtered = await manager.getVoices({ - language: ["en", "fr"], - gender: "male", - quality: ["high", "normal"], - provider: "Google", - offlineOnly: true, - excludeNovelty: true, - excludeVeryLowQuality: true - }); - - t.is(filtered.length, 1); - t.true(filtered.every(v => - (v.language.startsWith("en") || v.language.startsWith("fr")) && - v.gender === "male" && - (v.quality?.includes("high") || v.quality?.includes("normal")) && - v.provider === "Google" && - v.offlineAvailability === true - )); -}); - -testWithContext("getVoices: handles empty navigator.languages", async (t) => { - const manager = t.context.manager; - - // Create test voices - const testVoices = [ - { voiceURI: "voice1", name: "Voice 1", language: "en-US" }, - { voiceURI: "voice2", name: "Voice 2", language: "fr-FR" } - ]; - - // Replace the voices in the manager - (manager as any).voices = testVoices; - - // Mock empty navigator.languages - const originalLanguages = [...(globalThis.navigator as any).languages]; - (globalThis.navigator as any).languages = []; - - try { - const voices = await manager.getVoices(); - - // Should still return all voices even with empty languages - t.is(voices.length, 2); - } finally { - // Restore original languages - (globalThis.navigator as any).languages = originalLanguages; - } -}); - -testWithContext("getVoices: handles undefined navigator.languages", async (t) => { - const manager = t.context.manager; - - // Create test voices - const testVoices = [ - { voiceURI: "voice1", name: "Voice 1", language: "en-US" }, - { voiceURI: "voice2", name: "Voice 2", language: "fr-FR" } - ]; - - // Replace the voices in the manager - (manager as any).voices = testVoices; - - // Mock undefined navigator.languages - const originalLanguages = (globalThis.navigator as any).languages; - delete (globalThis.navigator as any).languages; - - try { - const voices = await manager.getVoices(); - - // Should still return all voices even with undefined languages - t.is(voices.length, 2); - } finally { - // Restore original languages - (globalThis.navigator as any).languages = originalLanguages; - } -}); - - -testWithContext("getVoices: returns empty array when no voices are available", async (t) => { - // Save the original getVoices implementation - const originalGetVoices = mockSpeechSynthesis.getVoices; - - try { - // Override getVoices to return empty array - mockSpeechSynthesis.getVoices = () => []; - - // Create a fresh instance to avoid interference from other tests - (WebSpeechVoiceManager as any).instance = undefined; - const manager = await WebSpeechVoiceManager.initialize(); - - // Reset initialization to force re-initialization with empty voices - (manager as any).initializationPromise = null; - (manager as any).voices = []; - (manager as any).browserVoices = []; - - // Should return empty array when no voices are available - const voices = manager.getVoices(); - t.deepEqual(voices, []); - } finally { - // Restore original getVoices implementation - mockSpeechSynthesis.getVoices = originalGetVoices; - } -}); - -testWithContext("getVoices: filters by language", async (t: ExecutionContext) => { - const manager = t.context.manager; - - // Single language - let voices = await manager.getVoices({ language: "en" }); - t.true(voices.length > 0); - t.true(voices.every((v: ReadiumSpeechVoice) => v.language.startsWith("en"))); - - // Multiple languages - voices = await manager.getVoices({ language: ["en", "fr"] }); - t.true(voices.length > 1); - t.true(voices.some((v: ReadiumSpeechVoice) => v.language.startsWith("en"))); - t.true(voices.some((v: ReadiumSpeechVoice) => v.language.startsWith("fr"))); -}); - -testWithContext("getVoices: filters by quality", async (t: ExecutionContext) => { - const manager = t.context.manager; - - // Mock quality property on voices - const voices = await manager.getVoices(); - const voicesWithQuality = voices.map((v: ReadiumSpeechVoice, i: number) => ({ - ...v, - quality: i % 2 === 0 ? "high" : "low" - })); - - // Replace the voices in the manager - (manager as any).voices = voicesWithQuality; - - const highQualityVoices = await manager.getVoices({ quality: "high" }); - t.true(highQualityVoices.length > 0); - t.true(highQualityVoices.every((v: ReadiumSpeechVoice) => v.quality === "high")); -}); - -testWithContext("getVoices: returns empty array when speechSynthesis is not available", async (t) => { - // Save original - const originalSpeechSynthesis = globalThis.speechSynthesis; - - try { - // Mock speechSynthesis to be undefined - Object.defineProperty(globalThis, "speechSynthesis", { - value: undefined, - configurable: true, - writable: true - }); - - // Create a new instance - (WebSpeechVoiceManager as any).instance = undefined; - const manager = await WebSpeechVoiceManager.initialize(); - - // Should return empty array when speechSynthesis is not available - const voices = manager.getVoices(); - t.deepEqual(voices, []); - } finally { - // Restore - Object.defineProperty(globalThis, "speechSynthesis", { - value: originalSpeechSynthesis, - configurable: true, - writable: true - }); - } -}); - -// ============================================= -// 3. Language Retrieval Tests -// ============================================= - -testWithContext("getLanguages: returns available languages with counts", async (t: ExecutionContext) => { - const languages = await t.context.manager.getLanguages(); - t.true(Array.isArray(languages)); - - // Check that we have at least one language - t.true(languages.length > 0); - - // Check structure of language entries - for (const lang of languages) { - t.truthy(lang.code); - t.truthy(lang.label); - t.true(typeof lang.count === "number"); - } -}); - -testWithContext("getLanguages: handles empty voices array", async (t: ExecutionContext) => { - // Save the original getVoices implementation - const originalGetVoices = mockSpeechSynthesis.getVoices; - - try { - // Override getVoices to return empty array - mockSpeechSynthesis.getVoices = () => []; - - // Create a fresh instance to avoid interference - (WebSpeechVoiceManager as any).instance = undefined; - const manager = await WebSpeechVoiceManager.initialize(); - - // Reset initialization to force re-initialization with empty voices - (manager as any).initializationPromise = null; - (manager as any).voices = []; - (manager as any).browserVoices = []; - - const languages = manager.getLanguages(); - t.deepEqual(languages, []); - } finally { - // Restore original getVoices implementation - mockSpeechSynthesis.getVoices = originalGetVoices; - } -}); - -// ============================================= -// 4. Region Retrieval Tests -// ============================================= - -testWithContext("getRegions: returns available regions with counts", async (t: ExecutionContext) => { - const regions = await t.context.manager.getRegions(); - t.true(Array.isArray(regions)); - - // Check that we have at least one region - t.true(regions.length > 0); - - // Check structure of region entries - for (const region of regions) { - t.truthy(region.code); - t.truthy(region.label); - t.true(typeof region.count === "number"); - } -}); - -testWithContext("getRegions: handles empty voices array", async (t: ExecutionContext) => { - // Create a fresh instance to avoid interference - (WebSpeechVoiceManager as any).instance = undefined; - const manager = await WebSpeechVoiceManager.initialize(); - - // Mock empty voices array - const emptyMockVoices: any[] = []; - const mockSpeechSynthesis = { - getVoices: () => emptyMockVoices, - onvoiceschanged: null as (() => void) | null, - addEventListener: function(event: string, callback: () => void) { - if (event === "voiceschanged") { - this.onvoiceschanged = callback; - } - }, - removeEventListener: function(event: string) { - }, - _triggerVoicesChanged: function() { - if (this.onvoiceschanged) { - this.onvoiceschanged(); - } - } - }; - - Object.defineProperty(globalThis.window, "speechSynthesis", { - value: mockSpeechSynthesis, - configurable: true, - writable: true - }); - - try { - // Reset initialization - (manager as any).initializationPromise = null; - (manager as any).voices = []; - (manager as any).browserVoices = []; - - const regions = manager.getRegions(); - t.deepEqual(regions, []); - } finally { - // Restore for other tests - Object.defineProperty(globalThis.window, "speechSynthesis", { - value: { - getVoices: () => mockVoices, - onvoiceschanged: null as (() => void) | null, - addEventListener: function(event: string, callback: () => void) { - if (event === "voiceschanged") { - this.onvoiceschanged = callback; - } - }, - removeEventListener: function(event: string) { - }, - _triggerVoicesChanged: function() { - if (this.onvoiceschanged) { - this.onvoiceschanged(); - } - } - }, - configurable: true, - writable: true - }); - } -}); - -// ============================================= -// 5. Default Voice Retrieval Tests -// ============================================= - -testWithContext("getDefaultVoice: selects highest quality voice regardless of isDefault flag", async (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with quality as the distinguishing factor - const testVoices = [ - { - voiceURI: "voice1", - name: "High Quality", - language: "en-US", - isDefault: false, // Not default but higher quality - quality: "high" - }, - { - voiceURI: "voice2", - name: "Normal Quality", - language: "en-US", - isDefault: true, // Default but lower quality - quality: "normal" - } - ]; - - (manager as any).voices = testVoices; - - const defaultVoice = await manager.getDefaultVoice("en-US"); - t.truthy(defaultVoice); - t.is(defaultVoice?.voiceURI, "voice1", "Should select highest quality voice regardless of isDefault flag"); -}); - -testWithContext("getDefaultVoice: falls back to base language", async (t: ExecutionContext) => { - const manager = t.context.manager; - - const testVoices = [ - { - voiceURI: "voice1", - name: "English Generic", - language: "en", // Base language - isDefault: false, - quality: "high" - }, - { - voiceURI: "voice2", - name: "US English", - language: "en-US", - isDefault: false, - quality: "high" - } - ]; - - (manager as any).voices = testVoices; - - // Request en-GB which isn't available, should fall back to en - const defaultVoice = await manager.getDefaultVoice("en-GB"); - t.truthy(defaultVoice); - t.is(defaultVoice?.language, "en", "Should fall back to base language when exact match not found"); -}); - -testWithContext("getDefaultVoice: respects quality sorting", async (t: ExecutionContext) => { - const manager = t.context.manager; - - const testVoices = [ - { - voiceURI: "voice1", - name: "High Quality", - language: "en-US", - isDefault: false, - quality: "high" - }, - { - voiceURI: "voice2", - name: "Very High Quality", - language: "en-US", - isDefault: false, - quality: "veryHigh" // Higher quality - }, - { - voiceURI: "voice3", - name: "Normal Quality", - language: "en-US", - isDefault: false, - quality: "normal" // Lower quality - } - ]; - - (manager as any).voices = testVoices; - - const defaultVoice = await manager.getDefaultVoice("en-US"); - t.truthy(defaultVoice); - t.is(defaultVoice?.voiceURI, "voice2", "Should select highest quality voice available"); -}); - -testWithContext("getDefaultVoice: returns undefined when no voices available", async (t) => { - // Create a fresh instance to avoid interference - (WebSpeechVoiceManager as any).instance = undefined; - const manager = await WebSpeechVoiceManager.initialize(); - - // Mock empty voices array - const emptyMockVoices: any[] = []; - const mockSpeechSynthesis = { - getVoices: () => emptyMockVoices, - onvoiceschanged: null as (() => void) | null, - addEventListener: function(event: string, callback: () => void) { - if (event === "voiceschanged") { - this.onvoiceschanged = callback; - } - }, - removeEventListener: function(event: string) { - }, - _triggerVoicesChanged: function() { - if (this.onvoiceschanged) { - this.onvoiceschanged(); - } - } - }; - - Object.defineProperty(globalThis.window, "speechSynthesis", { - value: mockSpeechSynthesis, - configurable: true, - writable: true - }); - - try { - // Reset initialization - (manager as any).initializationPromise = null; - (manager as any).voices = []; - (manager as any).browserVoices = []; - - const defaultVoice = manager.getDefaultVoice("en-US"); - t.is(defaultVoice, null); - } finally { - // Restore for other tests - Object.defineProperty(globalThis.window, "speechSynthesis", { - value: { - getVoices: () => mockVoices, - onvoiceschanged: null as (() => void) | null, - addEventListener: function(event: string, callback: () => void) { - if (event === "voiceschanged") { - this.onvoiceschanged = callback; - } - }, - removeEventListener: function(event: string) { - }, - _triggerVoicesChanged: function() { - if (this.onvoiceschanged) { - this.onvoiceschanged(); - } - } - }, - configurable: true, - writable: true - }); - } -}); - -testWithContext("getDefaultVoice: returns null when no matching language", async (t: ExecutionContext) => { - const manager = t.context.manager; - - // Test with language that has no voices - const result = await manager.getDefaultVoice("xx-XX"); - t.is(result, null); -}); - -// ============================================= -// 6. Test Utterance Retrieval Tests -// ============================================= - -testWithContext("getTestUtterance: returns test utterance for supported language", (t) => { - const manager = t.context.manager; - - // Test with a base language - const utterance1 = manager.getTestUtterance("en"); - t.is(typeof utterance1, "string"); - t.true(utterance1 && utterance1.length > 0); - - // Test with a locale variant (should fall back to base language) - const utterance2 = manager.getTestUtterance("en-US"); - t.is(typeof utterance2, "string"); - t.true(utterance2 && utterance2.length > 0); - t.is(utterance1, utterance2); // Should be the same -}); - -testWithContext("getTestUtterance: returns empty string for unsupported language", (t) => { - const manager = t.context.manager; - - // Test with an unsupported language - const utterance = manager.getTestUtterance("xx-XX"); - t.is(utterance, ""); -}); - -// ============================================= -// 7. Voice Filtering Tests -// ============================================= - -testWithContext("filterVoices: filters by language", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices - const testVoices = [ - createTestVoice({ name: "English Voice 1", language: "en-US" }), - createTestVoice({ name: "English Voice 2", language: "en-GB" }), - createTestVoice({ name: "French Voice", language: "fr-FR" }), - createTestVoice({ name: "Spanish Voice", language: "es-ES" }) - ]; - - const englishVoices = manager.filterVoices(testVoices, { language: "en" }); - t.is(englishVoices.length, 2); - t.true(englishVoices.every(v => v.language.startsWith("en"))); - - const multiLangVoices = manager.filterVoices(testVoices, { language: ["en", "fr"] }); - t.is(multiLangVoices.length, 3); - t.true(multiLangVoices.every(v => v.language.startsWith("en") || v.language.startsWith("fr"))); -}); - -testWithContext("filterVoices: filters by source", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different sources - const testVoices = [ - createTestVoice({ name: "JSON Voice 1", source: "json" }), - createTestVoice({ name: "JSON Voice 2", source: "json" }), - createTestVoice({ name: "Browser Voice 1", source: "browser" }), - ]; - - const jsonVoices = manager.filterVoices(testVoices, { source: "json" }); - t.is(jsonVoices.length, 2); - t.true(jsonVoices.every(v => v.source === "json")); - - const browserVoices = manager.filterVoices(testVoices, { source: "browser"}); - t.is(browserVoices.length, 1); - t.true(browserVoices.every(v => v.source === "browser")); -}); - -testWithContext("filterVoices: filters by gender", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different genders - const testVoices = [ - createTestVoice({ name: "Male Voice 1", language: "en-US", gender: "male" }), - createTestVoice({ name: "Female Voice 1", language: "en-US", gender: "female" }), - createTestVoice({ name: "Male Voice 2", language: "en-US", gender: "male" }), - createTestVoice({ name: "Unknown Gender Voice", language: "en-US" }) - ]; - - const maleVoices = manager.filterVoices(testVoices, { gender: "male" }); - t.is(maleVoices.length, 2); - t.true(maleVoices.every(v => v.gender === "male")); - - const femaleVoices = manager.filterVoices(testVoices, { gender: "female" }); - t.is(femaleVoices.length, 1); - t.is(femaleVoices[0].gender, "female"); -}); - -testWithContext("filterVoices: filters by quality array", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different quality levels - const testVoices = [ - createTestVoice({ name: "High Quality Voice", language: "en-US", quality: "high" }), - createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: "low" }), - createTestVoice({ name: "Normal Quality Voice", language: "en-US", quality: "normal" }), - createTestVoice({ name: "Very High Quality Voice", language: "en-US", quality: "veryHigh" }), - createTestVoice({ name: "No Quality Voice", language: "en-US", quality: undefined }) - ]; - - // Test single quality filter - const highQualityVoices = manager.filterVoices(testVoices, { quality: "high" }); - t.is(highQualityVoices.length, 1); // Only the high quality voice - - // Test multiple quality filter - const multiQualityVoices = manager.filterVoices(testVoices, { quality: ["high", "normal"] }); - t.is(multiQualityVoices.length, 2); // high and normal quality voices - - // Test that undefined quality voices are filtered out - const filteredVoices = manager.filterVoices(testVoices, { quality: "high" }); - t.false(filteredVoices.some(v => v.quality === undefined)); -}); - -testWithContext("filterVoices: filters out novelty and low quality voices", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices using the createTestVoice helper - const testVoices = [ - createTestVoice({ - voiceURI: "com.apple.speech.synthesis.voice.Albert", - name: "Albert", - language: "en-US", - isNovelty: true - }), - createTestVoice({ - voiceURI: "com.appk.it.speech.synthesis.voice.Eddy", - name: "Eddy", - language: "en-US", - quality: "veryLow" - }) - ]; - - // Test filtering with default options (should filter out both voices) - const filteredVoices = manager.filterVoices(testVoices, { - excludeNovelty: true, - excludeVeryLowQuality: true - }); - t.is(filteredVoices.length, 0, "Should filter out all test voices by default"); - - // Test including them by disabling the filters - const allVoices = manager.filterVoices(testVoices, { - excludeNovelty: false, - excludeVeryLowQuality: false - }); - t.is(allVoices.length, 2, "Should include all voices when not filtered"); -}); - -testWithContext("filterVoices: filters by offline availability", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different offline availability - const testVoices = [ - createTestVoice({ name: "Offline Voice 1", language: "en-US", offlineAvailability: true }), - createTestVoice({ name: "Online Voice 1", language: "en-US", offlineAvailability: false }), - createTestVoice({ name: "Offline Voice 2", language: "en-US", offlineAvailability: true }), - createTestVoice({ name: "Undefined Availability Voice", language: "en-US" }) - ]; - - const offlineVoices = manager.filterVoices(testVoices, { offlineOnly: true }); - t.is(offlineVoices.length, 2); - t.true(offlineVoices.every(v => v.offlineAvailability === true)); - - // Test that undefined and false values are filtered out - t.false(offlineVoices.some(v => v.offlineAvailability === false)); - t.false(offlineVoices.some(v => v.offlineAvailability === undefined)); -}); - -testWithContext("filterVoices: filters by provider", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different providers - const testVoices = [ - createTestVoice({ name: "Google Voice", language: "en-US", provider: "Google" }), - createTestVoice({ name: "Microsoft Voice", language: "en-US", provider: "Microsoft" }), - createTestVoice({ name: "Amazon Voice", language: "en-US", provider: "Amazon" }), - createTestVoice({ name: "Another Google Voice", language: "en-US", provider: "Google" }) - ]; - - const googleVoices = manager.filterVoices(testVoices, { provider: "Google" }); - t.is(googleVoices.length, 2); - t.true(googleVoices.every(v => v.provider === "Google")); - - // Test case insensitive matching - const caseInsensitiveVoices = manager.filterVoices(testVoices, { provider: "google" }); - t.is(caseInsensitiveVoices.length, 2); -}); - -testWithContext("filterVoices: combines multiple filters", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with various properties - const testVoices = [ - createTestVoice({ name: "Male High Quality English", language: "en-US", gender: "male", quality: "high", provider: "Google" }), - createTestVoice({ name: "Female Low Quality English", language: "en-US", gender: "female", quality: "low", provider: "Google" }), - createTestVoice({ name: "Male High Quality French", language: "fr-FR", gender: "male", quality: "high", provider: "Microsoft" }), - createTestVoice({ name: "Female Normal Quality English", language: "en-US", gender: "female", quality: "normal", provider: "Google" }) - ]; - - // Filter by language and gender - const englishFemaleVoices = manager.filterVoices(testVoices, { - language: "en", - gender: "female" - }); - t.is(englishFemaleVoices.length, 2); - t.true(englishFemaleVoices.every(v => - v.language.startsWith("en") && v.gender === "female" - )); - - // Filter by quality and provider - const highQualityGoogleVoices = manager.filterVoices(testVoices, { - quality: "high", - provider: "Google" - }); - t.is(highQualityGoogleVoices.length, 1); - t.is(highQualityGoogleVoices[0].name, "Male High Quality English"); -}); - -testWithContext("filterVoices: handles edge cases", (t: ExecutionContext) => { - const manager = t.context.manager; - - const testVoices = [ - createTestVoice({ name: "Voice 1", language: "en-US", gender: "male", quality: "high" }), - createTestVoice({ name: "Voice 2", language: "fr-FR", gender: "female", quality: "low" }), - createTestVoice({ name: "Voice 3", language: "de-DE", gender: "male", quality: "normal" }) - ]; - - // Test empty filter arrays - const emptyLanguageFilter = manager.filterVoices(testVoices, { language: [] }); - t.is(emptyLanguageFilter.length, 0); - - const emptyQualityFilter = manager.filterVoices(testVoices, { quality: undefined }); - t.is(emptyQualityFilter.length, testVoices.length, "Should return all voices when quality is undefined"); - - // Test case sensitivity for language - const caseSensitiveLanguage = manager.filterVoices(testVoices, { language: "EN-us" }); - t.is(caseSensitiveLanguage.length, 1); // Should match due to toLowerCase() - - // Test invalid quality values - cast to any for testing invalid input - const invalidQualityFilter = manager.filterVoices(testVoices, { quality: "invalid" as any }); - t.is(invalidQualityFilter.length, 0); -}); - -testWithContext("filterVoices: uses array values for multiple filters", (t: ExecutionContext) => { - const manager = t.context.manager; - - const testVoices = [ - createTestVoice({ name: "English Male", language: "en-US", gender: "male", quality: "high" }), - createTestVoice({ name: "English Female", language: "en-US", gender: "female", quality: "normal" }), - createTestVoice({ name: "French Male", language: "fr-FR", gender: "male", quality: "low" }), - createTestVoice({ name: "French Female", language: "fr-FR", gender: "female", quality: "high" }), - createTestVoice({ name: "Spanish Male", language: "es-ES", gender: "male", quality: "normal" }) - ]; - - // Test with array of languages and array of qualities - const filtered = manager.filterVoices(testVoices, { - language: ["en", "fr"], - quality: ["high", "normal"] - }); - t.is(filtered.length, 3); - t.true(filtered.every(v => - (v.language.startsWith("en") || v.language.startsWith("fr")) && - (v.quality === "high" || v.quality === "normal") - )); -}); - -testWithContext("filterOutNoveltyVoices: removes novelty voices", (t: ExecutionContext) => { - const manager = t.context.manager; - - const testVoices = [ - createTestVoice({ name: "Regular Voice 1", language: "en-US" }), - createTestVoice({ name: "Novelty Voice 1", language: "en-US", isNovelty: true }), - createTestVoice({ name: "Regular Voice 2", language: "en-US" }), - createTestVoice({ name: "Novelty Voice 2", language: "en-US", isNovelty: true }) - ]; - - const filtered = manager.filterOutNoveltyVoices(testVoices); - t.is(filtered.length, 2); - t.false(filtered.some((v: ReadiumSpeechVoice) => v.isNovelty)); -}); - -testWithContext("filterOutVeryLowQualityVoices: removes very low quality voices", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with one very low quality voice - const testVoices = [ - createTestVoice({ name: "Voice 1", language: "en-US", quality: "normal" }), - createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: "veryLow" }), - createTestVoice({ name: "Voice 2", language: "fr-FR", quality: "normal" }) - ]; - - const filtered = manager.filterOutVeryLowQualityVoices(testVoices); - t.is(filtered.length, testVoices.length - 1); - t.false(filtered.some((v: ReadiumSpeechVoice) => v.quality === "veryLow")); -}); - -// ============================================= -// 8. Voice Sorting Tests -// ============================================= - -testWithContext("sortVoices: sorts by name", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices - const testVoices = [ - createTestVoice({ name: "Zeta Voice", language: "en-US" }), - createTestVoice({ name: "Alpha Voice", language: "en-US" }), - createTestVoice({ name: "Beta Voice", language: "en-US" }) - ]; - - // Test ascending order - const sortedAsc = manager.sortVoices(testVoices, { by: "name", order: "asc" }); - t.is(sortedAsc[0].name, "Alpha Voice"); - t.is(sortedAsc[1].name, "Beta Voice"); - t.is(sortedAsc[2].name, "Zeta Voice"); - - // Test descending order - const sortedDesc = manager.sortVoices(testVoices, { by: "name", order: "desc" }); - t.is(sortedDesc[0].name, "Zeta Voice"); - t.is(sortedDesc[1].name, "Beta Voice"); - t.is(sortedDesc[2].name, "Alpha Voice"); -}); - -testWithContext("sortVoices: sorts by quality with proper direction", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different quality levels - const testVoices = [ - createTestVoice({ name: "High Quality Voice", language: "en-US", quality: "high" }), - createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: "low" }), - createTestVoice({ name: "Normal Quality Voice", language: "en-US", quality: "normal" }), - createTestVoice({ name: "Very High Quality Voice", language: "en-US", quality: "veryHigh" }), - createTestVoice({ name: "Very Low Quality Voice", language: "en-US", quality: "veryLow" }) - ]; - - // Test ascending order (low to high quality) - const sortedAsc = manager.sortVoices(testVoices, { by: "quality", order: "asc" }); - t.is(sortedAsc[0].quality, "veryLow"); - t.is(sortedAsc[1].quality, "low"); - t.is(sortedAsc[2].quality, "normal"); - t.is(sortedAsc[3].quality, "high"); - t.is(sortedAsc[4].quality, "veryHigh"); - - // Test descending order (high to low quality) - const sortedDesc = manager.sortVoices(testVoices, { by: "quality", order: "desc" }); - t.is(sortedDesc[0].quality, "veryHigh"); - t.is(sortedDesc[1].quality, "high"); - t.is(sortedDesc[2].quality, "normal"); - t.is(sortedDesc[3].quality, "low"); - t.is(sortedDesc[4].quality, "veryLow"); -}); - -testWithContext("sortVoices: sorts by language", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different languages - const testVoices = [ - createTestVoice({ name: "French Voice", language: "fr-FR" }), - createTestVoice({ name: "English Voice", language: "en-US" }), - createTestVoice({ name: "Spanish Voice", language: "es-ES" }), - createTestVoice({ name: "German Voice", language: "de-DE" }) - ]; - - // Test ascending order - const sortedAsc = manager.sortVoices(testVoices, { by: "language", order: "asc" }); - t.is(sortedAsc[0].language, "de-DE"); - t.is(sortedAsc[1].language, "en-US"); - t.is(sortedAsc[2].language, "es-ES"); - t.is(sortedAsc[3].language, "fr-FR"); - - // Test descending order - const sortedDesc = manager.sortVoices(testVoices, { by: "language", order: "desc" }); - t.is(sortedDesc[0].language, "fr-FR"); - t.is(sortedDesc[1].language, "es-ES"); - t.is(sortedDesc[2].language, "en-US"); - t.is(sortedDesc[3].language, "de-DE"); -}); - -testWithContext("sortVoices: sorts by gender", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different genders - const testVoices = [ - createTestVoice({ name: "Female Voice 1", language: "en-US", gender: "female" }), - createTestVoice({ name: "Male Voice 1", language: "en-US", gender: "male" }), - createTestVoice({ name: "Unknown Voice", language: "en-US" }), - createTestVoice({ name: "Female Voice 2", language: "en-US", gender: "female" }) - ]; - - // Test ascending order (undefined should come first, then female, then male) - const sortedAsc = manager.sortVoices(testVoices, { by: "gender", order: "asc" }); - t.is(sortedAsc[0].gender, undefined); - t.is(sortedAsc[1].gender, "female"); - t.is(sortedAsc[2].gender, "female"); - t.is(sortedAsc[3].gender, "male"); - - // Test descending order (male should come first, then female, then undefined) - const sortedDesc = manager.sortVoices(testVoices, { by: "gender", order: "desc" }); - t.is(sortedDesc[0].gender, "male"); - t.is(sortedDesc[1].gender, "female"); - t.is(sortedDesc[2].gender, "female"); - t.is(sortedDesc[3].gender, undefined); -}); - -testWithContext("sortVoices: sorts by region", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different regions - const testVoices = [ - createTestVoice({ name: "US Voice", language: "en-US" }), - createTestVoice({ name: "UK Voice", language: "en-GB" }), - createTestVoice({ name: "Canada Voice", language: "en-CA" }), - createTestVoice({ name: "Australia Voice", language: "en-AU" }) - ]; - - // Test ascending order - const sortedAsc = manager.sortVoices(testVoices, { by: "region", order: "asc" }); - t.is(sortedAsc[0].language, "en-AU"); - t.is(sortedAsc[1].language, "en-CA"); - t.is(sortedAsc[2].language, "en-GB"); - t.is(sortedAsc[3].language, "en-US"); - - // Test descending order - const sortedDesc = manager.sortVoices(testVoices, { by: "region", order: "desc" }); - t.is(sortedDesc[0].language, "en-US"); - t.is(sortedDesc[1].language, "en-GB"); - t.is(sortedDesc[2].language, "en-CA"); - t.is(sortedDesc[3].language, "en-AU"); -}); - -testWithContext("sortVoices: sorts by preferred languages", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different languages and regions - const testVoices = [ - createTestVoice({ name: "French Voice", language: "fr-FR" }), - createTestVoice({ name: "US English Voice", language: "en-US" }), - createTestVoice({ name: "UK English Voice", language: "en-GB" }), - createTestVoice({ name: "German Voice", language: "de-DE" }), - createTestVoice({ name: "Spanish Voice", language: "es-ES" }), - createTestVoice({ name: "French Canadian Voice", language: "fr-CA" }) - ]; - - // Test with preferred languages (exact matches first, then partial matches) - const preferredLangs = ["en-US", "fr", "es-ES"]; - const sorted = manager.sortVoices(testVoices, { - by: "language", - preferredLanguages: preferredLangs - }); - - // Exact matches should come first in the order of preferredLanguages - t.is(sorted[0].language, "en-US"); // Exact match - t.is(sorted[1].language, "fr-CA"); // Partial match for "fr" - sorts by region code - t.is(sorted[2].language, "fr-FR"); // Also partial match for "fr" - sorts by region code - t.is(sorted[3].language, "es-ES"); // Exact match - - // Non-preferred languages should come after, sorted alphabetically - t.is(sorted[4].language, "de-DE"); - t.is(sorted[5].language, "en-GB"); - - // Test with region-specific preferences - const regionSpecific = manager.sortVoices(testVoices, { - by: "language", - preferredLanguages: ["fr-CA", "en-GB"] - }); - - t.is(regionSpecific[0].language, "fr-CA"); // Exact match - t.is(regionSpecific[1].language, "en-GB"); // Exact match - // Others should be sorted alphabetically - t.is(regionSpecific[2].language, "de-DE"); - t.is(regionSpecific[3].language, "en-US"); - t.is(regionSpecific[4].language, "es-ES"); - t.is(regionSpecific[5].language, "fr-FR"); - - // Test with empty preferred languages (should sort alphabetically) - const emptyPreferred = manager.sortVoices(testVoices, { - by: "language", - preferredLanguages: [] - }); - t.is(emptyPreferred[0].language, "de-DE"); - t.is(emptyPreferred[1].language, "en-GB"); - t.is(emptyPreferred[2].language, "en-US"); - t.is(emptyPreferred[3].language, "es-ES"); - t.is(emptyPreferred[4].language, "fr-CA"); - t.is(emptyPreferred[5].language, "fr-FR"); - - // Test with undefined preferred languages (should sort alphabetically) - const undefinedPreferred = manager.sortVoices(testVoices, { - by: "language" - }); - t.is(undefinedPreferred[0].language, "de-DE"); - t.is(undefinedPreferred[1].language, "en-GB"); - t.is(undefinedPreferred[2].language, "en-US"); - t.is(undefinedPreferred[3].language, "es-ES"); - t.is(undefinedPreferred[4].language, "fr-CA"); - t.is(undefinedPreferred[5].language, "fr-FR"); - - // Test with case-insensitive matching - const caseInsensitive = manager.sortVoices(testVoices, { - by: "language", - preferredLanguages: ["EN-us", "FR"] // Mixed case and partial - }); - t.is(caseInsensitive[0].language, "en-US"); // Matches despite case difference - t.is(caseInsensitive[1].language, "fr-CA"); // Partial match, sorted by region - t.is(caseInsensitive[2].language, "fr-FR"); // Also partial match -}); - -testWithContext("sortVoices: sorts by region with preferred languages", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different regions - const testVoices = [ - createTestVoice({ name: "US English", language: "en-US" }), - createTestVoice({ name: "UK English", language: "en-GB" }), - createTestVoice({ name: "Australian English", language: "en-AU" }), - createTestVoice({ name: "Canadian French", language: "fr-CA" }), - createTestVoice({ name: "French", language: "fr-FR" }), - createTestVoice({ name: "Canadian English", language: "en-CA" }) - ]; - - // Test with preferred languages that include regions - const sorted = manager.sortVoices(testVoices, { - by: "region", - preferredLanguages: ["en-CA", "fr-CA", "en"] // Prefer Canadian English, then Canadian French, then any English - }); - - // Verify order: - // 1. en-CA (exact match for first preferred) - // 2. fr-CA (exact match for second preferred) - // 3. en-US (language match for third preferred) - // 4. en-GB (language match for third preferred) - // 5. en-AU (language match for third preferred) - // 6. fr-FR (no match, should come last) - t.is(sorted[0].language, "en-CA", "en-CA should be first (exact match)"); - t.is(sorted[1].language, "fr-CA", "fr-CA should be second (exact match)"); - - // The remaining English variants should be in their natural order - const remainingEnglish = sorted.slice(2, 5).map(v => v.language); - t.true( - ["en-US", "en-GB", "en-AU"].every(lang => remainingEnglish.includes(lang)), - "Should include all English variants after exact matches" - ); - - t.is(sorted[5].language, "fr-FR", "fr-FR should be last (no match)"); - - // Test with preferred languages that don't match any regions - const noMatches = manager.sortVoices(testVoices, { - by: "region", - preferredLanguages: ["es-ES", "de-DE"] // No matches in test data - }); - - // Should sort alphabetically by region - const regions = noMatches.map(v => v.language.split("-")[1]); - const sortedRegions = [...regions].sort(); - t.deepEqual(regions, sortedRegions, "Should sort alphabetically by region when no preferred matches"); -}); - -// ============================================= -// 9. Voice Grouping Tests -// ============================================= - -testWithContext("groupVoices: groups by language", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different languages - const testVoices = [ - { voiceURI: "voice1", name: "Voice 1", language: "en-US" }, - { voiceURI: "voice2", name: "Voice 2", language: "fr-FR" }, - { voiceURI: "voice3", name: "Voice 3", language: "en-US" } - ]; - - const groups = (manager as any).groupVoices(testVoices, "language"); - - // Check that groups were created for each language - t.truthy(groups["en"]); - t.truthy(groups["fr"]); - - // Check the number of voices in each group - t.is(groups["en"].length, 2); - t.is(groups["fr"].length, 1); -}); - -testWithContext("groupVoices: groups by gender", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different genders - const testVoices = [ - createTestVoice({ name: "Male Voice 1", language: "en-US", gender: "male" }), - createTestVoice({ name: "Female Voice 1", language: "en-US", gender: "female" }), - createTestVoice({ name: "Male Voice 2", language: "fr-FR", gender: "male" }), - createTestVoice({ name: "Unknown Voice", language: "es-ES" }) - ]; - - const groups = manager.groupVoices(testVoices, "gender"); - t.true(groups.hasOwnProperty("male")); - t.true(groups.hasOwnProperty("female")); - t.true(groups.hasOwnProperty("unknown")); - t.is(groups.male.length, 2); - t.is(groups.female.length, 1); - t.is(groups.unknown.length, 1); -}); - -testWithContext("groupVoices: groups by quality", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different qualities - const testVoices = [ - createTestVoice({ name: "High Quality 1", language: "en-US", quality: "high" }), - createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: "low" }), - createTestVoice({ name: "High Quality 2", language: "fr-FR", quality: "high" }) - ]; - - const groups = manager.groupVoices(testVoices, "quality"); - t.is(Object.keys(groups).length, 2); - t.is(groups.high.length, 2); - t.is(groups.low.length, 1); -}); - -testWithContext("groupVoices: groups by region", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Create test voices with different regions - const testVoices = [ - createTestVoice({ name: "US Voice", language: "en-US" }), - createTestVoice({ name: "UK Voice", language: "en-GB" }), - createTestVoice({ name: "Canada Voice", language: "en-CA" }), - createTestVoice({ name: "Australia Voice", language: "en-AU" }) - ]; - - const groups = manager.groupVoices(testVoices, "region"); - t.is(Object.keys(groups).length, 4); - t.is(groups.US.length, 1); - t.is(groups.GB.length, 1); - t.is(groups.CA.length, 1); - t.is(groups.AU.length, 1); -}); - -testWithContext("groupVoices: handles empty voices array", (t: ExecutionContext) => { - const manager = t.context.manager; - - const groups = manager.groupVoices([], "language"); - t.deepEqual(groups, {}); -}); - -testWithContext("groupVoices: handles voices with missing properties", (t: ExecutionContext) => { - const manager = t.context.manager; - - const testVoices = [ - createTestVoice({ name: "Voice 1", language: "en-US" }), - createTestVoice({ name: "Voice 2", language: undefined as any }), - createTestVoice({ name: "Voice 3", language: "fr-FR", gender: undefined as any }), - createTestVoice({ name: "Voice 4", language: "es-ES", quality: undefined as any }) - ]; - - // Should handle missing properties gracefully - const groupsByLanguage = manager.groupVoices(testVoices, "language"); - t.true(groupsByLanguage.hasOwnProperty("en")); - t.true(groupsByLanguage.hasOwnProperty("fr")); - t.true(groupsByLanguage.hasOwnProperty("es")); - - const groupsByGender = manager.groupVoices(testVoices, "gender"); - // Should have an "undefined" group for voices without gender - - const groupsByQuality = manager.groupVoices(testVoices, "quality"); - // Should have an "undefined" group for voices without quality -}); - -// ============================================= -// 10. Conversion Tests -// ============================================= - -testWithContext("convertToSpeechSynthesisVoice: converts ReadiumSpeechVoice to SpeechSynthesisVoice", async (t: ExecutionContext) => { - const manager = t.context.manager; - const voices = await manager.getVoices(); - t.plan(3); - - if (voices.length > 0) { - const speechVoice = manager.convertToSpeechSynthesisVoice(voices[0]); - t.truthy(speechVoice); - t.is(speechVoice?.name, voices[0].name); - t.is(speechVoice?.voiceURI, voices[0].voiceURI); - } else { - t.pass("No voices available to test"); - } -}); - -testWithContext("convertToSpeechSynthesisVoice: handles undefined voiceURI", async (t: ExecutionContext) => { - const manager = t.context.manager; - const voices = await manager.getVoices(); - - if (voices.length > 0) { - // Create a test voice with undefined voiceURI but with name and originalName - const testVoice = { - ...voices[0], - voiceURI: undefined, - name: "Test Voice", - originalName: "Test Voice (Original)" - }; - - // Mock browserVoices to include a matching voice by name - const mockBrowserVoice = { - voiceURI: "mock-voice-uri", - name: "Test Voice (Original)", - lang: "en-US", - localService: true, - default: false - }; - - // Save original browserVoices and replace with our mock - const originalBrowserVoices = (manager as any).browserVoices; - (manager as any).browserVoices = [mockBrowserVoice]; - - try { - const result = manager.convertToSpeechSynthesisVoice(testVoice); - t.truthy(result, "Should return a voice when matching by original name"); - t.is(result?.name, "Test Voice (Original)", "Should match by original name when voiceURI is undefined"); - } finally { - // Restore original browserVoices - (manager as any).browserVoices = originalBrowserVoices; - } - } else { - t.pass("No voices available to test"); - } -}); - -testWithContext("convertToSpeechSynthesisVoice: handles undefined voiceURI and originalName", async (t: ExecutionContext) => { - const manager = t.context.manager; - const voices = await manager.getVoices(); - - if (voices.length > 0) { - // Create a test voice with undefined voiceURI and originalName - const testVoice = { - ...voices[0], - voiceURI: undefined, - name: "Test Voice", - originalName: undefined - }; - - // Mock browserVoices to include a matching voice by name - const mockBrowserVoice = { - voiceURI: "mock-voice-uri", - name: "Test Voice (Original)", - lang: "en-US", - localService: true, - default: false - }; - - // Save original browserVoices and replace with our mock - const originalBrowserVoices = (manager as any).browserVoices; - (manager as any).browserVoices = [mockBrowserVoice]; - - try { - const result = manager.convertToSpeechSynthesisVoice(testVoice as any); - t.truthy(result, "Should return a voice when matching by original name"); - t.is(result?.name, "Test Voice (Original)", "Should match by name when voiceURI and Original name are undefined"); - } finally { - // Restore original browserVoices - (manager as any).browserVoices = originalBrowserVoices; - } - } else { - t.pass("No voices available to test"); - } -}); - -testWithContext("convertToSpeechSynthesisVoice: handles invalid voice", (t: ExecutionContext) => { - const manager = t.context.manager; - - // Test with undefined voice - const result1 = manager.convertToSpeechSynthesisVoice(undefined as any); - t.is(result1, undefined); - - // Test with voice that doesn't match any browser voice - const invalidVoice = createTestVoice({ name: "Non-existent Voice", language: "xx-XX" }); - const result2 = manager.convertToSpeechSynthesisVoice(invalidVoice); - t.is(result2, undefined); -}); \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/convertToSpeechSynthesisVoice.test.ts b/test/WebSpeechVoiceManager/convertToSpeechSynthesisVoice.test.ts new file mode 100644 index 0000000..7524660 --- /dev/null +++ b/test/WebSpeechVoiceManager/convertToSpeechSynthesisVoice.test.ts @@ -0,0 +1,132 @@ +import { type ExecutionContext } from "ava"; +import { testWithContext, TestContext, createTestVoice } from "./setup.js"; +import { WebSpeechVoiceManager } from "../../build/index.js"; + +// ============================================= +// Test Hooks +// ============================================= + +testWithContext.beforeEach(async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); +}); + +testWithContext.afterEach.always((t: ExecutionContext) => { + // Clean up singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; +}); + +// ============================================= +// convertToSpeechSynthesisVoice Tests +// ============================================= + +testWithContext("convertToSpeechSynthesisVoice: converts ReadiumSpeechVoice to SpeechSynthesisVoice", async (t: ExecutionContext) => { + const manager = t.context.manager; + const voices = await manager.getVoices(); + t.plan(3); + + if (voices.length > 0) { + const speechVoice = manager.convertToSpeechSynthesisVoice(voices[0]); + t.truthy(speechVoice); + t.is(speechVoice?.name, voices[0].name); + t.is(speechVoice?.voiceURI, voices[0].voiceURI); + } else { + t.pass("No voices available to test"); + } +}); + +testWithContext("convertToSpeechSynthesisVoice: handles undefined voiceURI", async (t: ExecutionContext) => { + const manager = t.context.manager; + const voices = await manager.getVoices(); + + if (voices.length > 0) { + // Create a test voice with undefined voiceURI but with name and originalName + const testVoice = { + ...voices[0], + voiceURI: undefined, + name: "Test Voice", + originalName: "Test Voice (Original)" + }; + + // Mock browserVoices to include a matching voice by name + const mockBrowserVoice = { + voiceURI: "mock-voice-uri", + name: "Test Voice (Original)", + lang: "en-US", + localService: true, + default: false + }; + + // Save original browserVoices and replace with our mock + const originalBrowserVoices = (manager as any).browserVoices; + (manager as any).browserVoices = [mockBrowserVoice]; + + try { + const result = manager.convertToSpeechSynthesisVoice(testVoice); + t.truthy(result, "Should return a voice when matching by original name"); + t.is(result?.name, "Test Voice (Original)", "Should match by original name when voiceURI is undefined"); + } finally { + // Restore original browserVoices + (manager as any).browserVoices = originalBrowserVoices; + } + } else { + t.pass("No voices available to test"); + } +}); + +testWithContext("convertToSpeechSynthesisVoice: handles undefined voiceURI and originalName", async (t: ExecutionContext) => { + const manager = t.context.manager; + const voices = await manager.getVoices(); + + if (voices.length > 0) { + // Create a test voice with undefined voiceURI and originalName + const testVoice = { + ...voices[0], + voiceURI: undefined, + name: "Test Voice", + originalName: undefined + }; + + // Mock browserVoices to include a matching voice by name + const mockBrowserVoice = { + voiceURI: "mock-voice-uri", + name: "Test Voice (Original)", + lang: "en-US", + localService: true, + default: false + }; + + // Save original browserVoices and replace with our mock + const originalBrowserVoices = (manager as any).browserVoices; + (manager as any).browserVoices = [mockBrowserVoice]; + + try { + const result = manager.convertToSpeechSynthesisVoice(testVoice as any); + t.truthy(result, "Should return a voice when matching by original name"); + t.is(result?.name, "Test Voice (Original)", "Should match by name when voiceURI and Original name are undefined"); + } finally { + // Restore original browserVoices + (manager as any).browserVoices = originalBrowserVoices; + } + } else { + t.pass("No voices available to test"); + } +}); + +testWithContext("convertToSpeechSynthesisVoice: handles invalid voice", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Test with undefined voice + const result1 = manager.convertToSpeechSynthesisVoice(undefined as any); + t.is(result1, undefined); + + // Test with voice that doesn't match any browser voice + const invalidVoice = createTestVoice({ name: "Non-existent Voice", language: "xx-XX" }); + const result2 = manager.convertToSpeechSynthesisVoice(invalidVoice); + t.is(result2, undefined); +}); \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/filterVoices.test.ts b/test/WebSpeechVoiceManager/filterVoices.test.ts new file mode 100644 index 0000000..796e5ee --- /dev/null +++ b/test/WebSpeechVoiceManager/filterVoices.test.ts @@ -0,0 +1,292 @@ +import { type ExecutionContext } from "ava"; +import { testWithContext, TestContext, createTestVoice } from "./setup.js"; +import { ReadiumSpeechVoice, WebSpeechVoiceManager } from "../../build/index.js"; + +// ============================================= +// Test Hooks +// ============================================= + +testWithContext.beforeEach(async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); +}); + +testWithContext.afterEach.always((t: ExecutionContext) => { + // Clean up singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; +}); + +// ============================================= +// filterVoices Tests +// ============================================= + +testWithContext("filterVoices: filters by language", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices + const testVoices = [ + createTestVoice({ name: "English Voice 1", language: "en-US" }), + createTestVoice({ name: "English Voice 2", language: "en-GB" }), + createTestVoice({ name: "French Voice", language: "fr-FR" }), + createTestVoice({ name: "Spanish Voice", language: "es-ES" }) + ]; + + const englishVoices = manager.filterVoices(testVoices, { language: "en" }); + t.is(englishVoices.length, 2); + t.true(englishVoices.every(v => v.language.startsWith("en"))); + + const multiLangVoices = manager.filterVoices(testVoices, { language: ["en", "fr"] }); + t.is(multiLangVoices.length, 3); + t.true(multiLangVoices.every(v => v.language.startsWith("en") || v.language.startsWith("fr"))); +}); + +testWithContext("filterVoices: filters by source", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different sources + const testVoices = [ + createTestVoice({ name: "JSON Voice 1", source: "json" }), + createTestVoice({ name: "JSON Voice 2", source: "json" }), + createTestVoice({ name: "Browser Voice 1", source: "browser" }), + ]; + + const jsonVoices = manager.filterVoices(testVoices, { source: "json" }); + t.is(jsonVoices.length, 2); + t.true(jsonVoices.every(v => v.source === "json")); + + const browserVoices = manager.filterVoices(testVoices, { source: "browser"}); + t.is(browserVoices.length, 1); + t.true(browserVoices.every(v => v.source === "browser")); +}); + +testWithContext("filterVoices: filters by gender", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different genders + const testVoices = [ + createTestVoice({ name: "Male Voice 1", language: "en-US", gender: "male" }), + createTestVoice({ name: "Female Voice 1", language: "en-US", gender: "female" }), + createTestVoice({ name: "Male Voice 2", language: "en-US", gender: "male" }), + createTestVoice({ name: "Unknown Gender Voice", language: "en-US" }) + ]; + + const maleVoices = manager.filterVoices(testVoices, { gender: "male" }); + t.is(maleVoices.length, 2); + t.true(maleVoices.every(v => v.gender === "male")); + + const femaleVoices = manager.filterVoices(testVoices, { gender: "female" }); + t.is(femaleVoices.length, 1); + t.is(femaleVoices[0].gender, "female"); +}); + +testWithContext("filterVoices: filters by quality array", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different quality levels + const testVoices = [ + createTestVoice({ name: "High Quality Voice", language: "en-US", quality: "high" }), + createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: "low" }), + createTestVoice({ name: "Normal Quality Voice", language: "en-US", quality: "normal" }), + createTestVoice({ name: "Very High Quality Voice", language: "en-US", quality: "veryHigh" }), + createTestVoice({ name: "No Quality Voice", language: "en-US", quality: undefined }) + ]; + + // Test single quality filter + const highQualityVoices = manager.filterVoices(testVoices, { quality: "high" }); + t.is(highQualityVoices.length, 1); // Only the high quality voice + + // Test multiple quality filter + const multiQualityVoices = manager.filterVoices(testVoices, { quality: ["high", "normal"] }); + t.is(multiQualityVoices.length, 2); // high and normal quality voices + + // Test that undefined quality voices are filtered out + const filteredVoices = manager.filterVoices(testVoices, { quality: "high" }); + t.false(filteredVoices.some(v => v.quality === undefined)); +}); + +testWithContext("filterVoices: filters out novelty and low quality voices", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices using the createTestVoice helper + const testVoices = [ + createTestVoice({ + voiceURI: "com.apple.speech.synthesis.voice.Albert", + name: "Albert", + language: "en-US", + isNovelty: true + }), + createTestVoice({ + voiceURI: "com.appk.it.speech.synthesis.voice.Eddy", + name: "Eddy", + language: "en-US", + quality: "veryLow" + }) + ]; + + // Test filtering with default options (should filter out both voices) + const filteredVoices = manager.filterVoices(testVoices, { + excludeNovelty: true, + excludeVeryLowQuality: true + }); + t.is(filteredVoices.length, 0, "Should filter out all test voices by default"); + + // Test including them by disabling the filters + const allVoices = manager.filterVoices(testVoices, { + excludeNovelty: false, + excludeVeryLowQuality: false + }); + t.is(allVoices.length, 2, "Should include all voices when not filtered"); +}); + +testWithContext("filterVoices: filters by offline availability", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different offline availability + const testVoices = [ + createTestVoice({ name: "Offline Voice 1", language: "en-US", offlineAvailability: true }), + createTestVoice({ name: "Online Voice 1", language: "en-US", offlineAvailability: false }), + createTestVoice({ name: "Offline Voice 2", language: "en-US", offlineAvailability: true }), + createTestVoice({ name: "Undefined Availability Voice", language: "en-US" }) + ]; + + const offlineVoices = manager.filterVoices(testVoices, { offlineOnly: true }); + t.is(offlineVoices.length, 2); + t.true(offlineVoices.every(v => v.offlineAvailability === true)); + + // Test that undefined and false values are filtered out + t.false(offlineVoices.some(v => v.offlineAvailability === false)); + t.false(offlineVoices.some(v => v.offlineAvailability === undefined)); +}); + +testWithContext("filterVoices: filters by provider", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different providers + const testVoices = [ + createTestVoice({ name: "Google Voice", language: "en-US", provider: "Google" }), + createTestVoice({ name: "Microsoft Voice", language: "en-US", provider: "Microsoft" }), + createTestVoice({ name: "Amazon Voice", language: "en-US", provider: "Amazon" }), + createTestVoice({ name: "Another Google Voice", language: "en-US", provider: "Google" }) + ]; + + const googleVoices = manager.filterVoices(testVoices, { provider: "Google" }); + t.is(googleVoices.length, 2); + t.true(googleVoices.every(v => v.provider === "Google")); + + // Test case insensitive matching + const caseInsensitiveVoices = manager.filterVoices(testVoices, { provider: "google" }); + t.is(caseInsensitiveVoices.length, 2); +}); + +testWithContext("filterVoices: combines multiple filters", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with various properties + const testVoices = [ + createTestVoice({ name: "Male High Quality English", language: "en-US", gender: "male", quality: "high", provider: "Google" }), + createTestVoice({ name: "Female Low Quality English", language: "en-US", gender: "female", quality: "low", provider: "Google" }), + createTestVoice({ name: "Male High Quality French", language: "fr-FR", gender: "male", quality: "high", provider: "Microsoft" }), + createTestVoice({ name: "Female Normal Quality English", language: "en-US", gender: "female", quality: "normal", provider: "Google" }) + ]; + + // Filter by language and gender + const englishFemaleVoices = manager.filterVoices(testVoices, { + language: "en", + gender: "female" + }); + t.is(englishFemaleVoices.length, 2); + t.true(englishFemaleVoices.every(v => + v.language.startsWith("en") && v.gender === "female" + )); + + // Filter by quality and provider + const highQualityGoogleVoices = manager.filterVoices(testVoices, { + quality: "high", + provider: "Google" + }); + t.is(highQualityGoogleVoices.length, 1); + t.is(highQualityGoogleVoices[0].name, "Male High Quality English"); +}); + +testWithContext("filterVoices: handles edge cases", (t: ExecutionContext) => { + const manager = t.context.manager; + + const testVoices = [ + createTestVoice({ name: "Voice 1", language: "en-US", gender: "male", quality: "high" }), + createTestVoice({ name: "Voice 2", language: "fr-FR", gender: "female", quality: "low" }), + createTestVoice({ name: "Voice 3", language: "de-DE", gender: "male", quality: "normal" }) + ]; + + // Test empty filter arrays + const emptyLanguageFilter = manager.filterVoices(testVoices, { language: [] }); + t.is(emptyLanguageFilter.length, 0); + + const emptyQualityFilter = manager.filterVoices(testVoices, { quality: undefined }); + t.is(emptyQualityFilter.length, testVoices.length, "Should return all voices when quality is undefined"); + + // Test case sensitivity for language + const caseSensitiveLanguage = manager.filterVoices(testVoices, { language: "EN-us" }); + t.is(caseSensitiveLanguage.length, 1); // Should match due to toLowerCase() + + // Test invalid quality values - cast to any for testing invalid input + const invalidQualityFilter = manager.filterVoices(testVoices, { quality: "invalid" as any }); + t.is(invalidQualityFilter.length, 0); +}); + +testWithContext("filterVoices: uses array values for multiple filters", (t: ExecutionContext) => { + const manager = t.context.manager; + + const testVoices = [ + createTestVoice({ name: "English Male", language: "en-US", gender: "male", quality: "high" }), + createTestVoice({ name: "English Female", language: "en-US", gender: "female", quality: "normal" }), + createTestVoice({ name: "French Male", language: "fr-FR", gender: "male", quality: "low" }), + createTestVoice({ name: "French Female", language: "fr-FR", gender: "female", quality: "high" }), + createTestVoice({ name: "Spanish Male", language: "es-ES", gender: "male", quality: "normal" }) + ]; + + // Test with array of languages and array of qualities + const filtered = manager.filterVoices(testVoices, { + language: ["en", "fr"], + quality: ["high", "normal"] + }); + t.is(filtered.length, 3); + t.true(filtered.every(v => + (v.language.startsWith("en") || v.language.startsWith("fr")) && + (v.quality === "high" || v.quality === "normal") + )); +}); + +testWithContext("filterOutNoveltyVoices: removes novelty voices", (t: ExecutionContext) => { + const manager = t.context.manager; + + const testVoices = [ + createTestVoice({ name: "Regular Voice 1", language: "en-US" }), + createTestVoice({ name: "Novelty Voice 1", language: "en-US", isNovelty: true }), + createTestVoice({ name: "Regular Voice 2", language: "en-US" }), + createTestVoice({ name: "Novelty Voice 2", language: "en-US", isNovelty: true }) + ]; + + const filtered = manager.filterOutNoveltyVoices(testVoices); + t.is(filtered.length, 2); + t.false(filtered.some((v: ReadiumSpeechVoice) => v.isNovelty)); +}); + +testWithContext("filterOutVeryLowQualityVoices: removes very low quality voices", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with one very low quality voice + const testVoices = [ + createTestVoice({ name: "Voice 1", language: "en-US", quality: "normal" }), + createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: "veryLow" }), + createTestVoice({ name: "Voice 2", language: "fr-FR", quality: "normal" }) + ]; + + const filtered = manager.filterOutVeryLowQualityVoices(testVoices); + t.is(filtered.length, testVoices.length - 1); + t.false(filtered.some((v: ReadiumSpeechVoice) => v.quality === "veryLow")); +}); \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/getDefaultVoice.test.ts b/test/WebSpeechVoiceManager/getDefaultVoice.test.ts new file mode 100644 index 0000000..f8049f6 --- /dev/null +++ b/test/WebSpeechVoiceManager/getDefaultVoice.test.ts @@ -0,0 +1,187 @@ +import { type ExecutionContext } from "ava"; +import { testWithContext, TestContext, mockVoices } from "./setup.js"; +import { WebSpeechVoiceManager } from "../../build/index.js"; + +// ============================================= +// Test Hooks +// ============================================= + +testWithContext.beforeEach(async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); +}); + +testWithContext.afterEach.always((t: ExecutionContext) => { + // Clean up singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; +}); + +// ============================================= +// getDefaultVoice Tests +// ============================================= + +testWithContext("getDefaultVoice: selects highest quality voice regardless of isDefault flag", async (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with quality as the distinguishing factor + const testVoices = [ + { + voiceURI: "voice1", + name: "High Quality", + language: "en-US", + isDefault: false, // Not default but higher quality + quality: "high" + }, + { + voiceURI: "voice2", + name: "Normal Quality", + language: "en-US", + isDefault: true, // Default but lower quality + quality: "normal" + } + ]; + + (manager as any).voices = testVoices; + + const defaultVoice = await manager.getDefaultVoice("en-US"); + t.truthy(defaultVoice); + t.is(defaultVoice?.voiceURI, "voice1", "Should select highest quality voice regardless of isDefault flag"); +}); + +testWithContext("getDefaultVoice: falls back to base language", async (t: ExecutionContext) => { + const manager = t.context.manager; + + const testVoices = [ + { + voiceURI: "voice1", + name: "English Generic", + language: "en", // Base language + isDefault: false, + quality: "high" + }, + { + voiceURI: "voice2", + name: "US English", + language: "en-US", + isDefault: false, + quality: "high" + } + ]; + + (manager as any).voices = testVoices; + + // Request en-GB which isn't available, should fall back to en + const defaultVoice = await manager.getDefaultVoice("en-GB"); + t.truthy(defaultVoice); + t.is(defaultVoice?.language, "en", "Should fall back to base language when exact match not found"); +}); + +testWithContext("getDefaultVoice: respects quality sorting", async (t: ExecutionContext) => { + const manager = t.context.manager; + + const testVoices = [ + { + voiceURI: "voice1", + name: "High Quality", + language: "en-US", + isDefault: false, + quality: "high" + }, + { + voiceURI: "voice2", + name: "Very High Quality", + language: "en-US", + isDefault: false, + quality: "veryHigh" // Higher quality + }, + { + voiceURI: "voice3", + name: "Normal Quality", + language: "en-US", + isDefault: false, + quality: "normal" // Lower quality + } + ]; + + (manager as any).voices = testVoices; + + const defaultVoice = await manager.getDefaultVoice("en-US"); + t.truthy(defaultVoice); + t.is(defaultVoice?.voiceURI, "voice2", "Should select highest quality voice available"); +}); + +testWithContext("getDefaultVoice: returns undefined when no voices available", async (t) => { + // Create a fresh instance to avoid interference + (WebSpeechVoiceManager as any).instance = undefined; + const manager = await WebSpeechVoiceManager.initialize(); + + // Mock empty voices array + const emptyMockVoices: any[] = []; + const mockSpeechSynthesis = { + getVoices: () => emptyMockVoices, + onvoiceschanged: null as (() => void) | null, + addEventListener: function(event: string, callback: () => void) { + if (event === "voiceschanged") { + this.onvoiceschanged = callback; + } + }, + removeEventListener: function(event: string) { + }, + _triggerVoicesChanged: function() { + if (this.onvoiceschanged) { + this.onvoiceschanged(); + } + } + }; + + Object.defineProperty(globalThis.window, "speechSynthesis", { + value: mockSpeechSynthesis, + configurable: true, + writable: true + }); + + try { + // Reset initialization + (manager as any).initializationPromise = null; + (manager as any).voices = []; + (manager as any).browserVoices = []; + + const defaultVoice = manager.getDefaultVoice("en-US"); + t.is(defaultVoice, null); + } finally { + // Restore for other tests + Object.defineProperty(globalThis.window, "speechSynthesis", { + value: { + getVoices: () => mockVoices, + onvoiceschanged: null as (() => void) | null, + addEventListener: function(event: string, callback: () => void) { + if (event === "voiceschanged") { + this.onvoiceschanged = callback; + } + }, + removeEventListener: function(event: string) { + }, + _triggerVoicesChanged: function() { + if (this.onvoiceschanged) { + this.onvoiceschanged(); + } + } + }, + configurable: true, + writable: true + }); + } +}); + +testWithContext("getDefaultVoice: returns null when no matching language", async (t: ExecutionContext) => { + const manager = t.context.manager; + + // Test with language that has no voices + const result = await manager.getDefaultVoice("xx-XX"); + t.is(result, null); +}); \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/getLanguages.test.ts b/test/WebSpeechVoiceManager/getLanguages.test.ts new file mode 100644 index 0000000..f537bbc --- /dev/null +++ b/test/WebSpeechVoiceManager/getLanguages.test.ts @@ -0,0 +1,148 @@ +import { type ExecutionContext } from "ava"; +import { testWithContext, TestContext, mockVoices, mockSpeechSynthesis } from "./setup.js"; +import { WebSpeechVoiceManager } from "../../build/index.js"; + +// ============================================= +// Test Hooks +// ============================================= + +testWithContext.beforeEach(async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); +}); + +testWithContext.afterEach.always((t: ExecutionContext) => { + // Clean up singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; +}); + +// ============================================= +// getLanguages Tests +// ============================================= + +testWithContext("getLanguages: returns available languages with counts", async (t: ExecutionContext) => { + const languages = await t.context.manager.getLanguages(); + t.true(Array.isArray(languages)); + + // Check that we have at least one language + t.true(languages.length > 0); + + // Check structure of language entries + for (const lang of languages) { + t.truthy(lang.code); + t.truthy(lang.label); + t.true(typeof lang.count === "number"); + } +}); + +testWithContext("getLanguages: handles empty voices array", async (t: ExecutionContext) => { + // Save the original getVoices implementation + const originalGetVoices = mockSpeechSynthesis.getVoices; + + try { + // Override getVoices to return empty array + mockSpeechSynthesis.getVoices = () => []; + + // Create a fresh instance to avoid interference + (WebSpeechVoiceManager as any).instance = undefined; + const manager = await WebSpeechVoiceManager.initialize(); + + // Reset initialization to force re-initialization with empty voices + (manager as any).initializationPromise = null; + (manager as any).voices = []; + (manager as any).browserVoices = []; + + const languages = manager.getLanguages(); + t.deepEqual(languages, []); + } finally { + // Restore original getVoices implementation + mockSpeechSynthesis.getVoices = originalGetVoices; + } +}); + +// ============================================= +// 4. Region Retrieval Tests +// ============================================= + +testWithContext("getRegions: returns available regions with counts", async (t: ExecutionContext) => { + const regions = await t.context.manager.getRegions(); + t.true(Array.isArray(regions)); + + // Check that we have at least one region + t.true(regions.length > 0); + + // Check structure of region entries + for (const region of regions) { + t.truthy(region.code); + t.truthy(region.label); + t.true(typeof region.count === "number"); + } +}); + +testWithContext("getRegions: handles empty voices array", async (t: ExecutionContext) => { + // Create a fresh instance to avoid interference + (WebSpeechVoiceManager as any).instance = undefined; + const manager = await WebSpeechVoiceManager.initialize(); + + // Mock empty voices array + const emptyMockVoices: any[] = []; + const mockSpeechSynthesis = { + getVoices: () => emptyMockVoices, + onvoiceschanged: null as (() => void) | null, + addEventListener: function(event: string, callback: () => void) { + if (event === "voiceschanged") { + this.onvoiceschanged = callback; + } + }, + removeEventListener: function(event: string) { + }, + _triggerVoicesChanged: function() { + if (this.onvoiceschanged) { + this.onvoiceschanged(); + } + } + }; + + Object.defineProperty(globalThis.window, "speechSynthesis", { + value: mockSpeechSynthesis, + configurable: true, + writable: true + }); + + try { + // Reset initialization + (manager as any).initializationPromise = null; + (manager as any).voices = []; + (manager as any).browserVoices = []; + + const regions = manager.getRegions(); + t.deepEqual(regions, []); + } finally { + // Restore for other tests + Object.defineProperty(globalThis.window, "speechSynthesis", { + value: { + getVoices: () => mockVoices, + onvoiceschanged: null as (() => void) | null, + addEventListener: function(event: string, callback: () => void) { + if (event === "voiceschanged") { + this.onvoiceschanged = callback; + } + }, + removeEventListener: function(event: string) { + }, + _triggerVoicesChanged: function() { + if (this.onvoiceschanged) { + this.onvoiceschanged(); + } + } + }, + configurable: true, + writable: true + }); + } +}); \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/getRegions.test.ts b/test/WebSpeechVoiceManager/getRegions.test.ts new file mode 100644 index 0000000..a08f03d --- /dev/null +++ b/test/WebSpeechVoiceManager/getRegions.test.ts @@ -0,0 +1,67 @@ +import test, { type ExecutionContext } from "ava"; +import { testWithContext, TestContext, originalNavigator, originalSpeechSynthesis, mockSpeechSynthesis } from "./setup.js"; +import { WebSpeechVoiceManager } from "../../build/index.js"; + +// ============================================= +// Test Hooks +// ============================================= + +testWithContext.beforeEach(async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); +}); + +testWithContext.afterEach.always((t: ExecutionContext) => { + // Clean up singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; +}); + +// ============================================= +// getRegions Tests +// ============================================= + +testWithContext("getRegions: returns available regions with counts", async (t: ExecutionContext) => { + const regions = await t.context.manager.getRegions(); + t.true(Array.isArray(regions)); + + // Check that we have at least one region + t.true(regions.length > 0); + + // Check structure of region entries + for (const region of regions) { + t.truthy(region.code); + t.truthy(region.label); + t.true(typeof region.count === "number"); + } +}); + +testWithContext("getRegions: handles empty voices array", async (t: ExecutionContext) => { + // Save the original getVoices implementation + const originalGetVoices = mockSpeechSynthesis.getVoices; + + try { + // Override getVoices to return empty array + mockSpeechSynthesis.getVoices = () => []; + + // Create a fresh instance to avoid interference + (WebSpeechVoiceManager as any).instance = undefined; + const manager = await WebSpeechVoiceManager.initialize(); + + // Reset initialization to force re-initialization with empty voices + (manager as any).initializationPromise = null; + (manager as any).voices = []; + + const regions = await manager.getRegions(); + t.true(Array.isArray(regions)); + t.is(regions.length, 0); + + } finally { + // Restore original getVoices + mockSpeechSynthesis.getVoices = originalGetVoices; + } +}); diff --git a/test/WebSpeechVoiceManager/getTestUtterance.test.ts b/test/WebSpeechVoiceManager/getTestUtterance.test.ts new file mode 100644 index 0000000..cfe54fa --- /dev/null +++ b/test/WebSpeechVoiceManager/getTestUtterance.test.ts @@ -0,0 +1,49 @@ +import { type ExecutionContext } from "ava"; +import { testWithContext, TestContext } from "./setup.js"; +import { WebSpeechVoiceManager } from "../../build/index.js"; + +// ============================================= +// Test Hooks +// ============================================= + +testWithContext.beforeEach(async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); +}); + +testWithContext.afterEach.always((t: ExecutionContext) => { + // Clean up singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; +}); + +// ============================================= +// getTestUtterance Tests +// ============================================= + +testWithContext("getTestUtterance: returns test utterance for supported language", (t) => { + const manager = t.context.manager; + + // Test with a base language + const utterance1 = manager.getTestUtterance("en"); + t.is(typeof utterance1, "string"); + t.true(utterance1 && utterance1.length > 0); + + // Test with a locale variant (should fall back to base language) + const utterance2 = manager.getTestUtterance("en-US"); + t.is(typeof utterance2, "string"); + t.true(utterance2 && utterance2.length > 0); + t.is(utterance1, utterance2); // Should be the same +}); + +testWithContext("getTestUtterance: returns empty string for unsupported language", (t) => { + const manager = t.context.manager; + + // Test with an unsupported language + const utterance = manager.getTestUtterance("xx-XX"); + t.is(utterance, ""); +}); \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/getVoices.test.ts b/test/WebSpeechVoiceManager/getVoices.test.ts new file mode 100644 index 0000000..6557a07 --- /dev/null +++ b/test/WebSpeechVoiceManager/getVoices.test.ts @@ -0,0 +1,214 @@ +import test, { type ExecutionContext } from "ava"; +import { testWithContext, TestContext, createTestVoice, mockVoices, originalNavigator, originalSpeechSynthesis, mockSpeechSynthesis } from "./setup.js"; +import { ReadiumSpeechVoice, WebSpeechVoiceManager } from "../../build/index.js"; + +// ============================================= +// Test Hooks +// ============================================= + +testWithContext.beforeEach(async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); +}); + +testWithContext.afterEach.always((t: ExecutionContext) => { + // Clean up singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; +}); + +// ============================================= +// getVoices Tests +// ============================================= + +testWithContext("getVoices: returns all voices when no filters are provided", (t) => { + const voices = t.context.manager.getVoices(); + t.is(voices.length, mockVoices.length); +}); + +testWithContext("getVoices: throws if not initialized", (t) => { + // Create a new instance without initializing + const manager = new (WebSpeechVoiceManager as any)(); + t.throws(() => manager.getVoices(), { + message: "WebSpeechVoiceManager not initialized. Call initialize() first." + }); +}); + +testWithContext("getVoices: combines all filters", async (t: ExecutionContext) => { + const manager = t.context.manager; + + (manager as any).voices = [ + createTestVoice({ name: "Male High Quality English", language: "en-US", gender: "male", quality: "high", provider: "Google", offlineAvailability: true }), + createTestVoice({ name: "English Female Normal", language: "en-US", gender: "female", quality: "normal", provider: "Microsoft", offlineAvailability: false }), + createTestVoice({ name: "French Male Low", language: "fr-FR", gender: "male", quality: "low", provider: "Google", offlineAvailability: true }), + createTestVoice({ name: "French Female High", language: "fr-FR", gender: "female", quality: "high", provider: "Amazon", offlineAvailability: false }), + createTestVoice({ name: "Spanish Male Normal", language: "es-ES", gender: "male", quality: "normal", provider: "Microsoft", offlineAvailability: true }) + ]; + + // Test with all filters combined + const filtered = await manager.getVoices({ + language: ["en", "fr"], + gender: "male", + quality: ["high", "normal"], + provider: "Google", + offlineOnly: true, + excludeNovelty: true, + excludeVeryLowQuality: true + }); + + t.is(filtered.length, 1); + t.true(filtered.every(v => + (v.language.startsWith("en") || v.language.startsWith("fr")) && + v.gender === "male" && + (v.quality?.includes("high") || v.quality?.includes("normal")) && + v.provider === "Google" && + v.offlineAvailability === true + )); +}); + +testWithContext("getVoices: handles empty navigator.languages", async (t) => { + const manager = t.context.manager; + + // Create test voices + const testVoices = [ + { voiceURI: "voice1", name: "Voice 1", language: "en-US" }, + { voiceURI: "voice2", name: "Voice 2", language: "fr-FR" } + ]; + + // Replace the voices in the manager + (manager as any).voices = testVoices; + + // Mock empty navigator.languages + const originalLanguages = [...(globalThis.navigator as any).languages]; + (globalThis.navigator as any).languages = []; + + try { + const voices = await manager.getVoices(); + + // Should still return all voices even with empty languages + t.is(voices.length, 2); + } finally { + // Restore original languages + (globalThis.navigator as any).languages = originalLanguages; + } +}); + +testWithContext("getVoices: handles undefined navigator.languages", async (t) => { + const manager = t.context.manager; + + // Create test voices + const testVoices = [ + { voiceURI: "voice1", name: "Voice 1", language: "en-US" }, + { voiceURI: "voice2", name: "Voice 2", language: "fr-FR" } + ]; + + // Replace the voices in the manager + (manager as any).voices = testVoices; + + // Mock undefined navigator.languages + const originalLanguages = (globalThis.navigator as any).languages; + delete (globalThis.navigator as any).languages; + + try { + const voices = await manager.getVoices(); + + // Should still return all voices even with undefined languages + t.is(voices.length, 2); + } finally { + // Restore original languages + (globalThis.navigator as any).languages = originalLanguages; + } +}); + + +testWithContext("getVoices: returns empty array when no voices are available", async (t) => { + // Save the original getVoices implementation + const originalGetVoices = mockSpeechSynthesis.getVoices; + + try { + // Override getVoices to return empty array + mockSpeechSynthesis.getVoices = () => []; + + // Create a fresh instance to avoid interference from other tests + (WebSpeechVoiceManager as any).instance = undefined; + const manager = await WebSpeechVoiceManager.initialize(); + + // Reset initialization to force re-initialization with empty voices + (manager as any).initializationPromise = null; + (manager as any).voices = []; + (manager as any).browserVoices = []; + + // Should return empty array when no voices are available + const voices = manager.getVoices(); + t.deepEqual(voices, []); + } finally { + // Restore original getVoices implementation + mockSpeechSynthesis.getVoices = originalGetVoices; + } +}); + +testWithContext("getVoices: filters by language", async (t: ExecutionContext) => { + const manager = t.context.manager; + + // Single language + let voices = await manager.getVoices({ language: "en" }); + t.true(voices.length > 0); + t.true(voices.every((v: ReadiumSpeechVoice) => v.language.startsWith("en"))); + + // Multiple languages + voices = await manager.getVoices({ language: ["en", "fr"] }); + t.true(voices.length > 1); + t.true(voices.some((v: ReadiumSpeechVoice) => v.language.startsWith("en"))); + t.true(voices.some((v: ReadiumSpeechVoice) => v.language.startsWith("fr"))); +}); + +testWithContext("getVoices: filters by quality", async (t: ExecutionContext) => { + const manager = t.context.manager; + + // Mock quality property on voices + const voices = await manager.getVoices(); + const voicesWithQuality = voices.map((v: ReadiumSpeechVoice, i: number) => ({ + ...v, + quality: i % 2 === 0 ? "high" : "low" + })); + + // Replace the voices in the manager + (manager as any).voices = voicesWithQuality; + + const highQualityVoices = await manager.getVoices({ quality: "high" }); + t.true(highQualityVoices.length > 0); + t.true(highQualityVoices.every((v: ReadiumSpeechVoice) => v.quality === "high")); +}); + +testWithContext("getVoices: returns empty array when speechSynthesis is not available", async (t) => { + // Save original + const originalSpeechSynthesis = globalThis.speechSynthesis; + + try { + // Mock speechSynthesis to be undefined + Object.defineProperty(globalThis, "speechSynthesis", { + value: undefined, + configurable: true, + writable: true + }); + + // Create a new instance + (WebSpeechVoiceManager as any).instance = undefined; + const manager = await WebSpeechVoiceManager.initialize(); + + // Should return empty array when speechSynthesis is not available + const voices = manager.getVoices(); + t.deepEqual(voices, []); + } finally { + // Restore + Object.defineProperty(globalThis, "speechSynthesis", { + value: originalSpeechSynthesis, + configurable: true, + writable: true + }); + } +}); \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/groupVoices.test.ts b/test/WebSpeechVoiceManager/groupVoices.test.ts new file mode 100644 index 0000000..893ceab --- /dev/null +++ b/test/WebSpeechVoiceManager/groupVoices.test.ts @@ -0,0 +1,136 @@ +import { type ExecutionContext } from "ava"; +import { testWithContext, TestContext, createTestVoice } from "./setup.js"; +import { WebSpeechVoiceManager } from "../../build/index.js"; + +// ============================================= +// Test Hooks +// ============================================= + +testWithContext.beforeEach(async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); +}); + +testWithContext.afterEach.always((t: ExecutionContext) => { + // Clean up singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; +}); + +// ============================================= +// groupVoices Tests +// ============================================= + +testWithContext("groupVoices: groups by language", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different languages + const testVoices = [ + { voiceURI: "voice1", name: "Voice 1", language: "en-US" }, + { voiceURI: "voice2", name: "Voice 2", language: "fr-FR" }, + { voiceURI: "voice3", name: "Voice 3", language: "en-US" } + ]; + + const groups = (manager as any).groupVoices(testVoices, "language"); + + // Check that groups were created for each language + t.truthy(groups["en"]); + t.truthy(groups["fr"]); + + // Check the number of voices in each group + t.is(groups["en"].length, 2); + t.is(groups["fr"].length, 1); +}); + +testWithContext("groupVoices: groups by gender", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different genders + const testVoices = [ + createTestVoice({ name: "Male Voice 1", language: "en-US", gender: "male" }), + createTestVoice({ name: "Female Voice 1", language: "en-US", gender: "female" }), + createTestVoice({ name: "Male Voice 2", language: "fr-FR", gender: "male" }), + createTestVoice({ name: "Unknown Voice", language: "es-ES" }) + ]; + + const groups = manager.groupVoices(testVoices, "gender"); + t.true(groups.hasOwnProperty("male")); + t.true(groups.hasOwnProperty("female")); + t.true(groups.hasOwnProperty("unknown")); + t.is(groups.male.length, 2); + t.is(groups.female.length, 1); + t.is(groups.unknown.length, 1); +}); + +testWithContext("groupVoices: groups by quality", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different qualities + const testVoices = [ + createTestVoice({ name: "High Quality 1", language: "en-US", quality: "high" }), + createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: "low" }), + createTestVoice({ name: "High Quality 2", language: "fr-FR", quality: "high" }) + ]; + + const groups = manager.groupVoices(testVoices, "quality"); + t.is(Object.keys(groups).length, 2); + t.is(groups.high.length, 2); + t.is(groups.low.length, 1); +}); + +testWithContext("groupVoices: groups by region", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different regions + const testVoices = [ + createTestVoice({ name: "US Voice", language: "en-US" }), + createTestVoice({ name: "UK Voice", language: "en-GB" }), + createTestVoice({ name: "Canada Voice", language: "en-CA" }), + createTestVoice({ name: "Australia Voice", language: "en-AU" }) + ]; + + const groups = manager.groupVoices(testVoices, "region"); + t.is(Object.keys(groups).length, 4); + t.is(groups.US.length, 1); + t.is(groups.GB.length, 1); + t.is(groups.CA.length, 1); + t.is(groups.AU.length, 1); +}); + +testWithContext("groupVoices: handles empty voices array", (t: ExecutionContext) => { + const manager = t.context.manager; + + const groups = manager.groupVoices([], "language"); + t.deepEqual(groups, {}); +}); + +testWithContext("groupVoices: handles voices with missing properties", (t: ExecutionContext) => { + const manager = t.context.manager; + + const testVoices = [ + createTestVoice({ name: "Voice 1", language: "en-US", gender: "female" }), + createTestVoice({ name: "Voice 2", language: undefined as any }), + createTestVoice({ name: "Voice 3", language: "fr-FR", gender: undefined as any }), + createTestVoice({ name: "Voice 4", language: "es-ES", quality: "high" }) + ]; + + // Should handle missing properties gracefully + const groupsByLanguage = manager.groupVoices(testVoices, "language"); + t.true(groupsByLanguage.hasOwnProperty("en")); + t.true(groupsByLanguage.hasOwnProperty("fr")); + t.true(groupsByLanguage.hasOwnProperty("es")); + + const groupsByGender = manager.groupVoices(testVoices, "gender"); + // Should have an "unknown" group for voices without gender + t.true(groupsByGender.hasOwnProperty("unknown")); + t.is(groupsByGender.unknown.length, 3); // Voice 2, Voice 3, Voice 4 have no gender + + const groupsByQuality = manager.groupVoices(testVoices, "quality"); + // Should have an "unknown" group for voices without quality + t.true(groupsByQuality.hasOwnProperty("unknown")); + t.is(groupsByQuality.unknown.length, 3); // Voice 2, Voice 3, Voice 4 have no quality +}); \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/initialization.test.ts b/test/WebSpeechVoiceManager/initialization.test.ts new file mode 100644 index 0000000..4badd2b --- /dev/null +++ b/test/WebSpeechVoiceManager/initialization.test.ts @@ -0,0 +1,187 @@ +import test, { type ExecutionContext } from "ava"; +import { testWithContext, TestContext } from "./setup.js"; +import { WebSpeechVoiceManager } from "../../build/index.js"; + +// ============================================= +// Test Hooks +// ============================================= + +testWithContext.beforeEach(async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); +}); + +testWithContext.afterEach.always((t: ExecutionContext) => { + // Clean up singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; +}); + +// ============================================= +// Initialization Tests +// ============================================= + +test("initialize: returns singleton instance", async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + const instance1 = await WebSpeechVoiceManager.initialize(); + const instance2 = await WebSpeechVoiceManager.initialize(); + t.is(instance1, instance2); +}); + +testWithContext("initialize: loads voices and gets voices successfully", (t) => { + const manager = t.context.manager; + const voices = manager.getVoices(); + t.true(Array.isArray(voices)); + t.true(voices.length > 0); +}); + +testWithContext("deduplication: keeps higher quality voice from voiceURI package name", (t) => { + const manager = t.context.manager; + + // Define test voices once + const lowVoice = { + voiceURI: "com.apple.speech.synthesis.voice.compact.samantha", + name: "Samantha", + lang: "en-US", + localService: true, + default: false + }; + + const normalVoice = { + voiceURI: "com.apple.speech.synthesis.voice.enhanced.samantha", + name: "Samantha (enhanced)", + lang: "en-US", + localService: true, + default: false + }; + + // 1. First parse separately to verify individual qualities + const lowQualityVoice = (manager as any).parseToReadiumSpeechVoices([lowVoice])[0]; + const normalQualityVoice = (manager as any).parseToReadiumSpeechVoices([normalVoice])[0]; + + // Verify individual qualities + t.is(lowQualityVoice.quality, "low", "Low quality voice should have low quality"); + t.is(normalQualityVoice.quality, "normal", "Normal quality voice should have normal quality"); + + // 2. Now parse both together to test deduplication + const [resultVoice] = (manager as any).parseToReadiumSpeechVoices([lowVoice, normalVoice]); + + // Verify the result + t.is(resultVoice.name, "Samantha", "Should use the JSON name of the voice"); + t.is(resultVoice.originalName, "Samantha (enhanced)", "Should keep the original name of the voice"); + t.deepEqual(resultVoice.quality, "normal", "Should keep the voice with normal quality"); +}); + +testWithContext("deduplication: keeps higher quality voice from voiceURI string", (t) => { + const manager = t.context.manager; + + // Define test voices once + const basicVoice = { + voiceURI: "Samantha", + name: "Samantha", + lang: "en-US", + localService: true, + default: false + }; + + const enhancedVoice = { + voiceURI: "Samantha (Premium)", + name: "Samantha (Premium)", + lang: "en-US", + localService: true, + default: false + }; + + // 1. First parse separately to verify individual qualities + const basicVoiceParsed = (manager as any).parseToReadiumSpeechVoices([basicVoice])[0]; + const enhancedVoiceParsed = (manager as any).parseToReadiumSpeechVoices([enhancedVoice])[0]; + + // Verify individual qualities + t.is(basicVoiceParsed.quality, "low", "Basic voice should have low quality"); + t.is(enhancedVoiceParsed.quality, "high", "Premium voice should have high quality"); + + // 2. Now parse both together to test deduplication + const [resultVoice] = (manager as any).parseToReadiumSpeechVoices([basicVoice, enhancedVoice]); + + // Verify the result + t.is(resultVoice.name, "Samantha", "Should use the JSON name of the voice"); + t.is(resultVoice.originalName, "Samantha (Premium)", "Should keep the original name of the voice"); + t.deepEqual(resultVoice.quality, "high", "Should keep the voice with high quality"); +}); + +testWithContext("deduplication: keeps higher quality voice from json quality array", (t) => { + const manager = t.context.manager; + + // Parse both voices together to get correct duplicate counts + const voices = (manager as any).parseToReadiumSpeechVoices([ + { + voiceURI: "Samantha", + name: "Samantha", + lang: "en-US", + localService: true, + default: false + }, + { + voiceURI: "Samantha superior", + name: "Samantha (Superior)", + lang: "en-US", + localService: true, + default: false + } + ]); + // Now test deduplication with both voices + const deduped = (manager as any).removeDuplicate(voices); + + // Verify only the higher quality voice remains with its original name + t.is(deduped.length, 1, "Should only keep one voice after deduplication"); + t.is(deduped[0].name, "Samantha", "Should use the JSON name of the voice"); + t.is(deduped[0].originalName, "Samantha (Superior)", "Should keep the original name of the voice"); + t.is(deduped[0].voiceURI, "Samantha superior", "Should keep the voice with superior quality"); + t.deepEqual(deduped[0].quality, "normal", "Should find the voice with normal quality from the array"); +}); + +testWithContext("quality inference: infers quality from nativeID when voiceURI has no indicators", (t) => { + const manager = t.context.manager; + + // Test Francesca voice from es.json which has nativeID with "enhanced" + // Use plain voiceURI to force nativeID quality inference + const testVoice = { + voiceURI: "plain.voice.uri", // No package indicators + name: "Francesca", // Must match the JSON voice name exactly + lang: "es-CL", // Must match the JSON voice language + localService: true, + default: false + }; + + // Parse the voice - it should find Francesca in es.json and infer quality from nativeID + const voices = (manager as any).parseToReadiumSpeechVoices([testVoice]); + + // Should infer "normal" quality from "enhanced" in nativeID array + t.is(voices[0].quality, "normal", "Should infer 'normal' quality from 'enhanced' in Francesca's nativeID"); +}); + +testWithContext("quality inference: voiceURI takes precedence over nativeID", (t) => { + const manager = t.context.manager; + + // Test Francesca voice with compact in voiceURI (should take precedence over nativeID enhanced) + const testVoice = { + voiceURI: "com.apple.speech.synthesis.voice.compact.Francesca", // compact should infer "low" + name: "Francesca", // Must match the JSON voice name exactly + lang: "es-CL", // Must match the JSON voice language + localService: true, + default: false + }; + + // Parse the voice - it should find Francesca but use voiceURI quality (takes precedence) + const voices = (manager as any).parseToReadiumSpeechVoices([testVoice]); + + // Should infer "low" from voiceURI, not "normal" from nativeID (voiceURI takes precedence) + t.is(voices[0].quality, "low", "Should infer 'low' quality from voiceURI, not 'normal' from nativeID"); +}); \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/setup.ts b/test/WebSpeechVoiceManager/setup.ts new file mode 100644 index 0000000..6c9f7ea --- /dev/null +++ b/test/WebSpeechVoiceManager/setup.ts @@ -0,0 +1,184 @@ +import test, { type ExecutionContext } from "ava"; +import { WebSpeechVoiceManager, ReadiumSpeechVoice } from "../../build/index.js"; + +// ============================================= +// Mock Data and Helpers +// ============================================= + +// Mock DisplayNames for testing +class MockDisplayNames { + options: any; + constructor(_: any, options: any) { + this.options = options; + } + + of(code: string): string { + if (this.options.type === "language") { + return `${code.toUpperCase()}_LANG`; + } + if (this.options.type === "region") { + return `${code.toUpperCase()}_REGION`; + } + return code; + } + + static supportedLocalesOf(locales: string[]): string[] { + return locales; + } +} + +// Mock Intl.DisplayNames +if (typeof (globalThis as any).Intl === "undefined") { + (globalThis as any).Intl = {}; +} +(globalThis as any).Intl.DisplayNames = MockDisplayNames as any; + +// ============================================= +// Test Context Type +// ============================================= + +export interface TestContext { + manager: WebSpeechVoiceManager; +} + +// ============================================= +// Test Setup Types +// ============================================= + +export type TestFn = (t: ExecutionContext) => void | Promise; +export const testWithContext = test as unknown as { + (name: string, fn: TestFn): void; + afterEach: { + always: (fn: (t: ExecutionContext) => void | Promise) => void; + }; + beforeEach: (fn: (t: ExecutionContext) => void | Promise) => void; +}; + +// ============================================= +// Helper Functions +// ============================================= + +// Helper function to create test voice objects that match ReadiumSpeechVoice interface +export function createTestVoice(overrides: Partial = {}): ReadiumSpeechVoice { + return { + source: "json", + label: overrides.name || "Test Voice", + name: overrides.name || "Test Voice", + originalName: overrides.originalName || "Test Voice", + voiceURI: `voice-${overrides.name || "test"}`, + language: "en-US", + ...overrides + }; +} + +// ============================================= +// Mock Data +// ============================================= + +// Default mock voices for testing +export const mockVoices = [ + { + voiceURI: "voice1", + name: "Voice 1", + lang: "en-US", + localService: true, + default: true + }, + { + voiceURI: "voice2", + name: "Voice 2", + lang: "fr-FR", + localService: true, + default: false + }, + { + voiceURI: "voice3", + name: "Voice 3", + lang: "es-ES", + localService: true, + default: false + }, + { + voiceURI: "voice4", + name: "Voice 4", + lang: "de-DE", + localService: true, + default: false + }, + { + voiceURI: "voice5", + name: "Voice 5", + lang: "it-IT", + localService: true, + default: false + } +]; + +// ============================================= +// Global Mock Setup +// ============================================= + +// Store original globals +export const originalNavigator = globalThis.navigator; +export const originalSpeechSynthesis = globalThis.speechSynthesis; + +// Set up global mocks before any tests run +if (typeof globalThis.window === "undefined") { + (globalThis as any).window = globalThis; +} + +// Mock the global objects +Object.defineProperty(globalThis, "navigator", { + value: { + ...originalNavigator, + languages: ["en-US", "fr-FR"] + }, + configurable: true, + writable: true +}); + +// Create a mock speechSynthesis object that matches the browser's API +export const mockSpeechSynthesis = { + getVoices: () => mockVoices, + onvoiceschanged: null as (() => void) | null, + addEventListener: function(event: string, callback: () => void) { + if (event === "voiceschanged") { + this.onvoiceschanged = callback; + } + }, + removeEventListener: function(event: string) { + }, + _triggerVoicesChanged: function() { + if (this.onvoiceschanged) { + this.onvoiceschanged(); + } + } +}; + +// Mock the window.speechSynthesis to return our mock voices +Object.defineProperty(globalThis.window, "speechSynthesis", { + value: mockSpeechSynthesis, + configurable: true, + writable: true +}); + +// ============================================= +// Test Hooks +// ============================================= + +export const testHooks = { + beforeEach: async (t: ExecutionContext) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); + }, + + afterEach: (t: ExecutionContext) => { + // Clean up singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + } +}; diff --git a/test/WebSpeechVoiceManager/sortVoices.test.ts b/test/WebSpeechVoiceManager/sortVoices.test.ts new file mode 100644 index 0000000..f3e5f28 --- /dev/null +++ b/test/WebSpeechVoiceManager/sortVoices.test.ts @@ -0,0 +1,283 @@ +import { type ExecutionContext } from "ava"; +import { testWithContext, TestContext, createTestVoice } from "./setup.js"; +import { WebSpeechVoiceManager } from "../../build/index.js"; + +// ============================================= +// Test Hooks +// ============================================= + +testWithContext.beforeEach(async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); +}); + +testWithContext.afterEach.always((t: ExecutionContext) => { + // Clean up singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; +}); + +// ============================================= +// sortVoices Tests +// ============================================= + +testWithContext("sortVoices: sorts by name", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices + const testVoices = [ + createTestVoice({ name: "Zeta Voice", language: "en-US" }), + createTestVoice({ name: "Alpha Voice", language: "en-US" }), + createTestVoice({ name: "Beta Voice", language: "en-US" }) + ]; + + // Test ascending order + const sortedAsc = manager.sortVoices(testVoices, { by: "name", order: "asc" }); + t.is(sortedAsc[0].name, "Alpha Voice"); + t.is(sortedAsc[1].name, "Beta Voice"); + t.is(sortedAsc[2].name, "Zeta Voice"); + + // Test descending order + const sortedDesc = manager.sortVoices(testVoices, { by: "name", order: "desc" }); + t.is(sortedDesc[0].name, "Zeta Voice"); + t.is(sortedDesc[1].name, "Beta Voice"); + t.is(sortedDesc[2].name, "Alpha Voice"); +}); + +testWithContext("sortVoices: sorts by quality with proper direction", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different quality levels + const testVoices = [ + createTestVoice({ name: "High Quality Voice", language: "en-US", quality: "high" }), + createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: "low" }), + createTestVoice({ name: "Normal Quality Voice", language: "en-US", quality: "normal" }), + createTestVoice({ name: "Very High Quality Voice", language: "en-US", quality: "veryHigh" }), + createTestVoice({ name: "Very Low Quality Voice", language: "en-US", quality: "veryLow" }) + ]; + + // Test ascending order (low to high quality) + const sortedAsc = manager.sortVoices(testVoices, { by: "quality", order: "asc" }); + t.is(sortedAsc[0].quality, "veryLow"); + t.is(sortedAsc[1].quality, "low"); + t.is(sortedAsc[2].quality, "normal"); + t.is(sortedAsc[3].quality, "high"); + t.is(sortedAsc[4].quality, "veryHigh"); + + // Test descending order (high to low quality) + const sortedDesc = manager.sortVoices(testVoices, { by: "quality", order: "desc" }); + t.is(sortedDesc[0].quality, "veryHigh"); + t.is(sortedDesc[1].quality, "high"); + t.is(sortedDesc[2].quality, "normal"); + t.is(sortedDesc[3].quality, "low"); + t.is(sortedDesc[4].quality, "veryLow"); +}); + +testWithContext("sortVoices: sorts by language", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different languages + const testVoices = [ + createTestVoice({ name: "French Voice", language: "fr-FR" }), + createTestVoice({ name: "English Voice", language: "en-US" }), + createTestVoice({ name: "Spanish Voice", language: "es-ES" }), + createTestVoice({ name: "German Voice", language: "de-DE" }) + ]; + + // Test ascending order + const sortedAsc = manager.sortVoices(testVoices, { by: "language", order: "asc" }); + t.is(sortedAsc[0].language, "de-DE"); + t.is(sortedAsc[1].language, "en-US"); + t.is(sortedAsc[2].language, "es-ES"); + t.is(sortedAsc[3].language, "fr-FR"); + + // Test descending order + const sortedDesc = manager.sortVoices(testVoices, { by: "language", order: "desc" }); + t.is(sortedDesc[0].language, "fr-FR"); + t.is(sortedDesc[1].language, "es-ES"); + t.is(sortedDesc[2].language, "en-US"); + t.is(sortedDesc[3].language, "de-DE"); +}); + +testWithContext("sortVoices: sorts by gender", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different genders + const testVoices = [ + createTestVoice({ name: "Female Voice 1", language: "en-US", gender: "female" }), + createTestVoice({ name: "Male Voice 1", language: "en-US", gender: "male" }), + createTestVoice({ name: "Unknown Voice", language: "en-US" }), + createTestVoice({ name: "Female Voice 2", language: "en-US", gender: "female" }) + ]; + + // Test ascending order (undefined should come first, then female, then male) + const sortedAsc = manager.sortVoices(testVoices, { by: "gender", order: "asc" }); + t.is(sortedAsc[0].gender, undefined); + t.is(sortedAsc[1].gender, "female"); + t.is(sortedAsc[2].gender, "female"); + t.is(sortedAsc[3].gender, "male"); + + // Test descending order (male should come first, then female, then undefined) + const sortedDesc = manager.sortVoices(testVoices, { by: "gender", order: "desc" }); + t.is(sortedDesc[0].gender, "male"); + t.is(sortedDesc[1].gender, "female"); + t.is(sortedDesc[2].gender, "female"); + t.is(sortedDesc[3].gender, undefined); +}); + +testWithContext("sortVoices: sorts by region", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different regions + const testVoices = [ + createTestVoice({ name: "US Voice", language: "en-US" }), + createTestVoice({ name: "UK Voice", language: "en-GB" }), + createTestVoice({ name: "Canada Voice", language: "en-CA" }), + createTestVoice({ name: "Australia Voice", language: "en-AU" }) + ]; + + // Test ascending order + const sortedAsc = manager.sortVoices(testVoices, { by: "region", order: "asc" }); + t.is(sortedAsc[0].language, "en-AU"); + t.is(sortedAsc[1].language, "en-CA"); + t.is(sortedAsc[2].language, "en-GB"); + t.is(sortedAsc[3].language, "en-US"); + + // Test descending order + const sortedDesc = manager.sortVoices(testVoices, { by: "region", order: "desc" }); + t.is(sortedDesc[0].language, "en-US"); + t.is(sortedDesc[1].language, "en-GB"); + t.is(sortedDesc[2].language, "en-CA"); + t.is(sortedDesc[3].language, "en-AU"); +}); + +testWithContext("sortVoices: sorts by preferred languages", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different languages and regions + const testVoices = [ + createTestVoice({ name: "French Voice", language: "fr-FR" }), + createTestVoice({ name: "US English Voice", language: "en-US" }), + createTestVoice({ name: "UK English Voice", language: "en-GB" }), + createTestVoice({ name: "German Voice", language: "de-DE" }), + createTestVoice({ name: "Spanish Voice", language: "es-ES" }), + createTestVoice({ name: "French Canadian Voice", language: "fr-CA" }) + ]; + + // Test with preferred languages (exact matches first, then partial matches) + const preferredLangs = ["en-US", "fr", "es-ES"]; + const sorted = manager.sortVoices(testVoices, { + by: "language", + preferredLanguages: preferredLangs + }); + + // Exact matches should come first in the order of preferredLanguages + t.is(sorted[0].language, "en-US"); // Exact match + t.is(sorted[1].language, "fr-CA"); // Partial match for "fr" - sorts by region code + t.is(sorted[2].language, "fr-FR"); // Also partial match for "fr" - sorts by region code + t.is(sorted[3].language, "es-ES"); // Exact match + + // Non-preferred languages should come after, sorted alphabetically + t.is(sorted[4].language, "de-DE"); + t.is(sorted[5].language, "en-GB"); + + // Test with region-specific preferences + const regionSpecific = manager.sortVoices(testVoices, { + by: "language", + preferredLanguages: ["fr-CA", "en-GB"] + }); + + t.is(regionSpecific[0].language, "fr-CA"); // Exact match + t.is(regionSpecific[1].language, "en-GB"); // Exact match + // Others should be sorted alphabetically + t.is(regionSpecific[2].language, "de-DE"); + t.is(regionSpecific[3].language, "en-US"); + t.is(regionSpecific[4].language, "es-ES"); + t.is(regionSpecific[5].language, "fr-FR"); + + // Test with empty preferred languages (should sort alphabetically) + const emptyPreferred = manager.sortVoices(testVoices, { + by: "language", + preferredLanguages: [] + }); + t.is(emptyPreferred[0].language, "de-DE"); + t.is(emptyPreferred[1].language, "en-GB"); + t.is(emptyPreferred[2].language, "en-US"); + t.is(emptyPreferred[3].language, "es-ES"); + t.is(emptyPreferred[4].language, "fr-CA"); + t.is(emptyPreferred[5].language, "fr-FR"); + + // Test with undefined preferred languages (should sort alphabetically) + const undefinedPreferred = manager.sortVoices(testVoices, { + by: "language" + }); + t.is(undefinedPreferred[0].language, "de-DE"); + t.is(undefinedPreferred[1].language, "en-GB"); + t.is(undefinedPreferred[2].language, "en-US"); + t.is(undefinedPreferred[3].language, "es-ES"); + t.is(undefinedPreferred[4].language, "fr-CA"); + t.is(undefinedPreferred[5].language, "fr-FR"); + + // Test with case-insensitive matching + const caseInsensitive = manager.sortVoices(testVoices, { + by: "language", + preferredLanguages: ["EN-us", "FR"] // Mixed case and partial + }); + t.is(caseInsensitive[0].language, "en-US"); // Matches despite case difference + t.is(caseInsensitive[1].language, "fr-CA"); // Partial match, sorted by region + t.is(caseInsensitive[2].language, "fr-FR"); // Also partial match +}); + +testWithContext("sortVoices: sorts by region with preferred languages", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different regions + const testVoices = [ + createTestVoice({ name: "US English", language: "en-US" }), + createTestVoice({ name: "UK English", language: "en-GB" }), + createTestVoice({ name: "Australian English", language: "en-AU" }), + createTestVoice({ name: "Canadian French", language: "fr-CA" }), + createTestVoice({ name: "French", language: "fr-FR" }), + createTestVoice({ name: "Canadian English", language: "en-CA" }) + ]; + + // Test with preferred languages that include regions + const sorted = manager.sortVoices(testVoices, { + by: "region", + preferredLanguages: ["en-CA", "fr-CA", "en"] // Prefer Canadian English, then Canadian French, then any English + }); + + // Verify order: + // 1. en-CA (exact match for first preferred) + // 2. fr-CA (exact match for second preferred) + // 3. en-US (language match for third preferred) + // 4. en-GB (language match for third preferred) + // 5. en-AU (language match for third preferred) + // 6. fr-FR (no match, should come last) + t.is(sorted[0].language, "en-CA", "en-CA should be first (exact match)"); + t.is(sorted[1].language, "fr-CA", "fr-CA should be second (exact match)"); + + // The remaining English variants should be in their natural order + const remainingEnglish = sorted.slice(2, 5).map(v => v.language); + t.true( + ["en-US", "en-GB", "en-AU"].every(lang => remainingEnglish.includes(lang)), + "Should include all English variants after exact matches" + ); + + t.is(sorted[5].language, "fr-FR", "fr-FR should be last (no match)"); + + // Test with preferred languages that don't match any regions + const noMatches = manager.sortVoices(testVoices, { + by: "region", + preferredLanguages: ["es-ES", "de-DE"] // No matches in test data + }); + + // Should sort alphabetically by region + const regions = noMatches.map(v => v.language.split("-")[1]); + const sortedRegions = [...regions].sort(); + t.deepEqual(regions, sortedRegions, "Should sort alphabetically by region when no preferred matches"); +}); \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/systemLocale.test.ts b/test/WebSpeechVoiceManager/systemLocale.test.ts new file mode 100644 index 0000000..ec1e86f --- /dev/null +++ b/test/WebSpeechVoiceManager/systemLocale.test.ts @@ -0,0 +1,129 @@ +import { type ExecutionContext } from "ava"; +import { testWithContext, TestContext, mockSpeechSynthesis } from "./setup.js"; +import { WebSpeechVoiceManager } from "../../build/index.js"; + +// ============================================= +// Test Hooks +// ============================================= + +testWithContext.beforeEach(async (t) => { + // Reset singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Initialize and store the manager + t.context.manager = await WebSpeechVoiceManager.initialize(); +}); + +testWithContext.afterEach.always((t: ExecutionContext) => { + // Clean up singleton instance + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; +}); + +// ============================================= +// systemLocale Tests +// ============================================= + +testWithContext("systemLocale: initializes with first navigator.language", async (t) => { + // Store original state + const originalNavigator = globalThis.navigator; + const originalInstance = (WebSpeechVoiceManager as any).instance; + const originalInitPromise = (WebSpeechVoiceManager as any).initializationPromise; + + try { + // Ensure speechSynthesis mock is available on globalThis + if (!globalThis.speechSynthesis) { + Object.defineProperty(globalThis, "speechSynthesis", { + value: mockSpeechSynthesis, + configurable: true, + writable: true + }); + } + + // Create a new navigator object with test languages + const testNavigator = { + ...originalNavigator, + languages: ["fr-FR", "en-US"] + }; + + // Override the global navigator + Object.defineProperty(globalThis, "navigator", { + value: testNavigator, + configurable: true, + writable: true + }); + + // Reset singleton to test fresh initialization + (WebSpeechVoiceManager as any).instance = undefined; + (WebSpeechVoiceManager as any).initializationPromise = null; + + // Test initialization with the modified navigator.languages + const manager = await WebSpeechVoiceManager.initialize(1000, 10); + + // Verify systemLocale is set to the first language code from navigator.languages + t.is((manager as any).systemLocale, "fr"); + + } finally { + // Restore original state + Object.defineProperty(globalThis, "navigator", { + value: originalNavigator, + configurable: true, + writable: true + }); + + (WebSpeechVoiceManager as any).instance = originalInstance; + (WebSpeechVoiceManager as any).initializationPromise = originalInitPromise; + } +}); + +testWithContext("systemLocale: updates with quality indicators from voices", async (t) => { + const manager = t.context.manager; + + // Create test voices with actual quality indicators for Spanish + const testVoices = [ + { + voiceURI: "test-voice-1", + name: "Test Voice (mejorada)", // Matches Spanish normal quality indicator + lang: "es-ES" + }, + { + voiceURI: "test-voice-2", + name: "Test Voice (premium)", // Matches Spanish high quality indicator + lang: "es-ES" + }, + ]; + + // Call updateSystemLocale with test voices + await (manager as any).updateSystemLocale(testVoices); + + // System locale should be updated to "es" when Spanish quality indicators are found + t.is((manager as any).systemLocale, "es", + "System locale should update to Spanish when quality indicators are found in Spanish voice names"); +}); + +testWithContext("systemLocale: falls back to navigator.language when no quality indicators found", async (t) => { + const manager = t.context.manager; + const originalLocale = (manager as any).systemLocale; + + // Create test voices without any quality indicators + const testVoices = [ + { + voiceURI: "test-voice-1", + name: "Random Voice 1", // No quality indicators + lang: "en-US" + }, + { + voiceURI: "test-voice-2", + name: "Random Voice 2", // No quality indicators + lang: "en-US" + }, + ]; + + // Call updateSystemLocale with test voices + await (manager as any).updateSystemLocale(testVoices); + + // System locale should remain unchanged + t.is((manager as any).systemLocale, originalLocale, + "System locale should not change when no quality indicators are found"); +}); \ No newline at end of file From 541cad39ccb7e896e90e0c73068f6214f36e1611 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Sat, 20 Dec 2025 07:45:38 +0100 Subject: [PATCH 15/32] Correct async in demos (#33) --- demo/article/script.js | 22 +++++++++++----------- demo/script.js | 24 ++++++++++++------------ 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/demo/article/script.js b/demo/article/script.js index 86e9fc4..b0072e5 100644 --- a/demo/article/script.js +++ b/demo/article/script.js @@ -57,7 +57,7 @@ async function initialize() { } // Initialize content - await initializeContent(); + initializeContent(); } catch (error) { console.error("Initialization error:", error); @@ -166,7 +166,7 @@ async function initializeContent() { }); // Load utterances into the navigator - await navigator.loadContent(utterances); + navigator.loadContent(utterances); // Update UI updateUI(); @@ -220,11 +220,11 @@ function populateVoiceSelect() { } const option = document.createElement("option"); - option.value = voice.voiceURI; + option.value = voice.name; option.textContent = `${voice.label || voice.name}`; option.dataset.voiceUri = voice.voiceURI; - if (currentVoice && voice.voiceURI === currentVoice.voiceURI) { + if (currentVoice && voice.name === currentVoice.name) { option.selected = true; } @@ -233,7 +233,7 @@ function populateVoiceSelect() { // Set the default voice selection if (currentVoice) { - const option = voiceSelect.querySelector(`option[data-voice-uri="${currentVoice.voiceURI}"]`); + const option = voiceSelect.querySelector(`option[value="${currentVoice.name}"]`); if (option) { option.selected = true; } @@ -277,7 +277,7 @@ function populateVoiceSelect() { } // Toggle sample text playback -async function togglePlayback() { +function togglePlayback() { if (!currentVoice) { console.error("No voice selected"); return; @@ -286,14 +286,14 @@ async function togglePlayback() { try { const state = navigator.getState(); if (state === "playing") { - await navigator.pause(); + navigator.pause(); } else if (state === "paused") { // Use play() to resume from paused state - await navigator.play(); + navigator.play(); } else { // Start from beginning if stopped or in an unknown state - await navigator.jumpTo(0); - await navigator.play(); + navigator.jumpTo(0); + navigator.play(); } } catch (error) { console.error("Error toggling playback:", error); @@ -336,7 +336,7 @@ async function handleVoiceChange(e) { if (navigator) { try { // Stop the current speech - await navigator.stop(); + navigator.stop(); // Set the new voice navigator.setVoice(currentVoice); diff --git a/demo/script.js b/demo/script.js index b0dab47..733e53b 100644 --- a/demo/script.js +++ b/demo/script.js @@ -501,7 +501,7 @@ async function loadSampleText(languageCode) { sampleTextDisplay.appendChild(demoSection); // Load utterances into the navigator - await speechNavigator.loadContent(utterances); + speechNavigator.loadContent(utterances); // Update total utterances display const totalUtterancesSpan = document.getElementById("total-utterances"); @@ -767,7 +767,7 @@ async function playTestUtterance() { } // Toggle sample text playback -async function togglePlayback() { +function togglePlayback() { if (!currentVoice) { console.error("No voice selected"); return; @@ -776,14 +776,14 @@ async function togglePlayback() { try { const state = speechNavigator.getState(); if (state === "playing") { - await speechNavigator.pause(); + speechNavigator.pause(); } else if (state === "paused") { // Use play() to resume from paused state - await speechNavigator.play(); + speechNavigator.play(); } else { // Start from beginning if stopped or in an unknown state - await speechNavigator.jumpTo(0); - await speechNavigator.play(); + speechNavigator.jumpTo(0); + speechNavigator.play(); } } catch (error) { console.error("Error toggling playback:", error); @@ -794,9 +794,9 @@ async function togglePlayback() { } // Stop sample playback -async function stopPlayback() { +function stopPlayback() { try { - await speechNavigator.stop(); + speechNavigator.stop(); clearWordHighlighting(); playPauseBtn.textContent = "Play Sample"; updateUI(); @@ -806,14 +806,14 @@ async function stopPlayback() { } // Go to previous utterance -async function previousUtterance() { - await speechNavigator.previous(); +function previousUtterance() { + speechNavigator.previous(); updateUI(); } // Go to next utterance -async function nextUtterance() { - await speechNavigator.next(); +function nextUtterance() { + speechNavigator.next(); updateUI(); } From 3701f7b31e1d8ae90064f324fe4c31e48097f157 Mon Sep 17 00:00:00 2001 From: Hadrien Gardeur Date: Sat, 20 Dec 2025 11:12:38 +0100 Subject: [PATCH 16/32] Fixed Aru voice --- json/kk.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/json/kk.json b/json/kk.json index 3a66682..83a1515 100644 --- a/json/kk.json +++ b/json/kk.json @@ -41,9 +41,9 @@ ], "localizedName": "apple", "language": "kk-KZ", - "gender": "male", + "gender": "female", "quality": [ - "high" + "normal" ], "rate": 1, "pitch": 1, From 5f623585d1cb7f8e914dca3a05c3c1d34362f612 Mon Sep 17 00:00:00 2001 From: Hadrien Gardeur Date: Mon, 5 Jan 2026 11:29:57 +0100 Subject: [PATCH 17/32] Added alt language for Wuu Chinese --- json/wuu.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/json/wuu.json b/json/wuu.json index 564f0b1..40fbb0f 100644 --- a/json/wuu.json +++ b/json/wuu.json @@ -8,6 +8,7 @@ "name": "Nannan", "localizedName": "apple", "language": "wuu-CN", + "altLanguage": "zh-CN", "gender": "female", "quality": [ "normal" @@ -22,4 +23,4 @@ "preloaded": true } ] -} \ No newline at end of file +} From e8954a2791d7f1aded2c41b5c2fe8e7bcdcc237d Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Fri, 9 Jan 2026 11:56:15 +0100 Subject: [PATCH 18/32] Languages handling (#36) This creates a new helper whose purpose is to process languages array in order to list explicit and inferred regions for each language within. This helps sort and get a best matching defaultVoice. --- README.md | 12 +- demo/article/script.js | 2 +- demo/script.js | 19 +- package.json | 2 +- src/WebSpeech/WebSpeechVoiceManager.ts | 273 +++++++++++------- src/WebSpeech/webSpeechEngine.ts | 4 +- src/voices/languages.ts | 130 +++++++++ .../filterVoices.test.ts | 12 +- .../getDefaultVoice.test.ts | 54 +++- test/WebSpeechVoiceManager/getVoices.test.ts | 6 +- .../WebSpeechVoiceManager/groupVoices.test.ts | 6 +- test/WebSpeechVoiceManager/sortVoices.test.ts | 150 +++++----- test/testUtils.ts | 16 + 13 files changed, 477 insertions(+), 209 deletions(-) create mode 100644 test/testUtils.ts diff --git a/README.md b/README.md index 7298357..57dc10c 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ async function setupVoices() { // Get voices with filters const filteredVoices = voiceManager.getVoices({ - language: ["en", "fr"], + languages: ["en", "fr"], gender: "female", quality: "high", offlineOnly: true, @@ -105,7 +105,7 @@ async function setupVoices() { // Get voices grouped by language const voices = voiceManager.getVoices(); - const groupedByLanguage = voiceManager.groupVoices(voices, "language"); + const groupedByLanguage = voiceManager.groupVoices(voices, "languages"); // Get a test utterance for a specific language const testText = voiceManager.getTestUtterance("en"); @@ -153,7 +153,7 @@ Fetches all available voices that match the specified filter criteria. ```typescript interface VoiceFilterOptions { - language?: string | string[]; // Filter by language code(s) (e.g., "en", "fr") + languages?: string | string[]; // Filter by language code(s) (e.g., "en", "fr-FR") source?: TSource; // Filter by voice source ("json" | "browser") gender?: TGender; // "male" | "female" | "other" quality?: TQuality | TQuality[]; // "high" | "medium" | "low" | "veryLow" @@ -175,12 +175,12 @@ Filters voices based on the specified criteria. #### Group Voices ```typescript -voiceManager.groupVoices(voices: ReadiumSpeechVoice[], groupBy: "language" | "region" | "gender" | "quality" | "provider"): VoiceGroup +voiceManager.groupVoices(voices: ReadiumSpeechVoice[], groupBy: "languages" | "region" | "gender" | "quality" | "provider"): VoiceGroup ``` Organizes voices into groups based on the specified criteria. The available grouping options are: -- `"language"`: Groups voices by their language code +- `"languages"`: Groups voices by their language code - `"region"`: Groups voices by their region - `"gender"`: Groups voices by gender - `"quality"`: Groups voices by quality level @@ -196,7 +196,7 @@ Arranges voices according to the specified sorting criteria. The `SortOptions` i ```typescript interface SortOptions { - by: "name" | "language" | "gender" | "quality" | "region"; + by: "name" | "languages" | "gender" | "quality" | "region"; order?: "asc" | "desc"; } ``` diff --git a/demo/article/script.js b/demo/article/script.js index b0072e5..81a57b6 100644 --- a/demo/article/script.js +++ b/demo/article/script.js @@ -28,7 +28,7 @@ async function initialize() { voiceManager = await WebSpeechVoiceManager.initialize(); // Only get English voices - allVoices = voiceManager.getVoices({language: "en"}); + allVoices = voiceManager.getVoices({languages: "en"}); // Initialize the navigator navigator = new WebSpeechReadAloudNavigator(); diff --git a/demo/script.js b/demo/script.js index 733e53b..318aa64 100644 --- a/demo/script.js +++ b/demo/script.js @@ -88,7 +88,7 @@ async function init() { name: lang.label })), { - by: "language", + by: "languages", order: "asc", preferredLanguages: window.navigator.languages } @@ -285,8 +285,8 @@ function filterVoices() { // Now apply language filter if needed if (language) { - filterOptions.language = language; - filteredVoices = voiceManager.filterVoices(voicesFilteredExceptLanguage, { language }); + filterOptions.languages = language; + filteredVoices = voiceManager.filterVoices(voicesFilteredExceptLanguage, { languages: language }); } else { filteredVoices = voicesFilteredExceptLanguage; } @@ -306,7 +306,7 @@ function filterVoices() { } // Populate the voice dropdown with filtered voices -function populateVoiceDropdown(language = "") { +function populateVoiceDropdown() { voiceSelect.innerHTML = ""; try { @@ -320,7 +320,7 @@ function populateVoiceDropdown(language = "") { // Sort voices with browser's preferred languages first const sortedVoices = voiceManager.sortVoices([...filteredVoices], { - by: "language", + by: "region", order: "asc", preferredLanguages: window.navigator.languages }); @@ -582,12 +582,11 @@ function setupEventListeners() { // Get the default voice for the selected language using pre-filtered voices if (baseLanguage) { - // Find the first matching language from the user's preferences - const preferredLanguage = (window.navigator.languages || [window.navigator.language] || []) - .find(lang => lang && lang.startsWith(baseLanguage)) || baseLanguage; - + // Use the full navigator.languages array for proper language preference handling + const preferredLanguages = [...(window.navigator.languages || [window.navigator.language] || [baseLanguage])]; + currentVoice = voiceManager.getDefaultVoice( - preferredLanguage, + preferredLanguages, filteredVoices.length ? filteredVoices : undefined ); diff --git a/package.json b/package.json index 00e0d1b..679aa6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@readium/speech", - "version": "0.1.0-beta.3", + "version": "0.1.0-beta.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.", "main": "./build/index.cjs", "module": "./build/index.js", diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index 22bc118..70fe1ec 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -1,5 +1,5 @@ import { ReadiumSpeechJSONVoice, ReadiumSpeechVoice, TGender, TQuality, TSource } from "../voices/types"; -import { getTestUtterance, getVoices } from "../voices/languages"; +import { getTestUtterance, getVoices, processLanguages } from "../voices/languages"; import { isNoveltyVoice, isVeryLowQualityVoice, @@ -14,7 +14,7 @@ import { extractLangRegionFromBCP47 } from "../utils/language"; * Options for filtering voices */ interface VoiceFilterOptions { - language?: string | string[]; + languages?: string | string[]; source?: TSource; gender?: TGender; quality?: TQuality | TQuality[]; @@ -48,7 +48,7 @@ type SortOrder = "asc" | "desc"; /** * Grouping criteria for voices */ -type GroupBy = "language" | "gender" | "quality" | "region"; +type GroupBy = "languages" | "gender" | "quality" | "region"; /** * Sort options for voices @@ -398,16 +398,19 @@ export class WebSpeechVoiceManager { /** - * Get the default voice for a language - * @param language The language code to get the default voice for (e.g., "en-US") + * Get the default voice for language preferences + * @param languages Array of preferred languages in order of preference, or a single language string * @param voices Optional pre-filtered voices array to use instead of fetching voices * @returns The default voice for the language, or null if no voices are available */ - getDefaultVoice(language: string, voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice | null { - if (!language) return null; + getDefaultVoice(languages: string | string[], voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice | null { + if (!languages) return null; + + // Convert single language to array for consistent handling + const languageArray = Array.isArray(languages) ? languages : [languages]; // Use provided voices or get filtered voices if not provided - let filteredVoices = voices || this.getVoices({ language }); + let filteredVoices = voices || this.getVoices({ languages: languageArray }); if (!filteredVoices.length) return null; // First sort by quality (highest first) @@ -416,11 +419,11 @@ export class WebSpeechVoiceManager { order: "desc" }); - // Then sort by language to ensure we get the best match for the requested language + // Then sort by language to ensure we get the best match for the requested language(s) filteredVoices = this.sortVoices(filteredVoices, { - by: "language", + by: "languages", order: "asc", - preferredLanguages: [language] + preferredLanguages: languageArray }); // Return the best available voice (already sorted by quality and language) @@ -582,8 +585,8 @@ export class WebSpeechVoiceManager { filterVoices(voices: ReadiumSpeechVoice[], options: VoiceFilterOptions): ReadiumSpeechVoice[] { let result = [...voices]; - if (options.language) { - const langs = Array.isArray(options.language) ? options.language : [options.language]; + if (options.languages) { + const langs = Array.isArray(options.languages) ? options.languages : [options.languages]; result = result.filter(voice => { return langs.some(requestedLang => { @@ -679,7 +682,7 @@ export class WebSpeechVoiceManager { sortVoices(voices: ReadiumSpeechVoice[], options: SortOptions): ReadiumSpeechVoice[] { if (!voices?.length) return []; - const result = [...voices]; + let result = [...voices]; switch (options.by) { case "name": @@ -690,57 +693,93 @@ export class WebSpeechVoiceManager { ); break; - case "language": - result.sort((a, b) => { - const [aLang, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); - const [bLang, bRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); - - // Get display names for both languages for comparison - const aDisplayName = WebSpeechVoiceManager.getLanguageDisplayName(aLang).toLowerCase(); - const bDisplayName = WebSpeechVoiceManager.getLanguageDisplayName(bLang).toLowerCase(); + case "languages": + // Use processLanguages to get language and region information + const processedLangs = processLanguages(options.preferredLanguages || []); + const langInfo = new Map(processedLangs.map(info => [info.baseLang, info])); + + // Group voices by language + const voicesByLang = new Map(); + const otherLangVoices: ReadiumSpeechVoice[] = []; + + for (const voice of result) { + const [lang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(voice.language); + const langInfoForVoice = langInfo.get(lang.toLowerCase()); - // If preferredLanguages is provided, prioritize them - if (options.preferredLanguages?.length) { - const aIndex = options.preferredLanguages.findIndex(prefLang => { - const [prefLangBase, prefRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(prefLang); - // Match both language and region if specified in preferred language - return aLang === prefLangBase.toLowerCase() && - (!prefRegion || !aRegion || prefRegion === aRegion); - }); - - const bIndex = options.preferredLanguages.findIndex(prefLang => { - const [prefLangBase, prefRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(prefLang); - return bLang === prefLangBase.toLowerCase() && - (!prefRegion || !bRegion || prefRegion === bRegion); + if (langInfoForVoice) { + if (!voicesByLang.has(lang)) { + voicesByLang.set(lang, []); + } + voicesByLang.get(lang)!.push(voice); + } else { + otherLangVoices.push(voice); + } + } + + // Sort each language group separately + const langSortedResult: ReadiumSpeechVoice[] = []; + + for (const processedLang of processedLangs) { + const langVoices = voicesByLang.get(processedLang.baseLang); + if (langVoices) { + // Sort this language's voices by region + langVoices.sort((a, b) => { + const [, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); + const [, bRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); + + // Check if regions are in the processed languages for this base language + const aHasMatch = aRegion && processedLang.regions.includes(aRegion); + const bHasMatch = bRegion && processedLang.regions.includes(bRegion); + + if (aHasMatch && bHasMatch) { + // Both have matches - sort by their order in this language's regions + const aIndex = processedLang.regions.indexOf(aRegion!); + const bIndex = processedLang.regions.indexOf(bRegion!); + return aIndex - bIndex; + } + + // Only one has match - it comes first + if (aHasMatch) return -1; + if (bHasMatch) return 1; + + // Neither has match - sort alphabetically by region + return (aRegion || "").localeCompare(bRegion || ""); }); - // If both languages are in preferred list, sort by their position - if (aIndex !== -1 && bIndex !== -1) { - // If same preferred language but different regions, sort by region if specified - if (aIndex === bIndex && aRegion && bRegion) { - return options.order === "desc" - ? bRegion.localeCompare(aRegion) - : aRegion.localeCompare(bRegion); - } - return options.order === "desc" ? bIndex - aIndex : aIndex - bIndex; - } - // If only one language is in preferred list, it comes first - if (aIndex !== -1) return -1; - if (bIndex !== -1) return 1; + langSortedResult.push(...langVoices); } + } + + // Add other voices sorted by display name + otherLangVoices.sort((a, b) => { + const [aLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); + const [bLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); + const aDisplayName = WebSpeechVoiceManager.getLanguageDisplayName(aLang).toLowerCase(); + const bDisplayName = WebSpeechVoiceManager.getLanguageDisplayName(bLang).toLowerCase(); - // Sort by display name for all languages const compare = aDisplayName.localeCompare(bDisplayName); + if (compare !== 0) { + return options.order === "desc" ? -compare : compare; + } // If same display name, sort by region if available - if (compare === 0 && aRegion && bRegion) { - return options.order === "desc" + const [, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); + const [, bRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); + if (aRegion && bRegion) { + return options.order === "desc" ? bRegion.localeCompare(aRegion) : aRegion.localeCompare(bRegion); } - return options.order === "desc" ? -compare : compare; + // If one has a region and the other doesn't, the one with region comes first + if (aRegion) return -1; + if (bRegion) return 1; + + return 0; }); + + langSortedResult.push(...otherLangVoices); + result = langSortedResult; break; case "gender": @@ -765,65 +804,99 @@ export class WebSpeechVoiceManager { break; case "region": - result.sort((a, b) => { - const [aLang, aRegion = ""] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); - const [bLang, bRegion = ""] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); + // Use processLanguages to get language and region information + const processedRegions = processLanguages(options.preferredLanguages || []); + + // Create region preference order from processedLanguages + const regionOrder: string[] = []; + const regionToLangs = new Map(); + + for (const processedLang of processedRegions) { + for (const region of processedLang.regions) { + if (!regionOrder.includes(region)) { + regionOrder.push(region); + } + if (!regionToLangs.has(region)) { + regionToLangs.set(region, []); + } + regionToLangs.get(region)!.push(processedLang.baseLang); + } + } + + // Group voices by region + const voicesByRegion = new Map(); + const otherRegionVoices: ReadiumSpeechVoice[] = []; + + for (const voice of result) { + const [, region] = WebSpeechVoiceManager.extractLangRegionFromBCP47(voice.language); - // If preferredLanguages is provided, prioritize exact matches first - if (options.preferredLanguages?.length) { - // Check for exact language-region matches first (e.g., "en-US" matches "en-US") - const aExactMatchIndex = options.preferredLanguages.findIndex(prefLang => { - const [prefLangBase, prefRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(prefLang); - return aLang === prefLangBase.toLowerCase() && - aRegion === prefRegion?.toUpperCase(); - }); - - const bExactMatchIndex = options.preferredLanguages.findIndex(prefLang => { - const [prefLangBase, prefRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(prefLang); - return bLang === prefLangBase.toLowerCase() && - bRegion === prefRegion?.toUpperCase(); - }); - - // If one has an exact match and the other doesn't, the exact match comes first - if (aExactMatchIndex !== -1 && bExactMatchIndex === -1) return -1; - if (aExactMatchIndex === -1 && bExactMatchIndex !== -1) return 1; - - // If both have exact matches, sort by their position in preferredLanguages - if (aExactMatchIndex !== -1 && bExactMatchIndex !== -1 && aExactMatchIndex !== bExactMatchIndex) { - return aExactMatchIndex - bExactMatchIndex; + if (region && regionToLangs.has(region)) { + if (!voicesByRegion.has(region)) { + voicesByRegion.set(region, []); } - - // Then check for language-only matches (e.g., "en" matches "en-US") - const aLangMatchIndex = options.preferredLanguages.findIndex(prefLang => { - const [prefLangBase] = WebSpeechVoiceManager.extractLangRegionFromBCP47(prefLang); - return aLang === prefLangBase.toLowerCase(); - }); - - const bLangMatchIndex = options.preferredLanguages.findIndex(prefLang => { - const [prefLangBase] = WebSpeechVoiceManager.extractLangRegionFromBCP47(prefLang); - return bLang === prefLangBase.toLowerCase(); + voicesByRegion.get(region)!.push(voice); + } else { + otherRegionVoices.push(voice); + } + } + + // Sort each region group separately + const regionSortedResult: ReadiumSpeechVoice[] = []; + + for (const region of regionOrder) { + const regionVoices = voicesByRegion.get(region); + if (regionVoices) { + // Sort this region's voices by language preference + regionVoices.sort((a, b) => { + const [aLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); + const [bLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); + + // Check if languages are in the preferred languages for this region + const preferredLangsForRegion = regionToLangs.get(region) || []; + const aIndex = preferredLangsForRegion.indexOf(aLang); + const bIndex = preferredLangsForRegion.indexOf(bLang); + + if (aIndex !== -1 && bIndex !== -1) { + // Both have matches - sort by their order in this region's languages + return aIndex - bIndex; + } + + if (aIndex !== -1 && bIndex === -1) { + // A has match, B doesn't - A comes first + return -1; + } + + if (aIndex === -1 && bIndex !== -1) { + // B has match, A doesn't - B comes first + return 1; + } + + // Neither has match - sort alphabetically by language + return aLang.localeCompare(bLang); }); - // If one has a language match and the other doesn't, the language match comes first - if (aLangMatchIndex !== -1 && bLangMatchIndex === -1) return -1; - if (aLangMatchIndex === -1 && bLangMatchIndex !== -1) return 1; - - // If both have language matches, sort by their position in preferredLanguages - if (aLangMatchIndex !== -1 && bLangMatchIndex !== -1 && aLangMatchIndex !== bLangMatchIndex) { - return aLangMatchIndex - bLangMatchIndex; - } + regionSortedResult.push(...regionVoices); } + } + + // Add other voices sorted by region then language + otherRegionVoices.sort((a, b) => { + const [, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); + const [, bRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); + const [aLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); + const [bLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); - // If no preferred language matches, sort alphabetically by region const regionCompare = options.order === "desc" - ? bRegion.localeCompare(aRegion) - : aRegion.localeCompare(bRegion); + ? (bRegion || "").localeCompare(aRegion || "") + : (aRegion || "").localeCompare(bRegion || ""); - // If regions are the same, sort by language return regionCompare === 0 ? aLang.localeCompare(bLang) : regionCompare; }); + + regionSortedResult.push(...otherRegionVoices); + result = regionSortedResult; break; } @@ -843,7 +916,7 @@ export class WebSpeechVoiceManager { let key = "Unknown"; switch (by) { - case "language": + case "languages": key = WebSpeechVoiceManager.extractLangRegionFromBCP47(voice.language)[0]; break; diff --git a/src/WebSpeech/webSpeechEngine.ts b/src/WebSpeech/webSpeechEngine.ts index 2445caf..002a7df 100644 --- a/src/WebSpeech/webSpeechEngine.ts +++ b/src/WebSpeech/webSpeechEngine.ts @@ -87,7 +87,7 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { this.voices = this.voiceManager.getVoices(); // Find the best matching voice for the user's language using the optimized method - this.defaultVoice = this.voiceManager.getDefaultVoice(navigator.languages[0] || "en", this.voices); + this.defaultVoice = this.voiceManager.getDefaultVoice([...(navigator.languages || ["en"])], this.voices); this.initialized = true; return true; @@ -177,7 +177,7 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { this.defaultVoice && this.currentVoice && this.currentVoice.language !== this.defaultVoice.language ) { - this.defaultVoice = this.voiceManager.getDefaultVoice(this.currentVoice.language, this.voices); + this.defaultVoice = this.voiceManager.getDefaultVoice([this.currentVoice.language], this.voices); } } diff --git a/src/voices/languages.ts b/src/voices/languages.ts index 4b72a9e..3041385 100644 --- a/src/voices/languages.ts +++ b/src/voices/languages.ts @@ -49,6 +49,11 @@ import vi from "@json/vi.json"; import wuu from "@json/wuu.json"; import yue from "@json/yue.json"; +export interface LanguageWithRegions { + baseLang: string; // Base language code (e.g., "en", "fr") + regions: string[]; // Regions to use for this language (explicit, inferred, or default) +} + // Helper function to cast voice data to the correct type const castVoice = (voice: any): ReadiumSpeechVoice => ({ ...voice, @@ -196,5 +201,130 @@ export const getTestUtterance = (lang: string): string => { } }; +/** + * Get the default region for a language + * @param {string} lang - Language code (e.g., "en", "fr", "zh-CN") + * @returns {string} The default region code or empty string if not found + */ +export const getDefaultRegion = (lang: string): string => { + if (!lang) return ""; + + try { + // Normalize the language code first + const normalizedLang = normalizeLanguageCode(lang); + + // Try with the normalized language code + let voiceData = getVoiceData(normalizedLang); + + // If no default region found and it's a Chinese variant, try with the mapped variant code + if ((!voiceData?.defaultRegion) && normalizedLang in chineseVariantMap) { + const variantCode = chineseVariantMap[normalizedLang]; + if (variantCode) { + const variantData = getVoiceData(variantCode); + if (variantData?.defaultRegion) { + return variantData.defaultRegion; + } + } + } + + // If still no default region, try with the base language code + if (!voiceData?.defaultRegion) { + const [baseLang] = extractLangRegionFromBCP47(normalizedLang); + if (baseLang !== normalizedLang) { + const baseLangData = getVoiceData(baseLang); + if (baseLangData?.defaultRegion) { + return baseLangData.defaultRegion; + } + } + } + + return voiceData?.defaultRegion || ""; + } catch (error) { + console.error(`Failed to get default region for ${lang}:`, error); + return ""; + } +}; + +/** + * Process languages with region inference + * @param languages - Array of language codes (e.g., ["fr", "en-CA"]) + * @returns Array of LanguageWithRegions objects with language and region information + */ +export const processLanguages = (languages: string[]): LanguageWithRegions[] => { + if (!languages?.length) return []; + + const allRegions = new Set(); + const langMap = new Map>(); + const regionPriority = new Map(); + + // Single pass: collect regions, language mappings, and region priorities + for (const [index, lang] of languages.entries()) { + if (!lang) continue; + + const normalizedLang = normalizeLanguageCode(lang); + const [baseLang, region] = extractLangRegionFromBCP47(normalizedLang); + + // Track the region in allRegions and its priority if it exists + if (region) { + allRegions.add(region); + // Only set the priority if it hasn't been set yet (first occurrence has highest priority) + if (!regionPriority.has(region)) { + regionPriority.set(region, index); + } + } + + // Track the language and its explicit regions + if (!langMap.has(baseLang)) { + langMap.set(baseLang, new Set()); + } + + if (region) { + langMap.get(baseLang)!.add(region); + } + } + + // Convert to the output format + return Array.from(langMap.entries()).map(([baseLang, explicitRegionsSet]) => { + // Get all regions from the voices for this language + const allLangVoices = getVoices(baseLang); + const validRegionsForLang = new Set( + allLangVoices.map(voice => { + const [, region] = extractLangRegionFromBCP47(voice.language); + return region; + }).filter(Boolean) + ); + + // Get explicit regions with their original priority + const explicitRegions = Array.from(explicitRegionsSet); + + // Add inferred regions (from allRegions) that are valid for this language + const inferredRegions = Array.from(allRegions).filter(region => + validRegionsForLang.has(region) && !explicitRegions.includes(region) + ); + + // Combine and sort regions based on their original priority + const regions = Array.from(new Set([...explicitRegions, ...inferredRegions])) + .sort((a, b) => { + const aPriority = regionPriority.get(a) ?? Number.MAX_SAFE_INTEGER; + const bPriority = regionPriority.get(b) ?? Number.MAX_SAFE_INTEGER; + return aPriority - bPriority; + }); + + // If still no regions, add the default region + if (regions.length === 0) { + const defaultRegion = getDefaultRegion(baseLang); + const [, defaultRegionCode] = extractLangRegionFromBCP47(defaultRegion); + if (defaultRegionCode) { + regions.push(defaultRegionCode); + } + } + + return { + baseLang, + regions + }; + }); +}; + // Re-export types for backward compatibility export * from "./types"; diff --git a/test/WebSpeechVoiceManager/filterVoices.test.ts b/test/WebSpeechVoiceManager/filterVoices.test.ts index 796e5ee..0d52a63 100644 --- a/test/WebSpeechVoiceManager/filterVoices.test.ts +++ b/test/WebSpeechVoiceManager/filterVoices.test.ts @@ -36,11 +36,11 @@ testWithContext("filterVoices: filters by language", (t: ExecutionContext v.language.startsWith("en"))); - const multiLangVoices = manager.filterVoices(testVoices, { language: ["en", "fr"] }); + const multiLangVoices = manager.filterVoices(testVoices, { languages: ["en", "fr"] }); t.is(multiLangVoices.length, 3); t.true(multiLangVoices.every(v => v.language.startsWith("en") || v.language.startsWith("fr"))); }); @@ -196,7 +196,7 @@ testWithContext("filterVoices: combines multiple filters", (t: ExecutionContext< // Filter by language and gender const englishFemaleVoices = manager.filterVoices(testVoices, { - language: "en", + languages: "en", gender: "female" }); t.is(englishFemaleVoices.length, 2); @@ -223,14 +223,14 @@ testWithContext("filterVoices: handles edge cases", (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create test voices with different languages + const testVoices = [ + { + voiceURI: "voice1", + name: "French Voice", + language: "fr-FR", + isDefault: false, + quality: "high" + }, + { + voiceURI: "voice2", + name: "German Voice", + language: "de-DE", + isDefault: false, + quality: "high" + }, + { + voiceURI: "voice3", + name: "Spanish Voice", + language: "es-ES", + isDefault: false, + quality: "high" + } + ]; + + (manager as any).voices = testVoices; + + // Test with preferred languages in specific order + const defaultVoice = await manager.getDefaultVoice(["es-ES", "fr-FR", "de-DE"]); + t.truthy(defaultVoice); + t.is(defaultVoice?.language, "es-ES", "Should select first preferred language when available"); + + // Test with different order + const defaultVoice2 = await manager.getDefaultVoice(["de-DE", "es-ES"]); + t.truthy(defaultVoice2); + t.is(defaultVoice2?.language, "de-DE", "Should respect the order of preferred languages"); + + // Test with non-existent language first + const defaultVoice3 = await manager.getDefaultVoice(["it-IT", "fr-FR", "de-DE"]); + t.truthy(defaultVoice3); + t.is(defaultVoice3?.language, "fr-FR", "Should skip non-existent languages and use next preferred"); +}); + testWithContext("getDefaultVoice: falls back to base language", async (t: ExecutionContext) => { const manager = t.context.manager; @@ -60,14 +106,14 @@ testWithContext("getDefaultVoice: falls back to base language", async (t: Execut { voiceURI: "voice1", name: "English Generic", - language: "en", // Base language + language: "en-AU", isDefault: false, quality: "high" }, { voiceURI: "voice2", name: "US English", - language: "en-US", + language: "en-US", isDefault: false, quality: "high" } @@ -75,10 +121,10 @@ testWithContext("getDefaultVoice: falls back to base language", async (t: Execut (manager as any).voices = testVoices; - // Request en-GB which isn't available, should fall back to en + // Request en-GB which isn't available, should fall back to alphabetical const defaultVoice = await manager.getDefaultVoice("en-GB"); t.truthy(defaultVoice); - t.is(defaultVoice?.language, "en", "Should fall back to base language when exact match not found"); + t.is(defaultVoice?.language, "en-AU", "Should fall back to first alphabetical region when exact match not found"); }); testWithContext("getDefaultVoice: respects quality sorting", async (t: ExecutionContext) => { diff --git a/test/WebSpeechVoiceManager/getVoices.test.ts b/test/WebSpeechVoiceManager/getVoices.test.ts index 6557a07..49d2e8c 100644 --- a/test/WebSpeechVoiceManager/getVoices.test.ts +++ b/test/WebSpeechVoiceManager/getVoices.test.ts @@ -51,7 +51,7 @@ testWithContext("getVoices: combines all filters", async (t: ExecutionContext 0); t.true(voices.every((v: ReadiumSpeechVoice) => v.language.startsWith("en"))); // Multiple languages - voices = await manager.getVoices({ language: ["en", "fr"] }); + voices = await manager.getVoices({ languages: ["en", "fr"] }); t.true(voices.length > 1); t.true(voices.some((v: ReadiumSpeechVoice) => v.language.startsWith("en"))); t.true(voices.some((v: ReadiumSpeechVoice) => v.language.startsWith("fr"))); diff --git a/test/WebSpeechVoiceManager/groupVoices.test.ts b/test/WebSpeechVoiceManager/groupVoices.test.ts index 893ceab..e29251c 100644 --- a/test/WebSpeechVoiceManager/groupVoices.test.ts +++ b/test/WebSpeechVoiceManager/groupVoices.test.ts @@ -35,7 +35,7 @@ testWithContext("groupVoices: groups by language", (t: ExecutionContext) => { const manager = t.context.manager; - const groups = manager.groupVoices([], "language"); + const groups = manager.groupVoices([], "languages"); t.deepEqual(groups, {}); }); @@ -119,7 +119,7 @@ testWithContext("groupVoices: handles voices with missing properties", (t: Execu ]; // Should handle missing properties gracefully - const groupsByLanguage = manager.groupVoices(testVoices, "language"); + const groupsByLanguage = manager.groupVoices(testVoices, "languages"); t.true(groupsByLanguage.hasOwnProperty("en")); t.true(groupsByLanguage.hasOwnProperty("fr")); t.true(groupsByLanguage.hasOwnProperty("es")); diff --git a/test/WebSpeechVoiceManager/sortVoices.test.ts b/test/WebSpeechVoiceManager/sortVoices.test.ts index f3e5f28..a5c899c 100644 --- a/test/WebSpeechVoiceManager/sortVoices.test.ts +++ b/test/WebSpeechVoiceManager/sortVoices.test.ts @@ -1,6 +1,7 @@ import { type ExecutionContext } from "ava"; import { testWithContext, TestContext, createTestVoice } from "./setup.js"; import { WebSpeechVoiceManager } from "../../build/index.js"; +import { getDefaultRegion } from "../testUtils.js"; // ============================================= // Test Hooks @@ -89,14 +90,14 @@ testWithContext("sortVoices: sorts by language", (t: ExecutionContext t.is(sortedDesc[3].language, "en-AU"); }); -testWithContext("sortVoices: sorts by preferred languages", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts by preferred languages with region inference", (t: ExecutionContext) => { const manager = t.context.manager; // Create test voices with different languages and regions const testVoices = [ - createTestVoice({ name: "French Voice", language: "fr-FR" }), + createTestVoice({ name: "French Voice", language: "fr-FR" }), // Default region + createTestVoice({ name: "French Canadian Voice", language: "fr-CA" }), // CA region + createTestVoice({ name: "French Belgian Voice", language: "fr-BE" }), // BE region createTestVoice({ name: "US English Voice", language: "en-US" }), createTestVoice({ name: "UK English Voice", language: "en-GB" }), + createTestVoice({ name: "Canadian English Voice", language: "en-CA" }), createTestVoice({ name: "German Voice", language: "de-DE" }), - createTestVoice({ name: "Spanish Voice", language: "es-ES" }), - createTestVoice({ name: "French Canadian Voice", language: "fr-CA" }) + createTestVoice({ name: "Spanish Voice", language: "es-ES" }), // Default region + createTestVoice({ name: "Mexican Spanish Voice", language: "es-MX" }) // MX region ]; - - // Test with preferred languages (exact matches first, then partial matches) - const preferredLangs = ["en-US", "fr", "es-ES"]; - const sorted = manager.sortVoices(testVoices, { - by: "language", - preferredLanguages: preferredLangs + + // Test 1: Basic language code should use default region + const defaultRegionTest = manager.sortVoices(testVoices, { + by: "languages", + preferredLanguages: ["fr"] // Should use default region (fr-FR) }); - - // Exact matches should come first in the order of preferredLanguages - t.is(sorted[0].language, "en-US"); // Exact match - t.is(sorted[1].language, "fr-CA"); // Partial match for "fr" - sorts by region code - t.is(sorted[2].language, "fr-FR"); // Also partial match for "fr" - sorts by region code - t.is(sorted[3].language, "es-ES"); // Exact match - - // Non-preferred languages should come after, sorted alphabetically - t.is(sorted[4].language, "de-DE"); - t.is(sorted[5].language, "en-GB"); - - // Test with region-specific preferences - const regionSpecific = manager.sortVoices(testVoices, { - by: "language", - preferredLanguages: ["fr-CA", "en-GB"] + + // French voices should come first, with fr-FR (default) first + t.is(defaultRegionTest[0].language, getDefaultRegion("fr"), "Default region should come first"); + t.is(defaultRegionTest[1].language, "fr-BE", "Other French regions should follow alphabetically"); + t.is(defaultRegionTest[2].language, "fr-CA", "Other French regions should follow alphabetically"); + + // Test 2: Region inference from other languages + const inferredRegionTest = manager.sortVoices(testVoices, { + by: "languages", + preferredLanguages: ["fr", "en-CA"] // Should infer fr-CA as preferred French }); - - t.is(regionSpecific[0].language, "fr-CA"); // Exact match - t.is(regionSpecific[1].language, "en-GB"); // Exact match - // Others should be sorted alphabetically - t.is(regionSpecific[2].language, "de-DE"); - t.is(regionSpecific[3].language, "en-US"); - t.is(regionSpecific[4].language, "es-ES"); - t.is(regionSpecific[5].language, "fr-FR"); - - // Test with empty preferred languages (should sort alphabetically) + + // fr-CA should come first because en-CA provides the CA region hint + t.is(inferredRegionTest[0].language, "fr-CA", "Should infer fr-CA from en-CA"); + t.is(inferredRegionTest[1].language, "fr-BE", "Other French regions should follow alphabetically"); + t.is(inferredRegionTest[2].language, "fr-FR", "Default region should come after inferred region"); + + // Test 3: Multiple regional preferences + const multipleRegionsTest = manager.sortVoices(testVoices, { + by: "languages", + preferredLanguages: ["fr-BE", "fr-CA", "es"] // Explicit regional preferences + }); + + // Should respect the order of regional preferences + t.is(multipleRegionsTest[0].language, "fr-BE", "First regional preference should come first"); + t.is(multipleRegionsTest[1].language, "fr-CA", "Second regional preference should come second"); + t.is(multipleRegionsTest[2].language, "fr-FR", "Default region should come last"); + t.is(multipleRegionsTest[3].language, getDefaultRegion("es"), "Spanish default region should come first"); + t.is(multipleRegionsTest[4].language, "es-MX", "Other Spanish regions should follow"); + + // Test 4: Keeping prioritization of regions + const prioritizedRegionsTest = manager.sortVoices(testVoices, { + by: "languages", + preferredLanguages: ["en-CA", "fr-BE", "fr-FR"] // Inferred region should come first + }); + + // Should respect the exact order of regional preferences + t.is(prioritizedRegionsTest[0].language, "en-CA", "First explicit preference should be en-CA"); + t.is(prioritizedRegionsTest[1].language, "en-GB", "UK English should come second"); + t.is(prioritizedRegionsTest[2].language, "en-US", "US English should come third"); + t.is(prioritizedRegionsTest[3].language, "fr-CA", "Inferred French Canadian should come fourth"); + t.is(prioritizedRegionsTest[4].language, "fr-BE", "French Belgian should come fifth"); + t.is(prioritizedRegionsTest[5].language, "fr-FR", "French French should come sixth"); + + // Test 5: Empty/undefined preferred languages (should sort alphabetically) const emptyPreferred = manager.sortVoices(testVoices, { - by: "language", + by: "languages", preferredLanguages: [] }); t.is(emptyPreferred[0].language, "de-DE"); - t.is(emptyPreferred[1].language, "en-GB"); - t.is(emptyPreferred[2].language, "en-US"); - t.is(emptyPreferred[3].language, "es-ES"); - t.is(emptyPreferred[4].language, "fr-CA"); - t.is(emptyPreferred[5].language, "fr-FR"); - - // Test with undefined preferred languages (should sort alphabetically) - const undefinedPreferred = manager.sortVoices(testVoices, { - by: "language" - }); - t.is(undefinedPreferred[0].language, "de-DE"); - t.is(undefinedPreferred[1].language, "en-GB"); - t.is(undefinedPreferred[2].language, "en-US"); - t.is(undefinedPreferred[3].language, "es-ES"); - t.is(undefinedPreferred[4].language, "fr-CA"); - t.is(undefinedPreferred[5].language, "fr-FR"); - - // Test with case-insensitive matching - const caseInsensitive = manager.sortVoices(testVoices, { - by: "language", - preferredLanguages: ["EN-us", "FR"] // Mixed case and partial - }); - t.is(caseInsensitive[0].language, "en-US"); // Matches despite case difference - t.is(caseInsensitive[1].language, "fr-CA"); // Partial match, sorted by region - t.is(caseInsensitive[2].language, "fr-FR"); // Also partial match + t.is(emptyPreferred[1].language, "en-CA"); + t.is(emptyPreferred[2].language, "en-GB"); + t.is(emptyPreferred[3].language, "en-US"); + t.is(emptyPreferred[4].language, "es-ES"); + t.is(emptyPreferred[5].language, "es-MX"); + t.is(emptyPreferred[6].language, "fr-BE"); + t.is(emptyPreferred[7].language, "fr-CA"); + t.is(emptyPreferred[8].language, "fr-FR"); }); testWithContext("sortVoices: sorts by region with preferred languages", (t: ExecutionContext) => { @@ -248,28 +253,27 @@ testWithContext("sortVoices: sorts by region with preferred languages", (t: Exec // Test with preferred languages that include regions const sorted = manager.sortVoices(testVoices, { by: "region", - preferredLanguages: ["en-CA", "fr-CA", "en"] // Prefer Canadian English, then Canadian French, then any English + preferredLanguages: ["en-CA", "fr-CA", "fr-FR"] // Prefer Canadian English, then Canadian French, then French French, then all other languages }); // Verify order: // 1. en-CA (exact match for first preferred) // 2. fr-CA (exact match for second preferred) - // 3. en-US (language match for third preferred) - // 4. en-GB (language match for third preferred) - // 5. en-AU (language match for third preferred) - // 6. fr-FR (no match, should come last) + // 3. fr-FR (language match for third preferred) + // 4. en-AU (alphabetical order) + // 5. en-GB (alphabetical order) + // 6. en-US (alphabetical order) t.is(sorted[0].language, "en-CA", "en-CA should be first (exact match)"); t.is(sorted[1].language, "fr-CA", "fr-CA should be second (exact match)"); + t.is(sorted[2].language, "fr-FR", "fr-FR should be third (language match)"); // The remaining English variants should be in their natural order - const remainingEnglish = sorted.slice(2, 5).map(v => v.language); + const remainingEnglish = sorted.slice(3, 6).map(v => v.language); t.true( - ["en-US", "en-GB", "en-AU"].every(lang => remainingEnglish.includes(lang)), + ["en-AU", "en-GB", "en-US"].every(lang => remainingEnglish.includes(lang)), "Should include all English variants after exact matches" ); - - t.is(sorted[5].language, "fr-FR", "fr-FR should be last (no match)"); - + // Test with preferred languages that don't match any regions const noMatches = manager.sortVoices(testVoices, { by: "region", diff --git a/test/testUtils.ts b/test/testUtils.ts new file mode 100644 index 0000000..594880e --- /dev/null +++ b/test/testUtils.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Test-only helper to get default region from JSON + * This file is only used in tests and never exposed in the main codebase + */ +export function getDefaultRegion(language: string): string { + const jsonPath = join(__dirname, `../json/${language}.json`); + const langData = JSON.parse(readFileSync(jsonPath, "utf-8")); + return langData.defaultRegion; +} From 2dfd773803fe827953e4626db3b947adf227ba40 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Fri, 9 Jan 2026 11:58:55 +0100 Subject: [PATCH 19/32] Cosmetic fixes (#35) * Correct lowercasing of browser voices * Make utterance input customizable --- demo/index.html | 2 +- demo/script.js | 45 +++++++++++++++++++------- src/WebSpeech/WebSpeechVoiceManager.ts | 19 +++++++---- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/demo/index.html b/demo/index.html index cfc47a0..9d866d3 100644 --- a/demo/index.html +++ b/demo/index.html @@ -28,7 +28,7 @@

Readium Speech Demo

- +
diff --git a/demo/script.js b/demo/script.js index 318aa64..e1eca93 100644 --- a/demo/script.js +++ b/demo/script.js @@ -34,6 +34,7 @@ let filteredVoices = []; let languages = []; let currentVoice = null; let testUtterance = ""; +let userCustomUtterance = ""; let lastNavigatorPosition = 1; const speechNavigator = new WebSpeechReadAloudNavigator(); @@ -532,12 +533,18 @@ function updateTestUtterance(voice, languageCode) { return; } - // Use the voice's language as the primary source, fall back to the language selector, then default to "en" - const language = voice.language || languageCode || "en"; - const baseUtterance = voiceManager.getTestUtterance(language) || - `This is a test of the {name} voice.`; - testUtterance = baseUtterance.replace(/\{\s*name\s*\}/g, voice.label || voice.name || "this voice"); - testUtteranceInput.value = testUtterance; + // Only update if we don't have custom text + if (!userCustomUtterance) { + const language = voice.language || languageCode || "en"; + const baseUtterance = voiceManager.getTestUtterance(language) || + `This is a test of the {name} voice.`; + testUtterance = baseUtterance.replace(/\{\s*name\s*\}/g, voice.label || voice.name || "this voice"); + testUtteranceInput.value = testUtterance; + } else { + // Use the custom text + testUtterance = userCustomUtterance; + } + testUtteranceBtn.disabled = false; } @@ -567,12 +574,16 @@ function setupEventListeners() { languageSelect.addEventListener("change", async () => { const baseLanguage = languageSelect.value; - // Reset voice selection and clear test utterance + // Reset voice selection voiceSelect.disabled = false; currentVoice = null; - testUtterance = ""; - testUtteranceInput.value = ""; - testUtteranceBtn.disabled = true; + + // Only reset test utterance if there's no custom text + if (!userCustomUtterance) { + testUtterance = ""; + testUtteranceInput.value = ""; + testUtteranceBtn.disabled = true; + } // Clear voice properties displayVoiceProperties(null); @@ -706,6 +717,17 @@ function setupEventListeners() { // Download voices button downloadVoicesBtn.addEventListener("click", downloadVoicesAsJson); + + // Update custom utterance when user types in the input + testUtteranceInput.addEventListener("input", (e) => { + userCustomUtterance = e.target.value.trim(); + testUtterance = userCustomUtterance; + + // If user clears the input and we have a current voice, update with default utterance + if (!userCustomUtterance && currentVoice) { + updateTestUtterance(currentVoice, languageSelect.value); + } + }); } // Play test utterance - independent of the navigator @@ -724,8 +746,9 @@ async function playTestUtterance() { // Get test utterance for the selected language let testText = testUtteranceInput.value; if (!testText) { + // If input is empty, generate default and use it updateTestUtterance(currentVoice, languageSelect.value); - testText = testUtteranceInput.value; + testText = testUtteranceInput.value.trim(); } // Create a new SpeechSynthesisUtterance diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index 70fe1ec..8c5b0d1 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -149,22 +149,29 @@ export class WebSpeechVoiceManager { } /** - * Normalize voice name for comparison by removing common variations + * Clean voice name by removing specific formatting * @private */ - - private normalizeVoiceName(name: string): string { + private cleanVoiceName(name: string): string { if (!name) return ""; - // Convert to lowercase and remove only the specific formatting we don't want + // Remove only the specific formatting we don't want, preserving case return name - .toLowerCase() .replace(/\s*\([^)]*\)/g, "") // Remove anything in parentheses .replace(/[^\p{L}\p{N}\s-]/gu, "") // Keep letters, numbers, spaces, and hyphens .replace(/\s+/g, " ") // Normalize spaces .trim(); } + /** + * Normalize voice name for comparison by removing common variations + * @private + */ + private normalizeVoiceName(name: string): string { + // Convert to lowercase first, then clean + return this.cleanVoiceName(name.toLowerCase()); + } + /** * Count occurrences of each voice based on language and normalized name * @private @@ -549,7 +556,7 @@ export class WebSpeechVoiceManager { // No match found in JSON, create basic voice object return { source: "browser", - label: this.normalizeVoiceName(voice.name), + label: this.cleanVoiceName(voice.name), name: voice.name, originalName: voice.name, language: formattedLang, From 4c0a278692620e8c9f34af3e3283f8b5e0f00961 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Fri, 9 Jan 2026 16:24:18 +0100 Subject: [PATCH 20/32] Handle chinese variants in getLanguages (#37) --- demo/script.js | 5 ++-- src/WebSpeech/WebSpeechVoiceManager.ts | 35 ++++++++++++-------------- src/voices/languages.ts | 32 ++++++++++++----------- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/demo/script.js b/demo/script.js index e1eca93..f3b327d 100644 --- a/demo/script.js +++ b/demo/script.js @@ -135,8 +135,9 @@ function updateLanguageCounts(voices) { const languageCounts = new Map(); voices.forEach(voice => { - const langCode = voice.language.split("-")[0]; // Get base language code - languageCounts.set(langCode, (languageCounts.get(langCode) || 0) + 1); + const langCode = voice.language; + const baseLang = langCode.split("-")[0]; + languageCounts.set(baseLang, (languageCounts.get(baseLang) || 0) + 1) }); // Update the languages array with new counts diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index 8c5b0d1..d4bdda8 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -1,5 +1,5 @@ import { ReadiumSpeechJSONVoice, ReadiumSpeechVoice, TGender, TQuality, TSource } from "../voices/types"; -import { getTestUtterance, getVoices, processLanguages } from "../voices/languages"; +import { getTestUtterance, getVoices, processLanguages, normalizeLanguageCode } from "../voices/languages"; import { isNoveltyVoice, isVeryLowQualityVoice, @@ -349,13 +349,19 @@ export class WebSpeechVoiceManager { voicesToCount.forEach(voice => { const langCode = voice.language; - const [baseLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(langCode); + const normalizedLang = normalizeLanguageCode(langCode); - // Use the base language code for grouping (e.g., "en" for both "en-US" and "en-GB") - const key = baseLang; - const displayName = WebSpeechVoiceManager.getLanguageDisplayName(baseLang, localization); + const key = normalizedLang.split("-")[0]; + const displayName = WebSpeechVoiceManager.getLanguageDisplayName( + key, + localization + ); - const entry = languages.get(key) || { count: 0, label: displayName, code: baseLang }; + const entry = languages.get(key) || { + count: 0, + label: displayName, + code: key + }; languages.set(key, { ...entry, count: entry.count + 1 }); }); @@ -502,14 +508,6 @@ export class WebSpeechVoiceManager { * @private */ private parseToReadiumSpeechVoices(speechVoices: SpeechSynthesisVoice[]): ReadiumSpeechVoice[] { - const parseAndFormatBCP47 = (lang: string) => { - const speechVoiceLang = lang.replace(/_/g, "-"); - if (/\w{2,3}-\w{2,3}/.test(speechVoiceLang)) { - return `${speechVoiceLang.split("-")[0].toLowerCase()}-${speechVoiceLang.split("-")[1].toUpperCase()}`; - } - return lang; - }; - // Count duplicates first const duplicateCounts = this.countVoiceDuplicates(speechVoices); @@ -517,14 +515,14 @@ export class WebSpeechVoiceManager { const mappedVoices = speechVoices .filter(voice => voice?.name && voice?.lang) .map(voice => { - const formattedLang = parseAndFormatBCP47(voice.lang); - const [baseLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(formattedLang); + const normalizedLang = normalizeLanguageCode(voice.lang); + const [baseLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(normalizedLang); const normalizedName = this.normalizeVoiceName(voice.name); const voiceKey = `${voice.lang.toLowerCase()}_${normalizedName}`; const duplicatesCount = duplicateCounts.get(voiceKey) || 1; // First try with the full language code to handle variants like zh-HK - let langVoices = getVoices(formattedLang); + let langVoices = getVoices(normalizedLang); // If no voices found, try with the base language code if (!langVoices || langVoices.length === 0) { @@ -543,7 +541,6 @@ export class WebSpeechVoiceManager { ...jsonVoice, source: "json", originalName: voice.name, - language: voice.lang, voiceURI: voice.voiceURI, quality, isDefault: voice.default || false, @@ -559,7 +556,7 @@ export class WebSpeechVoiceManager { label: this.cleanVoiceName(voice.name), name: voice.name, originalName: voice.name, - language: formattedLang, + language: normalizedLang, voiceURI: voice.voiceURI, quality, isDefault: voice.default || false, diff --git a/src/voices/languages.ts b/src/voices/languages.ts index 3041385..97aa2cb 100644 --- a/src/voices/languages.ts +++ b/src/voices/languages.ts @@ -92,16 +92,16 @@ const getVoiceData = (lang: string): VoiceData | undefined => voiceDataMap[lang] // Chinese variant mapping for special handling export const chineseVariantMap: {[key: string]: string} = { "cmn": "cmn", - "cmn-cn": "cmn", - "cmn-tw": "cmn", + "cmn-CN": "cmn-CN", + "cmn-TW": "cmn-TW", "zh": "cmn", - "zh-cn": "cmn", - "zh-tw": "cmn", + "zh-CN": "cmn-CN", + "zh-TW": "cmn-TW", "yue": "yue", - "yue-hk": "yue", - "zh-hk": "yue", + "yue-HK": "yue-HK", + "zh-HK": "yue-HK", "wuu": "wuu", - "wuu-cn": "wuu" + "wuu-CN": "wuu-CN" }; /** @@ -109,10 +109,19 @@ export const chineseVariantMap: {[key: string]: string} = { * @param lang - Input language code * @returns Normalized language code */ -const normalizeLanguageCode = (lang: string): string => { +export const normalizeLanguageCode = (lang: string): string => { if (!lang) return ""; - const normalized = lang.toLowerCase().replace(/_/g, "-"); + // First normalize to lowercase and replace underscores with hyphens + let normalized = lang.toLowerCase().replace(/_/g, "-"); + + // Handle BCP47 formatting (e.g., "en-US" -> "en-US", "en-us" -> "en-US") + if (/\w{2,3}-\w{2,3}/.test(normalized)) { + const [language, region] = normalized.split("-"); + normalized = `${language.toLowerCase()}-${region.toUpperCase()}`; + } + + // Then check for Chinese variants return chineseVariantMap[normalized] || normalized; }; @@ -131,11 +140,6 @@ export const getVoices = (lang: string): ReadiumSpeechVoice[] => { // Try with the normalized language code let voiceData = getVoiceData(normalizedLang); - // If no voices found and it's a Chinese variant, try with the base Chinese code - if ((!voiceData || !voiceData.voices?.length) && normalizedLang in chineseVariantMap) { - voiceData = getVoiceData("zh"); - } - // If still no voices, try with the base language code if (!voiceData || !voiceData.voices?.length) { const [baseLang] = extractLangRegionFromBCP47(normalizedLang); From 7f785f14eb3b006ab59c3232920bfdfcc01b64e5 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Fri, 9 Jan 2026 17:17:27 +0100 Subject: [PATCH 21/32] Update sortOptions in quickstart --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 57dc10c..32c3ce5 100644 --- a/README.md +++ b/README.md @@ -192,12 +192,15 @@ Organizes voices into groups based on the specified criteria. The available grou voiceManager.sortVoices(voices: ReadiumSpeechVoice[], options: SortOptions): ReadiumSpeechVoice[] ``` -Arranges voices according to the specified sorting criteria. The `SortOptions` interface allows you to sort by various properties and specify sort order. +Arranges voices according to the specified sorting criteria. The `SortOptions` interface allows you to sort by various properties and specify sort order. + +If `preferredLanguages` is provided, voices from those languages will be prioritized in the sorting by languages and region. ```typescript interface SortOptions { by: "name" | "languages" | "gender" | "quality" | "region"; order?: "asc" | "desc"; + preferredLanguages?: string[]; } ``` From 028ad2ed483299b81b23238acb764d47b7b4dfda Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Fri, 9 Jan 2026 17:30:26 +0100 Subject: [PATCH 22/32] Correct packageQualityInference --- src/voices/packages.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/voices/packages.ts b/src/voices/packages.ts index d5460bc..e0cbb35 100644 --- a/src/voices/packages.ts +++ b/src/voices/packages.ts @@ -26,10 +26,13 @@ export const getInferredQualityFromPackageName = (voiceName: string): TQuality | if (!voiceName) return undefined; const lowerName = voiceName.toLowerCase(); + // Split the name into segments using common separators + const segments = lowerName.split(/[._-]/); // Check each quality level for (const quality of Object.values(packageQualities)) { - if (quality.values.some(value => lowerName.includes(`.${value}.`))) { + // Check if any of the quality values exist as a complete segment + if (quality.values.some(value => segments.includes(value))) { return quality.quality; } } From b1114fb05463c1c68af1c76d2e5d08d79aef39ea Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 13 Jan 2026 11:04:37 +0100 Subject: [PATCH 23/32] Rewrite sorting methods (#38) This replaces sortVoices with more opinionated sorting methods. --- README.md | 61 ++- demo/article/script.js | 14 +- demo/script.js | 44 +- src/WebSpeech/WebSpeechVoiceManager.ts | 514 ++++++++++-------- src/voices/sorting.ts | 56 ++ .../getDefaultVoice.test.ts | 2 +- test/WebSpeechVoiceManager/sortVoices.test.ts | 276 +++++----- 7 files changed, 526 insertions(+), 441 deletions(-) create mode 100644 src/voices/sorting.ts diff --git a/README.md b/README.md index 32c3ce5..167e344 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,31 @@ interface VoiceFilterOptions { } ``` +#### Get Default Voice + +```typescript +voiceManager.getDefaultVoice(languages: string | string[], voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice | null +``` + +Automatically selects the best available voice based on quality and language preferences. This is the recommended method for getting a suitable voice without manual selection. + +```typescript +// Get the best voice for user's browser language +const defaultVoice = voiceManager.getDefaultVoice(navigator.languages || ["en"]); + +// Get the best voice for specific preferred languages +const frenchVoice = voiceManager.getDefaultVoice(["fr-FR", "fr-CA"]); + +// Get the best voice from a pre-filtered voice list +const customVoice = voiceManager.getDefaultVoice(["en-US", "en-GB"], customVoiceList); +``` + +The selection algorithm: +1. Filters voices by the specified languages (or uses provided voices array) +2. Sorts by region preference within matching languages +3. Returns the highest quality voice from the best language/region match +4. Returns `null` if no voices match or if languages parameter is empty + #### Filter Voices ```typescript @@ -188,20 +213,40 @@ Organizes voices into groups based on the specified criteria. The available grou #### Sort Voices +The library provides opinionated voice sorting capabilities to help you find the best voice for your needs. + +If you need more control over the sorting process, you can implement and apply your own sorting logic on filtered voices. + +##### 1. Sort by Quality + +Sort voices from highest to lowest quality: + ```typescript -voiceManager.sortVoices(voices: ReadiumSpeechVoice[], options: SortOptions): ReadiumSpeechVoice[] +const sortedVoices = voiceManager.sortVoicesByQuality(voices); +// Returns: [veryHigh, high, normal, low, veryLow, null] ``` -Arranges voices according to the specified sorting criteria. The `SortOptions` interface allows you to sort by various properties and specify sort order. +##### 2. Sort by Language -If `preferredLanguages` is provided, voices from those languages will be prioritized in the sorting by languages and region. +Prioritize specific languages while maintaining JSON data’s quality order within each language group: ```typescript -interface SortOptions { - by: "name" | "languages" | "gender" | "quality" | "region"; - order?: "asc" | "desc"; - preferredLanguages?: string[]; -} +// Basic usage +const sortedByLanguage = voiceManager.sortVoicesByLanguages(voices); + +// With preferred languages first +const preferredFirst = voiceManager.sortVoicesByLanguages(voices, ["fr", "en"]); +// Returns: [fr voices, en voices, other languages voices...] +``` + +##### 3. Sort by Region + +Sort voices by preferred languages and regions, while maintaining JSON data’s quality order within each region group: + +```typescript +// With preferred regions +const preferredRegions = voiceManager.sortVoicesByRegions(voices, ["fr-FR", "en-US"]); +// Returns: [fr-FR voices, other fr regions voices, en-US voices, other en regions voices, other regions voices...] ``` ### Testing diff --git a/demo/article/script.js b/demo/article/script.js index 81a57b6..457b9ba 100644 --- a/demo/article/script.js +++ b/demo/article/script.js @@ -187,18 +187,8 @@ function populateVoiceSelect() { } try { - // First sort by quality within each language/region - const sortedByQuality = voiceManager.sortVoices(allVoices, { - by: "quality", - order: "desc" - }); - - // Then sort by region while preserving quality order within each region - const sortedVoices = voiceManager.sortVoices(sortedByQuality, { - by: "region", - order: "asc", - preferredLanguages: window.navigator.languages - }); + // Sort by region while preserving quality order within each region + const sortedVoices = voiceManager.sortVoicesByRegions(allVoices, window.navigator.languages); let currentRegion = null; let optgroup = null; diff --git a/demo/script.js b/demo/script.js index f3b327d..b33d5de 100644 --- a/demo/script.js +++ b/demo/script.js @@ -82,22 +82,20 @@ async function init() { const allLanguages = voiceManager.getLanguages(window.navigator.language, initOptions); // Sort languages with browser's preferred languages first - languages = voiceManager.sortVoices( - allLanguages.map(lang => ({ + languages = allLanguages + .map(lang => ({ ...lang, language: lang.code, name: lang.label - })), - { - by: "languages", - order: "asc", - preferredLanguages: window.navigator.languages - } - ).map(voice => ({ - code: voice.language, - label: voice.name, - count: voice.count - })); + })); + + // Sort using the manager's sortRegions method + languages = voiceManager.sortVoicesByRegions(languages, window.navigator.languages) + .map(voice => ({ + code: voice.language, + label: voice.name, + count: voice.count + })); // Populate language dropdown populateLanguageDropdown(); @@ -293,12 +291,6 @@ function filterVoices() { filteredVoices = voicesFilteredExceptLanguage; } - // Sort voices by quality (highest first) - filteredVoices = voiceManager.sortVoices(filteredVoices, { - by: "quality", - order: "desc" - }); - populateVoiceDropdown(language); // Replace current voice if it was filtered out @@ -321,11 +313,7 @@ function populateVoiceDropdown() { } // Sort voices with browser's preferred languages first - const sortedVoices = voiceManager.sortVoices([...filteredVoices], { - by: "region", - order: "asc", - preferredLanguages: window.navigator.languages - }); + const sortedVoices = voiceManager.sortVoicesByRegions([...filteredVoices], window.navigator.languages); // Group the sorted voices by region const voiceGroups = voiceManager.groupVoices(sortedVoices, "region"); @@ -339,13 +327,7 @@ function populateVoiceDropdown() { const optgroup = document.createElement("optgroup"); optgroup.label = `${getCountryFlag(countryCode)} ${regionName}`; - // Sort voices by quality within each region - const sortedVoicesInRegion = voiceManager.sortVoices(voices, { - by: "quality", - order: "desc" - }); - - for (const voice of sortedVoicesInRegion) { + for (const voice of voices) { const option = document.createElement("option"); option.value = voice.name; option.textContent = [ diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index d4bdda8..e91b8c1 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -1,5 +1,6 @@ import { ReadiumSpeechJSONVoice, ReadiumSpeechVoice, TGender, TQuality, TSource } from "../voices/types"; -import { getTestUtterance, getVoices, processLanguages, normalizeLanguageCode } from "../voices/languages"; +import { getTestUtterance, getVoices, processLanguages, normalizeLanguageCode, getDefaultRegion, LanguageWithRegions } from "../voices/languages"; +import { createJsonOrderMap } from "../voices/sorting"; import { isNoveltyVoice, isVeryLowQualityVoice, @@ -278,8 +279,8 @@ export class WebSpeechVoiceManager { if (!existing) { voiceMap.set(key, voice); } else { - const existingQuality = this.getQualityValue(existing.quality); - const newQuality = this.getQualityValue(voice.quality); + const existingQuality = WebSpeechVoiceManager.getQualityValue(existing.quality); + const newQuality = WebSpeechVoiceManager.getQualityValue(voice.quality); // If new voice has higher or equal quality, use it (preferring the newer one) if (newQuality >= existingQuality) { @@ -425,19 +426,9 @@ export class WebSpeechVoiceManager { // Use provided voices or get filtered voices if not provided let filteredVoices = voices || this.getVoices({ languages: languageArray }); if (!filteredVoices.length) return null; - - // First sort by quality (highest first) - filteredVoices = this.sortVoices(filteredVoices, { - by: "quality", - order: "desc" - }); - // Then sort by language to ensure we get the best match for the requested language(s) - filteredVoices = this.sortVoices(filteredVoices, { - by: "languages", - order: "asc", - preferredLanguages: languageArray - }); + // Then sort by region to ensure we get the best match for the requested language(s) + filteredVoices = this.sortVoicesByRegions(filteredVoices, languageArray); // Return the best available voice (already sorted by quality and language) return filteredVoices[0]; @@ -664,249 +655,290 @@ export class WebSpeechVoiceManager { } /** - * Get the numeric value for a quality level - * @private - */ - private getQualityValue(quality: TQuality | undefined): number { - const qualityOrder: Record = { - "veryLow": 1, - "low": 2, - "normal": 3, - "high": 4, - "veryHigh": 5 - }; - - // Return 0 for null/undefined, otherwise the quality value or 0 if not found - return quality ? (qualityOrder[quality] ?? 0) : 0; + * Get the numeric value for a quality level + * @param quality Quality level + * @returns Numeric value (higher = better quality, 0 for undefined/null) + */ +private static getQualityValue(quality: string | null | undefined): number { + const qualityOrder: Record = { + "veryLow": 1, + "low": 2, + "normal": 3, + "high": 4, + "veryHigh": 5 + }; + + // Return 0 for null/undefined, otherwise the quality value or 0 if not found + return quality ? (qualityOrder[quality] ?? 0) : 0; +} + +/** + * Sort two voices by quality, using JSON order as fallback for undefined/null quality + * @param a First voice + * @param b Second voice + * @param jsonOrderMaps Optional map of language codes to voice order maps + * @param baseLang Base language code to use for looking up the order map + * @returns Comparison result (-1, 0, or 1) + */ +private static sortByQuality( + a: ReadiumSpeechVoice, + b: ReadiumSpeechVoice, + jsonOrderMaps?: Map>, + baseLang?: string +): number { + const aQuality = WebSpeechVoiceManager.getQualityValue(a.quality); + const bQuality = WebSpeechVoiceManager.getQualityValue(b.quality); + + // If both have defined quality, sort by quality (highest first) + if (aQuality > 0 && bQuality > 0) { + return bQuality - aQuality; } + + // If one has quality and the other doesn't, the one with quality comes first + if (aQuality > 0 && bQuality === 0) return -1; + if (aQuality === 0 && bQuality > 0) return 1; + + // Both have undefined/null quality - use JSON order if possible + if (aQuality === 0 && bQuality === 0 && jsonOrderMaps && baseLang) { + if (a.source === "json" && b.source === "json") { + // Get the language-specific order map + const langOrderMap = jsonOrderMaps.get(baseLang); + if (langOrderMap) { + const aOrder = langOrderMap.get(a.name) ?? Number.MAX_SAFE_INTEGER; + const bOrder = langOrderMap.get(b.name) ?? Number.MAX_SAFE_INTEGER; + + if (aOrder !== Number.MAX_SAFE_INTEGER && bOrder !== Number.MAX_SAFE_INTEGER) { + return aOrder - bOrder; + } + } + } + } + + // Fallback to alphabetical by name + return a.name.localeCompare(b.name); +} /** - * Sort voices by the specified criteria + * Sort voices by quality, respecting JSON name order, then alphabetically for undefined/null quality + * @param voices Array of voices to sort + * @returns Sorted array of voices */ - sortVoices(voices: ReadiumSpeechVoice[], options: SortOptions): ReadiumSpeechVoice[] { + sortVoicesByQuality(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { if (!voices?.length) return []; - let result = [...voices]; + const jsonOrderMaps = createJsonOrderMap(voices); + return [...voices].sort((a, b) => WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps)); + } + + /** + * Group voices by language based on processed preferred languages + */ + private static groupVoicesByLanguage( + voices: ReadiumSpeechVoice[], + processedLangs: LanguageWithRegions[] + ): { voicesByLang: Map, otherLangVoices: ReadiumSpeechVoice[] } { + const langInfo = new Map(processedLangs.map(info => [info.baseLang, info])); + const voicesByLang = new Map(); + const otherLangVoices: ReadiumSpeechVoice[] = []; - switch (options.by) { - case "name": - result.sort((a, b) => - options.order === "desc" - ? b.name.localeCompare(a.name) - : a.name.localeCompare(b.name) - ); - break; - - case "languages": - // Use processLanguages to get language and region information - const processedLangs = processLanguages(options.preferredLanguages || []); - const langInfo = new Map(processedLangs.map(info => [info.baseLang, info])); - - // Group voices by language - const voicesByLang = new Map(); - const otherLangVoices: ReadiumSpeechVoice[] = []; - - for (const voice of result) { - const [lang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(voice.language); - const langInfoForVoice = langInfo.get(lang.toLowerCase()); - - if (langInfoForVoice) { - if (!voicesByLang.has(lang)) { - voicesByLang.set(lang, []); - } - voicesByLang.get(lang)!.push(voice); - } else { - otherLangVoices.push(voice); - } - } - - // Sort each language group separately - const langSortedResult: ReadiumSpeechVoice[] = []; - - for (const processedLang of processedLangs) { - const langVoices = voicesByLang.get(processedLang.baseLang); - if (langVoices) { - // Sort this language's voices by region - langVoices.sort((a, b) => { - const [, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); - const [, bRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); - - // Check if regions are in the processed languages for this base language - const aHasMatch = aRegion && processedLang.regions.includes(aRegion); - const bHasMatch = bRegion && processedLang.regions.includes(bRegion); - - if (aHasMatch && bHasMatch) { - // Both have matches - sort by their order in this language's regions - const aIndex = processedLang.regions.indexOf(aRegion!); - const bIndex = processedLang.regions.indexOf(bRegion!); - return aIndex - bIndex; - } - - // Only one has match - it comes first - if (aHasMatch) return -1; - if (bHasMatch) return 1; - - // Neither has match - sort alphabetically by region - return (aRegion || "").localeCompare(bRegion || ""); - }); - - langSortedResult.push(...langVoices); - } - } - - // Add other voices sorted by display name - otherLangVoices.sort((a, b) => { - const [aLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); - const [bLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); - const aDisplayName = WebSpeechVoiceManager.getLanguageDisplayName(aLang).toLowerCase(); - const bDisplayName = WebSpeechVoiceManager.getLanguageDisplayName(bLang).toLowerCase(); - - const compare = aDisplayName.localeCompare(bDisplayName); - if (compare !== 0) { - return options.order === "desc" ? -compare : compare; - } - - // If same display name, sort by region if available - const [, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); - const [, bRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); - if (aRegion && bRegion) { - return options.order === "desc" - ? bRegion.localeCompare(aRegion) - : aRegion.localeCompare(bRegion); - } - - // If one has a region and the other doesn't, the one with region comes first - if (aRegion) return -1; - if (bRegion) return 1; - - return 0; - }); - - langSortedResult.push(...otherLangVoices); - result = langSortedResult; - break; - - case "gender": - result.sort((a, b) => { - const aGender = a.gender || ""; - const bGender = b.gender || ""; - return options.order === "desc" - ? bGender.localeCompare(aGender) - : aGender.localeCompare(bGender); - }); - break; - - case "quality": - result.sort((a, b) => { - const aQuality = this.getQualityValue(a.quality); - const bQuality = this.getQualityValue(b.quality); - - return options.order === "desc" - ? bQuality - aQuality // desc: high quality first - : aQuality - bQuality; // asc: low quality first - }); - break; - - case "region": - // Use processLanguages to get language and region information - const processedRegions = processLanguages(options.preferredLanguages || []); - - // Create region preference order from processedLanguages - const regionOrder: string[] = []; - const regionToLangs = new Map(); - - for (const processedLang of processedRegions) { - for (const region of processedLang.regions) { - if (!regionOrder.includes(region)) { - regionOrder.push(region); - } - if (!regionToLangs.has(region)) { - regionToLangs.set(region, []); - } - regionToLangs.get(region)!.push(processedLang.baseLang); - } + for (const voice of voices) { + const [lang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(voice.language); + const langInfoForVoice = langInfo.get(lang); + + if (langInfoForVoice) { + if (!voicesByLang.has(lang)) { + voicesByLang.set(lang, []); } + voicesByLang.get(lang)!.push(voice); + } else { + otherLangVoices.push(voice); + } + } + + return { voicesByLang, otherLangVoices }; + } + + /** + * Sort regions by default then alphabetically, sort voices by quality + */ + private static sortByDefaultRegion( + voices: ReadiumSpeechVoice[], + baseLang: string + ): void { + const jsonOrderMaps = createJsonOrderMap(voices); + const defaultRegion = getDefaultRegion(baseLang); + + voices.sort((a, b) => { + const [, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); + const [, bRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); + + const aIsDefault = defaultRegion && aRegion === defaultRegion.split("-")[1]; + const bIsDefault = defaultRegion && bRegion === defaultRegion.split("-")[1]; + + // Default region comes first + if (aIsDefault && !bIsDefault) return -1; + if (!aIsDefault && bIsDefault) return 1; + + // Both default or both non-default - sort by quality + return WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps, baseLang); + }); + } + + /** + * Sort voices alphabetically by language, then region, then quality + */ + private static sortAlphabetically( + voices: ReadiumSpeechVoice[] + ): void { + voices.sort((a, b) => { + const [aLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); + const [bLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); + const aDisplayName = WebSpeechVoiceManager.getLanguageDisplayName(aLang).toLowerCase(); + const bDisplayName = WebSpeechVoiceManager.getLanguageDisplayName(bLang).toLowerCase(); + + const langCompare = aDisplayName.localeCompare(bDisplayName); + if (langCompare !== 0) { + return langCompare; + } + + // Same language - prioritize default region + if (aLang === bLang) { + const defaultRegion = getDefaultRegion(aLang); + const [, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); + const [, bRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); - // Group voices by region - const voicesByRegion = new Map(); - const otherRegionVoices: ReadiumSpeechVoice[] = []; + const aIsDefault = defaultRegion && aRegion === defaultRegion.split("-")[1]; + const bIsDefault = defaultRegion && bRegion === defaultRegion.split("-")[1]; - for (const voice of result) { - const [, region] = WebSpeechVoiceManager.extractLangRegionFromBCP47(voice.language); - - if (region && regionToLangs.has(region)) { - if (!voicesByRegion.has(region)) { - voicesByRegion.set(region, []); - } - voicesByRegion.get(region)!.push(voice); - } else { - otherRegionVoices.push(voice); - } - } + // Default region comes first + if (aIsDefault && !bIsDefault) return -1; + if (!aIsDefault && bIsDefault) return 1; - // Sort each region group separately - const regionSortedResult: ReadiumSpeechVoice[] = []; + // Both default or both non-default - sort by quality + const sameLangVoices = voices.filter(v => + WebSpeechVoiceManager.extractLangRegionFromBCP47(v.language)[0] === aLang + ); + const jsonOrderMaps = createJsonOrderMap(sameLangVoices); + return WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps, aLang); + } + + return langCompare; + }); + } + + /** + * Sort voices by language preference, then alphabetically + * @param voices Array of voices to sort + * @param preferredLanguages Array of preferred language codes in order of preference + * @returns Sorted array of voices + */ + sortVoicesByLanguages(voices: ReadiumSpeechVoice[], preferredLanguages?: string[]): ReadiumSpeechVoice[] { + if (!voices?.length) return []; + if (!preferredLanguages?.length) { + // If no preferred languages, sort alphabetically by language display name, + // but prioritize default region voices within each language group + const sortedVoices = [...voices]; + WebSpeechVoiceManager.sortAlphabetically(sortedVoices); + return sortedVoices; + } + + const processedLangs = processLanguages(preferredLanguages); + const { voicesByLang, otherLangVoices } = WebSpeechVoiceManager.groupVoicesByLanguage(voices, processedLangs); + + // Sort each language group by quality using helper + const langSortedResult: ReadiumSpeechVoice[] = []; + + for (const processedLang of processedLangs) { + const langVoices = voicesByLang.get(processedLang.baseLang); + if (langVoices) { + WebSpeechVoiceManager.sortByDefaultRegion(langVoices, processedLang.baseLang); + langSortedResult.push(...langVoices); + } + } + + // Add other voices sorted alphabetically with region and quality fallback + WebSpeechVoiceManager.sortAlphabetically(otherLangVoices); + langSortedResult.push(...otherLangVoices); + return langSortedResult; + } + + /** + * Sort languages by region preference, then voices by quality + */ + private static sortByPreferredRegion( + voices: ReadiumSpeechVoice[], + processedLang: LanguageWithRegions + ): void { + voices.sort((a, b) => { + const [, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); + const [, bRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); + + // Check if regions are in processed languages for this base language + const aHasMatch = aRegion && processedLang.regions.includes(aRegion); + const bHasMatch = bRegion && processedLang.regions.includes(bRegion); + + if (aHasMatch && bHasMatch) { + // Both have matches - sort by their order in this language's regions + const aIndex = processedLang.regions.indexOf(aRegion!); + const bIndex = processedLang.regions.indexOf(bRegion!); - for (const region of regionOrder) { - const regionVoices = voicesByRegion.get(region); - if (regionVoices) { - // Sort this region's voices by language preference - regionVoices.sort((a, b) => { - const [aLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); - const [bLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); - - // Check if languages are in the preferred languages for this region - const preferredLangsForRegion = regionToLangs.get(region) || []; - const aIndex = preferredLangsForRegion.indexOf(aLang); - const bIndex = preferredLangsForRegion.indexOf(bLang); - - if (aIndex !== -1 && bIndex !== -1) { - // Both have matches - sort by their order in this region's languages - return aIndex - bIndex; - } - - if (aIndex !== -1 && bIndex === -1) { - // A has match, B doesn't - A comes first - return -1; - } - - if (aIndex === -1 && bIndex !== -1) { - // B has match, A doesn't - B comes first - return 1; - } - - // Neither has match - sort alphabetically by language - return aLang.localeCompare(bLang); - }); - - regionSortedResult.push(...regionVoices); - } + // If same region, sort by quality + if (aIndex === bIndex) { + const jsonOrderMaps = createJsonOrderMap(voices); + return WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps, processedLang.baseLang); } - // Add other voices sorted by region then language - otherRegionVoices.sort((a, b) => { - const [, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); - const [, bRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); - const [aLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); - const [bLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); - - const regionCompare = options.order === "desc" - ? (bRegion || "").localeCompare(aRegion || "") - : (aRegion || "").localeCompare(bRegion || ""); - - return regionCompare === 0 - ? aLang.localeCompare(bLang) - : regionCompare; - }); - - regionSortedResult.push(...otherRegionVoices); - result = regionSortedResult; - break; + return aIndex - bIndex; + } + + // Only one has match - it comes first + if (aHasMatch) return -1; + if (bHasMatch) return 1; + + // Neither has match - always prioritize default region first, then alphabetical + const defaultRegion = getDefaultRegion(processedLang.baseLang); + const [, defaultRegionCode] = WebSpeechVoiceManager.extractLangRegionFromBCP47(defaultRegion); + + const aIsDefault = aRegion === defaultRegionCode; + const bIsDefault = bRegion === defaultRegionCode; + + if (aIsDefault && !bIsDefault) return -1; + if (!aIsDefault && bIsDefault) return 1; + + // Sort alphabetically by region + return (aRegion || "").localeCompare(bRegion || ""); + }); + } + + /** + * Sort voices by region preference, then alphabetically + * @param voices Array of voices to sort + * @param preferredLanguages Array of preferred language codes in order of preference + * @returns Sorted array of voices + */ + sortVoicesByRegions(voices: ReadiumSpeechVoice[], preferredLanguages: string[]): ReadiumSpeechVoice[] { + if (!voices?.length) return []; + + const processedLangs = processLanguages(preferredLanguages || []); + const { voicesByLang, otherLangVoices } = WebSpeechVoiceManager.groupVoicesByLanguage(voices, processedLangs); + + // Sort each language group by region preference + const langSortedResult: ReadiumSpeechVoice[] = []; + + for (const processedLang of processedLangs) { + const langVoices = voicesByLang.get(processedLang.baseLang); + if (langVoices) { + WebSpeechVoiceManager.sortByPreferredRegion(langVoices, processedLang); + langSortedResult.push(...langVoices); + } } - return result; + // Add other voices sorted alphabetically with region and quality fallback + WebSpeechVoiceManager.sortAlphabetically(otherLangVoices); + langSortedResult.push(...otherLangVoices); + return langSortedResult; } - + /** * Group voices by the specified criteria * @param voices Array of voices to group diff --git a/src/voices/sorting.ts b/src/voices/sorting.ts new file mode 100644 index 0000000..1c06c32 --- /dev/null +++ b/src/voices/sorting.ts @@ -0,0 +1,56 @@ +import { ReadiumSpeechVoice } from "./types"; +import { getVoices } from "./languages"; +import { extractLangRegionFromBCP47 } from "../utils/language"; + +/** + * Create a map of language codes to their respective voice order maps + * @param voices Array of voices to analyze + * @returns Map where key is language code and value is a map of voice names to their original JSON indices + */ +export function createJsonOrderMap(voices: ReadiumSpeechVoice[]): Map> { + // First, group voices by language + const voicesByLanguage = new Map(); + + for (const voice of voices) { + if (voice.source !== "json") continue; + + const [baseLang] = extractLangRegionFromBCP47(voice.language); + if (!voicesByLanguage.has(baseLang)) { + voicesByLanguage.set(baseLang, []); + } + voicesByLanguage.get(baseLang)!.push(voice); + } + + // Then create order maps for each language + const orderMaps = new Map>(); + + for (const [baseLang, langVoices] of voicesByLanguage.entries()) { + const langOrderMap = new Map(); + const jsonVoices = getVoices(baseLang); + + // Create a lookup map for faster searching + const voiceLookup = new Map(); + jsonVoices.forEach((v, i) => { + voiceLookup.set(v.name.toLowerCase(), i); + v.altNames?.forEach(altName => { + voiceLookup.set(altName.toLowerCase(), i); + }); + }); + + // Map the voices to their original order + for (const voice of langVoices) { + const voiceKey = voice.name.toLowerCase(); + const jsonIndex = voiceLookup.get(voiceKey); + + if (jsonIndex !== undefined) { + langOrderMap.set(voice.name, jsonIndex); + } + } + + if (langOrderMap.size > 0) { + orderMaps.set(baseLang, langOrderMap); + } + } + + return orderMaps; +} \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/getDefaultVoice.test.ts b/test/WebSpeechVoiceManager/getDefaultVoice.test.ts index 10e6f40..3613b83 100644 --- a/test/WebSpeechVoiceManager/getDefaultVoice.test.ts +++ b/test/WebSpeechVoiceManager/getDefaultVoice.test.ts @@ -124,7 +124,7 @@ testWithContext("getDefaultVoice: falls back to base language", async (t: Execut // Request en-GB which isn't available, should fall back to alphabetical const defaultVoice = await manager.getDefaultVoice("en-GB"); t.truthy(defaultVoice); - t.is(defaultVoice?.language, "en-AU", "Should fall back to first alphabetical region when exact match not found"); + t.is(defaultVoice?.language, "en-US", "Should fall back to default region when exact match not found"); }); testWithContext("getDefaultVoice: respects quality sorting", async (t: ExecutionContext) => { diff --git a/test/WebSpeechVoiceManager/sortVoices.test.ts b/test/WebSpeechVoiceManager/sortVoices.test.ts index a5c899c..5c50cca 100644 --- a/test/WebSpeechVoiceManager/sortVoices.test.ts +++ b/test/WebSpeechVoiceManager/sortVoices.test.ts @@ -26,56 +26,61 @@ testWithContext.afterEach.always((t: ExecutionContext) => { // sortVoices Tests // ============================================= -testWithContext("sortVoices: sorts by name", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts by quality", (t: ExecutionContext) => { const manager = t.context.manager; - // Create test voices + // Create test voices with different quality levels const testVoices = [ - createTestVoice({ name: "Zeta Voice", language: "en-US" }), - createTestVoice({ name: "Alpha Voice", language: "en-US" }), - createTestVoice({ name: "Beta Voice", language: "en-US" }) + createTestVoice({ name: "High Quality Voice", language: "en-US", quality: "high" }), + createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: "low" }), + createTestVoice({ name: "Normal Quality Voice", language: "en-US", quality: "normal" }), + createTestVoice({ name: "Very High Quality Voice", language: "en-US", quality: "veryHigh" }), + createTestVoice({ name: "Very Low Quality Voice", language: "en-US", quality: "veryLow" }), + createTestVoice({ name: "Unknown Quality Voice", language: "en-US", quality: null }) ]; - // Test ascending order - const sortedAsc = manager.sortVoices(testVoices, { by: "name", order: "asc" }); - t.is(sortedAsc[0].name, "Alpha Voice"); - t.is(sortedAsc[1].name, "Beta Voice"); - t.is(sortedAsc[2].name, "Zeta Voice"); - - // Test descending order - const sortedDesc = manager.sortVoices(testVoices, { by: "name", order: "desc" }); - t.is(sortedDesc[0].name, "Zeta Voice"); - t.is(sortedDesc[1].name, "Beta Voice"); - t.is(sortedDesc[2].name, "Alpha Voice"); + const sortedAsc = manager.sortVoicesByQuality(testVoices); + t.is(sortedAsc[0].quality, "veryHigh"); + t.is(sortedAsc[1].quality, "high"); + t.is(sortedAsc[2].quality, "normal"); + t.is(sortedAsc[3].quality, "low"); + t.is(sortedAsc[4].quality, "veryLow"); + t.is(sortedAsc[5].quality, null); }); -testWithContext("sortVoices: sorts by quality with proper direction", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts by quality across languages", (t: ExecutionContext) => { const manager = t.context.manager; - // Create test voices with different quality levels + // Create test voices with different languages and quality levels const testVoices = [ - createTestVoice({ name: "High Quality Voice", language: "en-US", quality: "high" }), - createTestVoice({ name: "Low Quality Voice", language: "en-US", quality: "low" }), - createTestVoice({ name: "Normal Quality Voice", language: "en-US", quality: "normal" }), - createTestVoice({ name: "Very High Quality Voice", language: "en-US", quality: "veryHigh" }), - createTestVoice({ name: "Very Low Quality Voice", language: "en-US", quality: "veryLow" }) + createTestVoice({ name: "French Low Quality", language: "fr-FR", quality: "low" }), + createTestVoice({ name: "English High Quality", language: "en-US", quality: "high" }), + createTestVoice({ name: "Spanish Normal Quality", language: "es-ES", quality: "normal" }), + createTestVoice({ name: "German Very High Quality", language: "de-DE", quality: "veryHigh" }), + createTestVoice({ name: "Italian Very Low Quality", language: "it-IT", quality: "veryLow" }), + createTestVoice({ name: "Portuguese Unknown Quality", language: "pt-BR", quality: null }) ]; - // Test ascending order (low to high quality) - const sortedAsc = manager.sortVoices(testVoices, { by: "quality", order: "asc" }); - t.is(sortedAsc[0].quality, "veryLow"); - t.is(sortedAsc[1].quality, "low"); + const sortedAsc = manager.sortVoicesByQuality(testVoices); + + // Check both quality and language to ensure correct sorting + t.is(sortedAsc[0].quality, "veryHigh"); + t.is(sortedAsc[0].language, "de-DE"); + + t.is(sortedAsc[1].quality, "high"); + t.is(sortedAsc[1].language, "en-US"); + t.is(sortedAsc[2].quality, "normal"); - t.is(sortedAsc[3].quality, "high"); - t.is(sortedAsc[4].quality, "veryHigh"); + t.is(sortedAsc[2].language, "es-ES"); + + t.is(sortedAsc[3].quality, "low"); + t.is(sortedAsc[3].language, "fr-FR"); + + t.is(sortedAsc[4].quality, "veryLow"); + t.is(sortedAsc[4].language, "it-IT"); - // Test descending order (high to low quality) - const sortedDesc = manager.sortVoices(testVoices, { by: "quality", order: "desc" }); - t.is(sortedDesc[0].quality, "veryHigh"); - t.is(sortedDesc[1].quality, "high"); - t.is(sortedDesc[2].quality, "normal"); - t.is(sortedDesc[3].quality, "low"); - t.is(sortedDesc[4].quality, "veryLow"); + t.is(sortedAsc[5].quality, null); + t.is(sortedAsc[5].language, "pt-BR"); }); testWithContext("sortVoices: sorts by language", (t: ExecutionContext) => { @@ -89,45 +94,66 @@ testWithContext("sortVoices: sorts by language", (t: ExecutionContext) => { + const manager = t.context.manager; - // Test descending order - const sortedDesc = manager.sortVoices(testVoices, { by: "languages", order: "desc" }); - t.is(sortedDesc[0].language, "fr-FR"); - t.is(sortedDesc[1].language, "es-ES"); - t.is(sortedDesc[2].language, "en-US"); - t.is(sortedDesc[3].language, "de-DE"); + // Create test voices with different languages and regions + const testVoices = [ + createTestVoice({ name: "French Voice", language: "fr-FR" }), + createTestVoice({ name: "French Canadian Voice", language: "fr-CA" }), + createTestVoice({ name: "English Voice", language: "en-US" }), + createTestVoice({ name: "UK English Voice", language: "en-GB" }), + createTestVoice({ name: "Spanish Voice", language: "es-ES" }), + createTestVoice({ name: "Mexican Spanish Voice", language: "es-MX" }), + createTestVoice({ name: "German Voice", language: "de-DE" }) + ]; + + // Test with preferred languages + const sortedPreferred = manager.sortVoicesByLanguages(testVoices, ["fr", "en"]); + + // French voices should come first (preferred language) + t.is(sortedPreferred[0].language, "fr-FR"); + t.is(sortedPreferred[1].language, "fr-CA"); + + // English voices should come second (preferred language) + t.is(sortedPreferred[2].language, "en-US"); + t.is(sortedPreferred[3].language, "en-GB"); + + // Other languages should come after preferred ones + t.is(sortedPreferred[4].language, "de-DE"); + t.is(sortedPreferred[5].language, "es-ES"); + t.is(sortedPreferred[6].language, "es-MX"); }); -testWithContext("sortVoices: sorts by gender", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts by JSON order", (t: ExecutionContext) => { const manager = t.context.manager; - // Create test voices with different genders + // Create test voices using actual names from JSON files in their exact JSON order const testVoices = [ - createTestVoice({ name: "Female Voice 1", language: "en-US", gender: "female" }), - createTestVoice({ name: "Male Voice 1", language: "en-US", gender: "male" }), - createTestVoice({ name: "Unknown Voice", language: "en-US" }), - createTestVoice({ name: "Female Voice 2", language: "en-US", gender: "female" }) + // French voices from fr.json - first 5 fr-FR voices in exact order + createTestVoice({ name: "Microsoft VivienneMultilingual Online (Natural) - French (France)", language: "fr-FR" }), + createTestVoice({ name: "Microsoft Denise Online (Natural) - French (France)", language: "fr-FR" }), + createTestVoice({ name: "Microsoft Eloise Online (Natural) - French (France)", language: "fr-FR" }), + createTestVoice({ name: "Microsoft RemyMultilingual Online (Natural) - French (France)", language: "fr-FR" }), + createTestVoice({ name: "Microsoft Henri Online (Natural) - French (France)", language: "fr-FR" }) ]; - // Test ascending order (undefined should come first, then female, then male) - const sortedAsc = manager.sortVoices(testVoices, { by: "gender", order: "asc" }); - t.is(sortedAsc[0].gender, undefined); - t.is(sortedAsc[1].gender, "female"); - t.is(sortedAsc[2].gender, "female"); - t.is(sortedAsc[3].gender, "male"); + // Test with preferred language + const sorted = manager.sortVoicesByLanguages(testVoices, ["fr"]); - // Test descending order (male should come first, then female, then undefined) - const sortedDesc = manager.sortVoices(testVoices, { by: "gender", order: "desc" }); - t.is(sortedDesc[0].gender, "male"); - t.is(sortedDesc[1].gender, "female"); - t.is(sortedDesc[2].gender, "female"); - t.is(sortedDesc[3].gender, undefined); + // Should be in exact JSON order + t.is(sorted[0].name, "Microsoft VivienneMultilingual Online (Natural) - French (France)"); + t.is(sorted[1].name, "Microsoft Denise Online (Natural) - French (France)"); + t.is(sorted[2].name, "Microsoft Eloise Online (Natural) - French (France)"); + t.is(sorted[3].name, "Microsoft RemyMultilingual Online (Natural) - French (France)"); + t.is(sorted[4].name, "Microsoft Henri Online (Natural) - French (France)"); }); testWithContext("sortVoices: sorts by region", (t: ExecutionContext) => { @@ -141,22 +167,16 @@ testWithContext("sortVoices: sorts by region", (t: ExecutionContext createTestVoice({ name: "Australia Voice", language: "en-AU" }) ]; - // Test ascending order - const sortedAsc = manager.sortVoices(testVoices, { by: "region", order: "asc" }); - t.is(sortedAsc[0].language, "en-AU"); - t.is(sortedAsc[1].language, "en-CA"); - t.is(sortedAsc[2].language, "en-GB"); - t.is(sortedAsc[3].language, "en-US"); - - // Test descending order - const sortedDesc = manager.sortVoices(testVoices, { by: "region", order: "desc" }); - t.is(sortedDesc[0].language, "en-US"); - t.is(sortedDesc[1].language, "en-GB"); - t.is(sortedDesc[2].language, "en-CA"); - t.is(sortedDesc[3].language, "en-AU"); + const sortedAsc = manager.sortVoicesByRegions(testVoices, []); + t.is(sortedAsc[0].language, "en-US"); + t.is(sortedAsc[1].language, "en-AU"); + t.is(sortedAsc[2].language, "en-CA"); + t.is(sortedAsc[3].language, "en-GB"); }); -testWithContext("sortVoices: sorts by preferred languages with region inference", (t: ExecutionContext) => { + + +testWithContext("sortVoices: sorts regions by preferred", (t: ExecutionContext) => { const manager = t.context.manager; // Create test voices with different languages and regions @@ -173,10 +193,7 @@ testWithContext("sortVoices: sorts by preferred languages with region inference" ]; // Test 1: Basic language code should use default region - const defaultRegionTest = manager.sortVoices(testVoices, { - by: "languages", - preferredLanguages: ["fr"] // Should use default region (fr-FR) - }); + const defaultRegionTest = manager.sortVoicesByRegions(testVoices, ["fr"]); // French voices should come first, with fr-FR (default) first t.is(defaultRegionTest[0].language, getDefaultRegion("fr"), "Default region should come first"); @@ -184,21 +201,15 @@ testWithContext("sortVoices: sorts by preferred languages with region inference" t.is(defaultRegionTest[2].language, "fr-CA", "Other French regions should follow alphabetically"); // Test 2: Region inference from other languages - const inferredRegionTest = manager.sortVoices(testVoices, { - by: "languages", - preferredLanguages: ["fr", "en-CA"] // Should infer fr-CA as preferred French - }); + const inferredRegionTest = manager.sortVoicesByRegions(testVoices, ["fr", "en-CA"]); // fr-CA should come first because en-CA provides the CA region hint t.is(inferredRegionTest[0].language, "fr-CA", "Should infer fr-CA from en-CA"); - t.is(inferredRegionTest[1].language, "fr-BE", "Other French regions should follow alphabetically"); - t.is(inferredRegionTest[2].language, "fr-FR", "Default region should come after inferred region"); + t.is(inferredRegionTest[1].language, "fr-FR", "Default region should come after inferred region"); + t.is(inferredRegionTest[2].language, "fr-BE", "Other French regions should follow alphabetically"); // Test 3: Multiple regional preferences - const multipleRegionsTest = manager.sortVoices(testVoices, { - by: "languages", - preferredLanguages: ["fr-BE", "fr-CA", "es"] // Explicit regional preferences - }); + const multipleRegionsTest = manager.sortVoicesByRegions(testVoices, ["fr-BE", "fr-CA", "es"]); // Should respect the order of regional preferences t.is(multipleRegionsTest[0].language, "fr-BE", "First regional preference should come first"); @@ -208,80 +219,49 @@ testWithContext("sortVoices: sorts by preferred languages with region inference" t.is(multipleRegionsTest[4].language, "es-MX", "Other Spanish regions should follow"); // Test 4: Keeping prioritization of regions - const prioritizedRegionsTest = manager.sortVoices(testVoices, { - by: "languages", - preferredLanguages: ["en-CA", "fr-BE", "fr-FR"] // Inferred region should come first - }); + const prioritizedRegionsTest = manager.sortVoicesByRegions(testVoices, ["en-CA", "fr-BE", "fr-FR"]); // Should respect the exact order of regional preferences t.is(prioritizedRegionsTest[0].language, "en-CA", "First explicit preference should be en-CA"); - t.is(prioritizedRegionsTest[1].language, "en-GB", "UK English should come second"); - t.is(prioritizedRegionsTest[2].language, "en-US", "US English should come third"); + t.is(prioritizedRegionsTest[1].language, "en-US", "Default US English should come second"); + t.is(prioritizedRegionsTest[2].language, "en-GB", "UK English should come third"); t.is(prioritizedRegionsTest[3].language, "fr-CA", "Inferred French Canadian should come fourth"); t.is(prioritizedRegionsTest[4].language, "fr-BE", "French Belgian should come fifth"); t.is(prioritizedRegionsTest[5].language, "fr-FR", "French French should come sixth"); - // Test 5: Empty/undefined preferred languages (should sort alphabetically) - const emptyPreferred = manager.sortVoices(testVoices, { - by: "languages", - preferredLanguages: [] - }); + // Test 5: Empty/undefined preferred languages (should sort default then alphabetically) + const emptyPreferred = manager.sortVoicesByRegions(testVoices, []); t.is(emptyPreferred[0].language, "de-DE"); - t.is(emptyPreferred[1].language, "en-CA"); - t.is(emptyPreferred[2].language, "en-GB"); - t.is(emptyPreferred[3].language, "en-US"); + t.is(emptyPreferred[1].language, "en-US"); + t.is(emptyPreferred[2].language, "en-CA"); + t.is(emptyPreferred[3].language, "en-GB"); t.is(emptyPreferred[4].language, "es-ES"); t.is(emptyPreferred[5].language, "es-MX"); - t.is(emptyPreferred[6].language, "fr-BE"); - t.is(emptyPreferred[7].language, "fr-CA"); - t.is(emptyPreferred[8].language, "fr-FR"); + t.is(emptyPreferred[6].language, "fr-FR"); + t.is(emptyPreferred[7].language, "fr-BE"); + t.is(emptyPreferred[8].language, "fr-CA"); }); -testWithContext("sortVoices: sorts by region with preferred languages", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts regions by JSON order", (t: ExecutionContext) => { const manager = t.context.manager; - // Create test voices with different regions + // Create test voices using actual names from JSON files in their exact JSON order const testVoices = [ - createTestVoice({ name: "US English", language: "en-US" }), - createTestVoice({ name: "UK English", language: "en-GB" }), - createTestVoice({ name: "Australian English", language: "en-AU" }), - createTestVoice({ name: "Canadian French", language: "fr-CA" }), - createTestVoice({ name: "French", language: "fr-FR" }), - createTestVoice({ name: "Canadian English", language: "en-CA" }) + // French voices from fr.json - first 5 fr-FR voices in exact order + createTestVoice({ name: "Microsoft VivienneMultilingual Online (Natural) - French (France)", language: "fr-FR" }), + createTestVoice({ name: "Microsoft Denise Online (Natural) - French (France)", language: "fr-FR" }), + createTestVoice({ name: "Microsoft Eloise Online (Natural) - French (France)", language: "fr-FR" }), + createTestVoice({ name: "Microsoft RemyMultilingual Online (Natural) - French (France)", language: "fr-FR" }), + createTestVoice({ name: "Microsoft Henri Online (Natural) - French (France)", language: "fr-FR" }) ]; - // Test with preferred languages that include regions - const sorted = manager.sortVoices(testVoices, { - by: "region", - preferredLanguages: ["en-CA", "fr-CA", "fr-FR"] // Prefer Canadian English, then Canadian French, then French French, then all other languages - }); - - // Verify order: - // 1. en-CA (exact match for first preferred) - // 2. fr-CA (exact match for second preferred) - // 3. fr-FR (language match for third preferred) - // 4. en-AU (alphabetical order) - // 5. en-GB (alphabetical order) - // 6. en-US (alphabetical order) - t.is(sorted[0].language, "en-CA", "en-CA should be first (exact match)"); - t.is(sorted[1].language, "fr-CA", "fr-CA should be second (exact match)"); - t.is(sorted[2].language, "fr-FR", "fr-FR should be third (language match)"); - - // The remaining English variants should be in their natural order - const remainingEnglish = sorted.slice(3, 6).map(v => v.language); - t.true( - ["en-AU", "en-GB", "en-US"].every(lang => remainingEnglish.includes(lang)), - "Should include all English variants after exact matches" - ); - - // Test with preferred languages that don't match any regions - const noMatches = manager.sortVoices(testVoices, { - by: "region", - preferredLanguages: ["es-ES", "de-DE"] // No matches in test data - }); + // Test with preferred language + const sorted = manager.sortVoicesByRegions(testVoices, ["fr-FR"]); - // Should sort alphabetically by region - const regions = noMatches.map(v => v.language.split("-")[1]); - const sortedRegions = [...regions].sort(); - t.deepEqual(regions, sortedRegions, "Should sort alphabetically by region when no preferred matches"); + // Should be in exact JSON order + t.is(sorted[0].name, "Microsoft VivienneMultilingual Online (Natural) - French (France)"); + t.is(sorted[1].name, "Microsoft Denise Online (Natural) - French (France)"); + t.is(sorted[2].name, "Microsoft Eloise Online (Natural) - French (France)"); + t.is(sorted[3].name, "Microsoft RemyMultilingual Online (Natural) - French (France)"); + t.is(sorted[4].name, "Microsoft Henri Online (Natural) - French (France)"); }); \ No newline at end of file From 01c7b3cc27b93d596c377e8db72510aeade32a46 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 13 Jan 2026 16:19:44 +0100 Subject: [PATCH 24/32] Chrome os duplicates (#39) --- package.json | 2 +- src/WebSpeech/WebSpeechVoiceManager.ts | 20 ++++-- src/voices/voiceDuplicates.ts | 68 +++++++++++++++++++ .../initialization.test.ts | 63 +++++++++++++++++ 4 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 src/voices/voiceDuplicates.ts diff --git a/package.json b/package.json index 679aa6b..9952319 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@readium/speech", - "version": "0.1.0-beta.4", + "version": "0.1.0-beta.5", "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.", "main": "./build/index.cjs", "module": "./build/index.js", diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index e91b8c1..c8b7dcf 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -10,6 +10,7 @@ import { import { findLocaleWithQualityIndicators, getInferredQualityFromPlatform } from "../voices/localized"; import { getInferredQualityFromPackageName } from "../voices/packages"; import { extractLangRegionFromBCP47 } from "../utils/language"; +import { shouldMergeVoicesByName, selectPreferredVoiceByName } from "../voices/voiceDuplicates"; /** * Options for filtering voices @@ -279,12 +280,19 @@ export class WebSpeechVoiceManager { if (!existing) { voiceMap.set(key, voice); } else { - const existingQuality = WebSpeechVoiceManager.getQualityValue(existing.quality); - const newQuality = WebSpeechVoiceManager.getQualityValue(voice.quality); - - // If new voice has higher or equal quality, use it (preferring the newer one) - if (newQuality >= existingQuality) { - voiceMap.set(key, voice); + // Check if these are the same voice with different names (have altNames) + if (shouldMergeVoicesByName(voice, existing)) { + const preferredVoice = selectPreferredVoiceByName(voice, existing); + voiceMap.set(key, preferredVoice); + } else { + // Use existing quality-based logic for different voices + const existingQuality = WebSpeechVoiceManager.getQualityValue(existing.quality); + const newQuality = WebSpeechVoiceManager.getQualityValue(voice.quality); + + // If new voice has higher or equal quality, use it (preferring the newer one) + if (newQuality >= existingQuality) { + voiceMap.set(key, voice); + } } } } diff --git a/src/voices/voiceDuplicates.ts b/src/voices/voiceDuplicates.ts new file mode 100644 index 0000000..b073ff8 --- /dev/null +++ b/src/voices/voiceDuplicates.ts @@ -0,0 +1,68 @@ +import { ReadiumSpeechVoice } from "./types"; + +/** + * Selects the preferred voice between two voices that represent the same voice + * but have different names, based on name and altNames preference order. + * + * @param voice1 - First voice + * @param voice2 - Second voice + * @returns The preferred voice based on name/altNames order + */ +export function selectPreferredVoiceByName(voice1: ReadiumSpeechVoice, voice2: ReadiumSpeechVoice): ReadiumSpeechVoice { + // If voice1 has name === originalName, it's preferred + if (voice1.name === voice1.originalName) return voice1; + // If voice2 has name === originalName, it's preferred + if (voice2.name === voice2.originalName) return voice2; + + // Otherwise, use altNames priority order + const voice1Names = [voice1.originalName, ...(voice1.altNames || [])]; + const voice2Names = [voice2.originalName, ...(voice2.altNames || [])]; + + // Find the best matching priority for each voice + const voice1Priority = voice1Names.findIndex(name => voice2Names.includes(name)); + const voice2Priority = voice2Names.findIndex(name => voice1Names.includes(name)); + + // No matches found, default to voice1 + if (voice1Priority === -1 && voice2Priority === -1) return voice1; + + // Return the voice with the better (lower) priority, or voice1 if equal + return voice1Priority !== -1 && (voice2Priority === -1 || voice1Priority <= voice2Priority) + ? voice1 + : voice2; +} + +/** + * Determines if two voices should be considered the same voice with different names + * based on altNames matching. + * + * @param voice1 - First voice + * @param voice2 - Second voice + * @returns True if voices should be merged based on name/altNames + */ +export function shouldMergeVoicesByName(voice1: ReadiumSpeechVoice, voice2: ReadiumSpeechVoice): boolean { + // If neither has altNames, they're different voices + if (!voice1.altNames && !voice2.altNames) { + return false; + } + + // Check if voice1 name matches voice2's altNames or vice versa + const voice1Name = voice1.originalName; + const voice2Name = voice2.originalName; + + const voice1AltNames = voice1.altNames || []; + const voice2AltNames = voice2.altNames || []; + + // Check if voice1 name is in voice2's altNames + if (voice2AltNames.includes(voice1Name)) { + return true; + } + + // Check if voice2 name is in voice1's altNames + if (voice1AltNames.includes(voice2Name)) { + return true; + } + + // Check if they share any altNames + const sharedAltNames = voice1AltNames.filter(name => voice2AltNames.includes(name)); + return sharedAltNames.length > 0; +} diff --git a/test/WebSpeechVoiceManager/initialization.test.ts b/test/WebSpeechVoiceManager/initialization.test.ts index 4badd2b..234fde3 100644 --- a/test/WebSpeechVoiceManager/initialization.test.ts +++ b/test/WebSpeechVoiceManager/initialization.test.ts @@ -147,6 +147,69 @@ testWithContext("deduplication: keeps higher quality voice from json quality arr t.deepEqual(deduped[0].quality, "normal", "Should find the voice with normal quality from the array"); }); +testWithContext("deduplication: prefers voice with matching name over altNames", (t) => { + const manager = t.context.manager; + + // Test scenario: two browser voices + // One matches primary name in JSON, other matches altName in JSON + const voices = [ + { + voiceURI: "Google US English 5 (Natural)", + name: "Google US English 5 (Natural)", + lang: "en-US", + localService: true, + default: false + }, + { + voiceURI: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", + name: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", + lang: "en-US", + localService: true, + default: false + } + ]; + + const parsedVoices = (manager as any).parseToReadiumSpeechVoices(voices); + const deduped = (manager as any).removeDuplicate(parsedVoices); + + // Should only keep one voice + t.is(deduped.length, 1, "Should only keep one voice after deduplication"); + // Should prefer the voice with the primary name (Google US English 5 (Natural)) + t.is(deduped[0].name, "Google US English 5 (Natural)", "Should prefer voice with primary name over altName"); + t.is(deduped[0].originalName, "Google US English 5 (Natural)", "Should keep the original name of preferred voice"); +}); + +testWithContext("deduplication: prefers voice with earlier altName over later altName", (t) => { + const manager = t.context.manager; + + // Test scenario: two browser voices + // Both match different altNames in same JSON voice entry + const voices = [ + { + voiceURI: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", + name: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", + lang: "en-US", + localService: true, + default: false + }, + { + voiceURI: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", + name: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", + lang: "en-US", + localService: true, + default: false + } + ]; + + const parsedVoices = (manager as any).parseToReadiumSpeechVoices(voices); + const deduped = (manager as any).removeDuplicate(parsedVoices); + + // Should only keep one voice + t.is(deduped.length, 1, "Should only keep one voice after deduplication"); + // Should prefer the voice with the earlier altName (network comes before local in JSON) + t.is(deduped[0].originalName, "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", "Should prefer voice with earlier altName"); +}); + testWithContext("quality inference: infers quality from nativeID when voiceURI has no indicators", (t) => { const manager = t.context.manager; From 6f4012f64e5489c01d1072dc15535e9ba2b9f2da Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 13 Jan 2026 18:05:36 +0100 Subject: [PATCH 25/32] Json order (#40) --- src/WebSpeech/WebSpeechVoiceManager.ts | 38 +++++++++---------- test/WebSpeechVoiceManager/sortVoices.test.ts | 19 ++++++---- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index c8b7dcf..42cf562 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -697,32 +697,27 @@ private static sortByQuality( const aQuality = WebSpeechVoiceManager.getQualityValue(a.quality); const bQuality = WebSpeechVoiceManager.getQualityValue(b.quality); - // If both have defined quality, sort by quality (highest first) - if (aQuality > 0 && bQuality > 0) { - return bQuality - aQuality; - } - - // If one has quality and the other doesn't, the one with quality comes first - if (aQuality > 0 && bQuality === 0) return -1; - if (aQuality === 0 && bQuality > 0) return 1; - - // Both have undefined/null quality - use JSON order if possible - if (aQuality === 0 && bQuality === 0 && jsonOrderMaps && baseLang) { - if (a.source === "json" && b.source === "json") { - // Get the language-specific order map - const langOrderMap = jsonOrderMaps.get(baseLang); - if (langOrderMap) { - const aOrder = langOrderMap.get(a.name) ?? Number.MAX_SAFE_INTEGER; - const bOrder = langOrderMap.get(b.name) ?? Number.MAX_SAFE_INTEGER; - - if (aOrder !== Number.MAX_SAFE_INTEGER && bOrder !== Number.MAX_SAFE_INTEGER) { + // Use JSON order for same-quality JSON voices + if (jsonOrderMaps && baseLang && a.source === "json" && b.source === "json") { + const langOrderMap = jsonOrderMaps.get(baseLang); + if (langOrderMap) { + const aOrder = langOrderMap.get(a.name); + const bOrder = langOrderMap.get(b.name); + + if (aOrder !== undefined && bOrder !== undefined) { + // Both have JSON order - use it if same quality or both no quality + if ((aQuality > 0 && bQuality > 0 && aQuality === bQuality) || + (aQuality === 0 && bQuality === 0)) { return aOrder - bOrder; } } } } - // Fallback to alphabetical by name + // Sort by quality (highest first) + if (bQuality !== aQuality) return bQuality - aQuality; + + // Same quality - fallback to alphabetical return a.name.localeCompare(b.name); } @@ -877,6 +872,8 @@ private static sortByQuality( voices: ReadiumSpeechVoice[], processedLang: LanguageWithRegions ): void { + const jsonOrderMaps = createJsonOrderMap(voices); + voices.sort((a, b) => { const [, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); const [, bRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); @@ -892,7 +889,6 @@ private static sortByQuality( // If same region, sort by quality if (aIndex === bIndex) { - const jsonOrderMaps = createJsonOrderMap(voices); return WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps, processedLang.baseLang); } diff --git a/test/WebSpeechVoiceManager/sortVoices.test.ts b/test/WebSpeechVoiceManager/sortVoices.test.ts index 5c50cca..e3d4782 100644 --- a/test/WebSpeechVoiceManager/sortVoices.test.ts +++ b/test/WebSpeechVoiceManager/sortVoices.test.ts @@ -247,21 +247,26 @@ testWithContext("sortVoices: sorts regions by JSON order", (t: ExecutionContext< // Create test voices using actual names from JSON files in their exact JSON order const testVoices = [ - // French voices from fr.json - first 5 fr-FR voices in exact order - createTestVoice({ name: "Microsoft VivienneMultilingual Online (Natural) - French (France)", language: "fr-FR" }), - createTestVoice({ name: "Microsoft Denise Online (Natural) - French (France)", language: "fr-FR" }), - createTestVoice({ name: "Microsoft Eloise Online (Natural) - French (France)", language: "fr-FR" }), - createTestVoice({ name: "Microsoft RemyMultilingual Online (Natural) - French (France)", language: "fr-FR" }), - createTestVoice({ name: "Microsoft Henri Online (Natural) - French (France)", language: "fr-FR" }) + // Add some lower quality voices to test quality ordering + createTestVoice({ name: "Microsoft Hortense Online (Natural) - French (France)", language: "fr-FR", quality: "high" }), + createTestVoice({ name: "Microsoft Paul Online (Natural) - French (France)", language: "fr-FR", quality: "normal" }), + // French voices from fr.json - first 5 fr-FR voices in inexact order + createTestVoice({ name: "Microsoft Eloise Online (Natural) - French (France)", language: "fr-FR", quality: "veryHigh" }), + createTestVoice({ name: "Microsoft VivienneMultilingual Online (Natural) - French (France)", language: "fr-FR", quality: "veryHigh" }), + createTestVoice({ name: "Microsoft Henri Online (Natural) - French (France)", language: "fr-FR", quality: "veryHigh" }), + createTestVoice({ name: "Microsoft Denise Online (Natural) - French (France)", language: "fr-FR", quality: "veryHigh" }), + createTestVoice({ name: "Microsoft RemyMultilingual Online (Natural) - French (France)", language: "fr-FR", quality: "veryHigh" }) ]; // Test with preferred language const sorted = manager.sortVoicesByRegions(testVoices, ["fr-FR"]); - // Should be in exact JSON order + // Should be in exact JSON order for same quality, then quality ordering t.is(sorted[0].name, "Microsoft VivienneMultilingual Online (Natural) - French (France)"); t.is(sorted[1].name, "Microsoft Denise Online (Natural) - French (France)"); t.is(sorted[2].name, "Microsoft Eloise Online (Natural) - French (France)"); t.is(sorted[3].name, "Microsoft RemyMultilingual Online (Natural) - French (France)"); t.is(sorted[4].name, "Microsoft Henri Online (Natural) - French (France)"); + t.is(sorted[5].name, "Microsoft Hortense Online (Natural) - French (France)"); + t.is(sorted[6].name, "Microsoft Paul Online (Natural) - French (France)"); }); \ No newline at end of file From 7d72c9ae568bd4e852fce53b3323661d0d98be58 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Thu, 15 Jan 2026 13:34:39 +0100 Subject: [PATCH 26/32] Improve ChromeOS support (#41) --- README.md | 87 +- debug/index.html | 115 + debug/mock-voices.js | 114 + debug/speech-voices-2026-01-09.json | 3823 +++++++++++++++++ demo/article/script.js | 16 +- demo/script.js | 42 +- json/en.json | 4 +- package.json | 2 +- src/WebSpeech/WebSpeechVoiceManager.ts | 249 +- .../filterVoices.test.ts | 213 +- .../getLanguages.test.ts | 124 +- test/WebSpeechVoiceManager/getRegions.test.ts | 64 +- .../WebSpeechVoiceManager/groupVoices.test.ts | 16 +- .../initialization.test.ts | 146 +- test/WebSpeechVoiceManager/sortVoices.test.ts | 20 +- 15 files changed, 4655 insertions(+), 380 deletions(-) create mode 100644 debug/index.html create mode 100644 debug/mock-voices.js create mode 100644 debug/speech-voices-2026-01-09.json diff --git a/README.md b/README.md index 167e344..43d5758 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,9 @@ Creates and initializes a new WebSpeechVoiceManager instance. This static factor - `interval`: Interval in milliseconds between voice loading checks (default: 100ms) - Returns: Promise that resolves with a new WebSpeechVoiceManager instance -#### Get Available Voices +#### Get filtered Voices + +By default, the instance keeps all voices in memory. You can filter them using the `getVoices` method with optional filter criteria and use this array instead. ```typescript voiceManager.getVoices(options?: VoiceFilterOptions): ReadiumSpeechVoice[] @@ -164,6 +166,16 @@ interface VoiceFilterOptions { } ``` +#### Get Languages and Regions + +```typescript +voiceManager.getLanguages(localization?: string, filterOptions?: VoiceFilterOptions, voices?: ReadiumSpeechVoice[]): { code: string; label: string; count: number }[] + +voiceManager.getRegions(localization?: string, filterOptions?: VoiceFilterOptions, voices?: ReadiumSpeechVoice[]): { code: string; label: string; count: number }[] +``` + +Returns arrays of languages and regions with their display names and voice counts. Both methods preserve the order of first occurrence when custom voices are provided. + #### Get Default Voice ```typescript @@ -192,15 +204,15 @@ The selection algorithm: #### Filter Voices ```typescript -voiceManager.filterVoices(voices: ReadiumSpeechVoice[], options: VoiceFilterOptions): ReadiumSpeechVoice[] +voiceManager.filterVoices(options: VoiceFilterOptions, voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] ``` -Filters voices based on the specified criteria. +Filters voices based on the specified criteria. If no voices are provided, it filters the instance's internal voice list. #### Group Voices ```typescript -voiceManager.groupVoices(voices: ReadiumSpeechVoice[], groupBy: "languages" | "region" | "gender" | "quality" | "provider"): VoiceGroup +voiceManager.groupVoices(groupBy: "languages" | "region" | "gender" | "quality" | "provider", voices?: ReadiumSpeechVoice[]): VoiceGroup ``` Organizes voices into groups based on the specified criteria. The available grouping options are: @@ -211,6 +223,8 @@ Organizes voices into groups based on the specified criteria. The available grou - `"quality"`: Groups voices by quality level - `"provider"`: Groups voices by their provider +If no voices are provided, it groups the instance's internal voice list. + #### Sort Voices The library provides opinionated voice sorting capabilities to help you find the best voice for your needs. @@ -222,31 +236,30 @@ If you need more control over the sorting process, you can implement and apply y Sort voices from highest to lowest quality: ```typescript -const sortedVoices = voiceManager.sortVoicesByQuality(voices); +voiceManager.sortVoicesByQuality(voices?: ReadiumSpeechVoice[]); // Returns: [veryHigh, high, normal, low, veryLow, null] ``` +If no voices are provided, it sorts the instance's internal voice list. + ##### 2. Sort by Language Prioritize specific languages while maintaining JSON data’s quality order within each language group: ```typescript -// Basic usage -const sortedByLanguage = voiceManager.sortVoicesByLanguages(voices); - -// With preferred languages first -const preferredFirst = voiceManager.sortVoicesByLanguages(voices, ["fr", "en"]); -// Returns: [fr voices, en voices, other languages voices...] +voiceManager.sortVoicesByLanguages(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]); +// Returns: [preferred languages voices, other languages voices...] ``` +If no voices are provided, it sorts the instance's internal voice list. + ##### 3. Sort by Region Sort voices by preferred languages and regions, while maintaining JSON data’s quality order within each region group: ```typescript -// With preferred regions -const preferredRegions = voiceManager.sortVoicesByRegions(voices, ["fr-FR", "en-US"]); -// Returns: [fr-FR voices, other fr regions voices, en-US voices, other en regions voices, other regions voices...] +voiceManager.sortVoicesByRegions(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]); +// Returns: [languages in preferred then alphabetical order → regions: preferred regions → default region → alphabetical regions → voice quality within each region] ``` ### Testing @@ -416,3 +429,49 @@ type ReadiumSpeechPlaybackEvent = { ```typescript type ReadiumSpeechPlaybackState = "playing" | "paused" | "idle" | "loading" | "ready"; ``` + +## Development + +We are trying to use a test-driven development approach as much as possible, where we write tests before implementing the code. Currently, this is true for the `WebSpeechVoiceManager` class as it deals primarily with voice selection and management, where mocking is straightforward. + +The playback logic is more complex and may not be suitable for this approach yet, as it involves more intricate state management and user interactions that is difficult to handle through mock objects, especially as browsers vary significantly in their implementation of the Web Speech API. + +### Building the Library + +To build the library: +```bash +npm run build +``` + +This will compile the TypeScript code and generate the following outputs in the `build/` directory: +- `index.js` (ES modules) +- `index.cjs` (CommonJS) +- TypeScript type definitions + +### Running Demos Locally + +The project includes two demo applications that can be served locally: + +1. Start the local development server: + ```bash + npm run serve + ``` + +2. Open your browser to: + - [Voice selection demo](http://localhost:8080/demo) + - [In-context reading demo](http://localhost:8080/demo/article) + +### ChromeOS Debugging + +For ChromeOS development, the project includes a debug mode that mocks the Web Speech API with the set of voices exported from the ChromeOS browser: + +1. Open the debug page: http://localhost:8080/debug + +2. The debug page loads mock voices from a json file which contains a snapshot of ChromeOS voices. + +### Running Tests + +To run the test suite for `WebSpeechVoiceManager`: +```bash +npm test +``` diff --git a/debug/index.html b/debug/index.html new file mode 100644 index 0000000..29bf362 --- /dev/null +++ b/debug/index.html @@ -0,0 +1,115 @@ + + + + + + Readium Speech Debug + + + +

Readium Speech Debug

+ +

This page uses mock voices to simulate Chrome OS. More sets could be added in the future for additional platforms. Do not expect playback to work in this page.

+ +
+
+ + +
+ +
+ + +
+ +
+
+
+ + +
+
+ +
+
+
+ +
+
+ Voice Details +
+

Select a voice to see its properties

+
+
+
+ +
+ Filters +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+
+ + + + +
+ +
+ + of - + +
+
+ +
+
+ Select a language to load sample text... +
+
+
+ +
+
+ Developer Tools +
+ +
+
+
+ + + + + + + diff --git a/debug/mock-voices.js b/debug/mock-voices.js new file mode 100644 index 0000000..d8a8ec0 --- /dev/null +++ b/debug/mock-voices.js @@ -0,0 +1,114 @@ +// Mock window.speechSynthesis.getVoices() for ChromeOS debugging +// Load this script before the main speech library + +(async function() { + // Load the mock voices data + const response = await fetch("../debug/speech-voices-2026-01-09.json"); + const mockData = await response.json(); + + // Convert JSON voices to SpeechSynthesisVoice objects + const mockVoices = mockData.voices.map(voice => ({ + voiceURI: voice.voiceURI, + name: voice.name, + lang: voice.lang, + localService: voice.localService, + default: voice.default + })); + + // Mock SpeechSynthesisUtterance constructor + class MockSpeechSynthesisUtterance { + constructor(text) { + this.text = text; + this.voice = null; + this.lang = null; + this.pitch = 1; + this.rate = 1; + this.volume = 1; + this.onstart = null; + this.onend = null; + this.onerror = null; + this.onboundary = null; + this.onmark = null; + this.onpause = null; + this.onresume = null; + } + } + + // Replace SpeechSynthesisUtterance constructor + if (typeof window !== "undefined") { + window.SpeechSynthesisUtterance = MockSpeechSynthesisUtterance; + } + + // Create mock speechSynthesis object + const mockSpeechSynthesis = { + getVoices: () => mockVoices, + speak: (utterance) => { + console.log("Mock speak:", utterance.text); + + // Store current utterance for state tracking + mockSpeechSynthesis.speaking = true; + mockSpeechSynthesis.pending = false; + + // Trigger events after a short delay to simulate real speech + setTimeout(() => { + if (utterance.onstart) utterance.onstart(); + + // Simulate word boundaries (optional, but helpful for testing) + const words = utterance.text.split(" "); + let charIndex = 0; + words.forEach((word, i) => { + setTimeout(() => { + if (utterance.onboundary) { + utterance.onboundary({ + name: "word", + charIndex: charIndex, + charLength: word.length + }); + } + charIndex += word.length + 1; // +1 for space + }, i * 200); // 200ms per word + }); + + // End after total duration + const totalDuration = words.length * 200 + 500; // 500ms buffer + setTimeout(() => { + mockSpeechSynthesis.speaking = false; + if (utterance.onend) utterance.onend(); + }, totalDuration); + }, 100); + }, + cancel: () => { + console.log("Mock cancel"); + mockSpeechSynthesis.speaking = false; + mockSpeechSynthesis.pending = false; + }, + pause: () => { + console.log("Mock pause"); + mockSpeechSynthesis.paused = true; + }, + resume: () => { + console.log("Mock resume"); + mockSpeechSynthesis.paused = false; + }, + speaking: false, + paused: false, + pending: false, + onvoiceschanged: null + }; + + // Replace the real speechSynthesis with our mock + Object.defineProperty(window, "speechSynthesis", { + value: mockSpeechSynthesis, + writable: true, + configurable: true + }); + + // Trigger onvoiceschanged to simulate voice loading + setTimeout(() => { + if (mockSpeechSynthesis.onvoiceschanged) { + mockSpeechSynthesis.onvoiceschanged(); + } + }, 100); + + console.log(`Loaded ${mockVoices.length} mock voices for ChromeOS debugging`); +})(); diff --git a/debug/speech-voices-2026-01-09.json b/debug/speech-voices-2026-01-09.json new file mode 100644 index 0000000..3095bb8 --- /dev/null +++ b/debug/speech-voices-2026-01-09.json @@ -0,0 +1,3823 @@ +{ + "metadata": { + "timestamp": "2026-01-09T16:17:10.226Z", + "voicesCount": 545 + }, + "voices": [ + { + "voiceURI": "Chrome OS US English 1", + "name": "Chrome OS US English 1", + "lang": "en-US", + "localService": true, + "default": true + }, + { + "voiceURI": "Chrome OS US English 2", + "name": "Chrome OS US English 2", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS US English 3", + "name": "Chrome OS US English 3", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS US English 4", + "name": "Chrome OS US English 4", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS US English 5", + "name": "Chrome OS US English 5", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS US English 6", + "name": "Chrome OS US English 6", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS US English 7", + "name": "Chrome OS US English 7", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS US English 8", + "name": "Chrome OS US English 8", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS हिन्दी 1", + "name": "Chrome OS हिन्दी 1", + "lang": "hi-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS हिन्दी 2", + "name": "Chrome OS हिन्दी 2", + "lang": "hi-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS हिन्दी 3", + "name": "Chrome OS हिन्दी 3", + "lang": "hi-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS हिन्दी 4", + "name": "Chrome OS हिन्दी 4", + "lang": "hi-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS हिन्दी 5", + "name": "Chrome OS हिन्दी 5", + "lang": "hi-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS français 1", + "name": "Chrome OS français 1", + "lang": "fr-FR", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS français 2", + "name": "Chrome OS français 2", + "lang": "fr-FR", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS français 3", + "name": "Chrome OS français 3", + "lang": "fr-FR", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS français 4", + "name": "Chrome OS français 4", + "lang": "fr-FR", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS français 5", + "name": "Chrome OS français 5", + "lang": "fr-FR", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS español 1", + "name": "Chrome OS español 1", + "lang": "es-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS español 2", + "name": "Chrome OS español 2", + "lang": "es-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS español 3", + "name": "Chrome OS español 3", + "lang": "es-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS español 4", + "name": "Chrome OS español 4", + "lang": "es-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS español 5", + "name": "Chrome OS español 5", + "lang": "es-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS Nederlands 1", + "name": "Chrome OS Nederlands 1", + "lang": "nl-NL", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS Nederlands 2", + "name": "Chrome OS Nederlands 2", + "lang": "nl-NL", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS Nederlands 3", + "name": "Chrome OS Nederlands 3", + "lang": "nl-NL", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS Nederlands 4", + "name": "Chrome OS Nederlands 4", + "lang": "nl-NL", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS Nederlands 5", + "name": "Chrome OS Nederlands 5", + "lang": "nl-NL", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS 日本語 1", + "name": "Chrome OS 日本語 1", + "lang": "ja-JP", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS 日本語 2", + "name": "Chrome OS 日本語 2", + "lang": "ja-JP", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS 日本語 3", + "name": "Chrome OS 日本語 3", + "lang": "ja-JP", + "localService": true, + "default": false + }, + { + "voiceURI": "Chrome OS 日本語 4", + "name": "Chrome OS 日本語 4", + "lang": "ja-JP", + "localService": true, + "default": false + }, + { + "voiceURI": "Google US English 1 (Natural)", + "name": "Google US English 1 (Natural)", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Google US English 2 (Natural)", + "name": "Google US English 2 (Natural)", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Google US English 3 (Natural)", + "name": "Google US English 3 (Natural)", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Google US English 4 (Natural)", + "name": "Google US English 4 (Natural)", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Google US English 5 (Natural)", + "name": "Google US English 5 (Natural)", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Google US English 6 (Natural)", + "name": "Google US English 6 (Natural)", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Google US English 7 (Natural)", + "name": "Google US English 7 (Natural)", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ms-my-x-msd-local", + "name": "Android Speech Recognition and Synthesis from Google ms-my-x-msd-local", + "lang": "ms-MY", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-gb-x-gba-local", + "name": "Android Speech Recognition and Synthesis from Google en-gb-x-gba-local", + "lang": "en-GB", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-iol-local", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-iol-local", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google it-it-x-kda-local", + "name": "Android Speech Recognition and Synthesis from Google it-it-x-kda-local", + "lang": "it-IT", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google as-in-x-end-local", + "name": "Android Speech Recognition and Synthesis from Google as-in-x-end-local", + "lang": "as-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google as-IN-language", + "name": "Android Speech Recognition and Synthesis from Google as-IN-language", + "lang": "as-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-fr-x-frc-local", + "name": "Android Speech Recognition and Synthesis from Google fr-fr-x-frc-local", + "lang": "fr-FR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pl-pl-x-oda-local", + "name": "Android Speech Recognition and Synthesis from Google pl-pl-x-oda-local", + "lang": "pl-PL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-be-x-bec-local", + "name": "Android Speech Recognition and Synthesis from Google nl-be-x-bec-local", + "lang": "nl-BE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-pt-x-pmj-local", + "name": "Android Speech Recognition and Synthesis from Google pt-pt-x-pmj-local", + "lang": "pt-PT", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sv-se-x-lfs-local", + "name": "Android Speech Recognition and Synthesis from Google sv-se-x-lfs-local", + "lang": "sv-SE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nb-NO-language", + "name": "Android Speech Recognition and Synthesis from Google nb-NO-language", + "lang": "nb-NO", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ar-xa-x-arz-local", + "name": "Android Speech Recognition and Synthesis from Google ar-xa-x-arz-local", + "lang": "ar", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-ca-x-cad-local", + "name": "Android Speech Recognition and Synthesis from Google fr-ca-x-cad-local", + "lang": "fr-CA", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-tpf-local", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-tpf-local", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ja-jp-x-htm-local", + "name": "Android Speech Recognition and Synthesis from Google ja-jp-x-htm-local", + "lang": "ja-JP", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cy-gb-x-cyf-local", + "name": "Android Speech Recognition and Synthesis from Google cy-gb-x-cyf-local", + "lang": "cy-GB", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-cn-x-cce-local", + "name": "Android Speech Recognition and Synthesis from Google cmn-cn-x-cce-local", + "lang": "zh-CN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-pt-x-jfb-local", + "name": "Android Speech Recognition and Synthesis from Google pt-pt-x-jfb-local", + "lang": "pt-PT", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sv-SE-language", + "name": "Android Speech Recognition and Synthesis from Google sv-SE-language", + "lang": "sv-SE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ru-ru-x-ruc-local", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-ruc-local", + "lang": "ru-RU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ar-xa-x-ard-local", + "name": "Android Speech Recognition and Synthesis from Google ar-xa-x-ard-local", + "lang": "ar", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ms-my-x-mse-local", + "name": "Android Speech Recognition and Synthesis from Google ms-my-x-mse-local", + "lang": "ms-MY", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-NL-language", + "name": "Android Speech Recognition and Synthesis from Google nl-NL-language", + "lang": "nl-NL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sl-SI-language", + "name": "Android Speech Recognition and Synthesis from Google sl-SI-language", + "lang": "sl-SI", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nb-no-x-tfs-local", + "name": "Android Speech Recognition and Synthesis from Google nb-no-x-tfs-local", + "lang": "nb-NO", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sw-ke-language", + "name": "Android Speech Recognition and Synthesis from Google sw-ke-language", + "lang": "sw-KE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ru-ru-x-ruf-local", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-ruf-local", + "lang": "ru-RU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google da-dk-x-sfp-local", + "name": "Android Speech Recognition and Synthesis from Google da-dk-x-sfp-local", + "lang": "da-DK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ja-jp-x-jac-local", + "name": "Android Speech Recognition and Synthesis from Google ja-jp-x-jac-local", + "lang": "ja-JP", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-au-x-auc-local", + "name": "Android Speech Recognition and Synthesis from Google en-au-x-auc-local", + "lang": "en-AU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-US-language", + "name": "Android Speech Recognition and Synthesis from Google es-US-language", + "lang": "es-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bn-bd-x-ban-local", + "name": "Android Speech Recognition and Synthesis from Google bn-bd-x-ban-local", + "lang": "bn-BD", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google zh-TW-language", + "name": "Android Speech Recognition and Synthesis from Google zh-TW-language", + "lang": "zh-TW", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google mni-in-x-end-local", + "name": "Android Speech Recognition and Synthesis from Google mni-in-x-end-local", + "lang": "mni-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-br-x-ptd-local", + "name": "Android Speech Recognition and Synthesis from Google pt-br-x-ptd-local", + "lang": "pt-BR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-es-x-eec-local", + "name": "Android Speech Recognition and Synthesis from Google es-es-x-eec-local", + "lang": "es-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hu-hu-x-kfl-local", + "name": "Android Speech Recognition and Synthesis from Google hu-hu-x-kfl-local", + "lang": "hu-HU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-ca-x-caa-local", + "name": "Android Speech Recognition and Synthesis from Google fr-ca-x-caa-local", + "lang": "fr-CA", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bn-in-x-bnf-local", + "name": "Android Speech Recognition and Synthesis from Google bn-in-x-bnf-local", + "lang": "bn-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google kok-IN-language", + "name": "Android Speech Recognition and Synthesis from Google kok-IN-language", + "lang": "kok-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google jv-ID-language", + "name": "Android Speech Recognition and Synthesis from Google jv-ID-language", + "lang": "jv-ID", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pa-in-x-pad-local", + "name": "Android Speech Recognition and Synthesis from Google pa-in-x-pad-local", + "lang": "pa-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ml-IN-language", + "name": "Android Speech Recognition and Synthesis from Google ml-IN-language", + "lang": "ml-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-in-x-ena-local", + "name": "Android Speech Recognition and Synthesis from Google en-in-x-ena-local", + "lang": "en-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cy-GB-language", + "name": "Android Speech Recognition and Synthesis from Google cy-GB-language", + "lang": "cy-GB", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google de-de-x-deb-local", + "name": "Android Speech Recognition and Synthesis from Google de-de-x-deb-local", + "lang": "de-DE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google uk-ua-x-hfd-local", + "name": "Android Speech Recognition and Synthesis from Google uk-ua-x-hfd-local", + "lang": "uk-UA", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-es-x-eee-local", + "name": "Android Speech Recognition and Synthesis from Google es-es-x-eee-local", + "lang": "es-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google he-il-x-hec-local", + "name": "Android Speech Recognition and Synthesis from Google he-il-x-hec-local", + "lang": "iw-IL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-BE-language", + "name": "Android Speech Recognition and Synthesis from Google nl-BE-language", + "lang": "nl-BE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-cn-x-ccc-local", + "name": "Android Speech Recognition and Synthesis from Google cmn-cn-x-ccc-local", + "lang": "zh-CN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google id-ID-language", + "name": "Android Speech Recognition and Synthesis from Google id-ID-language", + "lang": "in-ID", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-tw-x-ctd-local", + "name": "Android Speech Recognition and Synthesis from Google cmn-tw-x-ctd-local", + "lang": "zh-TW", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-iog-local", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-iog-local", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-pt-x-sfs-local", + "name": "Android Speech Recognition and Synthesis from Google pt-pt-x-sfs-local", + "lang": "pt-PT", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-tw-x-ctc-local", + "name": "Android Speech Recognition and Synthesis from Google cmn-tw-x-ctc-local", + "lang": "zh-TW", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-gb-x-rjs-local", + "name": "Android Speech Recognition and Synthesis from Google en-gb-x-rjs-local", + "lang": "en-GB", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ko-kr-x-kob-local", + "name": "Android Speech Recognition and Synthesis from Google ko-kr-x-kob-local", + "lang": "ko-KR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google vi-vn-x-vie-local", + "name": "Android Speech Recognition and Synthesis from Google vi-vn-x-vie-local", + "lang": "vi-VN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pl-PL-language", + "name": "Android Speech Recognition and Synthesis from Google pl-PL-language", + "lang": "pl-PL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google km-kh-x-khm-local", + "name": "Android Speech Recognition and Synthesis from Google km-kh-x-khm-local", + "lang": "km-KH", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google id-id-x-idd-local", + "name": "Android Speech Recognition and Synthesis from Google id-id-x-idd-local", + "lang": "in-ID", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google doi-in-x-end-local", + "name": "Android Speech Recognition and Synthesis from Google doi-in-x-end-local", + "lang": "doi-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ms-my-x-msc-local", + "name": "Android Speech Recognition and Synthesis from Google ms-my-x-msc-local", + "lang": "ms-MY", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ro-ro-x-vfv-local", + "name": "Android Speech Recognition and Synthesis from Google ro-ro-x-vfv-local", + "lang": "ro-RO", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bg-bg-language", + "name": "Android Speech Recognition and Synthesis from Google bg-bg-language", + "lang": "bg-BG", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bs-ba-x-bsm-local", + "name": "Android Speech Recognition and Synthesis from Google bs-ba-x-bsm-local", + "lang": "bs-BA", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google jv-id-x-jvf-local", + "name": "Android Speech Recognition and Synthesis from Google jv-id-x-jvf-local", + "lang": "jv-ID", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-PT-language", + "name": "Android Speech Recognition and Synthesis from Google pt-PT-language", + "lang": "pt-PT", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google lt-lt-x-amc-local", + "name": "Android Speech Recognition and Synthesis from Google lt-lt-x-amc-local", + "lang": "lt-LT", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-br-x-pte-local", + "name": "Android Speech Recognition and Synthesis from Google pt-br-x-pte-local", + "lang": "pt-BR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google gu-in-x-gum-local", + "name": "Android Speech Recognition and Synthesis from Google gu-in-x-gum-local", + "lang": "gu-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sat-IN-language", + "name": "Android Speech Recognition and Synthesis from Google sat-IN-language", + "lang": "sat-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-sfg-local", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-sfg-local", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fil-PH-language", + "name": "Android Speech Recognition and Synthesis from Google fil-PH-language", + "lang": "fil-PH", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-in-x-ene-local", + "name": "Android Speech Recognition and Synthesis from Google en-in-x-ene-local", + "lang": "en-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-BR-language", + "name": "Android Speech Recognition and Synthesis from Google pt-BR-language", + "lang": "pt-BR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ca-es-x-caf-local", + "name": "Android Speech Recognition and Synthesis from Google ca-es-x-caf-local", + "lang": "ca-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ar-language", + "name": "Android Speech Recognition and Synthesis from Google ar-language", + "lang": "ar", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google tr-TR-language", + "name": "Android Speech Recognition and Synthesis from Google tr-TR-language", + "lang": "tr-TR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-gb-x-gbg-local", + "name": "Android Speech Recognition and Synthesis from Google en-gb-x-gbg-local", + "lang": "en-GB", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fil-ph-x-fie-local", + "name": "Android Speech Recognition and Synthesis from Google fil-ph-x-fie-local", + "lang": "fil-PH", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sv-se-x-afp-local", + "name": "Android Speech Recognition and Synthesis from Google sv-se-x-afp-local", + "lang": "sv-SE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ta-in-x-tac-local", + "name": "Android Speech Recognition and Synthesis from Google ta-in-x-tac-local", + "lang": "ta-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cs-CZ-language", + "name": "Android Speech Recognition and Synthesis from Google cs-CZ-language", + "lang": "cs-CZ", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google si-lk-x-sin-local", + "name": "Android Speech Recognition and Synthesis from Google si-lk-x-sin-local", + "lang": "si-LK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google de-de-x-dea-local", + "name": "Android Speech Recognition and Synthesis from Google de-de-x-dea-local", + "lang": "de-DE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-iob-local", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-iob-local", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google th-th-x-mol-local", + "name": "Android Speech Recognition and Synthesis from Google th-th-x-mol-local", + "lang": "th-TH", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-nl-x-tfb-local", + "name": "Android Speech Recognition and Synthesis from Google nl-nl-x-tfb-local", + "lang": "nl-NL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google km-KH-language", + "name": "Android Speech Recognition and Synthesis from Google km-KH-language", + "lang": "km-KH", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bn-in-x-bin-local", + "name": "Android Speech Recognition and Synthesis from Google bn-in-x-bin-local", + "lang": "bn-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bs-ba-language", + "name": "Android Speech Recognition and Synthesis from Google bs-ba-language", + "lang": "bs-BA", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pa-in-x-pac-local", + "name": "Android Speech Recognition and Synthesis from Google pa-in-x-pac-local", + "lang": "pa-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ru-RU-language", + "name": "Android Speech Recognition and Synthesis from Google ru-RU-language", + "lang": "ru-RU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-gb-x-gbd-local", + "name": "Android Speech Recognition and Synthesis from Google en-gb-x-gbd-local", + "lang": "en-GB", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pl-pl-x-bmg-local", + "name": "Android Speech Recognition and Synthesis from Google pl-pl-x-bmg-local", + "lang": "pl-PL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google tr-tr-x-ama-local", + "name": "Android Speech Recognition and Synthesis from Google tr-tr-x-ama-local", + "lang": "tr-TR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google tr-tr-x-cfs-local", + "name": "Android Speech Recognition and Synthesis from Google tr-tr-x-cfs-local", + "lang": "tr-TR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sd-in-x-end-local", + "name": "Android Speech Recognition and Synthesis from Google sd-in-x-end-local", + "lang": "sd-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google kn-IN-language", + "name": "Android Speech Recognition and Synthesis from Google kn-IN-language", + "lang": "kn-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-pt-x-jmn-local", + "name": "Android Speech Recognition and Synthesis from Google pt-pt-x-jmn-local", + "lang": "pt-PT", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google id-id-x-ide-local", + "name": "Android Speech Recognition and Synthesis from Google id-id-x-ide-local", + "lang": "in-ID", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google et-ee-x-tms-local", + "name": "Android Speech Recognition and Synthesis from Google et-ee-x-tms-local", + "lang": "et-EE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-IN-language", + "name": "Android Speech Recognition and Synthesis from Google en-IN-language", + "lang": "en-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google kn-in-x-knf-local", + "name": "Android Speech Recognition and Synthesis from Google kn-in-x-knf-local", + "lang": "kn-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-us-x-esd-local", + "name": "Android Speech Recognition and Synthesis from Google es-us-x-esd-local", + "lang": "es-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google te-in-x-tef-local", + "name": "Android Speech Recognition and Synthesis from Google te-in-x-tef-local", + "lang": "te-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hi-in-x-hic-local", + "name": "Android Speech Recognition and Synthesis from Google hi-in-x-hic-local", + "lang": "hi-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fi-fi-x-afi-local", + "name": "Android Speech Recognition and Synthesis from Google fi-fi-x-afi-local", + "lang": "fi-FI", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ml-in-x-mlm-local", + "name": "Android Speech Recognition and Synthesis from Google ml-in-x-mlm-local", + "lang": "ml-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sw-ke-x-swm-local", + "name": "Android Speech Recognition and Synthesis from Google sw-ke-x-swm-local", + "lang": "sw-KE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google el-GR-language", + "name": "Android Speech Recognition and Synthesis from Google el-GR-language", + "lang": "el-GR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google is-is-x-isf-local", + "name": "Android Speech Recognition and Synthesis from Google is-is-x-isf-local", + "lang": "is-IS", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hi-in-x-hie-local", + "name": "Android Speech Recognition and Synthesis from Google hi-in-x-hie-local", + "lang": "hi-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sv-se-x-dmc-local", + "name": "Android Speech Recognition and Synthesis from Google sv-se-x-dmc-local", + "lang": "sv-SE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-nl-x-lfc-local", + "name": "Android Speech Recognition and Synthesis from Google nl-nl-x-lfc-local", + "lang": "nl-NL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sq-al-x-sqm-local", + "name": "Android Speech Recognition and Synthesis from Google sq-al-x-sqm-local", + "lang": "sq-AL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google he-il-language", + "name": "Android Speech Recognition and Synthesis from Google he-il-language", + "lang": "iw-IL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ar-xa-x-arc-local", + "name": "Android Speech Recognition and Synthesis from Google ar-xa-x-arc-local", + "lang": "ar", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google mr-in-x-mrf-local", + "name": "Android Speech Recognition and Synthesis from Google mr-in-x-mrf-local", + "lang": "mr-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google mai-IN-language", + "name": "Android Speech Recognition and Synthesis from Google mai-IN-language", + "lang": "mai-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-es-x-eef-local", + "name": "Android Speech Recognition and Synthesis from Google es-es-x-eef-local", + "lang": "es-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ko-kr-x-kod-local", + "name": "Android Speech Recognition and Synthesis from Google ko-kr-x-kod-local", + "lang": "ko-KR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-fr-x-frd-local", + "name": "Android Speech Recognition and Synthesis from Google fr-fr-x-frd-local", + "lang": "fr-FR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ta-IN-language", + "name": "Android Speech Recognition and Synthesis from Google ta-IN-language", + "lang": "ta-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google kn-in-x-knm-local", + "name": "Android Speech Recognition and Synthesis from Google kn-in-x-knm-local", + "lang": "kn-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ta-in-x-tad-local", + "name": "Android Speech Recognition and Synthesis from Google ta-in-x-tad-local", + "lang": "ta-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bn-in-x-bnm-local", + "name": "Android Speech Recognition and Synthesis from Google bn-in-x-bnm-local", + "lang": "bn-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cs-cz-x-jfs-local", + "name": "Android Speech Recognition and Synthesis from Google cs-cz-x-jfs-local", + "lang": "cs-CZ", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sv-se-x-cmh-local", + "name": "Android Speech Recognition and Synthesis from Google sv-se-x-cmh-local", + "lang": "sv-SE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google de-DE-language", + "name": "Android Speech Recognition and Synthesis from Google de-DE-language", + "lang": "de-DE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nb-no-x-tmg-local", + "name": "Android Speech Recognition and Synthesis from Google nb-no-x-tmg-local", + "lang": "nb-NO", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fi-FI-language", + "name": "Android Speech Recognition and Synthesis from Google fi-FI-language", + "lang": "fi-FI", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sl-si-x-frm-local", + "name": "Android Speech Recognition and Synthesis from Google sl-si-x-frm-local", + "lang": "sl-SI", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ja-jp-x-jad-local", + "name": "Android Speech Recognition and Synthesis from Google ja-jp-x-jad-local", + "lang": "ja-JP", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google vi-vn-x-vid-local", + "name": "Android Speech Recognition and Synthesis from Google vi-vn-x-vid-local", + "lang": "vi-VN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google de-de-x-nfh-local", + "name": "Android Speech Recognition and Synthesis from Google de-de-x-nfh-local", + "lang": "de-DE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ur-pk-x-urm-local", + "name": "Android Speech Recognition and Synthesis from Google ur-pk-x-urm-local", + "lang": "ur-PK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-in-x-end-local", + "name": "Android Speech Recognition and Synthesis from Google en-in-x-end-local", + "lang": "en-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-gb-x-gbb-local", + "name": "Android Speech Recognition and Synthesis from Google en-gb-x-gbb-local", + "lang": "en-GB", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-es-x-eea-local", + "name": "Android Speech Recognition and Synthesis from Google es-es-x-eea-local", + "lang": "es-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-cn-x-ccd-local", + "name": "Android Speech Recognition and Synthesis from Google cmn-cn-x-ccd-local", + "lang": "zh-CN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google da-dk-x-kfm-local", + "name": "Android Speech Recognition and Synthesis from Google da-dk-x-kfm-local", + "lang": "da-DK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google he-il-x-heb-local", + "name": "Android Speech Recognition and Synthesis from Google he-il-x-heb-local", + "lang": "iw-IL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google lv-lv-x-imr-local", + "name": "Android Speech Recognition and Synthesis from Google lv-lv-x-imr-local", + "lang": "lv-LV", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google id-id-x-dfz-local", + "name": "Android Speech Recognition and Synthesis from Google id-id-x-dfz-local", + "lang": "in-ID", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ko-kr-x-ism-local", + "name": "Android Speech Recognition and Synthesis from Google ko-kr-x-ism-local", + "lang": "ko-KR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google gu-IN-language", + "name": "Android Speech Recognition and Synthesis from Google gu-IN-language", + "lang": "gu-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sk-SK-language", + "name": "Android Speech Recognition and Synthesis from Google sk-SK-language", + "lang": "sk-SK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google or-IN-language", + "name": "Android Speech Recognition and Synthesis from Google or-IN-language", + "lang": "or-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google or-in-x-end-local", + "name": "Android Speech Recognition and Synthesis from Google or-in-x-end-local", + "lang": "or-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-ca-x-cac-local", + "name": "Android Speech Recognition and Synthesis from Google fr-ca-x-cac-local", + "lang": "fr-CA", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google el-gr-x-vfz-local", + "name": "Android Speech Recognition and Synthesis from Google el-gr-x-vfz-local", + "lang": "el-GR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google it-it-x-itb-local", + "name": "Android Speech Recognition and Synthesis from Google it-it-x-itb-local", + "lang": "it-IT", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ms-my-x-msg-local", + "name": "Android Speech Recognition and Synthesis from Google ms-my-x-msg-local", + "lang": "ms-MY", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google da-dk-x-vfb-local", + "name": "Android Speech Recognition and Synthesis from Google da-dk-x-vfb-local", + "lang": "da-DK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google yue-hk-x-yuc-local", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-yuc-local", + "lang": "yue-HK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google yue-hk-x-yuf-local", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-yuf-local", + "lang": "yue-HK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-fr-x-vlf-local", + "name": "Android Speech Recognition and Synthesis from Google fr-fr-x-vlf-local", + "lang": "fr-FR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ru-ru-x-dfc-local", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-dfc-local", + "lang": "ru-RU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google si-LK-language", + "name": "Android Speech Recognition and Synthesis from Google si-LK-language", + "lang": "si-LK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google brx-IN-language", + "name": "Android Speech Recognition and Synthesis from Google brx-IN-language", + "lang": "brx-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-au-x-aub-local", + "name": "Android Speech Recognition and Synthesis from Google en-au-x-aub-local", + "lang": "en-AU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ru-ru-x-rue-local", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-rue-local", + "lang": "ru-RU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google gu-in-x-guf-local", + "name": "Android Speech Recognition and Synthesis from Google gu-in-x-guf-local", + "lang": "gu-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ms-MY-language", + "name": "Android Speech Recognition and Synthesis from Google ms-MY-language", + "lang": "ms-MY", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fil-ph-x-fid-local", + "name": "Android Speech Recognition and Synthesis from Google fil-ph-x-fid-local", + "lang": "fil-PH", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bn-in-x-bnx-local", + "name": "Android Speech Recognition and Synthesis from Google bn-in-x-bnx-local", + "lang": "bn-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google de-de-x-deg-local", + "name": "Android Speech Recognition and Synthesis from Google de-de-x-deg-local", + "lang": "de-DE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google tr-tr-x-mfm-local", + "name": "Android Speech Recognition and Synthesis from Google tr-tr-x-mfm-local", + "lang": "tr-TR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ro-RO-language", + "name": "Android Speech Recognition and Synthesis from Google ro-RO-language", + "lang": "ro-RO", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-gb-x-gbc-local", + "name": "Android Speech Recognition and Synthesis from Google en-gb-x-gbc-local", + "lang": "en-GB", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ne-NP-language", + "name": "Android Speech Recognition and Synthesis from Google ne-NP-language", + "lang": "ne-NP", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google yue-hk-x-jar-local", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-jar-local", + "lang": "yue-HK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google yue-hk-x-yue-local", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-yue-local", + "lang": "yue-HK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-tw-x-cte-local", + "name": "Android Speech Recognition and Synthesis from Google cmn-tw-x-cte-local", + "lang": "zh-TW", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fil-ph-x-cfc-local", + "name": "Android Speech Recognition and Synthesis from Google fil-ph-x-cfc-local", + "lang": "fil-PH", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ne-np-x-nep-local", + "name": "Android Speech Recognition and Synthesis from Google ne-np-x-nep-local", + "lang": "ne-NP", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google it-it-x-itc-local", + "name": "Android Speech Recognition and Synthesis from Google it-it-x-itc-local", + "lang": "it-IT", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nb-no-x-cfl-local", + "name": "Android Speech Recognition and Synthesis from Google nb-no-x-cfl-local", + "lang": "nb-NO", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google su-ID-language", + "name": "Android Speech Recognition and Synthesis from Google su-ID-language", + "lang": "su-ID", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google th-TH-language", + "name": "Android Speech Recognition and Synthesis from Google th-TH-language", + "lang": "th-TH", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google zh-CN-language", + "name": "Android Speech Recognition and Synthesis from Google zh-CN-language", + "lang": "zh-CN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google it-it-x-itd-local", + "name": "Android Speech Recognition and Synthesis from Google it-it-x-itd-local", + "lang": "it-IT", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-au-x-aud-local", + "name": "Android Speech Recognition and Synthesis from Google en-au-x-aud-local", + "lang": "en-AU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-AU-language", + "name": "Android Speech Recognition and Synthesis from Google en-AU-language", + "lang": "en-AU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hr-HR-language", + "name": "Android Speech Recognition and Synthesis from Google hr-HR-language", + "lang": "hr-HR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ja-JP-language", + "name": "Android Speech Recognition and Synthesis from Google ja-JP-language", + "lang": "ja-JP", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-FR-language", + "name": "Android Speech Recognition and Synthesis from Google fr-FR-language", + "lang": "fr-FR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-fr-x-frb-local", + "name": "Android Speech Recognition and Synthesis from Google fr-fr-x-frb-local", + "lang": "fr-FR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sr-rs-language", + "name": "Android Speech Recognition and Synthesis from Google sr-rs-language", + "lang": "sr-RS", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sa-in-x-end-local", + "name": "Android Speech Recognition and Synthesis from Google sa-in-x-end-local", + "lang": "sa-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nb-no-x-rfj-local", + "name": "Android Speech Recognition and Synthesis from Google nb-no-x-rfj-local", + "lang": "nb-NO", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pl-pl-x-afb-local", + "name": "Android Speech Recognition and Synthesis from Google pl-pl-x-afb-local", + "lang": "pl-PL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pa-in-x-pag-local", + "name": "Android Speech Recognition and Synthesis from Google pa-in-x-pag-local", + "lang": "pa-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bn-BD-language", + "name": "Android Speech Recognition and Synthesis from Google bn-BD-language", + "lang": "bn-BD", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google vi-vn-x-vic-local", + "name": "Android Speech Recognition and Synthesis from Google vi-vn-x-vic-local", + "lang": "vi-VN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hr-hr-x-hra-local", + "name": "Android Speech Recognition and Synthesis from Google hr-hr-x-hra-local", + "lang": "hr-HR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nb-no-x-cmj-local", + "name": "Android Speech Recognition and Synthesis from Google nb-no-x-cmj-local", + "lang": "nb-NO", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google da-DK-language", + "name": "Android Speech Recognition and Synthesis from Google da-DK-language", + "lang": "da-DK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google he-il-x-hee-local", + "name": "Android Speech Recognition and Synthesis from Google he-il-x-hee-local", + "lang": "iw-IL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pa-IN-language", + "name": "Android Speech Recognition and Synthesis from Google pa-IN-language", + "lang": "pa-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google yue-hk-x-yud-local", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-yud-local", + "lang": "yue-HK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ml-in-x-mlf-local", + "name": "Android Speech Recognition and Synthesis from Google ml-in-x-mlf-local", + "lang": "ml-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ks-IN-language", + "name": "Android Speech Recognition and Synthesis from Google ks-IN-language", + "lang": "ks-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google da-dk-x-nmm-local", + "name": "Android Speech Recognition and Synthesis from Google da-dk-x-nmm-local", + "lang": "da-DK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google doi-IN-language", + "name": "Android Speech Recognition and Synthesis from Google doi-IN-language", + "lang": "doi-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google kok-in-x-end-local", + "name": "Android Speech Recognition and Synthesis from Google kok-in-x-end-local", + "lang": "kok-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google lv-LV-language", + "name": "Android Speech Recognition and Synthesis from Google lv-LV-language", + "lang": "lv-LV", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-be-x-bed-local", + "name": "Android Speech Recognition and Synthesis from Google nl-be-x-bed-local", + "lang": "nl-BE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-GB-language", + "name": "Android Speech Recognition and Synthesis from Google en-GB-language", + "lang": "en-GB", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hi-in-x-hia-local", + "name": "Android Speech Recognition and Synthesis from Google hi-in-x-hia-local", + "lang": "hi-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ks-in-x-end-local", + "name": "Android Speech Recognition and Synthesis from Google ks-in-x-end-local", + "lang": "ks-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-nl-x-yfr-local", + "name": "Android Speech Recognition and Synthesis from Google nl-nl-x-yfr-local", + "lang": "nl-NL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ar-xa-x-are-local", + "name": "Android Speech Recognition and Synthesis from Google ar-xa-x-are-local", + "lang": "ar", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ca-ES-language", + "name": "Android Speech Recognition and Synthesis from Google ca-ES-language", + "lang": "ca-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google mr-IN-language", + "name": "Android Speech Recognition and Synthesis from Google mr-IN-language", + "lang": "mr-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google te-in-x-tem-local", + "name": "Android Speech Recognition and Synthesis from Google te-in-x-tem-local", + "lang": "te-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ru-ru-x-rud-local", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-rud-local", + "lang": "ru-RU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-nl-x-dma-local", + "name": "Android Speech Recognition and Synthesis from Google nl-nl-x-dma-local", + "lang": "nl-NL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google et-EE-language", + "name": "Android Speech Recognition and Synthesis from Google et-EE-language", + "lang": "et-EE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ko-KR-language", + "name": "Android Speech Recognition and Synthesis from Google ko-KR-language", + "lang": "ko-KR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bg-bg-x-ifk-local", + "name": "Android Speech Recognition and Synthesis from Google bg-bg-x-ifk-local", + "lang": "bg-BG", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google tr-tr-x-tmc-local", + "name": "Android Speech Recognition and Synthesis from Google tr-tr-x-tmc-local", + "lang": "tr-TR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-NG-language", + "name": "Android Speech Recognition and Synthesis from Google en-NG-language", + "lang": "en-NG", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-in-x-enc-local", + "name": "Android Speech Recognition and Synthesis from Google en-in-x-enc-local", + "lang": "en-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-au-x-aua-local", + "name": "Android Speech Recognition and Synthesis from Google en-au-x-aua-local", + "lang": "en-AU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google te-IN-language", + "name": "Android Speech Recognition and Synthesis from Google te-IN-language", + "lang": "te-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-ES-language", + "name": "Android Speech Recognition and Synthesis from Google es-ES-language", + "lang": "es-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-nl-x-bmh-local", + "name": "Android Speech Recognition and Synthesis from Google nl-nl-x-bmh-local", + "lang": "nl-NL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ur-PK-language", + "name": "Android Speech Recognition and Synthesis from Google ur-PK-language", + "lang": "ur-PK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google is-is-language", + "name": "Android Speech Recognition and Synthesis from Google is-is-language", + "lang": "is-IS", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google vi-vn-x-vif-local", + "name": "Android Speech Recognition and Synthesis from Google vi-vn-x-vif-local", + "lang": "vi-VN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google vi-VN-language", + "name": "Android Speech Recognition and Synthesis from Google vi-VN-language", + "lang": "vi-VN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-us-x-esf-local", + "name": "Android Speech Recognition and Synthesis from Google es-us-x-esf-local", + "lang": "es-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-us-x-sfb-local", + "name": "Android Speech Recognition and Synthesis from Google es-us-x-sfb-local", + "lang": "es-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hi-in-x-hid-local", + "name": "Android Speech Recognition and Synthesis from Google hi-in-x-hid-local", + "lang": "hi-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ur-pk-x-cfn-local", + "name": "Android Speech Recognition and Synthesis from Google ur-pk-x-cfn-local", + "lang": "ur-PK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-us-x-esc-local", + "name": "Android Speech Recognition and Synthesis from Google es-us-x-esc-local", + "lang": "es-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pl-pl-x-jmk-local", + "name": "Android Speech Recognition and Synthesis from Google pl-pl-x-jmk-local", + "lang": "pl-PL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google mni-IN-language", + "name": "Android Speech Recognition and Synthesis from Google mni-IN-language", + "lang": "mni-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-CA-language", + "name": "Android Speech Recognition and Synthesis from Google fr-CA-language", + "lang": "fr-CA", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google it-IT-language", + "name": "Android Speech Recognition and Synthesis from Google it-IT-language", + "lang": "it-IT", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pl-pl-x-zfg-local", + "name": "Android Speech Recognition and Synthesis from Google pl-pl-x-zfg-local", + "lang": "pl-PL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-tpd-local", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-tpd-local", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-iom-local", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-iom-local", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ja-jp-x-jab-local", + "name": "Android Speech Recognition and Synthesis from Google ja-jp-x-jab-local", + "lang": "ja-JP", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google he-il-x-hed-local", + "name": "Android Speech Recognition and Synthesis from Google he-il-x-hed-local", + "lang": "iw-IL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-br-x-afs-local", + "name": "Android Speech Recognition and Synthesis from Google pt-br-x-afs-local", + "lang": "pt-BR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google brx-in-x-end-local", + "name": "Android Speech Recognition and Synthesis from Google brx-in-x-end-local", + "lang": "brx-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fil-ph-x-fic-local", + "name": "Android Speech Recognition and Synthesis from Google fil-ph-x-fic-local", + "lang": "fil-PH", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hr-hr-x-hrb-local", + "name": "Android Speech Recognition and Synthesis from Google hr-hr-x-hrb-local", + "lang": "hr-HR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-cn-x-ssa-local", + "name": "Android Speech Recognition and Synthesis from Google cmn-cn-x-ssa-local", + "lang": "zh-CN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-ca-x-cab-local", + "name": "Android Speech Recognition and Synthesis from Google fr-ca-x-cab-local", + "lang": "fr-CA", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hi-IN-language", + "name": "Android Speech Recognition and Synthesis from Google hi-IN-language", + "lang": "hi-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-es-x-eed-local", + "name": "Android Speech Recognition and Synthesis from Google es-es-x-eed-local", + "lang": "es-ES", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-fr-x-fra-local", + "name": "Android Speech Recognition and Synthesis from Google fr-fr-x-fra-local", + "lang": "fr-FR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google vi-vn-x-gft-local", + "name": "Android Speech Recognition and Synthesis from Google vi-vn-x-gft-local", + "lang": "vi-VN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hu-HU-language", + "name": "Android Speech Recognition and Synthesis from Google hu-HU-language", + "lang": "hu-HU", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google mai-in-x-end-local", + "name": "Android Speech Recognition and Synthesis from Google mai-in-x-end-local", + "lang": "mai-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google id-id-x-idc-local", + "name": "Android Speech Recognition and Synthesis from Google id-id-x-idc-local", + "lang": "in-ID", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google lt-lt-language", + "name": "Android Speech Recognition and Synthesis from Google lt-lt-language", + "lang": "lt-LT", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sv-se-x-cfg-local", + "name": "Android Speech Recognition and Synthesis from Google sv-se-x-cfg-local", + "lang": "sv-SE", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bn-IN-language", + "name": "Android Speech Recognition and Synthesis from Google bn-IN-language", + "lang": "bn-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sa-IN-language", + "name": "Android Speech Recognition and Synthesis from Google sa-IN-language", + "lang": "sa-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-ng-x-tfn-local", + "name": "Android Speech Recognition and Synthesis from Google en-ng-x-tfn-local", + "lang": "en-NG", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google tr-tr-x-efu-local", + "name": "Android Speech Recognition and Synthesis from Google tr-tr-x-efu-local", + "lang": "tr-TR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sat-in-x-end-local", + "name": "Android Speech Recognition and Synthesis from Google sat-in-x-end-local", + "lang": "sat-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ko-kr-x-koc-local", + "name": "Android Speech Recognition and Synthesis from Google ko-kr-x-koc-local", + "lang": "ko-KR", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-US-language", + "name": "Android Speech Recognition and Synthesis from Google en-US-language", + "lang": "en-US", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google yue-HK-language", + "name": "Android Speech Recognition and Synthesis from Google yue-HK-language", + "lang": "yue-HK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pa-in-x-pae-local", + "name": "Android Speech Recognition and Synthesis from Google pa-in-x-pae-local", + "lang": "pa-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sk-sk-x-sfk-local", + "name": "Android Speech Recognition and Synthesis from Google sk-sk-x-sfk-local", + "lang": "sk-SK", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sq-al-language", + "name": "Android Speech Recognition and Synthesis from Google sq-al-language", + "lang": "sq-AL", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google su-id-x-suf-local", + "name": "Android Speech Recognition and Synthesis from Google su-id-x-suf-local", + "lang": "su-ID", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google uk-UA-language", + "name": "Android Speech Recognition and Synthesis from Google uk-UA-language", + "lang": "uk-UA", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sd-IN-language", + "name": "Android Speech Recognition and Synthesis from Google sd-IN-language", + "lang": "sd-IN", + "localService": true, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-tw-x-ctd-network", + "name": "Android Speech Recognition and Synthesis from Google cmn-tw-x-ctd-network", + "lang": "zh-TW", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google kn-in-x-knm-network", + "name": "Android Speech Recognition and Synthesis from Google kn-in-x-knm-network", + "lang": "kn-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-gb-x-gbc-network", + "name": "Android Speech Recognition and Synthesis from Google en-gb-x-gbc-network", + "lang": "en-GB", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hu-hu-x-kfl-network", + "name": "Android Speech Recognition and Synthesis from Google hu-hu-x-kfl-network", + "lang": "hu-HU", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ja-jp-x-htm-network", + "name": "Android Speech Recognition and Synthesis from Google ja-jp-x-htm-network", + "lang": "ja-JP", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hi-in-x-hia-network", + "name": "Android Speech Recognition and Synthesis from Google hi-in-x-hia-network", + "lang": "hi-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ru-ru-x-ruc-network", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-ruc-network", + "lang": "ru-RU", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google lt-lt-x-amc-network", + "name": "Android Speech Recognition and Synthesis from Google lt-lt-x-amc-network", + "lang": "lt-LT", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google he-il-x-hed-network", + "name": "Android Speech Recognition and Synthesis from Google he-il-x-hed-network", + "lang": "iw-IL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-au-x-aua-network", + "name": "Android Speech Recognition and Synthesis from Google en-au-x-aua-network", + "lang": "en-AU", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ja-jp-x-jad-network", + "name": "Android Speech Recognition and Synthesis from Google ja-jp-x-jad-network", + "lang": "ja-JP", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google da-dk-x-sfp-network", + "name": "Android Speech Recognition and Synthesis from Google da-dk-x-sfp-network", + "lang": "da-DK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-be-x-bec-network", + "name": "Android Speech Recognition and Synthesis from Google nl-be-x-bec-network", + "lang": "nl-BE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sw-ke-x-swm-network", + "name": "Android Speech Recognition and Synthesis from Google sw-ke-x-swm-network", + "lang": "sw-KE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sv-se-x-afp-network", + "name": "Android Speech Recognition and Synthesis from Google sv-se-x-afp-network", + "lang": "sv-SE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-ng-x-tfn-network", + "name": "Android Speech Recognition and Synthesis from Google en-ng-x-tfn-network", + "lang": "en-NG", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pa-in-x-pag-network", + "name": "Android Speech Recognition and Synthesis from Google pa-in-x-pag-network", + "lang": "pa-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-in-x-ene-network", + "name": "Android Speech Recognition and Synthesis from Google en-in-x-ene-network", + "lang": "en-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google de-de-x-deb-network", + "name": "Android Speech Recognition and Synthesis from Google de-de-x-deb-network", + "lang": "de-DE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sv-se-x-cmh-network", + "name": "Android Speech Recognition and Synthesis from Google sv-se-x-cmh-network", + "lang": "sv-SE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google si-lk-x-sin-network", + "name": "Android Speech Recognition and Synthesis from Google si-lk-x-sin-network", + "lang": "si-LK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pl-pl-x-bmg-network", + "name": "Android Speech Recognition and Synthesis from Google pl-pl-x-bmg-network", + "lang": "pl-PL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ko-kr-x-kod-network", + "name": "Android Speech Recognition and Synthesis from Google ko-kr-x-kod-network", + "lang": "ko-KR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bn-bd-x-ban-network", + "name": "Android Speech Recognition and Synthesis from Google bn-bd-x-ban-network", + "lang": "bn-BD", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-in-x-enc-network", + "name": "Android Speech Recognition and Synthesis from Google en-in-x-enc-network", + "lang": "en-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-au-x-auc-network", + "name": "Android Speech Recognition and Synthesis from Google en-au-x-auc-network", + "lang": "en-AU", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sq-al-x-sqm-network", + "name": "Android Speech Recognition and Synthesis from Google sq-al-x-sqm-network", + "lang": "sq-AL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ar-xa-x-ard-network", + "name": "Android Speech Recognition and Synthesis from Google ar-xa-x-ard-network", + "lang": "ar", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-cn-x-ccd-network", + "name": "Android Speech Recognition and Synthesis from Google cmn-cn-x-ccd-network", + "lang": "zh-CN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-gb-x-gba-network", + "name": "Android Speech Recognition and Synthesis from Google en-gb-x-gba-network", + "lang": "en-GB", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-us-x-esf-network", + "name": "Android Speech Recognition and Synthesis from Google es-us-x-esf-network", + "lang": "es-US", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hi-in-x-hic-network", + "name": "Android Speech Recognition and Synthesis from Google hi-in-x-hic-network", + "lang": "hi-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google tr-tr-x-cfs-network", + "name": "Android Speech Recognition and Synthesis from Google tr-tr-x-cfs-network", + "lang": "tr-TR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google yue-hk-x-yuc-network", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-yuc-network", + "lang": "yue-HK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pl-pl-x-jmk-network", + "name": "Android Speech Recognition and Synthesis from Google pl-pl-x-jmk-network", + "lang": "pl-PL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google vi-vn-x-vie-network", + "name": "Android Speech Recognition and Synthesis from Google vi-vn-x-vie-network", + "lang": "vi-VN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google id-id-x-dfz-network", + "name": "Android Speech Recognition and Synthesis from Google id-id-x-dfz-network", + "lang": "in-ID", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-ca-x-cab-network", + "name": "Android Speech Recognition and Synthesis from Google fr-ca-x-cab-network", + "lang": "fr-CA", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hr-hr-x-hrb-network", + "name": "Android Speech Recognition and Synthesis from Google hr-hr-x-hrb-network", + "lang": "hr-HR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ja-jp-x-jab-network", + "name": "Android Speech Recognition and Synthesis from Google ja-jp-x-jab-network", + "lang": "ja-JP", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hr-hr-x-hra-network", + "name": "Android Speech Recognition and Synthesis from Google hr-hr-x-hra-network", + "lang": "hr-HR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google jv-id-x-jvf-network", + "name": "Android Speech Recognition and Synthesis from Google jv-id-x-jvf-network", + "lang": "jv-ID", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google id-id-x-idd-network", + "name": "Android Speech Recognition and Synthesis from Google id-id-x-idd-network", + "lang": "in-ID", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ko-kr-x-kob-network", + "name": "Android Speech Recognition and Synthesis from Google ko-kr-x-kob-network", + "lang": "ko-KR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pl-pl-x-zfg-network", + "name": "Android Speech Recognition and Synthesis from Google pl-pl-x-zfg-network", + "lang": "pl-PL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fil-ph-x-fic-network", + "name": "Android Speech Recognition and Synthesis from Google fil-ph-x-fic-network", + "lang": "fil-PH", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-es-x-eea-network", + "name": "Android Speech Recognition and Synthesis from Google es-es-x-eea-network", + "lang": "es-ES", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fil-ph-x-cfc-network", + "name": "Android Speech Recognition and Synthesis from Google fil-ph-x-cfc-network", + "lang": "fil-PH", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-nl-x-tfb-network", + "name": "Android Speech Recognition and Synthesis from Google nl-nl-x-tfb-network", + "lang": "nl-NL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google it-it-x-kda-network", + "name": "Android Speech Recognition and Synthesis from Google it-it-x-kda-network", + "lang": "it-IT", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ml-in-x-mlm-network", + "name": "Android Speech Recognition and Synthesis from Google ml-in-x-mlm-network", + "lang": "ml-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pa-in-x-pac-network", + "name": "Android Speech Recognition and Synthesis from Google pa-in-x-pac-network", + "lang": "pa-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-in-x-ena-network", + "name": "Android Speech Recognition and Synthesis from Google en-in-x-ena-network", + "lang": "en-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google it-it-x-itc-network", + "name": "Android Speech Recognition and Synthesis from Google it-it-x-itc-network", + "lang": "it-IT", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-iom-network", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-iom-network", + "lang": "en-US", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-es-x-eed-network", + "name": "Android Speech Recognition and Synthesis from Google es-es-x-eed-network", + "lang": "es-ES", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ms-my-x-mse-network", + "name": "Android Speech Recognition and Synthesis from Google ms-my-x-mse-network", + "lang": "ms-MY", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google th-th-x-mol-network", + "name": "Android Speech Recognition and Synthesis from Google th-th-x-mol-network", + "lang": "th-TH", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-ca-x-cad-network", + "name": "Android Speech Recognition and Synthesis from Google fr-ca-x-cad-network", + "lang": "fr-CA", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google vi-vn-x-vic-network", + "name": "Android Speech Recognition and Synthesis from Google vi-vn-x-vic-network", + "lang": "vi-VN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-be-x-bed-network", + "name": "Android Speech Recognition and Synthesis from Google nl-be-x-bed-network", + "lang": "nl-BE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bn-in-x-bnm-network", + "name": "Android Speech Recognition and Synthesis from Google bn-in-x-bnm-network", + "lang": "bn-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ru-ru-x-rue-network", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-rue-network", + "lang": "ru-RU", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bn-in-x-bnx-network", + "name": "Android Speech Recognition and Synthesis from Google bn-in-x-bnx-network", + "lang": "bn-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-iol-network", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-iol-network", + "lang": "en-US", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google he-il-x-heb-network", + "name": "Android Speech Recognition and Synthesis from Google he-il-x-heb-network", + "lang": "iw-IL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-iob-network", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-iob-network", + "lang": "en-US", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-pt-x-jmn-network", + "name": "Android Speech Recognition and Synthesis from Google pt-pt-x-jmn-network", + "lang": "pt-PT", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google su-id-x-suf-network", + "name": "Android Speech Recognition and Synthesis from Google su-id-x-suf-network", + "lang": "su-ID", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google tr-tr-x-efu-network", + "name": "Android Speech Recognition and Synthesis from Google tr-tr-x-efu-network", + "lang": "tr-TR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google mr-in-x-mrf-network", + "name": "Android Speech Recognition and Synthesis from Google mr-in-x-mrf-network", + "lang": "mr-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hi-in-x-hie-network", + "name": "Android Speech Recognition and Synthesis from Google hi-in-x-hie-network", + "lang": "hi-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ne-np-x-nep-network", + "name": "Android Speech Recognition and Synthesis from Google ne-np-x-nep-network", + "lang": "ne-NP", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-iog-network", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-iog-network", + "lang": "en-US", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google he-il-x-hec-network", + "name": "Android Speech Recognition and Synthesis from Google he-il-x-hec-network", + "lang": "iw-IL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fil-ph-x-fie-network", + "name": "Android Speech Recognition and Synthesis from Google fil-ph-x-fie-network", + "lang": "fil-PH", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ru-ru-x-dfc-network", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-dfc-network", + "lang": "ru-RU", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pl-pl-x-afb-network", + "name": "Android Speech Recognition and Synthesis from Google pl-pl-x-afb-network", + "lang": "pl-PL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google de-de-x-nfh-network", + "name": "Android Speech Recognition and Synthesis from Google de-de-x-nfh-network", + "lang": "de-DE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google km-kh-x-khm-network", + "name": "Android Speech Recognition and Synthesis from Google km-kh-x-khm-network", + "lang": "km-KH", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-fr-x-frd-network", + "name": "Android Speech Recognition and Synthesis from Google fr-fr-x-frd-network", + "lang": "fr-FR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-nl-x-lfc-network", + "name": "Android Speech Recognition and Synthesis from Google nl-nl-x-lfc-network", + "lang": "nl-NL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google te-in-x-tef-network", + "name": "Android Speech Recognition and Synthesis from Google te-in-x-tef-network", + "lang": "te-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google it-it-x-itb-network", + "name": "Android Speech Recognition and Synthesis from Google it-it-x-itb-network", + "lang": "it-IT", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nb-no-x-cmj-network", + "name": "Android Speech Recognition and Synthesis from Google nb-no-x-cmj-network", + "lang": "nb-NO", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sv-se-x-cfg-network", + "name": "Android Speech Recognition and Synthesis from Google sv-se-x-cfg-network", + "lang": "sv-SE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bs-ba-x-bsm-network", + "name": "Android Speech Recognition and Synthesis from Google bs-ba-x-bsm-network", + "lang": "bs-BA", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-nl-x-dma-network", + "name": "Android Speech Recognition and Synthesis from Google nl-nl-x-dma-network", + "lang": "nl-NL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google uk-ua-x-hfd-network", + "name": "Android Speech Recognition and Synthesis from Google uk-ua-x-hfd-network", + "lang": "uk-UA", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nb-no-x-cfl-network", + "name": "Android Speech Recognition and Synthesis from Google nb-no-x-cfl-network", + "lang": "nb-NO", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google gu-in-x-guf-network", + "name": "Android Speech Recognition and Synthesis from Google gu-in-x-guf-network", + "lang": "gu-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sv-se-x-dmc-network", + "name": "Android Speech Recognition and Synthesis from Google sv-se-x-dmc-network", + "lang": "sv-SE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nb-no-x-rfj-network", + "name": "Android Speech Recognition and Synthesis from Google nb-no-x-rfj-network", + "lang": "nb-NO", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-es-x-eec-network", + "name": "Android Speech Recognition and Synthesis from Google es-es-x-eec-network", + "lang": "es-ES", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-nl-x-yfr-network", + "name": "Android Speech Recognition and Synthesis from Google nl-nl-x-yfr-network", + "lang": "nl-NL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nb-no-x-tmg-network", + "name": "Android Speech Recognition and Synthesis from Google nb-no-x-tmg-network", + "lang": "nb-NO", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", + "lang": "en-US", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sr-rs-x-gfg-network", + "name": "Android Speech Recognition and Synthesis from Google sr-rs-x-gfg-network", + "lang": "sr-RS", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google yue-hk-x-jar-network", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-jar-network", + "lang": "yue-HK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google is-is-x-isf-network", + "name": "Android Speech Recognition and Synthesis from Google is-is-x-isf-network", + "lang": "is-IS", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google it-it-x-itd-network", + "name": "Android Speech Recognition and Synthesis from Google it-it-x-itd-network", + "lang": "it-IT", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ar-xa-x-arz-network", + "name": "Android Speech Recognition and Synthesis from Google ar-xa-x-arz-network", + "lang": "ar", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ro-ro-x-vfv-network", + "name": "Android Speech Recognition and Synthesis from Google ro-ro-x-vfv-network", + "lang": "ro-RO", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google kn-in-x-knf-network", + "name": "Android Speech Recognition and Synthesis from Google kn-in-x-knf-network", + "lang": "kn-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-pt-x-pmj-network", + "name": "Android Speech Recognition and Synthesis from Google pt-pt-x-pmj-network", + "lang": "pt-PT", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ml-in-x-mlf-network", + "name": "Android Speech Recognition and Synthesis from Google ml-in-x-mlf-network", + "lang": "ml-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google da-dk-x-kfm-network", + "name": "Android Speech Recognition and Synthesis from Google da-dk-x-kfm-network", + "lang": "da-DK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-tpf-network", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-tpf-network", + "lang": "en-US", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-tpd-network", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-tpd-network", + "lang": "en-US", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cs-cz-x-jfs-network", + "name": "Android Speech Recognition and Synthesis from Google cs-cz-x-jfs-network", + "lang": "cs-CZ", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google tr-tr-x-mfm-network", + "name": "Android Speech Recognition and Synthesis from Google tr-tr-x-mfm-network", + "lang": "tr-TR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ms-my-x-msg-network", + "name": "Android Speech Recognition and Synthesis from Google ms-my-x-msg-network", + "lang": "ms-MY", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google gu-in-x-gum-network", + "name": "Android Speech Recognition and Synthesis from Google gu-in-x-gum-network", + "lang": "gu-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nl-nl-x-bmh-network", + "name": "Android Speech Recognition and Synthesis from Google nl-nl-x-bmh-network", + "lang": "nl-NL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google nb-no-x-tfs-network", + "name": "Android Speech Recognition and Synthesis from Google nb-no-x-tfs-network", + "lang": "nb-NO", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-fr-x-frc-network", + "name": "Android Speech Recognition and Synthesis from Google fr-fr-x-frc-network", + "lang": "fr-FR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pl-pl-x-oda-network", + "name": "Android Speech Recognition and Synthesis from Google pl-pl-x-oda-network", + "lang": "pl-PL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-gb-x-gbd-network", + "name": "Android Speech Recognition and Synthesis from Google en-gb-x-gbd-network", + "lang": "en-GB", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-gb-x-rjs-network", + "name": "Android Speech Recognition and Synthesis from Google en-gb-x-rjs-network", + "lang": "en-GB", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google he-il-x-hee-network", + "name": "Android Speech Recognition and Synthesis from Google he-il-x-hee-network", + "lang": "iw-IL", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ms-my-x-msd-network", + "name": "Android Speech Recognition and Synthesis from Google ms-my-x-msd-network", + "lang": "ms-MY", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pa-in-x-pad-network", + "name": "Android Speech Recognition and Synthesis from Google pa-in-x-pad-network", + "lang": "pa-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sv-se-x-lfs-network", + "name": "Android Speech Recognition and Synthesis from Google sv-se-x-lfs-network", + "lang": "sv-SE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google id-id-x-ide-network", + "name": "Android Speech Recognition and Synthesis from Google id-id-x-ide-network", + "lang": "in-ID", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-cn-x-ccc-network", + "name": "Android Speech Recognition and Synthesis from Google cmn-cn-x-ccc-network", + "lang": "zh-CN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bg-bg-x-ifk-network", + "name": "Android Speech Recognition and Synthesis from Google bg-bg-x-ifk-network", + "lang": "bg-BG", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ur-pk-x-cfn-network", + "name": "Android Speech Recognition and Synthesis from Google ur-pk-x-cfn-network", + "lang": "ur-PK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google sk-sk-x-sfk-network", + "name": "Android Speech Recognition and Synthesis from Google sk-sk-x-sfk-network", + "lang": "sk-SK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fil-ph-x-fid-network", + "name": "Android Speech Recognition and Synthesis from Google fil-ph-x-fid-network", + "lang": "fil-PH", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-fr-x-vlf-network", + "name": "Android Speech Recognition and Synthesis from Google fr-fr-x-vlf-network", + "lang": "fr-FR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google da-dk-x-nmm-network", + "name": "Android Speech Recognition and Synthesis from Google da-dk-x-nmm-network", + "lang": "da-DK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pa-in-x-pae-network", + "name": "Android Speech Recognition and Synthesis from Google pa-in-x-pae-network", + "lang": "pa-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ms-my-x-msc-network", + "name": "Android Speech Recognition and Synthesis from Google ms-my-x-msc-network", + "lang": "ms-MY", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-cn-x-ssa-network", + "name": "Android Speech Recognition and Synthesis from Google cmn-cn-x-ssa-network", + "lang": "zh-CN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-pt-x-jfb-network", + "name": "Android Speech Recognition and Synthesis from Google pt-pt-x-jfb-network", + "lang": "pt-PT", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ar-xa-x-arc-network", + "name": "Android Speech Recognition and Synthesis from Google ar-xa-x-arc-network", + "lang": "ar", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-ca-x-cac-network", + "name": "Android Speech Recognition and Synthesis from Google fr-ca-x-cac-network", + "lang": "fr-CA", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-br-x-pte-network", + "name": "Android Speech Recognition and Synthesis from Google pt-br-x-pte-network", + "lang": "pt-BR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google de-de-x-deg-network", + "name": "Android Speech Recognition and Synthesis from Google de-de-x-deg-network", + "lang": "de-DE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google et-ee-x-tms-network", + "name": "Android Speech Recognition and Synthesis from Google et-ee-x-tms-network", + "lang": "et-EE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ta-in-x-tac-network", + "name": "Android Speech Recognition and Synthesis from Google ta-in-x-tac-network", + "lang": "ta-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ru-ru-x-ruf-network", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-ruf-network", + "lang": "ru-RU", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google de-de-x-dea-network", + "name": "Android Speech Recognition and Synthesis from Google de-de-x-dea-network", + "lang": "de-DE", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google id-id-x-idc-network", + "name": "Android Speech Recognition and Synthesis from Google id-id-x-idc-network", + "lang": "in-ID", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google tr-tr-x-tmc-network", + "name": "Android Speech Recognition and Synthesis from Google tr-tr-x-tmc-network", + "lang": "tr-TR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google da-dk-x-vfb-network", + "name": "Android Speech Recognition and Synthesis from Google da-dk-x-vfb-network", + "lang": "da-DK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ko-kr-x-ism-network", + "name": "Android Speech Recognition and Synthesis from Google ko-kr-x-ism-network", + "lang": "ko-KR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google hi-in-x-hid-network", + "name": "Android Speech Recognition and Synthesis from Google hi-in-x-hid-network", + "lang": "hi-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-us-x-sfg-network", + "name": "Android Speech Recognition and Synthesis from Google en-us-x-sfg-network", + "lang": "en-US", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bn-in-x-bnf-network", + "name": "Android Speech Recognition and Synthesis from Google bn-in-x-bnf-network", + "lang": "bn-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-br-x-ptd-network", + "name": "Android Speech Recognition and Synthesis from Google pt-br-x-ptd-network", + "lang": "pt-BR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-au-x-aud-network", + "name": "Android Speech Recognition and Synthesis from Google en-au-x-aud-network", + "lang": "en-AU", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ko-kr-x-koc-network", + "name": "Android Speech Recognition and Synthesis from Google ko-kr-x-koc-network", + "lang": "ko-KR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-fr-x-fra-network", + "name": "Android Speech Recognition and Synthesis from Google fr-fr-x-fra-network", + "lang": "fr-FR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-us-x-esc-network", + "name": "Android Speech Recognition and Synthesis from Google es-us-x-esc-network", + "lang": "es-US", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-fr-x-frb-network", + "name": "Android Speech Recognition and Synthesis from Google fr-fr-x-frb-network", + "lang": "fr-FR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ca-es-x-caf-network", + "name": "Android Speech Recognition and Synthesis from Google ca-es-x-caf-network", + "lang": "ca-ES", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-gb-x-gbg-network", + "name": "Android Speech Recognition and Synthesis from Google en-gb-x-gbg-network", + "lang": "en-GB", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-cn-x-cce-network", + "name": "Android Speech Recognition and Synthesis from Google cmn-cn-x-cce-network", + "lang": "zh-CN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google yue-hk-x-yud-network", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-yud-network", + "lang": "yue-HK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google vi-vn-x-vid-network", + "name": "Android Speech Recognition and Synthesis from Google vi-vn-x-vid-network", + "lang": "vi-VN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ar-xa-x-are-network", + "name": "Android Speech Recognition and Synthesis from Google ar-xa-x-are-network", + "lang": "ar", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-pt-x-sfs-network", + "name": "Android Speech Recognition and Synthesis from Google pt-pt-x-sfs-network", + "lang": "pt-PT", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google te-in-x-tem-network", + "name": "Android Speech Recognition and Synthesis from Google te-in-x-tem-network", + "lang": "te-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google el-gr-x-vfz-network", + "name": "Android Speech Recognition and Synthesis from Google el-gr-x-vfz-network", + "lang": "el-GR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google bn-in-x-bin-network", + "name": "Android Speech Recognition and Synthesis from Google bn-in-x-bin-network", + "lang": "bn-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fr-ca-x-caa-network", + "name": "Android Speech Recognition and Synthesis from Google fr-ca-x-caa-network", + "lang": "fr-CA", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ja-jp-x-jac-network", + "name": "Android Speech Recognition and Synthesis from Google ja-jp-x-jac-network", + "lang": "ja-JP", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google fi-fi-x-afi-network", + "name": "Android Speech Recognition and Synthesis from Google fi-fi-x-afi-network", + "lang": "fi-FI", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-us-x-esd-network", + "name": "Android Speech Recognition and Synthesis from Google es-us-x-esd-network", + "lang": "es-US", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ru-ru-x-rud-network", + "name": "Android Speech Recognition and Synthesis from Google ru-ru-x-rud-network", + "lang": "ru-RU", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google pt-br-x-afs-network", + "name": "Android Speech Recognition and Synthesis from Google pt-br-x-afs-network", + "lang": "pt-BR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-au-x-aub-network", + "name": "Android Speech Recognition and Synthesis from Google en-au-x-aub-network", + "lang": "en-AU", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ur-pk-x-urm-network", + "name": "Android Speech Recognition and Synthesis from Google ur-pk-x-urm-network", + "lang": "ur-PK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google tr-tr-x-ama-network", + "name": "Android Speech Recognition and Synthesis from Google tr-tr-x-ama-network", + "lang": "tr-TR", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google es-us-x-sfb-network", + "name": "Android Speech Recognition and Synthesis from Google es-us-x-sfb-network", + "lang": "es-US", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cy-gb-x-cyf-network", + "name": "Android Speech Recognition and Synthesis from Google cy-gb-x-cyf-network", + "lang": "cy-GB", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-in-x-end-network", + "name": "Android Speech Recognition and Synthesis from Google en-in-x-end-network", + "lang": "en-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google yue-hk-x-yuf-network", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-yuf-network", + "lang": "yue-HK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-tw-x-ctc-network", + "name": "Android Speech Recognition and Synthesis from Google cmn-tw-x-ctc-network", + "lang": "zh-TW", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google cmn-tw-x-cte-network", + "name": "Android Speech Recognition and Synthesis from Google cmn-tw-x-cte-network", + "lang": "zh-TW", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google vi-vn-x-vif-network", + "name": "Android Speech Recognition and Synthesis from Google vi-vn-x-vif-network", + "lang": "vi-VN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google en-gb-x-gbb-network", + "name": "Android Speech Recognition and Synthesis from Google en-gb-x-gbb-network", + "lang": "en-GB", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google ta-in-x-tad-network", + "name": "Android Speech Recognition and Synthesis from Google ta-in-x-tad-network", + "lang": "ta-IN", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google yue-hk-x-yue-network", + "name": "Android Speech Recognition and Synthesis from Google yue-hk-x-yue-network", + "lang": "yue-HK", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google lv-lv-x-imr-network", + "name": "Android Speech Recognition and Synthesis from Google lv-lv-x-imr-network", + "lang": "lv-LV", + "localService": false, + "default": false + }, + { + "voiceURI": "Android Speech Recognition and Synthesis from Google vi-vn-x-gft-network", + "name": "Android Speech Recognition and Synthesis from Google vi-vn-x-gft-network", + "lang": "vi-VN", + "localService": false, + "default": false + }, + { + "voiceURI": "eSpeak Arabic", + "name": "eSpeak Arabic", + "lang": "ar", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Bulgarian", + "name": "eSpeak Bulgarian", + "lang": "bg", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Bengali", + "name": "eSpeak Bengali", + "lang": "bn", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Catalan", + "name": "eSpeak Catalan", + "lang": "ca", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Chinese (Mandarin, latin as English)", + "name": "eSpeak Chinese (Mandarin, latin as English)", + "lang": "cmn", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Czech", + "name": "eSpeak Czech", + "lang": "cs", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Danish", + "name": "eSpeak Danish", + "lang": "da", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak German", + "name": "eSpeak German", + "lang": "de", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Greek", + "name": "eSpeak Greek", + "lang": "el", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Spanish (Spain)", + "name": "eSpeak Spanish (Spain)", + "lang": "es", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Estonian", + "name": "eSpeak Estonian", + "lang": "et", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Persian", + "name": "eSpeak Persian", + "lang": "fa", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Finnish", + "name": "eSpeak Finnish", + "lang": "fi", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Gujarati", + "name": "eSpeak Gujarati", + "lang": "gu", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Croatian", + "name": "eSpeak Croatian", + "lang": "hr", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Hungarian", + "name": "eSpeak Hungarian", + "lang": "hu", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Indonesian", + "name": "eSpeak Indonesian", + "lang": "id", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Italian", + "name": "eSpeak Italian", + "lang": "it", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Kannada", + "name": "eSpeak Kannada", + "lang": "kn", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Korean", + "name": "eSpeak Korean", + "lang": "ko", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Lithuanian", + "name": "eSpeak Lithuanian", + "lang": "lt", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Latvian", + "name": "eSpeak Latvian", + "lang": "lv", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Malayalam", + "name": "eSpeak Malayalam", + "lang": "ml", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Marathi", + "name": "eSpeak Marathi", + "lang": "mr", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Malay", + "name": "eSpeak Malay", + "lang": "ms", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Norwegian Bokmål", + "name": "eSpeak Norwegian Bokmål", + "lang": "nb", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Polish", + "name": "eSpeak Polish", + "lang": "pl", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Portuguese (Brazil)", + "name": "eSpeak Portuguese (Brazil)", + "lang": "pt-br", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Romanian", + "name": "eSpeak Romanian", + "lang": "ro", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Russian", + "name": "eSpeak Russian", + "lang": "ru", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Slovak", + "name": "eSpeak Slovak", + "lang": "sk", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Slovenian", + "name": "eSpeak Slovenian", + "lang": "sl", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Serbian", + "name": "eSpeak Serbian", + "lang": "sr", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Swedish", + "name": "eSpeak Swedish", + "lang": "sv", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Swahili", + "name": "eSpeak Swahili", + "lang": "sw", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Tamil", + "name": "eSpeak Tamil", + "lang": "ta", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Telugu", + "name": "eSpeak Telugu", + "lang": "te", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Turkish", + "name": "eSpeak Turkish", + "lang": "tr", + "localService": true, + "default": false + }, + { + "voiceURI": "eSpeak Vietnamese (Northern)", + "name": "eSpeak Vietnamese (Northern)", + "lang": "vi", + "localService": true, + "default": false + } + ] +} \ No newline at end of file diff --git a/demo/article/script.js b/demo/article/script.js index 457b9ba..041940e 100644 --- a/demo/article/script.js +++ b/demo/article/script.js @@ -14,7 +14,7 @@ const readAlongCheckbox = document.getElementById("readAlong"); // State let voiceManager; let navigator; -let allVoices = []; +let enVoices = []; let currentVoice = null; let isPlaying = false; let utterances = []; @@ -28,7 +28,7 @@ async function initialize() { voiceManager = await WebSpeechVoiceManager.initialize(); // Only get English voices - allVoices = voiceManager.getVoices({languages: "en"}); + enVoices = voiceManager.getVoices({languages: "en", removeDuplicates: true}); // Initialize the navigator navigator = new WebSpeechReadAloudNavigator(); @@ -43,7 +43,7 @@ async function initialize() { populateVoiceSelect(); // Get the default voice for English - currentVoice = voiceManager.getDefaultVoice("en-US"); + currentVoice = voiceManager.getDefaultVoice("en"); if (currentVoice && navigator) { navigator.setVoice(currentVoice); @@ -178,7 +178,7 @@ function populateVoiceSelect() { voiceSelect.innerHTML = ""; - if (!allVoices || !allVoices.length) { + if (!enVoices || !enVoices.length) { const option = document.createElement("option"); option.disabled = true; option.textContent = "No voices available. Please check your browser settings and internet connection."; @@ -188,7 +188,9 @@ function populateVoiceSelect() { try { // Sort by region while preserving quality order within each region - const sortedVoices = voiceManager.sortVoicesByRegions(allVoices, window.navigator.languages); + const sortedVoices = voiceManager.sortVoicesByRegions(["en"], enVoices); + + console.log(sortedVoices); let currentRegion = null; let optgroup = null; @@ -232,7 +234,7 @@ function populateVoiceSelect() { } catch (error) { console.error("Error populating voice dropdown:", error); // Fallback to simple list if there's an error - allVoices.forEach(voice => { + enVoices.forEach(voice => { const option = document.createElement("option"); option.value = voice.name; option.textContent = [ @@ -315,7 +317,7 @@ async function handleVoiceChange(e) { if (!voiceName) return; // Find the selected voice by name - currentVoice = allVoices.find(v => v.name === voiceName); + currentVoice = enVoices.find(v => v.name === voiceName); if (!currentVoice) { console.error("Voice not found:", voiceName); diff --git a/demo/script.js b/demo/script.js index b33d5de..e17136c 100644 --- a/demo/script.js +++ b/demo/script.js @@ -29,7 +29,6 @@ let jumpInputUserChanged = false; // State let voiceManager; -let allVoices = []; let filteredVoices = []; let languages = []; let currentVoice = null; @@ -66,36 +65,15 @@ speechNavigator.on("error", (event) => { // Initialize the application async function init() { - try { + try { // Initialize the voice manager voiceManager = await WebSpeechVoiceManager.initialize(); - - const initOptions = { - excludeNovelty: true, - excludeVeryLowQuality: true - }; - - // Load all available voices - allVoices = voiceManager.getVoices(initOptions); - - // Get languages, excluding novelty and very low quality voices - const allLanguages = voiceManager.getLanguages(window.navigator.language, initOptions); - // Sort languages with browser's preferred languages first - languages = allLanguages - .map(lang => ({ - ...lang, - language: lang.code, - name: lang.label - })); + // Sort those voices by browser preference using sortVoicesByRegions + const voices = voiceManager.sortVoicesByRegions(window.navigator.languages); - // Sort using the manager's sortRegions method - languages = voiceManager.sortVoicesByRegions(languages, window.navigator.languages) - .map(voice => ({ - code: voice.language, - label: voice.name, - count: voice.count - })); + // Get languages + languages = voiceManager.getLanguages(window.navigator.languages[0], { removeDuplicates: true }, voices); // Populate language dropdown populateLanguageDropdown(); @@ -263,7 +241,7 @@ function filterVoices() { const source = sourceSelect.value; const offlineOnly = offlineOnlyCheckbox.checked; - const filterOptions = {}; + const filterOptions = { }; if (gender !== "all") { filterOptions.gender = gender; @@ -278,7 +256,7 @@ function filterVoices() { } // Filter voices once with all filters except language - let voicesFilteredExceptLanguage = voiceManager.filterVoices(allVoices, filterOptions); + let voicesFilteredExceptLanguage = voiceManager.filterVoices(filterOptions); // Update language counts using the filtered voices updateLanguageCounts(voicesFilteredExceptLanguage); @@ -286,7 +264,7 @@ function filterVoices() { // Now apply language filter if needed if (language) { filterOptions.languages = language; - filteredVoices = voiceManager.filterVoices(voicesFilteredExceptLanguage, { languages: language }); + filteredVoices = voiceManager.filterVoices({ languages: language }, voicesFilteredExceptLanguage); } else { filteredVoices = voicesFilteredExceptLanguage; } @@ -313,10 +291,10 @@ function populateVoiceDropdown() { } // Sort voices with browser's preferred languages first - const sortedVoices = voiceManager.sortVoicesByRegions([...filteredVoices], window.navigator.languages); + const sortedVoices = voiceManager.sortVoicesByRegions(window.navigator.languages, [...filteredVoices]); // Group the sorted voices by region - const voiceGroups = voiceManager.groupVoices(sortedVoices, "region"); + const voiceGroups = voiceManager.groupVoices("region", sortedVoices); // Add optgroups for each region for (const [region, voices] of Object.entries(voiceGroups)) { diff --git a/json/en.json b/json/en.json index 0c76525..703e636 100644 --- a/json/en.json +++ b/json/en.json @@ -1973,7 +1973,7 @@ "en-au-x-aub-local" ], "language": "en-AU", - "gender": "female", + "gender": "male", "quality": [ "high" ], @@ -1998,7 +1998,7 @@ "en-au-x-aud-local" ], "language": "en-AU", - "gender": "female", + "gender": "male", "quality": [ "high" ], diff --git a/package.json b/package.json index 9952319..c0f7f22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@readium/speech", - "version": "0.1.0-beta.5", + "version": "0.1.0-beta.6", "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.", "main": "./build/index.cjs", "module": "./build/index.js", diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index 42cf562..5710e28 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -24,6 +24,7 @@ interface VoiceFilterOptions { provider?: string; excludeNovelty?: boolean; excludeVeryLowQuality?: boolean; + removeDuplicates?: boolean; } /** @@ -270,7 +271,7 @@ export class WebSpeechVoiceManager { * @param voices Array of voices to remove duplicates from * @returns Filtered array with duplicates removed, keeping only the highest quality versions */ - private removeDuplicate(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + private removeDuplicates(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { const voiceMap = new Map(); for (const voice of voices) { @@ -331,93 +332,93 @@ export class WebSpeechVoiceManager { throw new Error("WebSpeechVoiceManager not initialized. Call initialize() first."); } - // Set default values for filter options - const filterOptions: VoiceFilterOptions = { - excludeNovelty: true, // Default to true to filter out novelty voices - excludeVeryLowQuality: true, // Default to true to filter out very low quality voices - ...options // Let explicit options override the defaults - }; - - return this.filterVoices([...this.voices], filterOptions); + return this.filterVoices(options, [...this.voices]); } /** * Get available languages with voice counts * @param localization Optional BCP 47 language tag to use for language names * @param filterOptions Optional filters to apply to voices before counting languages + * @param voices Optional array of voices to count (defaults to this.voices) */ - getLanguages(localization?: string, filterOptions?: VoiceFilterOptions): LanguageInfo[] { - if (!this.isInitialized) { + getLanguages(localization?: string, filterOptions?: VoiceFilterOptions, voices?: ReadiumSpeechVoice[]): LanguageInfo[] { + if (!voices && !this.isInitialized) { throw new Error("WebSpeechVoiceManager not initialized. Call initialize() first."); } - const languages = new Map(); + const voicesToCount = voices ?? this.voices; + const filteredVoices = filterOptions ? this.filterVoices(filterOptions, voicesToCount) : voicesToCount; - // Apply filters if provided - const voicesToCount = filterOptions ? this.filterVoices([...this.voices], filterOptions) : this.voices; + const result: { code: string; label: string; count: number }[] = []; + const seen = new Set(); - voicesToCount.forEach(voice => { - const langCode = voice.language; - const normalizedLang = normalizeLanguageCode(langCode); - + for (const voice of filteredVoices) { + const normalizedLang = normalizeLanguageCode(voice.language); const key = normalizedLang.split("-")[0]; - const displayName = WebSpeechVoiceManager.getLanguageDisplayName( - key, - localization - ); - const entry = languages.get(key) || { - count: 0, - label: displayName, - code: key - }; - languages.set(key, { ...entry, count: entry.count + 1 }); - }); + if (!seen.has(key)) { + const displayName = WebSpeechVoiceManager.getLanguageDisplayName(key, localization); + const count = filteredVoices.filter(v => + normalizeLanguageCode(v.language).split("-")[0] === key + ).length; + + result.push({ code: key, label: displayName, count }); + seen.add(key); + } + } - // Convert to array and sort - return Array.from(languages.entries()) - .map(([_, { code, label, count }]) => ({ - code, - label, - count - })) - .sort((a, b) => a.label.localeCompare(b.label)); + return voices ? result : result.sort((a, b) => a.label.localeCompare(b.label)); } /** * Get available regions with voice counts + * @param localization Optional BCP 47 language tag to use for region names + * @param filterOptions Optional filters to apply to voices before counting regions + * @param voices Optional array of voices to count (defaults to this.voices) */ - getRegions(localization?: string): LanguageInfo[] { - if (!this.isInitialized) { + getRegions(localization?: string, filterOptions?: VoiceFilterOptions, voices?: ReadiumSpeechVoice[]): LanguageInfo[] { + if (!voices && !this.isInitialized) { throw new Error("WebSpeechVoiceManager not initialized. Call initialize() first."); } - const regions = new Map(); + const voicesToCount = voices ?? this.voices; + const filteredVoices = filterOptions ? this.filterVoices(filterOptions, voicesToCount) : voicesToCount; + + const result: { code: string; label: string; count: number }[] = []; + const seen = new Set(); + const counts = new Map(); - this.voices.forEach(voice => { + // Count regions first + for (const voice of filteredVoices) { const [, region] = WebSpeechVoiceManager.extractLangRegionFromBCP47(voice.language); if (region) { - const entry = regions.get(region) || { count: 0, label: voice.language }; - regions.set(region, { ...entry, count: entry.count + 1 }); + counts.set(region, (counts.get(region) || 0) + 1); } - }); - - return Array.from(regions.entries()).map(([code, { count, label }]) => { - let displayName = label; - try { - const locale = localization || navigator.language; - displayName = new Intl.DisplayNames([locale], { type: "region" }).of(code) || label; - } catch (e) { - console.warn(`Failed to get display name for region ${code}`, e); + } + + // Preserve order + for (const voice of filteredVoices) { + const [, region] = WebSpeechVoiceManager.extractLangRegionFromBCP47(voice.language); + if (region && !seen.has(region)) { + let displayName = voice.language; + try { + const locale = localization || navigator.language; + displayName = new Intl.DisplayNames([locale], { type: "region" }).of(region) || voice.language; + } catch (e) { + console.warn(`Failed to get display name for region ${region}`, e); + } + + result.push({ + code: region, + label: displayName, + count: counts.get(region) || 0 + }); + seen.add(region); } - return { - code, - label: displayName, - count - }; - }); - } + } + return voices ? result : result.sort((a, b) => a.label.localeCompare(b.label)); + } /** * Get the default voice for language preferences @@ -436,7 +437,7 @@ export class WebSpeechVoiceManager { if (!filteredVoices.length) return null; // Then sort by region to ensure we get the best match for the requested language(s) - filteredVoices = this.sortVoicesByRegions(filteredVoices, languageArray); + filteredVoices = this.sortVoicesByRegions(languageArray, filteredVoices); // Return the best available voice (already sorted by quality and language) return filteredVoices[0]; @@ -565,8 +566,7 @@ export class WebSpeechVoiceManager { } as ReadiumSpeechVoice; }); - // Remove duplicates before returning - return this.removeDuplicate(mappedVoices); + return mappedVoices; } /** @@ -585,11 +585,19 @@ export class WebSpeechVoiceManager { /** * Filter voices based on the provided options */ - filterVoices(voices: ReadiumSpeechVoice[], options: VoiceFilterOptions): ReadiumSpeechVoice[] { - let result = [...voices]; + filterVoices(options: VoiceFilterOptions, voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + let result = voices ? [...voices] : [...this.voices]; + + // Set default values for filter options + const filterOptions: VoiceFilterOptions = { + excludeNovelty: true, // Default to true to filter out novelty voices + excludeVeryLowQuality: true, // Default to true to filter out very low quality voices + removeDuplicates: true, // Default to true - remove duplicates by default + ...options // Let explicit options override the defaults + }; - if (options.languages) { - const langs = Array.isArray(options.languages) ? options.languages : [options.languages]; + if (filterOptions.languages) { + const langs = Array.isArray(filterOptions.languages) ? filterOptions.languages : [filterOptions.languages]; result = result.filter(voice => { return langs.some(requestedLang => { @@ -610,37 +618,41 @@ export class WebSpeechVoiceManager { }); } - if (options.source) { - result = result.filter(v => v.source === options.source); + if (filterOptions.source) { + result = result.filter(v => v.source === filterOptions.source); } - if (options.gender) { - result = result.filter(v => v.gender === options.gender); + if (filterOptions.gender) { + result = result.filter(v => v.gender === filterOptions.gender); } - if (options.quality) { - const qualities = Array.isArray(options.quality) ? options.quality : [options.quality]; + if (filterOptions.quality) { + const qualities = Array.isArray(filterOptions.quality) ? filterOptions.quality : [filterOptions.quality]; result = result.filter(v => v.quality && qualities.includes(v.quality)); } - if (options.offlineOnly) { + if (filterOptions.offlineOnly) { result = result.filter(v => v.offlineAvailability === true); } - if (options.provider) { + if (filterOptions.provider) { result = result.filter(v => - v.provider?.toLowerCase() === options.provider?.toLowerCase() + v.provider?.toLowerCase() === filterOptions.provider?.toLowerCase() ); } - if (options.excludeNovelty) { + if (filterOptions.excludeNovelty) { result = filterOutNoveltyVoices(result); } - if (options.excludeVeryLowQuality) { + if (filterOptions.excludeVeryLowQuality) { result = filterOutVeryLowQualityVoices(result); } + if (filterOptions.removeDuplicates) { + result = this.removeDuplicates(result); + } + return result; } @@ -649,8 +661,9 @@ export class WebSpeechVoiceManager { * @param voices Array of voices to filter * @returns Filtered array with novelty voices removed */ - filterOutNoveltyVoices(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { - return filterOutNoveltyVoices(voices); + filterOutNoveltyVoices(voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + const voicesToFilter = voices ?? this.voices; + return filterOutNoveltyVoices(voicesToFilter); } /** @@ -658,8 +671,9 @@ export class WebSpeechVoiceManager { * @param voices Array of voices to filter * @returns Filtered array with very low quality voices removed */ - filterOutVeryLowQualityVoices(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { - return filterOutVeryLowQualityVoices(voices); + filterOutVeryLowQualityVoices(voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + const voicesToFilter = voices ?? this.voices; + return filterOutVeryLowQualityVoices(voicesToFilter); } /** @@ -705,11 +719,8 @@ private static sortByQuality( const bOrder = langOrderMap.get(b.name); if (aOrder !== undefined && bOrder !== undefined) { - // Both have JSON order - use it if same quality or both no quality - if ((aQuality > 0 && bQuality > 0 && aQuality === bQuality) || - (aQuality === 0 && bQuality === 0)) { - return aOrder - bOrder; - } + // Both have JSON order - always use it for JSON voices + return aOrder - bOrder; } } } @@ -726,11 +737,12 @@ private static sortByQuality( * @param voices Array of voices to sort * @returns Sorted array of voices */ - sortVoicesByQuality(voices: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { - if (!voices?.length) return []; + sortVoicesByQuality(voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + const voicesToSort = voices || this.voices; + if (!voicesToSort?.length) return []; - const jsonOrderMaps = createJsonOrderMap(voices); - return [...voices].sort((a, b) => WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps)); + const jsonOrderMaps = createJsonOrderMap(voicesToSort); + return [...voicesToSort].sort((a, b) => WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps)); } /** @@ -817,11 +829,18 @@ private static sortByQuality( if (aIsDefault && !bIsDefault) return -1; if (!aIsDefault && bIsDefault) return 1; - // Both default or both non-default - sort by quality - const sameLangVoices = voices.filter(v => - WebSpeechVoiceManager.extractLangRegionFromBCP47(v.language)[0] === aLang - ); - const jsonOrderMaps = createJsonOrderMap(sameLangVoices); + // Both default or both non-default - sort by region alphabetically, then quality + if (aRegion && bRegion) { + const regionCompare = aRegion.localeCompare(bRegion); + if (regionCompare !== 0) { + return regionCompare; + } + } + if (aRegion) return -1; + if (bRegion) return 1; + + // Same language group - sort by quality + const jsonOrderMaps = createJsonOrderMap(voices); return WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps, aLang); } @@ -835,18 +854,21 @@ private static sortByQuality( * @param preferredLanguages Array of preferred language codes in order of preference * @returns Sorted array of voices */ - sortVoicesByLanguages(voices: ReadiumSpeechVoice[], preferredLanguages?: string[]): ReadiumSpeechVoice[] { - if (!voices?.length) return []; + sortVoicesByLanguages(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + const voicesToSort = voices || this.voices; + + if (!voicesToSort?.length) return []; + if (!preferredLanguages?.length) { // If no preferred languages, sort alphabetically by language display name, // but prioritize default region voices within each language group - const sortedVoices = [...voices]; + const sortedVoices = [...voicesToSort]; WebSpeechVoiceManager.sortAlphabetically(sortedVoices); return sortedVoices; } const processedLangs = processLanguages(preferredLanguages); - const { voicesByLang, otherLangVoices } = WebSpeechVoiceManager.groupVoicesByLanguage(voices, processedLangs); + const { voicesByLang, otherLangVoices } = WebSpeechVoiceManager.groupVoicesByLanguage(voicesToSort, processedLangs); // Sort each language group by quality using helper const langSortedResult: ReadiumSpeechVoice[] = []; @@ -909,8 +931,19 @@ private static sortByQuality( if (aIsDefault && !bIsDefault) return -1; if (!aIsDefault && bIsDefault) return 1; - // Sort alphabetically by region - return (aRegion || "").localeCompare(bRegion || ""); + // Neither has match - sort by region alphabetically, then quality + if (aRegion && bRegion) { + const regionCompare = aRegion.localeCompare(bRegion); + if (regionCompare !== 0) { + return regionCompare; + } + // Same region - sort by quality + return WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps, processedLang.baseLang); + } + if (aRegion) return -1; + if (bRegion) return 1; + + return WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps, processedLang.baseLang); }); } @@ -920,11 +953,13 @@ private static sortByQuality( * @param preferredLanguages Array of preferred language codes in order of preference * @returns Sorted array of voices */ - sortVoicesByRegions(voices: ReadiumSpeechVoice[], preferredLanguages: string[]): ReadiumSpeechVoice[] { - if (!voices?.length) return []; + sortVoicesByRegions(preferredLanguages: string[], voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + const voicesToSort = voices || this.voices; + + if (!voicesToSort?.length) return []; const processedLangs = processLanguages(preferredLanguages || []); - const { voicesByLang, otherLangVoices } = WebSpeechVoiceManager.groupVoicesByLanguage(voices, processedLangs); + const { voicesByLang, otherLangVoices } = WebSpeechVoiceManager.groupVoicesByLanguage(voicesToSort, processedLangs); // Sort each language group by region preference const langSortedResult: ReadiumSpeechVoice[] = []; @@ -949,10 +984,12 @@ private static sortByQuality( * @param options Grouping options * @returns Object with voice groups keyed by the grouping criteria */ - groupVoices(voices: ReadiumSpeechVoice[], by: GroupBy): VoiceGroup { + groupVoices(by: GroupBy, voices?: ReadiumSpeechVoice[]): VoiceGroup { const groups: VoiceGroup = {}; + + const voicesToProcess = voices || this.voices - for (const voice of voices) { + for (const voice of voicesToProcess) { let key = "Unknown"; switch (by) { diff --git a/test/WebSpeechVoiceManager/filterVoices.test.ts b/test/WebSpeechVoiceManager/filterVoices.test.ts index 0d52a63..0cfa226 100644 --- a/test/WebSpeechVoiceManager/filterVoices.test.ts +++ b/test/WebSpeechVoiceManager/filterVoices.test.ts @@ -1,4 +1,4 @@ -import { type ExecutionContext } from "ava"; +import test, { type ExecutionContext } from "ava"; import { testWithContext, TestContext, createTestVoice } from "./setup.js"; import { ReadiumSpeechVoice, WebSpeechVoiceManager } from "../../build/index.js"; @@ -36,11 +36,11 @@ testWithContext("filterVoices: filters by language", (t: ExecutionContext v.language.startsWith("en"))); - const multiLangVoices = manager.filterVoices(testVoices, { languages: ["en", "fr"] }); + const multiLangVoices = manager.filterVoices({ languages: ["en", "fr"] }, testVoices); t.is(multiLangVoices.length, 3); t.true(multiLangVoices.every(v => v.language.startsWith("en") || v.language.startsWith("fr"))); }); @@ -55,11 +55,11 @@ testWithContext("filterVoices: filters by source", (t: ExecutionContext v.source === "json")); - const browserVoices = manager.filterVoices(testVoices, { source: "browser"}); + const browserVoices = manager.filterVoices({ source: "browser" }, testVoices); t.is(browserVoices.length, 1); t.true(browserVoices.every(v => v.source === "browser")); }); @@ -75,11 +75,11 @@ testWithContext("filterVoices: filters by gender", (t: ExecutionContext v.gender === "male")); - const femaleVoices = manager.filterVoices(testVoices, { gender: "female" }); + const femaleVoices = manager.filterVoices({ gender: "female" }, testVoices); t.is(femaleVoices.length, 1); t.is(femaleVoices[0].gender, "female"); }); @@ -97,15 +97,15 @@ testWithContext("filterVoices: filters by quality array", (t: ExecutionContext v.quality === undefined)); }); @@ -129,17 +129,17 @@ testWithContext("filterVoices: filters out novelty and low quality voices", (t: ]; // Test filtering with default options (should filter out both voices) - const filteredVoices = manager.filterVoices(testVoices, { + const filteredVoices = manager.filterVoices({ excludeNovelty: true, excludeVeryLowQuality: true - }); + }, testVoices); t.is(filteredVoices.length, 0, "Should filter out all test voices by default"); // Test including them by disabling the filters - const allVoices = manager.filterVoices(testVoices, { + const allVoices = manager.filterVoices({ excludeNovelty: false, excludeVeryLowQuality: false - }); + }, testVoices); t.is(allVoices.length, 2, "Should include all voices when not filtered"); }); @@ -154,7 +154,7 @@ testWithContext("filterVoices: filters by offline availability", (t: ExecutionCo createTestVoice({ name: "Undefined Availability Voice", language: "en-US" }) ]; - const offlineVoices = manager.filterVoices(testVoices, { offlineOnly: true }); + const offlineVoices = manager.filterVoices({ offlineOnly: true }, testVoices); t.is(offlineVoices.length, 2); t.true(offlineVoices.every(v => v.offlineAvailability === true)); @@ -174,12 +174,12 @@ testWithContext("filterVoices: filters by provider", (t: ExecutionContext v.provider === "Google")); // Test case insensitive matching - const caseInsensitiveVoices = manager.filterVoices(testVoices, { provider: "google" }); + const caseInsensitiveVoices = manager.filterVoices({ provider: "google" }, testVoices); t.is(caseInsensitiveVoices.length, 2); }); @@ -195,20 +195,20 @@ testWithContext("filterVoices: combines multiple filters", (t: ExecutionContext< ]; // Filter by language and gender - const englishFemaleVoices = manager.filterVoices(testVoices, { + const englishFemaleVoices = manager.filterVoices({ languages: "en", gender: "female" - }); + }, testVoices); t.is(englishFemaleVoices.length, 2); t.true(englishFemaleVoices.every(v => v.language.startsWith("en") && v.gender === "female" )); // Filter by quality and provider - const highQualityGoogleVoices = manager.filterVoices(testVoices, { + const highQualityGoogleVoices = manager.filterVoices({ quality: "high", provider: "Google" - }); + }, testVoices); t.is(highQualityGoogleVoices.length, 1); t.is(highQualityGoogleVoices[0].name, "Male High Quality English"); }); @@ -223,18 +223,18 @@ testWithContext("filterVoices: handles edge cases", (t: ExecutionContext (v.language.startsWith("en") || v.language.startsWith("fr")) && @@ -289,4 +289,163 @@ testWithContext("filterOutVeryLowQualityVoices: removes very low quality voices" const filtered = manager.filterOutVeryLowQualityVoices(testVoices); t.is(filtered.length, testVoices.length - 1); t.false(filtered.some((v: ReadiumSpeechVoice) => v.quality === "veryLow")); +}); + +testWithContext("filterVoices: deduplication keeps higher quality voice from voiceURI package name", (t) => { + const manager = t.context.manager; + + // Define test voices once + const lowVoice = { + voiceURI: "com.apple.speech.synthesis.voice.compact.samantha", + name: "Samantha", + lang: "en-US", + localService: true, + default: false + }; + + const normalVoice = { + voiceURI: "com.apple.speech.synthesis.voice.enhanced.samantha", + name: "Samantha (enhanced)", + lang: "en-US", + localService: true, + default: false + }; + + // Parse both voices and apply deduplication through filtering + const parsedVoices = (manager as any).parseToReadiumSpeechVoices([lowVoice, normalVoice]); + const deduped = manager.filterVoices({ removeDuplicates: true }, parsedVoices); + + // Verify the result + t.is(deduped.length, 1, "Should only keep one voice after deduplication"); + const resultVoice = deduped[0]; + t.is(resultVoice.name, "Samantha", "Should use the JSON name of the voice"); + t.is(resultVoice.originalName, "Samantha (enhanced)", "Should keep the original name of the higher quality voice"); + t.deepEqual(resultVoice.quality, "normal", "Should keep the voice with normal quality"); +}); + +testWithContext("filterVoices: deduplication keeps higher quality voice from voiceURI string", (t) => { + const manager = t.context.manager; + + // Define test voices once + const basicVoice = { + voiceURI: "Samantha", + name: "Samantha", + lang: "en-US", + localService: true, + default: false + }; + + const enhancedVoice = { + voiceURI: "Samantha (Premium)", + name: "Samantha (Premium)", + lang: "en-US", + localService: true, + default: false + }; + + // Parse both voices and apply deduplication through filtering + const parsedVoices = (manager as any).parseToReadiumSpeechVoices([basicVoice, enhancedVoice]); + const deduped = manager.filterVoices({ removeDuplicates: true }, parsedVoices); + + // Verify the result + t.is(deduped.length, 1, "Should only keep one voice after deduplication"); + const resultVoice = deduped[0]; + t.is(resultVoice.name, "Samantha", "Should use the JSON name of the voice"); + t.is(resultVoice.originalName, "Samantha (Premium)", "Should keep the original name of the higher quality voice"); + t.deepEqual(resultVoice.quality, "high", "Should keep the voice with high quality"); +}); + +testWithContext("filterVoices: deduplication keeps higher quality voice from json quality array", (t) => { + const manager = t.context.manager; + + // Parse both voices together to get correct duplicate counts + const voices = (manager as any).parseToReadiumSpeechVoices([ + { + voiceURI: "Samantha", + name: "Samantha", + lang: "en-US", + localService: true, + default: false + }, + { + voiceURI: "Samantha superior", + name: "Samantha (Superior)", + lang: "en-US", + localService: true, + default: false + } + ]); + // Now test deduplication with both voices + const deduped = manager.filterVoices({ removeDuplicates: true }, voices); + + // Verify only the higher quality voice remains with its original name + t.is(deduped.length, 1, "Should only keep one voice after deduplication"); + t.is(deduped[0].name, "Samantha", "Should use the JSON name of the voice"); + t.is(deduped[0].originalName, "Samantha (Superior)", "Should keep the original name of the higher quality voice"); + t.is(deduped[0].voiceURI, "Samantha superior", "Should keep the voice with superior quality"); + t.deepEqual(deduped[0].quality, "normal", "Should find the voice with normal quality from the array"); +}); + +testWithContext("filterVoices: deduplication prefers voice with matching name over altNames", (t) => { + const manager = t.context.manager; + + // Test scenario: two browser voices + // One matches primary name in JSON, other matches altName in JSON + const voices = [ + { + voiceURI: "Google US English 5 (Natural)", + name: "Google US English 5 (Natural)", + lang: "en-US", + localService: true, + default: false + }, + { + voiceURI: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", + name: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", + lang: "en-US", + localService: true, + default: false + } + ]; + + const parsedVoices = (manager as any).parseToReadiumSpeechVoices(voices); + const deduped = manager.filterVoices({ removeDuplicates: true }, parsedVoices); + + // Should only keep one voice + t.is(deduped.length, 1, "Should only keep one voice after deduplication"); + // Should prefer the voice with the primary name (Google US English 5 (Natural)) + t.is(deduped[0].name, "Google US English 5 (Natural)", "Should prefer voice with primary name over altName"); + t.is(deduped[0].originalName, "Google US English 5 (Natural)", "Should keep the original name of preferred voice"); +}); + +testWithContext("filterVoices: deduplication prefers voice with earlier altName over later altName", (t) => { + const manager = t.context.manager; + + // Test scenario: two browser voices + // Both match different altNames in same JSON voice entry + const voices = [ + { + voiceURI: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", + name: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", + lang: "en-US", + localService: true, + default: false + }, + { + voiceURI: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", + name: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", + lang: "en-US", + localService: true, + default: false + } + ]; + + const parsedVoices = (manager as any).parseToReadiumSpeechVoices(voices); + const deduped = manager.filterVoices({ removeDuplicates: true }, parsedVoices); + + // Should only keep one voice + t.is(deduped.length, 1, "Should only keep one voice after deduplication"); + // Should prefer the voice with the earlier altName (network comes before local in JSON) + t.is(deduped[0].name, "Google US English 5 (Natural)", "Should prefer voice with earlier altName"); + t.is(deduped[0].originalName, "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", "Should prefer voice with earlier altName"); }); \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/getLanguages.test.ts b/test/WebSpeechVoiceManager/getLanguages.test.ts index f537bbc..cd45893 100644 --- a/test/WebSpeechVoiceManager/getLanguages.test.ts +++ b/test/WebSpeechVoiceManager/getLanguages.test.ts @@ -1,5 +1,5 @@ import { type ExecutionContext } from "ava"; -import { testWithContext, TestContext, mockVoices, mockSpeechSynthesis } from "./setup.js"; +import { testWithContext, TestContext, mockSpeechSynthesis, createTestVoice } from "./setup.js"; import { WebSpeechVoiceManager } from "../../build/index.js"; // ============================================= @@ -65,84 +65,60 @@ testWithContext("getLanguages: handles empty voices array", async (t: ExecutionC } }); -// ============================================= -// 4. Region Retrieval Tests -// ============================================= - -testWithContext("getRegions: returns available regions with counts", async (t: ExecutionContext) => { - const regions = await t.context.manager.getRegions(); - t.true(Array.isArray(regions)); +testWithContext("getLanguages: works with provided voices array", async (t: ExecutionContext) => { + const manager = t.context.manager; - // Check that we have at least one region - t.true(regions.length > 0); + // Create custom voices using the helper + const customVoices = [ + createTestVoice({ + name: "Custom Voice 1", + language: "en-US", + voiceURI: "custom-voice-1" + }), + createTestVoice({ + name: "Custom Voice 2", + language: "fr-FR", + voiceURI: "custom-voice-2" + }) + ]; - // Check structure of region entries - for (const region of regions) { - t.truthy(region.code); - t.truthy(region.label); - t.true(typeof region.count === "number"); - } + // Test with provided voices + const languages = manager.getLanguages(undefined, undefined, customVoices); + + t.is(languages.length, 2, "Should return 2 languages"); + + const englishLang = languages.find((l: any) => l.code === "en"); + const frenchLang = languages.find((l: any) => l.code === "fr"); + + t.is(englishLang?.count, 1, "Should have 1 English voice"); + t.is(frenchLang?.count, 1, "Should have 1 French voice"); + t.truthy(englishLang?.label, "Should have English label"); + t.truthy(frenchLang?.label, "Should have French label"); }); -testWithContext("getRegions: handles empty voices array", async (t: ExecutionContext) => { - // Create a fresh instance to avoid interference - (WebSpeechVoiceManager as any).instance = undefined; - const manager = await WebSpeechVoiceManager.initialize(); +testWithContext("getLanguages: works with provided voices and filters", async (t: ExecutionContext) => { + const manager = t.context.manager; - // Mock empty voices array - const emptyMockVoices: any[] = []; - const mockSpeechSynthesis = { - getVoices: () => emptyMockVoices, - onvoiceschanged: null as (() => void) | null, - addEventListener: function(event: string, callback: () => void) { - if (event === "voiceschanged") { - this.onvoiceschanged = callback; - } - }, - removeEventListener: function(event: string) { - }, - _triggerVoicesChanged: function() { - if (this.onvoiceschanged) { - this.onvoiceschanged(); - } - } - }; + // Create custom voices with different qualities + const customVoices = [ + createTestVoice({ + name: "Custom Voice 1", + language: "en-US", + voiceURI: "custom-voice-1", + quality: "normal" + }), + createTestVoice({ + name: "Custom Voice 2", + language: "en-US", + voiceURI: "custom-voice-2", + quality: "low" + }) + ]; - Object.defineProperty(globalThis.window, "speechSynthesis", { - value: mockSpeechSynthesis, - configurable: true, - writable: true - }); + // Test with provided voices and quality filter + const languages = manager.getLanguages(undefined, { quality: ["normal", "high"] }, customVoices); - try { - // Reset initialization - (manager as any).initializationPromise = null; - (manager as any).voices = []; - (manager as any).browserVoices = []; - - const regions = manager.getRegions(); - t.deepEqual(regions, []); - } finally { - // Restore for other tests - Object.defineProperty(globalThis.window, "speechSynthesis", { - value: { - getVoices: () => mockVoices, - onvoiceschanged: null as (() => void) | null, - addEventListener: function(event: string, callback: () => void) { - if (event === "voiceschanged") { - this.onvoiceschanged = callback; - } - }, - removeEventListener: function(event: string) { - }, - _triggerVoicesChanged: function() { - if (this.onvoiceschanged) { - this.onvoiceschanged(); - } - } - }, - configurable: true, - writable: true - }); - } + t.is(languages.length, 1, "Should return 1 language after filtering"); + t.is(languages[0].count, 1, "Should have 1 voice after filtering"); + t.is(languages[0].code, "en", "Should be English language"); }); \ No newline at end of file diff --git a/test/WebSpeechVoiceManager/getRegions.test.ts b/test/WebSpeechVoiceManager/getRegions.test.ts index a08f03d..c0eef7b 100644 --- a/test/WebSpeechVoiceManager/getRegions.test.ts +++ b/test/WebSpeechVoiceManager/getRegions.test.ts @@ -1,5 +1,5 @@ import test, { type ExecutionContext } from "ava"; -import { testWithContext, TestContext, originalNavigator, originalSpeechSynthesis, mockSpeechSynthesis } from "./setup.js"; +import { testWithContext, TestContext, originalNavigator, originalSpeechSynthesis, mockSpeechSynthesis, createTestVoice } from "./setup.js"; import { WebSpeechVoiceManager } from "../../build/index.js"; // ============================================= @@ -65,3 +65,65 @@ testWithContext("getRegions: handles empty voices array", async (t: ExecutionCon mockSpeechSynthesis.getVoices = originalGetVoices; } }); + +testWithContext("getRegions: works with provided voices array", async (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create custom voices with different regions using the helper + const customVoices = [ + createTestVoice({ + name: "Custom Voice 1", + language: "en-US", + voiceURI: "custom-voice-1" + }), + createTestVoice({ + name: "Custom Voice 2", + language: "en-GB", + voiceURI: "custom-voice-2" + }), + createTestVoice({ + name: "Custom Voice 3", + language: "fr-FR", + voiceURI: "custom-voice-3" + }) + ]; + + // Test with provided voices + const regions = manager.getRegions(undefined, undefined, customVoices); + + t.is(regions.length, 3, "Should return 3 regions"); + + const usRegion = regions.find((r: any) => r.code === "US"); + const gbRegion = regions.find((r: any) => r.code === "GB"); + const frRegion = regions.find((r: any) => r.code === "FR"); + + t.is(usRegion?.count, 1, "Should have 1 US voice"); + t.is(gbRegion?.count, 1, "Should have 1 GB voice"); + t.is(frRegion?.count, 1, "Should have 1 FR voice"); + t.truthy(usRegion?.label, "Should have US label"); + t.truthy(gbRegion?.label, "Should have GB label"); + t.truthy(frRegion?.label, "Should have FR label"); +}); + +testWithContext("getRegions: handles voices without regions", async (t: ExecutionContext) => { + const manager = t.context.manager; + + // Create custom voices without regions (just language codes) using the helper + const customVoices = [ + createTestVoice({ + name: "Custom Voice 1", + language: "en", + voiceURI: "custom-voice-1" + }), + createTestVoice({ + name: "Custom Voice 2", + language: "fr", + voiceURI: "custom-voice-2" + }) + ]; + + // Test with provided voices (no regions should be extracted) + const regions = manager.getRegions(undefined, undefined, customVoices); + + t.is(regions.length, 0, "Should return 0 regions when no region codes present"); +}); diff --git a/test/WebSpeechVoiceManager/groupVoices.test.ts b/test/WebSpeechVoiceManager/groupVoices.test.ts index e29251c..f8a5f91 100644 --- a/test/WebSpeechVoiceManager/groupVoices.test.ts +++ b/test/WebSpeechVoiceManager/groupVoices.test.ts @@ -35,7 +35,7 @@ testWithContext("groupVoices: groups by language", (t: ExecutionContext) => { const manager = t.context.manager; - const groups = manager.groupVoices([], "languages"); + const groups = manager.groupVoices("languages", []); t.deepEqual(groups, {}); }); @@ -119,17 +119,17 @@ testWithContext("groupVoices: handles voices with missing properties", (t: Execu ]; // Should handle missing properties gracefully - const groupsByLanguage = manager.groupVoices(testVoices, "languages"); + const groupsByLanguage = manager.groupVoices("languages", testVoices); t.true(groupsByLanguage.hasOwnProperty("en")); t.true(groupsByLanguage.hasOwnProperty("fr")); t.true(groupsByLanguage.hasOwnProperty("es")); - const groupsByGender = manager.groupVoices(testVoices, "gender"); + const groupsByGender = manager.groupVoices("gender", testVoices); // Should have an "unknown" group for voices without gender t.true(groupsByGender.hasOwnProperty("unknown")); t.is(groupsByGender.unknown.length, 3); // Voice 2, Voice 3, Voice 4 have no gender - const groupsByQuality = manager.groupVoices(testVoices, "quality"); + const groupsByQuality = manager.groupVoices("quality", testVoices); // Should have an "unknown" group for voices without quality t.true(groupsByQuality.hasOwnProperty("unknown")); t.is(groupsByQuality.unknown.length, 3); // Voice 2, Voice 3, Voice 4 have no quality diff --git a/test/WebSpeechVoiceManager/initialization.test.ts b/test/WebSpeechVoiceManager/initialization.test.ts index 234fde3..5991a95 100644 --- a/test/WebSpeechVoiceManager/initialization.test.ts +++ b/test/WebSpeechVoiceManager/initialization.test.ts @@ -42,10 +42,10 @@ testWithContext("initialize: loads voices and gets voices successfully", (t) => t.true(voices.length > 0); }); -testWithContext("deduplication: keeps higher quality voice from voiceURI package name", (t) => { +testWithContext("initialization: keeps all voices by default", (t) => { const manager = t.context.manager; - // Define test voices once + // Test 1: Basic duplicate voices const lowVoice = { voiceURI: "com.apple.speech.synthesis.voice.compact.samantha", name: "Samantha", @@ -62,65 +62,20 @@ testWithContext("deduplication: keeps higher quality voice from voiceURI package default: false }; - // 1. First parse separately to verify individual qualities - const lowQualityVoice = (manager as any).parseToReadiumSpeechVoices([lowVoice])[0]; - const normalQualityVoice = (manager as any).parseToReadiumSpeechVoices([normalVoice])[0]; - - // Verify individual qualities - t.is(lowQualityVoice.quality, "low", "Low quality voice should have low quality"); - t.is(normalQualityVoice.quality, "normal", "Normal quality voice should have normal quality"); - - // 2. Now parse both together to test deduplication - const [resultVoice] = (manager as any).parseToReadiumSpeechVoices([lowVoice, normalVoice]); + const basicVoices = (manager as any).parseToReadiumSpeechVoices([lowVoice, normalVoice]); + t.is(basicVoices.length, 2, "Should keep both basic voices when parsing"); - // Verify the result - t.is(resultVoice.name, "Samantha", "Should use the JSON name of the voice"); - t.is(resultVoice.originalName, "Samantha (enhanced)", "Should keep the original name of the voice"); - t.deepEqual(resultVoice.quality, "normal", "Should keep the voice with normal quality"); -}); - -testWithContext("deduplication: keeps higher quality voice from voiceURI string", (t) => { - const manager = t.context.manager; + const basicFiltered = manager.filterVoices({removeDuplicates: false}, basicVoices); + t.is(basicFiltered.length, 2, "Should keep both basic voices by default"); - // Define test voices once - const basicVoice = { - voiceURI: "Samantha", - name: "Samantha", - lang: "en-US", - localService: true, - default: false - }; - - const enhancedVoice = { - voiceURI: "Samantha (Premium)", - name: "Samantha (Premium)", - lang: "en-US", - localService: true, - default: false - }; - - // 1. First parse separately to verify individual qualities - const basicVoiceParsed = (manager as any).parseToReadiumSpeechVoices([basicVoice])[0]; - const enhancedVoiceParsed = (manager as any).parseToReadiumSpeechVoices([enhancedVoice])[0]; - - // Verify individual qualities - t.is(basicVoiceParsed.quality, "low", "Basic voice should have low quality"); - t.is(enhancedVoiceParsed.quality, "high", "Premium voice should have high quality"); - - // 2. Now parse both together to test deduplication - const [resultVoice] = (manager as any).parseToReadiumSpeechVoices([basicVoice, enhancedVoice]); - - // Verify the result - t.is(resultVoice.name, "Samantha", "Should use the JSON name of the voice"); - t.is(resultVoice.originalName, "Samantha (Premium)", "Should keep the original name of the voice"); - t.deepEqual(resultVoice.quality, "high", "Should keep the voice with high quality"); -}); - -testWithContext("deduplication: keeps higher quality voice from json quality array", (t) => { - const manager = t.context.manager; + // Verify specific voices are preserved + const lowQualityVoice = basicFiltered.find((v: any) => v.voiceURI === lowVoice.voiceURI); + const normalQualityVoice = basicFiltered.find((v: any) => v.voiceURI === normalVoice.voiceURI); + t.is(lowQualityVoice?.originalName, "Samantha", "Should preserve low quality voice original name"); + t.is(normalQualityVoice?.originalName, "Samantha (enhanced)", "Should preserve normal quality voice original name"); - // Parse both voices together to get correct duplicate counts - const voices = (manager as any).parseToReadiumSpeechVoices([ + // Test 2: VoiceURI string duplicates + const premiumVoices = (manager as any).parseToReadiumSpeechVoices([ { voiceURI: "Samantha", name: "Samantha", @@ -129,30 +84,26 @@ testWithContext("deduplication: keeps higher quality voice from json quality arr default: false }, { - voiceURI: "Samantha superior", - name: "Samantha (Superior)", + voiceURI: "Samantha (Premium)", + name: "Samantha (Premium)", lang: "en-US", localService: true, default: false } ]); - // Now test deduplication with both voices - const deduped = (manager as any).removeDuplicate(voices); - - // Verify only the higher quality voice remains with its original name - t.is(deduped.length, 1, "Should only keep one voice after deduplication"); - t.is(deduped[0].name, "Samantha", "Should use the JSON name of the voice"); - t.is(deduped[0].originalName, "Samantha (Superior)", "Should keep the original name of the voice"); - t.is(deduped[0].voiceURI, "Samantha superior", "Should keep the voice with superior quality"); - t.deepEqual(deduped[0].quality, "normal", "Should find the voice with normal quality from the array"); -}); - -testWithContext("deduplication: prefers voice with matching name over altNames", (t) => { - const manager = t.context.manager; + t.is(premiumVoices.length, 2, "Should keep both premium voices when parsing"); - // Test scenario: two browser voices - // One matches primary name in JSON, other matches altName in JSON - const voices = [ + const premiumFiltered = manager.filterVoices({removeDuplicates: false}, premiumVoices); + t.is(premiumFiltered.length, 2, "Should keep both premium voices by default"); + + // Verify specific voices are preserved + const basicVoice = premiumFiltered.find((v: any) => v.voiceURI === "Samantha"); + const enhancedVoice = premiumFiltered.find((v: any) => v.voiceURI === "Samantha (Premium)"); + t.is(basicVoice?.originalName, "Samantha", "Should preserve basic voice original name"); + t.is(enhancedVoice?.originalName, "Samantha (Premium)", "Should preserve enhanced voice original name"); + + // Test 3: Primary name vs altName matches + const altNameVoices = (manager as any).parseToReadiumSpeechVoices([ { voiceURI: "Google US English 5 (Natural)", name: "Google US English 5 (Natural)", @@ -167,24 +118,20 @@ testWithContext("deduplication: prefers voice with matching name over altNames", localService: true, default: false } - ]; + ]); + t.is(altNameVoices.length, 2, "Should keep both altName voices when parsing"); - const parsedVoices = (manager as any).parseToReadiumSpeechVoices(voices); - const deduped = (manager as any).removeDuplicate(parsedVoices); + const altNameFiltered = manager.filterVoices({removeDuplicates: false}, altNameVoices); + t.is(altNameFiltered.length, 2, "Should keep both altName voices by default"); - // Should only keep one voice - t.is(deduped.length, 1, "Should only keep one voice after deduplication"); - // Should prefer the voice with the primary name (Google US English 5 (Natural)) - t.is(deduped[0].name, "Google US English 5 (Natural)", "Should prefer voice with primary name over altName"); - t.is(deduped[0].originalName, "Google US English 5 (Natural)", "Should keep the original name of preferred voice"); -}); - -testWithContext("deduplication: prefers voice with earlier altName over later altName", (t) => { - const manager = t.context.manager; + // Verify specific voices are preserved + const primaryVoice = altNameFiltered.find((v: any) => v.voiceURI === "Google US English 5 (Natural)"); + const altVoice = altNameFiltered.find((v: any) => v.voiceURI.includes("en-us-x-tpc-local")); + t.is(primaryVoice?.originalName, "Google US English 5 (Natural)", "Should preserve primary name voice original name"); + t.is(altVoice?.originalName, "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", "Should preserve altName voice original name"); - // Test scenario: two browser voices - // Both match different altNames in same JSON voice entry - const voices = [ + // Test 4: Multiple altName matches + const multiAltVoices = (manager as any).parseToReadiumSpeechVoices([ { voiceURI: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", name: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", @@ -199,17 +146,20 @@ testWithContext("deduplication: prefers voice with earlier altName over later al localService: true, default: false } - ]; + ]); + t.is(multiAltVoices.length, 2, "Should keep both multi-alt voices when parsing"); - const parsedVoices = (manager as any).parseToReadiumSpeechVoices(voices); - const deduped = (manager as any).removeDuplicate(parsedVoices); + const multiAltFiltered = manager.filterVoices({removeDuplicates: false}, multiAltVoices); + t.is(multiAltFiltered.length, 2, "Should keep both multi-alt voices by default"); - // Should only keep one voice - t.is(deduped.length, 1, "Should only keep one voice after deduplication"); - // Should prefer the voice with the earlier altName (network comes before local in JSON) - t.is(deduped[0].originalName, "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", "Should prefer voice with earlier altName"); + // Verify specific voices are preserved + const localVoice = multiAltFiltered.find((v: any) => v.voiceURI.includes("en-us-x-tpc-local")); + const networkVoice = multiAltFiltered.find((v: any) => v.voiceURI.includes("en-us-x-tpc-network")); + t.is(localVoice?.originalName, "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", "Should preserve local altName voice original name"); + t.is(networkVoice?.originalName, "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", "Should preserve network altName voice original name"); }); + testWithContext("quality inference: infers quality from nativeID when voiceURI has no indicators", (t) => { const manager = t.context.manager; diff --git a/test/WebSpeechVoiceManager/sortVoices.test.ts b/test/WebSpeechVoiceManager/sortVoices.test.ts index e3d4782..3365237 100644 --- a/test/WebSpeechVoiceManager/sortVoices.test.ts +++ b/test/WebSpeechVoiceManager/sortVoices.test.ts @@ -94,7 +94,7 @@ testWithContext("sortVoices: sorts by language", (t: ExecutionContext createTestVoice({ name: "Australia Voice", language: "en-AU" }) ]; - const sortedAsc = manager.sortVoicesByRegions(testVoices, []); + const sortedAsc = manager.sortVoicesByRegions([], testVoices); t.is(sortedAsc[0].language, "en-US"); t.is(sortedAsc[1].language, "en-AU"); t.is(sortedAsc[2].language, "en-CA"); @@ -193,7 +193,7 @@ testWithContext("sortVoices: sorts regions by preferred", (t: ExecutionContext Date: Thu, 15 Jan 2026 18:24:51 +0100 Subject: [PATCH 27/32] Code splitting (#42) --- README.md | 32 +- demo/article/script.js | 55 ++- demo/script.js | 40 +- package.json | 3 +- scripts/generate-metadata.js | 76 ++++ src/WebSpeech/WebSpeechVoiceManager.ts | 183 ++++---- src/WebSpeech/webSpeechEngine.ts | 41 +- src/generated/language-metadata.ts | 400 ++++++++++++++++++ src/vite-env.d.ts | 5 + src/voices/languages.ts | 216 +++++----- src/voices/sorting.ts | 8 +- .../filterVoices.test.ts | 20 +- .../getDefaultVoice.test.ts | 2 +- .../initialization.test.ts | 25 +- test/WebSpeechVoiceManager/sortVoices.test.ts | 40 +- .../systemLocale.test.ts | 5 +- vite.config.js | 4 +- 17 files changed, 843 insertions(+), 312 deletions(-) create mode 100644 scripts/generate-metadata.js create mode 100644 src/generated/language-metadata.ts create mode 100644 src/vite-env.d.ts diff --git a/README.md b/README.md index 43d5758..82072c6 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,14 @@ async function setupVoices() { excludeVeryLowQuality: true }); - // Get voices grouped by language - const voices = voiceManager.getVoices(); - const groupedByLanguage = voiceManager.groupVoices(voices, "languages"); + // Sort by quality + const sortedByQuality = await voiceManager.sortVoicesByQuality(filteredVoices); + + // Sort by preferred languages + const sortedByLanguage = await voiceManager.sortVoicesByLanguages(["en", "fr"], filteredVoices); + + // Sort by preferred languages and regions + const sortedByRegion = await voiceManager.sortVoicesByRegions(["en-US", "en-GB"], filteredVoices); // Get a test utterance for a specific language const testText = voiceManager.getTestUtterance("en"); @@ -134,11 +139,16 @@ The main class for managing Web Speech API voices with enhanced functionality. #### Initialize the Voice Manager ```typescript -static initialize(maxTimeout?: number, interval?: number): Promise +static initialize(options?: { + languages?: string[]; + maxTimeout?: number; + interval?: number; +}): Promise ``` Creates and initializes a new WebSpeechVoiceManager instance. This static factory method must be called to create an instance. +- `languages`: Optional array of preferred language codes to filter voices during initialization - `maxTimeout`: Maximum time in milliseconds to wait for voices to load (default: 10000ms) - `interval`: Interval in milliseconds between voice loading checks (default: 100ms) - Returns: Promise that resolves with a new WebSpeechVoiceManager instance @@ -179,20 +189,20 @@ Returns arrays of languages and regions with their display names and voice count #### Get Default Voice ```typescript -voiceManager.getDefaultVoice(languages: string | string[], voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice | null +async voiceManager.getDefaultVoice(languages: string | string[], voices?: ReadiumSpeechVoice[]): Promise ``` Automatically selects the best available voice based on quality and language preferences. This is the recommended method for getting a suitable voice without manual selection. ```typescript // Get the best voice for user's browser language -const defaultVoice = voiceManager.getDefaultVoice(navigator.languages || ["en"]); +const defaultVoice = await voiceManager.getDefaultVoice(navigator.languages || ["en"]); // Get the best voice for specific preferred languages -const frenchVoice = voiceManager.getDefaultVoice(["fr-FR", "fr-CA"]); +const frenchVoice = await voiceManager.getDefaultVoice(["fr-FR", "fr-CA"]); // Get the best voice from a pre-filtered voice list -const customVoice = voiceManager.getDefaultVoice(["en-US", "en-GB"], customVoiceList); +const customVoice = await voiceManager.getDefaultVoice(["en-US", "en-GB"], customVoiceList); ``` The selection algorithm: @@ -236,7 +246,7 @@ If you need more control over the sorting process, you can implement and apply y Sort voices from highest to lowest quality: ```typescript -voiceManager.sortVoicesByQuality(voices?: ReadiumSpeechVoice[]); +async voiceManager.sortVoicesByQuality(voices?: ReadiumSpeechVoice[]): Promise; // Returns: [veryHigh, high, normal, low, veryLow, null] ``` @@ -247,7 +257,7 @@ If no voices are provided, it sorts the instance's internal voice list. Prioritize specific languages while maintaining JSON data’s quality order within each language group: ```typescript -voiceManager.sortVoicesByLanguages(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]); +async voiceManager.sortVoicesByLanguages(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]): Promise; // Returns: [preferred languages voices, other languages voices...] ``` @@ -258,7 +268,7 @@ If no voices are provided, it sorts the instance's internal voice list. Sort voices by preferred languages and regions, while maintaining JSON data’s quality order within each region group: ```typescript -voiceManager.sortVoicesByRegions(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]); +async voiceManager.sortVoicesByRegions(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]): Promise; // Returns: [languages in preferred then alphabetical order → regions: preferred regions → default region → alphabetical regions → voice quality within each region] ``` diff --git a/demo/article/script.js b/demo/article/script.js index 041940e..faf59c9 100644 --- a/demo/article/script.js +++ b/demo/article/script.js @@ -25,10 +25,10 @@ let readAlongEnabled = true; // Default to true to match default checkbox state async function initialize() { try { // Initialize the voice manager - voiceManager = await WebSpeechVoiceManager.initialize(); + voiceManager = await WebSpeechVoiceManager.initialize({languages: ["en"]}); - // Only get English voices - enVoices = voiceManager.getVoices({languages: "en", removeDuplicates: true}); + // Get English voices asynchronously + enVoices = await voiceManager.getVoices({removeDuplicates: true}); // Initialize the navigator navigator = new WebSpeechReadAloudNavigator(); @@ -39,11 +39,11 @@ async function initialize() { // Initialize the UI updateUI(); - // Populate voice select - populateVoiceSelect(); + // Populate voice select asynchronously + await populateVoiceSelect(); - // Get the default voice for English - currentVoice = voiceManager.getDefaultVoice("en"); + // Get the default voice for English asynchronously + currentVoice = await voiceManager.getDefaultVoice("en", enVoices); if (currentVoice && navigator) { navigator.setVoice(currentVoice); @@ -173,24 +173,29 @@ async function initializeContent() { } // Populate voice select dropdown -function populateVoiceSelect() { +async function populateVoiceSelect() { if (!voiceSelect) return; - voiceSelect.innerHTML = ""; + voiceSelect.innerHTML = ""; - if (!enVoices || !enVoices.length) { - const option = document.createElement("option"); - option.disabled = true; - option.textContent = "No voices available. Please check your browser settings and internet connection."; - voiceSelect.appendChild(option); - return; - } + try { + if (!enVoices || !enVoices.length) { + enVoices = await voiceManager.getVoices({languages: "en", removeDuplicates: true}); + } + + voiceSelect.innerHTML = ""; + + if (!enVoices || !enVoices.length) { + const option = document.createElement("option"); + option.disabled = true; + option.textContent = "No voices available. Please check your browser settings and internet connection."; + voiceSelect.appendChild(option); + return; + } try { // Sort by region while preserving quality order within each region - const sortedVoices = voiceManager.sortVoicesByRegions(["en"], enVoices); - - console.log(sortedVoices); + const sortedVoices = await voiceManager.sortVoicesByRegions(["en"], enVoices); let currentRegion = null; let optgroup = null; @@ -223,14 +228,22 @@ function populateVoiceSelect() { optgroup?.appendChild(option); } - // Set the default voice selection + // If we have a current voice, select it if (currentVoice) { - const option = voiceSelect.querySelector(`option[value="${currentVoice.name}"]`); + const option = voiceSelect.querySelector(`option[data-voice-uri="${currentVoice.voiceURI}"]`); if (option) { option.selected = true; } } + // Update the UI to reflect the current state + updateUI(); + + } catch (error) { + console.error("Error populating voice select:", error); + voiceSelect.innerHTML = ""; + } + } catch (error) { console.error("Error populating voice dropdown:", error); // Fallback to simple list if there's an error diff --git a/demo/script.js b/demo/script.js index e17136c..9fe6f05 100644 --- a/demo/script.js +++ b/demo/script.js @@ -66,11 +66,11 @@ speechNavigator.on("error", (event) => { // Initialize the application async function init() { try { - // Initialize the voice manager + // Initialize the voice manager with preferred languages voiceManager = await WebSpeechVoiceManager.initialize(); // Sort those voices by browser preference using sortVoicesByRegions - const voices = voiceManager.sortVoicesByRegions(window.navigator.languages); + const voices = await voiceManager.sortVoicesByRegions(window.navigator.languages); // Get languages languages = voiceManager.getLanguages(window.navigator.languages[0], { removeDuplicates: true }, voices); @@ -201,18 +201,18 @@ function displayVoiceProperties(voice) { } // Replace current voice with a new default voice if it gets filtered out -function replaceCurrentVoiceIfFilteredOut(language) { +async function replaceCurrentVoiceIfFilteredOut(language) { const currentVoiceFilteredOut = currentVoice && !filteredVoices.some(voice => voice.voiceURI === currentVoice.voiceURI); const needNewVoice = !currentVoice && filteredVoices.length > 0; if (currentVoiceFilteredOut || needNewVoice) { // Current voice was filtered out or no voice selected, pick a new default voice based on language if (filteredVoices.length > 0) { - currentVoice = voiceManager.getDefaultVoice(language, filteredVoices); + currentVoice = await voiceManager.getDefaultVoice(language, filteredVoices); if (currentVoice) { try { - speechNavigator.setVoice(currentVoice); + await speechNavigator.setVoice(currentVoice); displayVoiceProperties(currentVoice); updateTestUtterance(currentVoice, language); @@ -235,7 +235,7 @@ function replaceCurrentVoiceIfFilteredOut(language) { } // Filter voices based on current filters -function filterVoices() { +async function filterVoices() { const language = languageSelect.value; const gender = genderSelect.value; const source = sourceSelect.value; @@ -269,16 +269,16 @@ function filterVoices() { filteredVoices = voicesFilteredExceptLanguage; } - populateVoiceDropdown(language); + await populateVoiceDropdown(language); // Replace current voice if it was filtered out - replaceCurrentVoiceIfFilteredOut(language); + await replaceCurrentVoiceIfFilteredOut(language); updateUI(); } // Populate the voice dropdown with filtered voices -function populateVoiceDropdown() { +async function populateVoiceDropdown() { voiceSelect.innerHTML = ""; try { @@ -291,7 +291,7 @@ function populateVoiceDropdown() { } // Sort voices with browser's preferred languages first - const sortedVoices = voiceManager.sortVoicesByRegions(window.navigator.languages, [...filteredVoices]); + const sortedVoices = await voiceManager.sortVoicesByRegions(window.navigator.languages, [...filteredVoices]); // Group the sorted voices by region const voiceGroups = voiceManager.groupVoices("region", sortedVoices); @@ -550,14 +550,14 @@ function setupEventListeners() { displayVoiceProperties(null); // Filter voices for the selected language - filterVoices(); + await filterVoices(); // Get the default voice for the selected language using pre-filtered voices if (baseLanguage) { // Use the full navigator.languages array for proper language preference handling const preferredLanguages = [...(window.navigator.languages || [window.navigator.language] || [baseLanguage])]; - currentVoice = voiceManager.getDefaultVoice( + currentVoice = await voiceManager.getDefaultVoice( preferredLanguages, filteredVoices.length ? filteredVoices : undefined ); @@ -565,7 +565,7 @@ function setupEventListeners() { if (currentVoice) { try { // Set the voice for the navigator - speechNavigator.setVoice(currentVoice); + await speechNavigator.setVoice(currentVoice); // Update the voice dropdown to reflect the selected voice const voiceOption = voiceSelect.querySelector(`option[value="${currentVoice.name}"]`); @@ -600,7 +600,7 @@ function setupEventListeners() { if (currentVoice) { try { // Set the voice for the navigator - speechNavigator.setVoice(currentVoice); + await speechNavigator.setVoice(currentVoice); // Display voice properties displayVoiceProperties(currentVoice); @@ -655,18 +655,18 @@ function setupEventListeners() { }); // Update voices when gender filter changes - genderSelect.addEventListener("change", () => { - filterVoices(); + genderSelect.addEventListener("change", async () => { + await filterVoices(); }); // Update voices when source filter changes - sourceSelect.addEventListener("change", () => { - filterVoices(); + sourceSelect.addEventListener("change", async () => { + await filterVoices(); }); // Update voices when offline filter changes - offlineOnlyCheckbox.addEventListener("change", () => { - filterVoices(); + offlineOnlyCheckbox.addEventListener("change", async () => { + await filterVoices(); }); // Update test utterance when language changes diff --git a/package.json b/package.json index c0f7f22..f5225db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@readium/speech", - "version": "0.1.0-beta.6", + "version": "0.1.0-beta.7", "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.", "main": "./build/index.cjs", "module": "./build/index.js", @@ -19,6 +19,7 @@ }, "scripts": { "test": "npm run build && NODE_NO_WARNINGS=1 ava test/**/*.test.ts", + "generate-metadata": "node scripts/generate-metadata.js", "clean": "rimraf ./build", "build": "vite build", "start": "node build/index.js", diff --git a/scripts/generate-metadata.js b/scripts/generate-metadata.js new file mode 100644 index 0000000..ee20fed --- /dev/null +++ b/scripts/generate-metadata.js @@ -0,0 +1,76 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const JSON_DIR = path.join(__dirname, "../json"); +const OUTPUT_DIR = path.join(__dirname, "../src/generated"); +const OUTPUT_FILE = path.join(OUTPUT_DIR, "language-metadata.ts"); + +// Ensure output directory exists +if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); +} + +// Read all JSON files and extract metadata +const metadata = {}; +const jsonFiles = fs.readdirSync(JSON_DIR).filter(file => file.endsWith(".json")); + +for (const file of jsonFiles) { + const filePath = path.join(JSON_DIR, file); + const langCode = path.basename(file, ".json"); + + try { + const data = JSON.parse(fs.readFileSync(filePath, "utf8")); + + // Extract default region + let defaultRegion = ""; + if (data.defaultRegion) { + const [, region] = data.defaultRegion.split("-"); + defaultRegion = region || data.defaultRegion; + } + + // Extract all unique regions from voices + const regions = new Set(); + if (data.voices && Array.isArray(data.voices)) { + for (const voice of data.voices) { + if (voice.language) { + const [, region] = voice.language.split("-"); + if (region) { + regions.add(region); + } + } + } + } + + metadata[langCode] = { + defaultRegion, + availableRegions: Array.from(regions).sort(), + testUtterance: data.testUtterance || "" + }; + } catch (error) { + console.warn(`Failed to process ${file}:`, error.message); + } +} + +// Generate TypeScript file +const content = `// Auto-generated language metadata +// Generated on: ${new Date().toISOString()} +// DO NOT EDIT MANUALLY - Run scripts/generate-metadata.js to regenerate + +export interface LanguageMetadata { + defaultRegion: string; + availableRegions: string[]; + testUtterance: string; +} + +export const LANGUAGE_METADATA: Record = ${JSON.stringify(metadata, null, 2)} as const; + +export type LanguageCode = keyof typeof LANGUAGE_METADATA; +`; + +fs.writeFileSync(OUTPUT_FILE, content); +console.log(`Generated language metadata: ${OUTPUT_FILE}`); +console.log(`Processed ${Object.keys(metadata).length} languages`); diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index 5710e28..9017ef3 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -81,15 +81,19 @@ export class WebSpeechVoiceManager { } /** - * Initialize the voice manager + * Initialize voice manager * @param options Configuration options for voice loading - * @param options.maxTime Maximum time in milliseconds to wait for voices to load (passed to getBrowserVoices) + * @param options.languages Optional array of preferred language codes to filter voices during initialization + * @param options.maxTimeout Maximum time in milliseconds to wait for voices to load (passed to getBrowserVoices) * @param options.interval Interval in milliseconds between voice loading checks (passed to getBrowserVoices) * @returns Promise that resolves with the WebSpeechVoiceManager instance */ static async initialize( - maxTimeout?: number, - interval?: number + options?: { + languages?: string[]; + maxTimeout?: number; + interval?: number; + } ): Promise { // If we already have an initialized instance, return it if (WebSpeechVoiceManager.instance?.isInitialized) { @@ -107,9 +111,16 @@ export class WebSpeechVoiceManager { const instance = new WebSpeechVoiceManager(); WebSpeechVoiceManager.instance = instance; - instance.browserVoices = await instance.getBrowserVoices(maxTimeout, interval); + instance.browserVoices = await instance.getBrowserVoices(options?.maxTimeout, options?.interval); instance.updateSystemLocale(instance.browserVoices); - instance.voices = await instance.parseToReadiumSpeechVoices(instance.browserVoices); + + // Filter browser voices if languages are provided + let voicesToParse = instance.browserVoices; + if (options?.languages && options.languages.length > 0) { + voicesToParse = instance.filterBrowserVoicesByLanguages(instance.browserVoices, options.languages); + } + + instance.voices = await instance.parseToReadiumSpeechVoices(voicesToParse); instance.isInitialized = true; return instance; @@ -124,6 +135,33 @@ export class WebSpeechVoiceManager { return WebSpeechVoiceManager.initializationPromise; } + /** + * Filter browser voices based on preferred languages + * @private + */ + private filterBrowserVoicesByLanguages(browserVoices: SpeechSynthesisVoice[], languages: string[]): SpeechSynthesisVoice[] { + if (!languages?.length) return browserVoices; + + // Extract just the base languages from input + const allowedBaseLangs = new Set( + languages.map(lang => { + const normalized = normalizeLanguageCode(lang); + const [baseLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(normalized); + return baseLang; + }) + ); + + return browserVoices.filter(voice => { + if (!voice?.lang) return false; + + const normalizedVoiceLang = normalizeLanguageCode(voice.lang); + const [voiceBaseLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(normalizedVoiceLang); + + // Include all voices for matching base languages, regardless of region + return allowedBaseLangs.has(voiceBaseLang); + }); + } + /** * Extract language and region from BCP47 language tag * @param lang - The BCP47 language tag (e.g., "en-US", "zh-CN") @@ -426,7 +464,7 @@ export class WebSpeechVoiceManager { * @param voices Optional pre-filtered voices array to use instead of fetching voices * @returns The default voice for the language, or null if no voices are available */ - getDefaultVoice(languages: string | string[], voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice | null { + async getDefaultVoice(languages: string | string[], voices?: ReadiumSpeechVoice[]): Promise { if (!languages) return null; // Convert single language to array for consistent handling @@ -437,7 +475,7 @@ export class WebSpeechVoiceManager { if (!filteredVoices.length) return null; // Then sort by region to ensure we get the best match for the requested language(s) - filteredVoices = this.sortVoicesByRegions(languageArray, filteredVoices); + filteredVoices = await this.sortVoicesByRegions(languageArray, filteredVoices); // Return the best available voice (already sorted by quality and language) return filteredVoices[0]; @@ -507,40 +545,57 @@ export class WebSpeechVoiceManager { * Convert SpeechSynthesisVoice array to ReadiumSpeechVoice array * @private */ - private parseToReadiumSpeechVoices(speechVoices: SpeechSynthesisVoice[]): ReadiumSpeechVoice[] { + private async parseToReadiumSpeechVoices(speechVoices: SpeechSynthesisVoice[]): Promise { // Count duplicates first const duplicateCounts = this.countVoiceDuplicates(speechVoices); // Map all browser voices to ReadiumSpeechVoice format - const mappedVoices = speechVoices - .filter(voice => voice?.name && voice?.lang) - .map(voice => { - const normalizedLang = normalizeLanguageCode(voice.lang); - const [baseLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(normalizedLang); - const normalizedName = this.normalizeVoiceName(voice.name); - const voiceKey = `${voice.lang.toLowerCase()}_${normalizedName}`; - const duplicatesCount = duplicateCounts.get(voiceKey) || 1; - - // First try with the full language code to handle variants like zh-HK - let langVoices = getVoices(normalizedLang); - - // If no voices found, try with the base language code - if (!langVoices || langVoices.length === 0) { - langVoices = getVoices(baseLang); - } - - // Find matching JSON voice - const jsonVoice = this.findMatchingJsonVoice(langVoices, normalizedName); - - // Infer quality using the helper method - const quality = this.inferVoiceQuality(voice, jsonVoice, duplicatesCount); - - if (jsonVoice) { - // Create the voice object with the determined quality + const mappedVoices = await Promise.all( + speechVoices + .filter(voice => voice?.name && voice?.lang) + .map(async (voice) => { + const normalizedLang = normalizeLanguageCode(voice.lang); + const [baseLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(normalizedLang); + const normalizedName = this.normalizeVoiceName(voice.name); + const voiceKey = `${voice.lang.toLowerCase()}_${normalizedName}`; + const duplicatesCount = duplicateCounts.get(voiceKey) || 1; + + // First try with the full language code to handle variants like zh-HK + let langVoices = await getVoices(normalizedLang); + + // If no voices found, try with the base language code + if (!langVoices || langVoices.length === 0) { + langVoices = await getVoices(baseLang); + } + + // Find matching JSON voice + const jsonVoice = this.findMatchingJsonVoice(langVoices, normalizedName); + + // Infer quality using the helper method + const quality = this.inferVoiceQuality(voice, jsonVoice, duplicatesCount); + + if (jsonVoice) { + // Create the voice object with the determined quality + return { + ...jsonVoice, + source: "json", + originalName: voice.name, + voiceURI: voice.voiceURI, + quality, + isDefault: voice.default || false, + offlineAvailability: voice.localService || false, + isNovelty: isNoveltyVoice(voice.name, voice.voiceURI), + isLowQuality: isVeryLowQualityVoice(voice.name, quality) + } as ReadiumSpeechVoice; + } + + // No match found in JSON, create basic voice object return { - ...jsonVoice, - source: "json", + source: "browser", + label: this.cleanVoiceName(voice.name), + name: voice.name, originalName: voice.name, + language: normalizedLang, voiceURI: voice.voiceURI, quality, isDefault: voice.default || false, @@ -548,23 +603,8 @@ export class WebSpeechVoiceManager { isNovelty: isNoveltyVoice(voice.name, voice.voiceURI), isLowQuality: isVeryLowQualityVoice(voice.name, quality) } as ReadiumSpeechVoice; - } - - // No match found in JSON, create basic voice object - return { - source: "browser", - label: this.cleanVoiceName(voice.name), - name: voice.name, - originalName: voice.name, - language: normalizedLang, - voiceURI: voice.voiceURI, - quality, - isDefault: voice.default || false, - offlineAvailability: voice.localService || false, - isNovelty: isNoveltyVoice(voice.name, voice.voiceURI), - isLowQuality: isVeryLowQualityVoice(voice.name, quality) - } as ReadiumSpeechVoice; - }); + }) + ); return mappedVoices; } @@ -737,11 +777,11 @@ private static sortByQuality( * @param voices Array of voices to sort * @returns Sorted array of voices */ - sortVoicesByQuality(voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + async sortVoicesByQuality(voices?: ReadiumSpeechVoice[]): Promise { const voicesToSort = voices || this.voices; if (!voicesToSort?.length) return []; - const jsonOrderMaps = createJsonOrderMap(voicesToSort); + const jsonOrderMaps = await createJsonOrderMap(voicesToSort); return [...voicesToSort].sort((a, b) => WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps)); } @@ -776,11 +816,11 @@ private static sortByQuality( /** * Sort regions by default then alphabetically, sort voices by quality */ - private static sortByDefaultRegion( + private static async sortByDefaultRegion( voices: ReadiumSpeechVoice[], baseLang: string - ): void { - const jsonOrderMaps = createJsonOrderMap(voices); + ): Promise { + const jsonOrderMaps = await createJsonOrderMap(voices); const defaultRegion = getDefaultRegion(baseLang); voices.sort((a, b) => { @@ -802,9 +842,11 @@ private static sortByQuality( /** * Sort voices alphabetically by language, then region, then quality */ - private static sortAlphabetically( + private static async sortAlphabetically( voices: ReadiumSpeechVoice[] - ): void { + ): Promise { + const jsonOrderMaps = await createJsonOrderMap(voices); + voices.sort((a, b) => { const [aLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); const [bLang] = WebSpeechVoiceManager.extractLangRegionFromBCP47(b.language); @@ -840,7 +882,6 @@ private static sortByQuality( if (bRegion) return 1; // Same language group - sort by quality - const jsonOrderMaps = createJsonOrderMap(voices); return WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps, aLang); } @@ -854,7 +895,7 @@ private static sortByQuality( * @param preferredLanguages Array of preferred language codes in order of preference * @returns Sorted array of voices */ - sortVoicesByLanguages(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + async sortVoicesByLanguages(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]): Promise { const voicesToSort = voices || this.voices; if (!voicesToSort?.length) return []; @@ -863,7 +904,7 @@ private static sortByQuality( // If no preferred languages, sort alphabetically by language display name, // but prioritize default region voices within each language group const sortedVoices = [...voicesToSort]; - WebSpeechVoiceManager.sortAlphabetically(sortedVoices); + await WebSpeechVoiceManager.sortAlphabetically(sortedVoices); return sortedVoices; } @@ -876,13 +917,13 @@ private static sortByQuality( for (const processedLang of processedLangs) { const langVoices = voicesByLang.get(processedLang.baseLang); if (langVoices) { - WebSpeechVoiceManager.sortByDefaultRegion(langVoices, processedLang.baseLang); + await WebSpeechVoiceManager.sortByDefaultRegion(langVoices, processedLang.baseLang); langSortedResult.push(...langVoices); } } // Add other voices sorted alphabetically with region and quality fallback - WebSpeechVoiceManager.sortAlphabetically(otherLangVoices); + await WebSpeechVoiceManager.sortAlphabetically(otherLangVoices); langSortedResult.push(...otherLangVoices); return langSortedResult; } @@ -890,11 +931,11 @@ private static sortByQuality( /** * Sort languages by region preference, then voices by quality */ - private static sortByPreferredRegion( + private static async sortByPreferredRegion( voices: ReadiumSpeechVoice[], processedLang: LanguageWithRegions - ): void { - const jsonOrderMaps = createJsonOrderMap(voices); + ): Promise { + const jsonOrderMaps = await createJsonOrderMap(voices); voices.sort((a, b) => { const [, aRegion] = WebSpeechVoiceManager.extractLangRegionFromBCP47(a.language); @@ -953,7 +994,7 @@ private static sortByQuality( * @param preferredLanguages Array of preferred language codes in order of preference * @returns Sorted array of voices */ - sortVoicesByRegions(preferredLanguages: string[], voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] { + async sortVoicesByRegions(preferredLanguages: string[], voices?: ReadiumSpeechVoice[]): Promise { const voicesToSort = voices || this.voices; if (!voicesToSort?.length) return []; @@ -967,13 +1008,13 @@ private static sortByQuality( for (const processedLang of processedLangs) { const langVoices = voicesByLang.get(processedLang.baseLang); if (langVoices) { - WebSpeechVoiceManager.sortByPreferredRegion(langVoices, processedLang); + await WebSpeechVoiceManager.sortByPreferredRegion(langVoices, processedLang); langSortedResult.push(...langVoices); } } // Add other voices sorted alphabetically with region and quality fallback - WebSpeechVoiceManager.sortAlphabetically(otherLangVoices); + await WebSpeechVoiceManager.sortAlphabetically(otherLangVoices); langSortedResult.push(...otherLangVoices); return langSortedResult; } diff --git a/src/WebSpeech/webSpeechEngine.ts b/src/WebSpeech/webSpeechEngine.ts index 002a7df..e4d5a60 100644 --- a/src/WebSpeech/webSpeechEngine.ts +++ b/src/WebSpeech/webSpeechEngine.ts @@ -69,11 +69,12 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { } async initialize(options: { + languages?: string[]; maxTimeout?: number; interval?: number; maxLengthExceeded?: "error" | "none" | "warn"; } = {}): Promise { - const { maxTimeout, interval, maxLengthExceeded = "warn" } = options; + const { languages, maxTimeout, interval, maxLengthExceeded = "warn" } = options; if (this.initialized) { return false; @@ -83,11 +84,16 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { try { // Initialize voice manager with provided options and get voices - this.voiceManager = await WebSpeechVoiceManager.initialize(maxTimeout, interval); + this.voiceManager = await WebSpeechVoiceManager.initialize({ + languages, + maxTimeout, + interval + }); this.voices = this.voiceManager.getVoices(); // Find the best matching voice for the user's language using the optimized method - this.defaultVoice = this.voiceManager.getDefaultVoice([...(navigator.languages || ["en"])], this.voices); + const preferredLanguages = languages || [...(navigator.languages || ["en"])]; + this.defaultVoice = await this.voiceManager.getDefaultVoice(preferredLanguages, this.voices); this.initialized = true; return true; @@ -148,7 +154,7 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { } // Voice Configuration - setVoice(voice: ReadiumSpeechVoice | string): void { + async setVoice(voice: ReadiumSpeechVoice | string): Promise { const previousVoice = this.currentVoice; if (typeof voice === "string") { @@ -177,23 +183,22 @@ export class WebSpeechEngine implements ReadiumSpeechPlaybackEngine { this.defaultVoice && this.currentVoice && this.currentVoice.language !== this.defaultVoice.language ) { - this.defaultVoice = this.voiceManager.getDefaultVoice([this.currentVoice.language], this.voices); + this.defaultVoice = await this.voiceManager.getDefaultVoice([this.currentVoice.language], this.voices); } } - getAvailableVoices(): Promise { - return new Promise((resolve) => { - if (this.voices.length > 0) { - resolve(this.voices); - } else { - // If voices not loaded yet, initialize first - this.initialize().then(() => { - resolve(this.voices); - }).catch(() => { - resolve([]); - }); - } - }); + async getAvailableVoices(): Promise { + if (this.voices.length > 0) { + return this.voices; + } + + // If voices not loaded yet, initialize first + try { + await this.initialize(); + return this.voices; + } catch { + return []; + } } // Playback Control diff --git a/src/generated/language-metadata.ts b/src/generated/language-metadata.ts new file mode 100644 index 0000000..ac36e93 --- /dev/null +++ b/src/generated/language-metadata.ts @@ -0,0 +1,400 @@ +// Auto-generated language metadata +// Generated on: 2026-01-15T13:47:16.304Z +// DO NOT EDIT MANUALLY - Run scripts/generate-metadata.js to regenerate + +export interface LanguageMetadata { + defaultRegion: string; + availableRegions: string[]; + testUtterance: string; +} + +export const LANGUAGE_METADATA: Record = { + "ar": { + "defaultRegion": "SA", + "availableRegions": [ + "001", + "AE", + "AS", + "BH", + "DZ", + "EG", + "IQ", + "JO", + "KW", + "LB", + "LY", + "MA", + "OM", + "QA", + "SA", + "SY", + "TN", + "YE" + ], + "testUtterance": "مرحبًا، اسمي {name} وأنا صوت عربي." + }, + "bg": { + "defaultRegion": "BG", + "availableRegions": [ + "BG" + ], + "testUtterance": "Здравейте, казвам се {name} и съм български глас." + }, + "bho": { + "defaultRegion": "IN", + "availableRegions": [ + "IN" + ], + "testUtterance": "नमस्कार, हमार नाम {name} ह आ हम भोजपुरी आवाज हईं" + }, + "bn": { + "defaultRegion": "IN", + "availableRegions": [ + "BD", + "IN" + ], + "testUtterance": "হ্যালো, আমার নাম {name} এবং আমি একজন বাংলা ভয়েস।" + }, + "ca": { + "defaultRegion": "ES", + "availableRegions": [ + "ES" + ], + "testUtterance": "Hola, em dic {name} i sóc una veu catalana" + }, + "cmn": { + "defaultRegion": "CN", + "availableRegions": [ + "CN", + "CTW", + "TW" + ], + "testUtterance": "你好,我的名字是 {name},我是普通话配音。" + }, + "cs": { + "defaultRegion": "CZ", + "availableRegions": [ + "CZ" + ], + "testUtterance": "Dobrý den, jmenuji se {name} a jsem český hlas." + }, + "da": { + "defaultRegion": "DK", + "availableRegions": [ + "DK" + ], + "testUtterance": "Hej, mit navn er {name} og jeg er en dansk stemme." + }, + "de": { + "defaultRegion": "DE", + "availableRegions": [ + "AT", + "CH", + "DE" + ], + "testUtterance": "Hallo, mein Name ist {name} und ich bin eine deutsche Stimme." + }, + "el": { + "defaultRegion": "GR", + "availableRegions": [ + "GR" + ], + "testUtterance": "Γεια σας, με λένε {name} και είμαι ελληνική φωνή." + }, + "en": { + "defaultRegion": "US", + "availableRegions": [ + "AU", + "CA", + "GB", + "HK", + "IE", + "IN", + "KE", + "NG", + "NZ", + "PH", + "SG", + "TZ", + "US", + "ZA" + ], + "testUtterance": "Hello, my name is {name} and I am an English voice." + }, + "es": { + "defaultRegion": "ES", + "availableRegions": [ + "AR", + "BO", + "CL", + "CO", + "CR", + "CU", + "DO", + "EC", + "ES", + "GQ", + "GT", + "HN", + "MX", + "NI", + "PA", + "PE", + "PR", + "PY", + "SV", + "US", + "UY", + "VE" + ], + "testUtterance": "Hola, mi nombre es {name} y soy una voz española." + }, + "eu": { + "defaultRegion": "ES", + "availableRegions": [ + "ES" + ], + "testUtterance": "Kaixo, nire izena {name} da eta euskal ahotsa naiz." + }, + "fa": { + "defaultRegion": "IR", + "availableRegions": [ + "IR" + ], + "testUtterance": "سلام اسم من {name} و صدای فارسی هستم" + }, + "fi": { + "defaultRegion": "FI", + "availableRegions": [ + "FI" + ], + "testUtterance": "Hei, nimeni on {name} ja olen suomalainen ääni." + }, + "fr": { + "defaultRegion": "FR", + "availableRegions": [ + "BE", + "CA", + "CH", + "FR" + ], + "testUtterance": "Bonjour, mon nom est {name} et je suis une voix française." + }, + "gl": { + "defaultRegion": "ES", + "availableRegions": [ + "ES" + ], + "testUtterance": "Ola, chámome {name} e son unha voz galega." + }, + "he": { + "defaultRegion": "IL", + "availableRegions": [ + "IL" + ], + "testUtterance": "שלום, שמי {name} ואני קול עברי." + }, + "hi": { + "defaultRegion": "IN", + "availableRegions": [ + "IN" + ], + "testUtterance": "नमस्कार, मेरा नाम {name} है और मैं एक हिंदी आवाज़ हूँ।" + }, + "hr": { + "defaultRegion": "HR", + "availableRegions": [ + "HR" + ], + "testUtterance": "Pozdrav, ja sam {name} i hrvatski sam glas." + }, + "hu": { + "defaultRegion": "HU", + "availableRegions": [ + "HU" + ], + "testUtterance": "Helló, a nevem {name} és magyar hangú vagyok." + }, + "id": { + "defaultRegion": "ID", + "availableRegions": [ + "ID" + ], + "testUtterance": "Halo, nama saya {name} dan saya suara Indonesia." + }, + "it": { + "defaultRegion": "IT", + "availableRegions": [ + "IT" + ], + "testUtterance": "Ciao, mi chiamo {name} e sono una voce italiana." + }, + "ja": { + "defaultRegion": "JP", + "availableRegions": [ + "JP" + ], + "testUtterance": "こんにちは。私の名前は{name}で、日本語の声を担当しています。" + }, + "kk": { + "defaultRegion": "KZ", + "availableRegions": [ + "KZ" + ], + "testUtterance": "Sälemetsiz be, meniñ atım {name} jäne men qazaq dawısımın." + }, + "kn": { + "defaultRegion": "IN", + "availableRegions": [ + "IN" + ], + "testUtterance": "ಹಲೋ, ನನ್ನ ಹೆಸರು {name} ಮತ್ತು ನಾನು ಕನ್ನಡ ಧ್ವನಿ." + }, + "ko": { + "defaultRegion": "KR", + "availableRegions": [ + "KR" + ], + "testUtterance": "안녕하세요, 저는 {name}이고 한국어 음성입니다." + }, + "mr": { + "defaultRegion": "IN", + "availableRegions": [ + "IN" + ], + "testUtterance": "नमस्कार, माझे नाव {name} आहे आणि मी एक मराठी आवाज आहे." + }, + "ms": { + "defaultRegion": "MY", + "availableRegions": [ + "MY" + ], + "testUtterance": "Hello, nama saya {name} dan saya suara Melayu." + }, + "nb": { + "defaultRegion": "NO", + "availableRegions": [ + "NO" + ], + "testUtterance": "Hei, jeg heter {name} og er en norsk stemme." + }, + "nl": { + "defaultRegion": "NL", + "availableRegions": [ + "BE", + "NL" + ], + "testUtterance": "Hallo, mijn naam is {name} en ik ben een Nederlandse stem." + }, + "pl": { + "defaultRegion": "PL", + "availableRegions": [ + "PL" + ], + "testUtterance": "Cześć, nazywam się {name} i mam polski głos." + }, + "pt": { + "defaultRegion": "BR", + "availableRegions": [ + "BR", + "PT" + ], + "testUtterance": "Olá, o meu nome é {name} e sou uma voz portuguesa." + }, + "ro": { + "defaultRegion": "RO", + "availableRegions": [ + "RO" + ], + "testUtterance": "Buna ziua, ma numesc {name} si sunt o voce romaneasca." + }, + "ru": { + "defaultRegion": "RU", + "availableRegions": [ + "RU" + ], + "testUtterance": "Здравствуйте, меня зовут {name} и я русский голос." + }, + "sk": { + "defaultRegion": "SK", + "availableRegions": [ + "SK" + ], + "testUtterance": "Dobrý deň, volám sa {name} a som slovenský hlas." + }, + "sl": { + "defaultRegion": "SI", + "availableRegions": [ + "SI" + ], + "testUtterance": "Pozdravljeni, moje ime je {name} in sem slovenski glas." + }, + "sv": { + "defaultRegion": "SE", + "availableRegions": [ + "SE" + ], + "testUtterance": "Hej, jag heter {name} och jag är en svensk röst." + }, + "ta": { + "defaultRegion": "IN", + "availableRegions": [ + "IN", + "LK", + "MY", + "SG" + ], + "testUtterance": "வணக்கம், என் பெயர் {name} மற்றும் நான் ஒரு தமிழ் குரல்" + }, + "te": { + "defaultRegion": "IN", + "availableRegions": [ + "IN" + ], + "testUtterance": "హలో, నా పేరు {name} మరియు నేను తెలుగు వాణిని." + }, + "th": { + "defaultRegion": "TH", + "availableRegions": [ + "TH" + ], + "testUtterance": "สวัสดีค่ะ ฉันชื่อ {name} และฉันเป็นคนมีเสียงภาษาไทย" + }, + "tr": { + "defaultRegion": "TR", + "availableRegions": [ + "TR" + ], + "testUtterance": "Merhaba, adım {name} ve Türk sesiyim." + }, + "uk": { + "defaultRegion": "UA", + "availableRegions": [ + "UA" + ], + "testUtterance": "Здравствуйте, меня зовут {name} и я украинский голос." + }, + "vi": { + "defaultRegion": "VN", + "availableRegions": [ + "VN" + ], + "testUtterance": "Xin chào, tôi tên là {name} và tôi là giọng nói tiếng Việt." + }, + "wuu": { + "defaultRegion": "CN", + "availableRegions": [ + "CN" + ], + "testUtterance": "你好,我的名字是 {name},我是吴语配音。" + }, + "yue": { + "defaultRegion": "HK", + "availableRegions": [ + "HK" + ], + "testUtterance": "你好,我叫 {name},係越中文聲。" + } +} as const; + +export type LanguageCode = keyof typeof LANGUAGE_METADATA; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..4895804 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,5 @@ +/// + +interface ImportMeta { + glob: (pattern: string) => Record Promise<{ [key: string]: any }>>; +} diff --git a/src/voices/languages.ts b/src/voices/languages.ts index 97aa2cb..473b420 100644 --- a/src/voices/languages.ts +++ b/src/voices/languages.ts @@ -1,59 +1,58 @@ import { extractLangRegionFromBCP47 } from "../utils/language"; import type { ReadiumSpeechVoice, VoiceData, TGender, TQuality, TLocalizedName } from "./types"; - -// Import all language JSON files statically -import ar from "@json/ar.json"; -import bg from "@json/bg.json"; -import bho from "@json/bho.json"; -import bn from "@json/bn.json"; -import ca from "@json/ca.json"; -import cmn from "@json/cmn.json"; -import cs from "@json/cs.json"; -import da from "@json/da.json"; -import de from "@json/de.json"; -import el from "@json/el.json"; -import en from "@json/en.json"; -import es from "@json/es.json"; -import eu from "@json/eu.json"; -import fa from "@json/fa.json"; -import fi from "@json/fi.json"; -import fr from "@json/fr.json"; -import gl from "@json/gl.json"; -import he from "@json/he.json"; -import hi from "@json/hi.json"; -import hr from "@json/hr.json"; -import hu from "@json/hu.json"; -import id from "@json/id.json"; -import it from "@json/it.json"; -import ja from "@json/ja.json"; -import kk from "@json/kk.json"; -import kn from "@json/kn.json"; -import ko from "@json/ko.json"; -import mr from "@json/mr.json"; -import ms from "@json/ms.json"; -import nb from "@json/nb.json"; -import nl from "@json/nl.json"; -import pl from "@json/pl.json"; -import pt from "@json/pt.json"; -import ro from "@json/ro.json"; -import ru from "@json/ru.json"; -import sk from "@json/sk.json"; -import sl from "@json/sl.json"; -import sv from "@json/sv.json"; -import ta from "@json/ta.json"; -import te from "@json/te.json"; -import th from "@json/th.json"; -import tr from "@json/tr.json"; -import uk from "@json/uk.json"; -import vi from "@json/vi.json"; -import wuu from "@json/wuu.json"; -import yue from "@json/yue.json"; +import { LANGUAGE_METADATA } from '../generated/language-metadata'; export interface LanguageWithRegions { baseLang: string; // Base language code (e.g., "en", "fr") regions: string[]; // Regions to use for this language (explicit, inferred, or default) } +// Cache for loaded language data +const voiceDataCache = new Map>(); + +// Use import.meta.glob to dynamically import JSON files +const jsonLoaders = import.meta.glob<{ default: VoiceData }>('../../json/*.json'); + +/** + * Dynamically imports and caches language data + */ +async function loadVoiceData(lang: string): Promise { + try { + // Extract language subtag (first part of BCP-47) + const langSubtag = lang.split("-")[0]; + const loader = jsonLoaders[`../../json/${langSubtag}.json`]; + if (!loader) { + throw new Error(`No voice data found for language: ${lang}`); + } + const module = await loader(); + const voiceData = module.default; + return { + ...voiceData, + voices: voiceData.voices.map(castVoice) + }; + } catch (error) { + // Log warning instead of error for unsupported languages + console.warn(`Failed to load voice data for ${lang}:`, error); + // Return empty voice data to prevent breaking the voice loading process + return { + language: lang, + defaultRegion: "", + testUtterance: "", + voices: [] + }; + } +} + +/** + * Gets voice data for a language, loading it if necessary + */ +function getVoiceData(lang: string): Promise { + if (!voiceDataCache.has(lang)) { + voiceDataCache.set(lang, loadVoiceData(lang)); + } + return voiceDataCache.get(lang)!; +} + // Helper function to cast voice data to the correct type const castVoice = (voice: any): ReadiumSpeechVoice => ({ ...voice, @@ -71,24 +70,6 @@ const castVoice = (voice: any): ReadiumSpeechVoice => ({ : undefined }); -// Map of language codes to their respective voice data with proper casting -const voiceDataMap: Record = Object.fromEntries( - Object.entries({ - ar, bg, bho, bn, ca, cmn, cs, da, de, el, en, es, eu, fa, fi, fr, gl, he, hi, - hr, hu, id, it, ja, kk, kn, ko, mr, ms, nb, nl, pl, pt, ro, ru, sk, sl, sv, ta, - te, th, tr, uk, vi, wuu, yue - }).map(([lang, data]) => [ - lang, - { - ...data, - voices: data.voices.map(castVoice) - } - ]) -); - -// Helper function to get voice data synchronously -const getVoiceData = (lang: string): VoiceData | undefined => voiceDataMap[lang]; - // Chinese variant mapping for special handling export const chineseVariantMap: {[key: string]: string} = { "cmn": "cmn", @@ -128,9 +109,9 @@ export const normalizeLanguageCode = (lang: string): string => { /** * Get all voices for a specific language * @param {string} lang - Language code (e.g., "en", "fr", "zh-CN") - * @returns {ReadiumSpeechVoice[]} Array of voices for the specified language + * @returns {Promise} Promise resolving to array of voices for the specified language */ -export const getVoices = (lang: string): ReadiumSpeechVoice[] => { +export const getVoices = async (lang: string): Promise => { if (!lang) return []; try { @@ -138,19 +119,31 @@ export const getVoices = (lang: string): ReadiumSpeechVoice[] => { const normalizedLang = normalizeLanguageCode(lang); // Try with the normalized language code - let voiceData = getVoiceData(normalizedLang); + try { + const voiceData = await getVoiceData(normalizedLang); + if (voiceData?.voices?.length) { + return voiceData.voices; + } + } catch (error) { + console.warn(`Failed to load voices for ${normalizedLang}:`, error); + } // If still no voices, try with the base language code - if (!voiceData || !voiceData.voices?.length) { - const [baseLang] = extractLangRegionFromBCP47(normalizedLang); - if (baseLang !== normalizedLang) { - voiceData = getVoiceData(baseLang); + const [baseLang] = extractLangRegionFromBCP47(normalizedLang); + if (baseLang !== normalizedLang) { + try { + const baseLangData = await getVoiceData(baseLang); + if (baseLangData?.voices?.length) { + return baseLangData.voices; + } + } catch (error) { + console.warn(`Failed to load voices for base language ${baseLang}:`, error); } } - return voiceData?.voices || []; + return []; } catch (error) { - console.error(`Failed to load voices for ${lang}:`, error); + console.error(`Error in getVoices for ${lang}:`, error); return []; } }; @@ -159,7 +152,7 @@ export const getVoices = (lang: string): ReadiumSpeechVoice[] => { * Get all available language codes * @returns {string[]} Array of available language codes */ -export const getAvailableLanguages = (): string[] => Object.keys(voiceDataMap); +export const getAvailableLanguages = (): string[] => Object.keys(LANGUAGE_METADATA); /** * Get the test utterance for a language @@ -170,37 +163,31 @@ export const getTestUtterance = (lang: string): string => { if (!lang) return ""; try { - // Normalize the language code first const normalizedLang = normalizeLanguageCode(lang); // Try with the normalized language code - let voiceData = getVoiceData(normalizedLang); + const metadata = LANGUAGE_METADATA[normalizedLang]; + if (metadata?.testUtterance) { + return metadata.testUtterance; + } - // If no test utterance found and it's a Chinese variant, try with the mapped variant code - if ((!voiceData?.testUtterance) && normalizedLang in chineseVariantMap) { + // Handle Chinese variants + if (normalizedLang in chineseVariantMap) { const variantCode = chineseVariantMap[normalizedLang]; - if (variantCode) { - const variantData = getVoiceData(variantCode); - if (variantData?.testUtterance) { - return variantData.testUtterance; - } + if (variantCode && LANGUAGE_METADATA[variantCode]?.testUtterance) { + return LANGUAGE_METADATA[variantCode].testUtterance; } } - // If still no test utterance, try with the base language code - if (!voiceData?.testUtterance) { - const [baseLang] = extractLangRegionFromBCP47(normalizedLang); - if (baseLang !== normalizedLang) { - const baseLangData = getVoiceData(baseLang); - if (baseLangData?.testUtterance) { - return baseLangData.testUtterance; - } - } + // Try with base language + const [baseLang] = extractLangRegionFromBCP47(normalizedLang); + if (baseLang !== normalizedLang && LANGUAGE_METADATA[baseLang]?.testUtterance) { + return LANGUAGE_METADATA[baseLang].testUtterance; } - return voiceData?.testUtterance ?? ""; + return ""; } catch (error) { - console.error(`Failed to get test utterance for ${lang}:`, error); + console.error(`Error in getTestUtterance for ${lang}:`, error); return ""; } }; @@ -217,32 +204,33 @@ export const getDefaultRegion = (lang: string): string => { // Normalize the language code first const normalizedLang = normalizeLanguageCode(lang); - // Try with the normalized language code - let voiceData = getVoiceData(normalizedLang); + // Use metadata for fast lookup + const metadata = LANGUAGE_METADATA[normalizedLang]; + if (metadata?.defaultRegion) { + return `${normalizedLang}-${metadata.defaultRegion}`; + } // If no default region found and it's a Chinese variant, try with the mapped variant code - if ((!voiceData?.defaultRegion) && normalizedLang in chineseVariantMap) { + if (normalizedLang in chineseVariantMap) { const variantCode = chineseVariantMap[normalizedLang]; if (variantCode) { - const variantData = getVoiceData(variantCode); - if (variantData?.defaultRegion) { - return variantData.defaultRegion; + const variantMetadata = LANGUAGE_METADATA[variantCode]; + if (variantMetadata?.defaultRegion) { + return `${variantCode}-${variantMetadata.defaultRegion}`; } } } // If still no default region, try with the base language code - if (!voiceData?.defaultRegion) { - const [baseLang] = extractLangRegionFromBCP47(normalizedLang); - if (baseLang !== normalizedLang) { - const baseLangData = getVoiceData(baseLang); - if (baseLangData?.defaultRegion) { - return baseLangData.defaultRegion; - } + const [baseLang] = extractLangRegionFromBCP47(normalizedLang); + if (baseLang !== normalizedLang) { + const baseLangMetadata = LANGUAGE_METADATA[baseLang]; + if (baseLangMetadata?.defaultRegion) { + return `${baseLang}-${baseLangMetadata.defaultRegion}`; } } - return voiceData?.defaultRegion || ""; + return ""; } catch (error) { console.error(`Failed to get default region for ${lang}:`, error); return ""; @@ -289,13 +277,9 @@ export const processLanguages = (languages: string[]): LanguageWithRegions[] => // Convert to the output format return Array.from(langMap.entries()).map(([baseLang, explicitRegionsSet]) => { - // Get all regions from the voices for this language - const allLangVoices = getVoices(baseLang); + // Get all available regions for this language from metadata const validRegionsForLang = new Set( - allLangVoices.map(voice => { - const [, region] = extractLangRegionFromBCP47(voice.language); - return region; - }).filter(Boolean) + LANGUAGE_METADATA[baseLang]?.availableRegions || [] ); // Get explicit regions with their original priority diff --git a/src/voices/sorting.ts b/src/voices/sorting.ts index 1c06c32..6e0c9f5 100644 --- a/src/voices/sorting.ts +++ b/src/voices/sorting.ts @@ -7,7 +7,7 @@ import { extractLangRegionFromBCP47 } from "../utils/language"; * @param voices Array of voices to analyze * @returns Map where key is language code and value is a map of voice names to their original JSON indices */ -export function createJsonOrderMap(voices: ReadiumSpeechVoice[]): Map> { +export async function createJsonOrderMap(voices: ReadiumSpeechVoice[]): Promise>> { // First, group voices by language const voicesByLanguage = new Map(); @@ -26,13 +26,13 @@ export function createJsonOrderMap(voices: ReadiumSpeechVoice[]): Map(); - const jsonVoices = getVoices(baseLang); + const jsonVoices = await getVoices(baseLang); // Create a lookup map for faster searching const voiceLookup = new Map(); - jsonVoices.forEach((v, i) => { + jsonVoices.forEach((v: any, i: number) => { voiceLookup.set(v.name.toLowerCase(), i); - v.altNames?.forEach(altName => { + v.altNames?.forEach((altName: any) => { voiceLookup.set(altName.toLowerCase(), i); }); }); diff --git a/test/WebSpeechVoiceManager/filterVoices.test.ts b/test/WebSpeechVoiceManager/filterVoices.test.ts index 0cfa226..ea3ba45 100644 --- a/test/WebSpeechVoiceManager/filterVoices.test.ts +++ b/test/WebSpeechVoiceManager/filterVoices.test.ts @@ -291,7 +291,7 @@ testWithContext("filterOutVeryLowQualityVoices: removes very low quality voices" t.false(filtered.some((v: ReadiumSpeechVoice) => v.quality === "veryLow")); }); -testWithContext("filterVoices: deduplication keeps higher quality voice from voiceURI package name", (t) => { +testWithContext("filterVoices: deduplication keeps higher quality voice from voiceURI package name", async (t) => { const manager = t.context.manager; // Define test voices once @@ -312,7 +312,7 @@ testWithContext("filterVoices: deduplication keeps higher quality voice from voi }; // Parse both voices and apply deduplication through filtering - const parsedVoices = (manager as any).parseToReadiumSpeechVoices([lowVoice, normalVoice]); + const parsedVoices = await (manager as any).parseToReadiumSpeechVoices([lowVoice, normalVoice]); const deduped = manager.filterVoices({ removeDuplicates: true }, parsedVoices); // Verify the result @@ -323,7 +323,7 @@ testWithContext("filterVoices: deduplication keeps higher quality voice from voi t.deepEqual(resultVoice.quality, "normal", "Should keep the voice with normal quality"); }); -testWithContext("filterVoices: deduplication keeps higher quality voice from voiceURI string", (t) => { +testWithContext("filterVoices: deduplication keeps higher quality voice from voiceURI string", async (t) => { const manager = t.context.manager; // Define test voices once @@ -344,7 +344,7 @@ testWithContext("filterVoices: deduplication keeps higher quality voice from voi }; // Parse both voices and apply deduplication through filtering - const parsedVoices = (manager as any).parseToReadiumSpeechVoices([basicVoice, enhancedVoice]); + const parsedVoices = await (manager as any).parseToReadiumSpeechVoices([basicVoice, enhancedVoice]); const deduped = manager.filterVoices({ removeDuplicates: true }, parsedVoices); // Verify the result @@ -355,11 +355,11 @@ testWithContext("filterVoices: deduplication keeps higher quality voice from voi t.deepEqual(resultVoice.quality, "high", "Should keep the voice with high quality"); }); -testWithContext("filterVoices: deduplication keeps higher quality voice from json quality array", (t) => { +testWithContext("filterVoices: deduplication keeps higher quality voice from json quality array", async (t) => { const manager = t.context.manager; // Parse both voices together to get correct duplicate counts - const voices = (manager as any).parseToReadiumSpeechVoices([ + const voices = await (manager as any).parseToReadiumSpeechVoices([ { voiceURI: "Samantha", name: "Samantha", @@ -386,7 +386,7 @@ testWithContext("filterVoices: deduplication keeps higher quality voice from jso t.deepEqual(deduped[0].quality, "normal", "Should find the voice with normal quality from the array"); }); -testWithContext("filterVoices: deduplication prefers voice with matching name over altNames", (t) => { +testWithContext("filterVoices: deduplication prefers voice with matching name over altNames", async (t) => { const manager = t.context.manager; // Test scenario: two browser voices @@ -408,7 +408,7 @@ testWithContext("filterVoices: deduplication prefers voice with matching name ov } ]; - const parsedVoices = (manager as any).parseToReadiumSpeechVoices(voices); + const parsedVoices = await (manager as any).parseToReadiumSpeechVoices(voices); const deduped = manager.filterVoices({ removeDuplicates: true }, parsedVoices); // Should only keep one voice @@ -418,7 +418,7 @@ testWithContext("filterVoices: deduplication prefers voice with matching name ov t.is(deduped[0].originalName, "Google US English 5 (Natural)", "Should keep the original name of preferred voice"); }); -testWithContext("filterVoices: deduplication prefers voice with earlier altName over later altName", (t) => { +testWithContext("filterVoices: deduplication prefers voice with earlier altName over later altName", async (t) => { const manager = t.context.manager; // Test scenario: two browser voices @@ -440,7 +440,7 @@ testWithContext("filterVoices: deduplication prefers voice with earlier altName } ]; - const parsedVoices = (manager as any).parseToReadiumSpeechVoices(voices); + const parsedVoices = await (manager as any).parseToReadiumSpeechVoices(voices); const deduped = manager.filterVoices({ removeDuplicates: true }, parsedVoices); // Should only keep one voice diff --git a/test/WebSpeechVoiceManager/getDefaultVoice.test.ts b/test/WebSpeechVoiceManager/getDefaultVoice.test.ts index 3613b83..bfaf9c7 100644 --- a/test/WebSpeechVoiceManager/getDefaultVoice.test.ts +++ b/test/WebSpeechVoiceManager/getDefaultVoice.test.ts @@ -197,7 +197,7 @@ testWithContext("getDefaultVoice: returns undefined when no voices available", a (manager as any).voices = []; (manager as any).browserVoices = []; - const defaultVoice = manager.getDefaultVoice("en-US"); + const defaultVoice = await manager.getDefaultVoice("en-US"); t.is(defaultVoice, null); } finally { // Restore for other tests diff --git a/test/WebSpeechVoiceManager/initialization.test.ts b/test/WebSpeechVoiceManager/initialization.test.ts index 5991a95..b5aa518 100644 --- a/test/WebSpeechVoiceManager/initialization.test.ts +++ b/test/WebSpeechVoiceManager/initialization.test.ts @@ -25,11 +25,7 @@ testWithContext.afterEach.always((t: ExecutionContext) => { // Initialization Tests // ============================================= -test("initialize: returns singleton instance", async (t) => { - // Reset singleton instance - (WebSpeechVoiceManager as any).instance = undefined; - (WebSpeechVoiceManager as any).initializationPromise = null; - +testWithContext("initialize: returns singleton instance", async (t) => { const instance1 = await WebSpeechVoiceManager.initialize(); const instance2 = await WebSpeechVoiceManager.initialize(); t.is(instance1, instance2); @@ -42,7 +38,7 @@ testWithContext("initialize: loads voices and gets voices successfully", (t) => t.true(voices.length > 0); }); -testWithContext("initialization: keeps all voices by default", (t) => { +testWithContext("initialization: keeps all voices by default", async (t: ExecutionContext) => { const manager = t.context.manager; // Test 1: Basic duplicate voices @@ -62,7 +58,7 @@ testWithContext("initialization: keeps all voices by default", (t) => { default: false }; - const basicVoices = (manager as any).parseToReadiumSpeechVoices([lowVoice, normalVoice]); + const basicVoices = await (manager as any).parseToReadiumSpeechVoices([lowVoice, normalVoice]); t.is(basicVoices.length, 2, "Should keep both basic voices when parsing"); const basicFiltered = manager.filterVoices({removeDuplicates: false}, basicVoices); @@ -75,7 +71,7 @@ testWithContext("initialization: keeps all voices by default", (t) => { t.is(normalQualityVoice?.originalName, "Samantha (enhanced)", "Should preserve normal quality voice original name"); // Test 2: VoiceURI string duplicates - const premiumVoices = (manager as any).parseToReadiumSpeechVoices([ + const premiumVoices = await (manager as any).parseToReadiumSpeechVoices([ { voiceURI: "Samantha", name: "Samantha", @@ -103,7 +99,7 @@ testWithContext("initialization: keeps all voices by default", (t) => { t.is(enhancedVoice?.originalName, "Samantha (Premium)", "Should preserve enhanced voice original name"); // Test 3: Primary name vs altName matches - const altNameVoices = (manager as any).parseToReadiumSpeechVoices([ + const altNameVoices = await (manager as any).parseToReadiumSpeechVoices([ { voiceURI: "Google US English 5 (Natural)", name: "Google US English 5 (Natural)", @@ -131,7 +127,7 @@ testWithContext("initialization: keeps all voices by default", (t) => { t.is(altVoice?.originalName, "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", "Should preserve altName voice original name"); // Test 4: Multiple altName matches - const multiAltVoices = (manager as any).parseToReadiumSpeechVoices([ + const multiAltVoices = await (manager as any).parseToReadiumSpeechVoices([ { voiceURI: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", name: "Android Speech Recognition and Synthesis from Google en-us-x-tpc-local", @@ -159,8 +155,7 @@ testWithContext("initialization: keeps all voices by default", (t) => { t.is(networkVoice?.originalName, "Android Speech Recognition and Synthesis from Google en-us-x-tpc-network", "Should preserve network altName voice original name"); }); - -testWithContext("quality inference: infers quality from nativeID when voiceURI has no indicators", (t) => { +testWithContext("initialization: quality inference: infers quality from nativeID when voiceURI has no indicators", async (t: ExecutionContext) => { const manager = t.context.manager; // Test Francesca voice from es.json which has nativeID with "enhanced" @@ -174,13 +169,13 @@ testWithContext("quality inference: infers quality from nativeID when voiceURI h }; // Parse the voice - it should find Francesca in es.json and infer quality from nativeID - const voices = (manager as any).parseToReadiumSpeechVoices([testVoice]); + const voices = await (manager as any).parseToReadiumSpeechVoices([testVoice]); // Should infer "normal" quality from "enhanced" in nativeID array t.is(voices[0].quality, "normal", "Should infer 'normal' quality from 'enhanced' in Francesca's nativeID"); }); -testWithContext("quality inference: voiceURI takes precedence over nativeID", (t) => { +testWithContext("initialization: quality inference: voiceURI takes precedence over nativeID", async (t: ExecutionContext) => { const manager = t.context.manager; // Test Francesca voice with compact in voiceURI (should take precedence over nativeID enhanced) @@ -193,7 +188,7 @@ testWithContext("quality inference: voiceURI takes precedence over nativeID", (t }; // Parse the voice - it should find Francesca but use voiceURI quality (takes precedence) - const voices = (manager as any).parseToReadiumSpeechVoices([testVoice]); + const voices = await (manager as any).parseToReadiumSpeechVoices([testVoice]); // Should infer "low" from voiceURI, not "normal" from nativeID (voiceURI takes precedence) t.is(voices[0].quality, "low", "Should infer 'low' quality from voiceURI, not 'normal' from nativeID"); diff --git a/test/WebSpeechVoiceManager/sortVoices.test.ts b/test/WebSpeechVoiceManager/sortVoices.test.ts index 3365237..6b14fa1 100644 --- a/test/WebSpeechVoiceManager/sortVoices.test.ts +++ b/test/WebSpeechVoiceManager/sortVoices.test.ts @@ -26,7 +26,7 @@ testWithContext.afterEach.always((t: ExecutionContext) => { // sortVoices Tests // ============================================= -testWithContext("sortVoices: sorts by quality", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts by quality", async (t: ExecutionContext) => { const manager = t.context.manager; // Create test voices with different quality levels @@ -39,7 +39,7 @@ testWithContext("sortVoices: sorts by quality", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts by quality across languages", async (t: ExecutionContext) => { const manager = t.context.manager; // Create test voices with different languages and quality levels @@ -61,7 +61,7 @@ testWithContext("sortVoices: sorts by quality across languages", (t: ExecutionCo createTestVoice({ name: "Portuguese Unknown Quality", language: "pt-BR", quality: null }) ]; - const sortedAsc = manager.sortVoicesByQuality(testVoices); + const sortedAsc = await manager.sortVoicesByQuality(testVoices); // Check both quality and language to ensure correct sorting t.is(sortedAsc[0].quality, "veryHigh"); @@ -83,7 +83,7 @@ testWithContext("sortVoices: sorts by quality across languages", (t: ExecutionCo t.is(sortedAsc[5].language, "pt-BR"); }); -testWithContext("sortVoices: sorts by language", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts by language", async (t: ExecutionContext) => { const manager = t.context.manager; // Create test voices with different languages @@ -94,14 +94,14 @@ testWithContext("sortVoices: sorts by language", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts languages by preferred", async (t: ExecutionContext) => { const manager = t.context.manager; // Create test voices with different languages and regions @@ -116,7 +116,7 @@ testWithContext("sortVoices: sorts languages by preferred", (t: ExecutionContext ]; // Test with preferred languages - const sortedPreferred = manager.sortVoicesByLanguages(["fr", "en"], testVoices); + const sortedPreferred = await manager.sortVoicesByLanguages(["fr", "en"], testVoices); // French voices should come first (preferred language) t.is(sortedPreferred[0].language, "fr-FR"); @@ -132,7 +132,7 @@ testWithContext("sortVoices: sorts languages by preferred", (t: ExecutionContext t.is(sortedPreferred[6].language, "es-MX"); }); -testWithContext("sortVoices: sorts by JSON order", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts by JSON order", async (t: ExecutionContext) => { const manager = t.context.manager; // Create test voices using actual names from JSON files in their exact JSON order @@ -146,7 +146,7 @@ testWithContext("sortVoices: sorts by JSON order", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts by region", async (t: ExecutionContext) => { const manager = t.context.manager; // Create test voices with different regions @@ -167,7 +167,7 @@ testWithContext("sortVoices: sorts by region", (t: ExecutionContext createTestVoice({ name: "Australia Voice", language: "en-AU" }) ]; - const sortedAsc = manager.sortVoicesByRegions([], testVoices); + const sortedAsc = await manager.sortVoicesByRegions([], testVoices); t.is(sortedAsc[0].language, "en-US"); t.is(sortedAsc[1].language, "en-AU"); t.is(sortedAsc[2].language, "en-CA"); @@ -176,7 +176,7 @@ testWithContext("sortVoices: sorts by region", (t: ExecutionContext -testWithContext("sortVoices: sorts regions by preferred", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts regions by preferred", async (t: ExecutionContext) => { const manager = t.context.manager; // Create test voices with different languages and regions @@ -193,7 +193,7 @@ testWithContext("sortVoices: sorts regions by preferred", (t: ExecutionContext) => { +testWithContext("sortVoices: sorts regions by JSON order", async (t: ExecutionContext) => { const manager = t.context.manager; // Create test voices using actual names from JSON files in their exact JSON order @@ -259,7 +259,7 @@ testWithContext("sortVoices: sorts regions by JSON order", (t: ExecutionContext< ]; // Test with preferred language - const sorted = manager.sortVoicesByRegions(["fr-FR"], testVoices); + const sorted = await manager.sortVoicesByRegions(["fr-FR"], testVoices); // Should be in exact JSON order for same quality, then quality ordering t.is(sorted[0].name, "Microsoft VivienneMultilingual Online (Natural) - French (France)"); diff --git a/test/WebSpeechVoiceManager/systemLocale.test.ts b/test/WebSpeechVoiceManager/systemLocale.test.ts index ec1e86f..7ccb29c 100644 --- a/test/WebSpeechVoiceManager/systemLocale.test.ts +++ b/test/WebSpeechVoiceManager/systemLocale.test.ts @@ -59,7 +59,10 @@ testWithContext("systemLocale: initializes with first navigator.language", async (WebSpeechVoiceManager as any).initializationPromise = null; // Test initialization with the modified navigator.languages - const manager = await WebSpeechVoiceManager.initialize(1000, 10); + const manager = await WebSpeechVoiceManager.initialize({ + maxTimeout: 1000, + interval: 10 + }); // Verify systemLocale is set to the first language code from navigator.languages t.is((manager as any).systemLocale, "fr"); diff --git a/vite.config.js b/vite.config.js index 9b63d1b..d329997 100644 --- a/vite.config.js +++ b/vite.config.js @@ -14,9 +14,7 @@ export default defineConfig({ rollupOptions: { external: [], output: { - inlineDynamicImports: true, - exports: "named", - preserveModules: false + exports: "named" } } }, From be3bb0fcf3ef404fd3bbab529721412fc0e7cf15 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Fri, 16 Jan 2026 11:04:19 +0100 Subject: [PATCH 28/32] Docs reorg (#43) * Move API to docs * Make Quickstart more helpful --- README.md | 371 ++++----------------------------------------------- docs/API.md | 355 ++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- 3 files changed, 379 insertions(+), 350 deletions(-) create mode 100644 docs/API.md diff --git a/README.md b/README.md index 82072c6..d1e3ff7 100644 --- a/README.md +++ b/README.md @@ -82,363 +82,38 @@ The second demo focuses on in-context reading with seamless voice selection (gro ### Basic Usage ```typescript -import { WebSpeechVoiceManager } from "readium-speech"; - -async function setupVoices() { - try { - // Initialize the voice manager - const voiceManager = await WebSpeechVoiceManager.initialize(); - - // Get all available voices - const allVoices = voiceManager.getVoices(); - console.log("Available voices:", allVoices); - - // Get voices with filters - const filteredVoices = voiceManager.getVoices({ - languages: ["en", "fr"], - gender: "female", - quality: "high", - offlineOnly: true, - excludeNovelty: true, - excludeVeryLowQuality: true - }); - - // Sort by quality - const sortedByQuality = await voiceManager.sortVoicesByQuality(filteredVoices); - - // Sort by preferred languages - const sortedByLanguage = await voiceManager.sortVoicesByLanguages(["en", "fr"], filteredVoices); - - // Sort by preferred languages and regions - const sortedByRegion = await voiceManager.sortVoicesByRegions(["en-US", "en-GB"], filteredVoices); - - // Get a test utterance for a specific language - const testText = voiceManager.getTestUtterance("en"); - - } catch (error) { - console.error("Error initializing voice manager:", error); - } -} - -await setupVoices(); -``` - -## Docs - -Documentation provides guide for: - -- [SpeechSynthesis in browsers and OSes](docs/WebSpeech.md) -- [Voices and Filtering](docs/VoicesAndFiltering.md) - -## API Reference - -### Class: WebSpeechVoiceManager - -The main class for managing Web Speech API voices with enhanced functionality. - -#### Initialize the Voice Manager - -```typescript -static initialize(options?: { - languages?: string[]; - maxTimeout?: number; - interval?: number; -}): Promise -``` - -Creates and initializes a new WebSpeechVoiceManager instance. This static factory method must be called to create an instance. - -- `languages`: Optional array of preferred language codes to filter voices during initialization -- `maxTimeout`: Maximum time in milliseconds to wait for voices to load (default: 10000ms) -- `interval`: Interval in milliseconds between voice loading checks (default: 100ms) -- Returns: Promise that resolves with a new WebSpeechVoiceManager instance - -#### Get filtered Voices - -By default, the instance keeps all voices in memory. You can filter them using the `getVoices` method with optional filter criteria and use this array instead. - -```typescript -voiceManager.getVoices(options?: VoiceFilterOptions): ReadiumSpeechVoice[] -``` - -Fetches all available voices that match the specified filter criteria. - -```typescript -interface VoiceFilterOptions { - languages?: string | string[]; // Filter by language code(s) (e.g., "en", "fr-FR") - source?: TSource; // Filter by voice source ("json" | "browser") - gender?: TGender; // "male" | "female" | "other" - quality?: TQuality | TQuality[]; // "high" | "medium" | "low" | "veryLow" - offlineOnly?: boolean; // Only return voices available offline - provider?: string; // Filter by voice provider - excludeNovelty?: boolean; // Exclude novelty voices, true by default - excludeVeryLowQuality?: boolean; // Exclude very low quality voices, true by default -} -``` - -#### Get Languages and Regions - -```typescript -voiceManager.getLanguages(localization?: string, filterOptions?: VoiceFilterOptions, voices?: ReadiumSpeechVoice[]): { code: string; label: string; count: number }[] - -voiceManager.getRegions(localization?: string, filterOptions?: VoiceFilterOptions, voices?: ReadiumSpeechVoice[]): { code: string; label: string; count: number }[] -``` - -Returns arrays of languages and regions with their display names and voice counts. Both methods preserve the order of first occurrence when custom voices are provided. - -#### Get Default Voice - -```typescript -async voiceManager.getDefaultVoice(languages: string | string[], voices?: ReadiumSpeechVoice[]): Promise -``` - -Automatically selects the best available voice based on quality and language preferences. This is the recommended method for getting a suitable voice without manual selection. - -```typescript -// Get the best voice for user's browser language -const defaultVoice = await voiceManager.getDefaultVoice(navigator.languages || ["en"]); - -// Get the best voice for specific preferred languages -const frenchVoice = await voiceManager.getDefaultVoice(["fr-FR", "fr-CA"]); - -// Get the best voice from a pre-filtered voice list -const customVoice = await voiceManager.getDefaultVoice(["en-US", "en-GB"], customVoiceList); -``` - -The selection algorithm: -1. Filters voices by the specified languages (or uses provided voices array) -2. Sorts by region preference within matching languages -3. Returns the highest quality voice from the best language/region match -4. Returns `null` if no voices match or if languages parameter is empty - -#### Filter Voices - -```typescript -voiceManager.filterVoices(options: VoiceFilterOptions, voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] -``` - -Filters voices based on the specified criteria. If no voices are provided, it filters the instance's internal voice list. - -#### Group Voices - -```typescript -voiceManager.groupVoices(groupBy: "languages" | "region" | "gender" | "quality" | "provider", voices?: ReadiumSpeechVoice[]): VoiceGroup -``` - -Organizes voices into groups based on the specified criteria. The available grouping options are: - -- `"languages"`: Groups voices by their language code -- `"region"`: Groups voices by their region -- `"gender"`: Groups voices by gender -- `"quality"`: Groups voices by quality level -- `"provider"`: Groups voices by their provider - -If no voices are provided, it groups the instance's internal voice list. - -#### Sort Voices +import { WebSpeechVoiceManager, WebSpeechReadAloudNavigator } from "readium-speech"; -The library provides opinionated voice sorting capabilities to help you find the best voice for your needs. +// 1. Initialize voice manager and get default (best quality) voice +const voiceManager = await WebSpeechVoiceManager.initialize({ languages: ["en"] }); +const defaultVoice = await voiceManager.getDefaultVoice("en-US"); -If you need more control over the sorting process, you can implement and apply your own sorting logic on filtered voices. +// 2. Create navigator and set voice +const navigator = new WebSpeechReadAloudNavigator(); // Will use WebSpeech engine +await navigator.setVoice(defaultVoice); -##### 1. Sort by Quality +// 3. Handle play event +navigator.on("play", () => { + console.log("Playback started"); +}); -Sort voices from highest to lowest quality: +// 4. Load and play content +navigator.loadContent([{ + text: "This is a test of the readium speech library.", + language: "en" +}]); -```typescript -async voiceManager.sortVoicesByQuality(voices?: ReadiumSpeechVoice[]): Promise; -// Returns: [veryHigh, high, normal, low, veryLow, null] -``` - -If no voices are provided, it sorts the instance's internal voice list. - -##### 2. Sort by Language - -Prioritize specific languages while maintaining JSON data’s quality order within each language group: - -```typescript -async voiceManager.sortVoicesByLanguages(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]): Promise; -// Returns: [preferred languages voices, other languages voices...] -``` - -If no voices are provided, it sorts the instance's internal voice list. - -##### 3. Sort by Region - -Sort voices by preferred languages and regions, while maintaining JSON data’s quality order within each region group: - -```typescript -async voiceManager.sortVoicesByRegions(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]): Promise; -// Returns: [languages in preferred then alphabetical order → regions: preferred regions → default region → alphabetical regions → voice quality within each region] -``` - -### Testing - -#### Get Test Utterance - -```typescript -voiceManager.getTestUtterance(language: string): string -``` - -Retrieves a sample text string suitable for testing text-to-speech functionality in the specified language. If no sample text is available for the specified language, it returns an empty string. - -### Interfaces - -#### `ReadiumSpeechVoice` - -```typescript -interface ReadiumSpeechVoice { - source: TSource; // "json" | "browser" - - // Core identification (required) - label: string; // Human-friendly label for the voice - name: string; // JSON Name (or Web Speech API name if not found) - originalName: string; // Original name of the voice - voiceURI?: string; // For Web Speech API compatibility - - // Localization - language: string; // BCP-47 language tag - localizedName?: TLocalizedName; // Localization pattern (android/apple) - altNames?: string[]; // Alternative names (mostly for Apple voices) - altLanguage?: string; // Alternative BCP-47 language tag - otherLanguages?: string[]; // Other languages this voice can speak - multiLingual?: boolean; // If voice can handle multiple languages - - // Voice characteristics - gender?: TGender; // Voice gender ("female" | "male" | "neutral") - children?: boolean; // If this is a children's voice - - // Quality and capabilities - quality?: TQuality[]; // Available quality levels for this voice ("veryLow" | "low" | "normal" | "high" | "veryHigh") - pitchControl?: boolean; // Whether pitch can be controlled - - // Performance settings - pitch?: number; // Current pitch (0-2, where 1 is normal) - rate?: number; // Speech rate (0.1-10, where 1 is normal) - - // Platform and compatibility - browser?: string[]; // Supported browsers - os?: string[]; // Supported operating systems - preloaded?: boolean; // If the voice is preloaded on the system - nativeID?: string | string[]; // Platform-specific voice ID(s) - - // Additional metadata - note?: string; // Additional notes about the voice - provider?: string; // Voice provider (e.g., "Microsoft", "Google") - - // Allow any additional properties that might be in the JSON - [key: string]: any; -} -``` - -#### `LanguageInfo` - -```typescript -interface LanguageInfo { - code: string; - label: string; - count: number; -} +// 5. Start playback +navigator.play(); ``` -### Enums - -#### `TQuality` - -```typescript -type TQuality = "veryLow" | "low" | "normal" | "high" | "veryHigh"; -``` - -#### `TGender` - -```typescript -type TGender = "female" | "male" | "neutral"; -``` - -#### `TSource` - -```typescript -type TSource = "json" | "browser"; -``` - -## Playback API - -### ReadiumSpeechNavigator - -```typescript -interface ReadiumSpeechNavigator { - // Voice Management - getVoices(): Promise; - setVoice(voice: ReadiumSpeechVoice | string): Promise; - getCurrentVoice(): ReadiumSpeechVoice | null; - - // Content Management - loadContent(content: ReadiumSpeechUtterance | ReadiumSpeechUtterance[]): void; - getCurrentContent(): ReadiumSpeechUtterance | null; - getContentQueue(): ReadiumSpeechUtterance[]; - - // Playback Control - play(): void; - pause(): void; - stop(): void; - - // Navigation - next(): boolean; - previous(): boolean; - jumpTo(utteranceIndex: number): void; - - // Playback Parameters - setRate(rate: number): void; - getRate(): number; - setPitch(pitch: number): void; - getPitch(): number; - setVolume(volume: number): void; - getVolume(): number; - - // State - getState(): ReadiumSpeechPlaybackState; - getCurrentUtteranceIndex(): number; - - // Events - on( - event: ReadiumSpeechPlaybackEvent["type"], - listener: (event: ReadiumSpeechPlaybackEvent) => void - ): void; - - // Cleanup - destroy(): void; -} -``` - -### Events - -#### ReadiumSpeechPlaybackEvent - -```typescript -type ReadiumSpeechPlaybackEvent = { - type: - | "start" // Playback started - | "pause" // Playback paused - | "resume" // Playback resumed - | "end" // Playback ended naturally - | "stop" // Playback stopped manually - | "skip" // Skipped to another utterance - | "error" // An error occurred - | "boundary" // Reached a word/sentence boundary - | "mark" // Reached a named mark in SSML - | "idle" // No content loaded - | "loading" // Loading content - | "ready" // Ready to play - | "voiceschanged"; // Available voices changed - detail?: any; // Event-specific data -}; -``` +## Docs -#### ReadiumSpeechPlaybackState +Documentation provides guides for: -```typescript -type ReadiumSpeechPlaybackState = "playing" | "paused" | "idle" | "loading" | "ready"; -``` +- [SpeechSynthesis in browsers and OSes](docs/WebSpeech.md) +- [Voices and Filtering](docs/VoicesAndFiltering.md) +- [API Reference](docs/API.md) ## Development diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..369d396 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,355 @@ +# API Reference + +## Class: WebSpeechVoiceManager + +The main class for managing Web Speech API voices with enhanced functionality. + +### Initialize the Voice Manager + +```typescript +static initialize(options?: { + languages?: string[]; + maxTimeout?: number; + interval?: number; +}): Promise +``` + +Creates and initializes a new WebSpeechVoiceManager instance. This static factory method must be called to create an instance. + +- `languages`: Optional array of preferred language codes to filter voices during initialization +- `maxTimeout`: Maximum time in milliseconds to wait for voices to load (default: 10000ms) +- `interval`: Interval in milliseconds between voice loading checks (default: 100ms) + +Returns a Promise that resolves with a `WebSpeechSpeechManager` instance. This instance is a singleton to ensure the same voice manager is used whether initialized directly or through the PlaybackEngine. + +### Get Voices + +By default, the instance keeps all voices in memory. You can filter them using the `getVoices` method with optional filter criteria and use this array instead. + +```typescript +voiceManager.getVoices(options?: VoiceFilterOptions): ReadiumSpeechVoice[] +``` + +Fetches all available voices that match the specified filter criteria. + +```typescript +interface VoiceFilterOptions { + languages?: string | string[]; // Filter by language code(s) (e.g., "en", "fr-FR") + source?: TSource; // Filter by voice source ("json" | "browser") + gender?: TGender; // "male" | "female" | "other" + quality?: TQuality | TQuality[]; // "high" | "medium" | "low" | "veryLow" + offlineOnly?: boolean; // Only return voices available offline + provider?: string; // Filter by voice provider + excludeNovelty?: boolean; // Exclude novelty voices, true by default + excludeVeryLowQuality?: boolean; // Exclude very low quality voices, true by default + removeDuplicates?: boolean; // Remove duplicate voices, true by default +} +``` + +By default, this method returns all voices, excluding novelty voices and very low quality voices, as well as removing what can be considered duplicate voices (lower quality, online/offline, etc). + +### Get Languages and Regions + +```typescript +voiceManager.getLanguages(localization?: string, filterOptions?: VoiceFilterOptions, voices?: ReadiumSpeechVoice[]): { code: string; label: string; count: number }[] + +voiceManager.getRegions(localization?: string, filterOptions?: VoiceFilterOptions, voices?: ReadiumSpeechVoice[]): { code: string; label: string; count: number }[] +``` + +Returns arrays of languages and regions with their display names and voice counts. Both methods preserve the order of first occurrence when custom voices are provided. + +### Get Default Voice + +```typescript +async voiceManager.getDefaultVoice(languages: string | string[], voices?: ReadiumSpeechVoice[]): Promise +``` + +Automatically selects the best available voice based on quality and language preferences. This is the recommended method for getting a suitable voice without manual selection. + +```typescript +// Get the best voice for user's browser language +const defaultVoice = await voiceManager.getDefaultVoice(navigator.languages); + +// Get the best voice for specific preferred languages +const frenchVoice = await voiceManager.getDefaultVoice(["fr-FR", "fr-CA"]); + +// Get the best voice from a pre-filtered voice list +const customVoice = await voiceManager.getDefaultVoice(["en-US", "en-GB"], customVoiceList); +``` + +The selection algorithm: +1. Filters voices by the specified languages (or uses provided voices array) +2. Sorts by region preference within matching languages +3. Returns the highest quality voice from the best language/region match +4. Returns `null` if no voices match or if languages parameter is empty + +### Filter Voices + +```typescript +voiceManager.filterVoices(options: VoiceFilterOptions, voices?: ReadiumSpeechVoice[]): ReadiumSpeechVoice[] +``` + +Filters voices based on the specified criteria. If no voices are provided, it filters the instance's internal voice list. + +### Group Voices + +```typescript +voiceManager.groupVoices(groupBy: "languages" | "region" | "gender" | "quality" | "provider", voices?: ReadiumSpeechVoice[]): VoiceGroup +``` + +Organizes voices into groups based on the specified criteria. The available grouping options are: + +- `"languages"`: Groups voices by their language code +- `"region"`: Groups voices by their region +- `"gender"`: Groups voices by gender +- `"quality"`: Groups voices by quality level +- `"provider"`: Groups voices by their provider + +If no voices are provided, it groups the instance's internal voice list. + +### Sort Voices + +The library provides opinionated voice sorting capabilities to help you find the best voice for your needs. + +If you need more control over the sorting process, you can implement and apply your own sorting logic on filtered voices. + +#### 1. Sort by Quality + +Sort voices from highest to lowest quality: + +```typescript +async voiceManager.sortVoicesByQuality(voices?: ReadiumSpeechVoice[]): Promise; +// Returns: [veryHigh, high, normal, low, veryLow, null] +``` + +If no voices are provided, it sorts the instance's internal voice list. + +#### 2. Sort by Language + +Prioritize specific languages while maintaining JSON data’s quality order within each language group: + +```typescript +async voiceManager.sortVoicesByLanguages(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]): Promise; +// Returns: [preferred languages voices, other languages voices...] +``` + +If no voices are provided, it sorts the instance's internal voice list. + +#### 3. Sort by Region + +Sort voices by preferred languages and regions, while maintaining JSON data’s quality order within each region group: + +```typescript +async voiceManager.sortVoicesByRegions(preferredLanguages?: string[], voices?: ReadiumSpeechVoice[]): Promise; +// Returns: [languages in preferred then alphabetical order → regions: preferred regions → default region → alphabetical regions → voice quality within each region] +``` + +If no voices are provided, it sorts the instance's internal voice list. + +## Testing + +### Get Test Utterance + +```typescript +voiceManager.getTestUtterance(language: string): string +``` + +Retrieves a sample text string suitable for testing text-to-speech functionality in the specified language. If no sample text is available for the specified language, it returns an empty string. + +## Playback API + +The playback API is a high-level API that provides a simple interface for playing, pausing, and stopping speech. It relies on an engine that you provide to it, or fallback to WebSpeech if none is provided. + +Once initialized, you can use the navigator to load content (utterances) and control playback. + +### ReadiumSpeechNavigator + +```typescript +interface ReadiumSpeechNavigator { + // Voice Management + getVoices(): Promise; + setVoice(voice: ReadiumSpeechVoice | string): Promise; + getCurrentVoice(): ReadiumSpeechVoice | null; + + // Content Management + loadContent(content: ReadiumSpeechUtterance | ReadiumSpeechUtterance[]): void; + getCurrentContent(): ReadiumSpeechUtterance | null; + getContentQueue(): ReadiumSpeechUtterance[]; + + // Playback Control + play(): void; + pause(): void; + stop(): void; + + // Navigation + next(): boolean; + previous(): boolean; + jumpTo(utteranceIndex: number): void; + + // Playback Parameters + setRate(rate: number): void; + getRate(): number; + setPitch(pitch: number): void; + getPitch(): number; + setVolume(volume: number): void; + getVolume(): number; + + // State + getState(): ReadiumSpeechPlaybackState; + getCurrentUtteranceIndex(): number; + + // Events + on( + event: ReadiumSpeechPlaybackEvent["type"], + listener: (event: ReadiumSpeechPlaybackEvent) => void + ): void; + + // Cleanup + destroy(): void; +} +``` + +#### Example Usage + +```typescript +import { WebSpeechReadAloudNavigator } from "@readium/speech"; + +const navigator = new WebSpeechReadAloudNavigator(); + +navigator.loadContent([ + { text: "Hello world.", language: "en" } +]); + +function togglePlayback() { + const state = navigator.getState(); + if (state === "playing") { + navigator.pause(); + } else { + navigator.play(); + } +} + +togglePlayback(); +``` + +## Events + +### ReadiumSpeechPlaybackEvent + +```typescript +type ReadiumSpeechPlaybackEvent = { + type: + | "start" // Playback started + | "pause" // Playback paused + | "resume" // Playback resumed + | "end" // Playback ended naturally + | "stop" // Playback stopped manually + | "skip" // Skipped to another utterance + | "error" // An error occurred + | "boundary" // Reached a word/sentence boundary + | "mark" // Reached a named mark in SSML + | "idle" // No content loaded + | "loading" // Loading content + | "ready" // Ready to play + | "voiceschanged"; // Available voices changed + detail?: any; // Event-specific data +}; +``` + +### ReadiumSpeechPlaybackState + +```typescript +type ReadiumSpeechPlaybackState = "playing" | "paused" | "idle" | "loading" | "ready"; +``` + +## Interfaces + +### `ReadiumSpeechVoice` + +```typescript +interface ReadiumSpeechVoice { + source: TSource; // "json" | "browser" + + // Core identification (required) + label: string; // Human-friendly label for the voice + name: string; // JSON Name (or Web Speech API name if not found) + originalName: string; // Original name of the voice + voiceURI?: string; // For Web Speech API compatibility + + // Localization + language: string; // BCP-47 language tag + localizedName?: TLocalizedName; // Localization pattern (android/apple) + altNames?: string[]; // Alternative names (mostly for Apple voices) + altLanguage?: string; // Alternative BCP-47 language tag + otherLanguages?: string[]; // Other languages this voice can speak + multiLingual?: boolean; // If voice can handle multiple languages + + // Voice characteristics + gender?: TGender; // Voice gender ("female" | "male" | "neutral") + children?: boolean; // If this is a children's voice + + // Quality and capabilities + quality?: TQuality[]; // Available quality levels for this voice ("veryLow" | "low" | "normal" | "high" | "veryHigh") + pitchControl?: boolean; // Whether pitch can be controlled + + // Performance settings + pitch?: number; // Current pitch (0-2, where 1 is normal) + rate?: number; // Speech rate (0.1-10, where 1 is normal) + + // Platform and compatibility + browser?: string[]; // Supported browsers + os?: string[]; // Supported operating systems + preloaded?: boolean; // If the voice is preloaded on the system + nativeID?: string | string[]; // Platform-specific voice ID(s) + + // Additional metadata + note?: string; // Additional notes about the voice + provider?: string; // Voice provider (e.g., "Microsoft", "Google") + + // Allow any additional properties that might be in the JSON + [key: string]: any; +} +``` + +### `LanguageInfo` + +```typescript +interface LanguageInfo { + code: string; + label: string; + count: number; +} +``` + +### ReadiumSpeechUtterance + +```typescript +interface ReadiumSpeechUtterance { + id?: string; // Unique identifier for this content + text: string; // Text or SSML content + ssml?: boolean; // If true, text contains SSML + language?: string; // Language of this content (BCP 47) +} +``` + +Represents a single piece of content to be spoken. Can contain plain text or SSML markup. + +## Enums + +### `TQuality` + +```typescript +type TQuality = null | "veryLow" | "low" | "normal" | "high" | "veryHigh"; +``` + +### `TGender` + +```typescript +type TGender = "female" | "male" | "neutral"; +``` + +### `TSource` + +```typescript +type TSource = "json" | "browser"; +``` \ No newline at end of file diff --git a/package.json b/package.json index f5225db..a2eefab 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,7 @@ "types": "./build/index.d.ts", "sideEffects": false, "files": [ - "build", - "json" + "build" ], "exports": { ".": { From a308973e3f80db6fdfeb80da56d79543b4d3b1c9 Mon Sep 17 00:00:00 2001 From: Hadrien Gardeur Date: Sun, 18 Jan 2026 16:40:02 +0100 Subject: [PATCH 29/32] Update test utterance for Kazakh New utterance is now based on Cyrillic, since Latin alphabet wasn't supported by Edge --- json/kk.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/json/kk.json b/json/kk.json index 83a1515..cc3ec96 100644 --- a/json/kk.json +++ b/json/kk.json @@ -1,7 +1,7 @@ { "language": "kk", "defaultRegion": "kk-KZ", - "testUtterance": "Sälemetsiz be, meniñ atım {name} jäne men qazaq dawısımın.", + "testUtterance": "Сәлеметсіз бе, менің атым {name} және мен қазақ дауыстымын.", "voices": [ { "label": "Aigul", @@ -54,4 +54,4 @@ ] } ] -} \ No newline at end of file +} From 62f9f322b2fd6db56f34538dfa36d9cb5d384faa Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 19 Jan 2026 13:25:34 +0100 Subject: [PATCH 30/32] Fix alphabetical sort (#44) --- package.json | 2 +- src/WebSpeech/WebSpeechVoiceManager.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index a2eefab..cc990ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@readium/speech", - "version": "0.1.0-beta.7", + "version": "0.1.0-beta.8", "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.", "main": "./build/index.cjs", "module": "./build/index.js", diff --git a/src/WebSpeech/WebSpeechVoiceManager.ts b/src/WebSpeech/WebSpeechVoiceManager.ts index 9017ef3..3f0e74a 100644 --- a/src/WebSpeech/WebSpeechVoiceManager.ts +++ b/src/WebSpeech/WebSpeechVoiceManager.ts @@ -878,14 +878,16 @@ private static sortByQuality( return regionCompare; } } - if (aRegion) return -1; - if (bRegion) return 1; + // If one has region and the other doesn't, the one with region comes first + if (aRegion && !bRegion) return -1; + if (!aRegion && bRegion) return 1; // Same language group - sort by quality return WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps, aLang); } - return langCompare; + // Fallback - should not reach here but ensure we return a number + return WebSpeechVoiceManager.sortByQuality(a, b, jsonOrderMaps, aLang); }); } From bd73fdb1a7667f89d57871153d39cb6ec38f8ad1 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 20 Jan 2026 10:31:37 +0100 Subject: [PATCH 31/32] Bump version out of beta --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cc990ae..6a43ef8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@readium/speech", - "version": "0.1.0-beta.8", + "version": "0.1.0", "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.", "main": "./build/index.cjs", "module": "./build/index.js", From 1949082761afb7aa583bcf1d091ce5ba26109133 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 20 Jan 2026 10:41:48 +0100 Subject: [PATCH 32/32] Prepare package for publishing --- README.md | 74 ++---- package-lock.json | 634 +++++++++++++++++++++++++--------------------- package.json | 27 +- src/index.ts | 3 +- 4 files changed, 391 insertions(+), 347 deletions(-) diff --git a/README.md b/README.md index d1e3ff7..9f3b497 100644 --- a/README.md +++ b/README.md @@ -47,63 +47,45 @@ The first demo showcases the following features: The second demo focuses on in-context reading with seamless voice selection (grouped by region and sorted based on quality), and playback control, providing an optional read-along experience that integrates naturally with the content. -## QuickStart +## Installation -### Prerequisites +Install the package using npm: -- Node.js -- npm - -### Installation - -1. Clone the repository: - ```bash - git clone https://github.com/readium/speech.git - cd speech - ``` +```bash +npm install @readium/speech +``` -2. Install dependencies: - ```bash - npm install - ``` +Or using yarn: -3. Build the package: - ```bash - npm run build - ``` - -4. Link the package locally (optional, for development): - ```bash - npm link - # Then in your project directory: - # npm link readium-speech - ``` +```bash +yarn add @readium/speech +``` -### Basic Usage +## Quick Start ```typescript -import { WebSpeechVoiceManager, WebSpeechReadAloudNavigator } from "readium-speech"; +import { WebSpeechVoiceManager, WebSpeechReadAloudNavigator } from "@readium/speech"; -// 1. Initialize voice manager and get default (best quality) voice -const voiceManager = await WebSpeechVoiceManager.initialize({ languages: ["en"] }); -const defaultVoice = await voiceManager.getDefaultVoice("en-US"); +// Initialize voice manager +const voiceManager = await WebSpeechVoiceManager.initialize({ + languages: ["en", "fr", "es"] // List of languages to fetch voices for +}); -// 2. Create navigator and set voice -const navigator = new WebSpeechReadAloudNavigator(); // Will use WebSpeech engine -await navigator.setVoice(defaultVoice); +// Get the best available voice for a specific language +const voice = await voiceManager.getDefaultVoice("en-US"); -// 3. Handle play event -navigator.on("play", () => { - console.log("Playback started"); -}); +// Create a navigator instance +const navigator = new WebSpeechReadAloudNavigator(); +await navigator.setVoice(voice); -// 4. Load and play content -navigator.loadContent([{ - text: "This is a test of the readium speech library.", - language: "en" -}]); +// Handle playback events +navigator.on("play", () => console.log("Playback started")); +navigator.on("pause", () => console.log("Playback paused")); +navigator.on("end", () => console.log("Playback completed")); -// 5. Start playback +// Load and play content +const content = document.getElementById("content"); +navigator.loadContent(content); navigator.play(); ``` @@ -139,7 +121,7 @@ The project includes two demo applications that can be served locally: 1. Start the local development server: ```bash - npm run serve + npm run start ``` 2. Open your browser to: diff --git a/package-lock.json b/package-lock.json index ebda130..079ef74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@readium/speech", - "version": "0.1.0-beta.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@readium/speech", - "version": "0.1.0-beta.1", + "version": "0.1.0", "license": "BSD-3-Clause", "dependencies": { "string-strip-html": "^13.4.23" @@ -57,13 +57,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.28.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -73,9 +73,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", "dev": true, "license": "MIT", "dependencies": { @@ -100,9 +100,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], @@ -117,9 +117,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], @@ -134,9 +134,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], @@ -151,9 +151,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], @@ -168,9 +168,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -185,9 +185,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], @@ -202,9 +202,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], @@ -219,9 +219,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], @@ -236,9 +236,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], @@ -253,9 +253,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], @@ -270,9 +270,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], @@ -287,9 +287,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], @@ -304,9 +304,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], @@ -321,9 +321,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], @@ -338,9 +338,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], @@ -355,9 +355,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], @@ -372,9 +372,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], @@ -389,9 +389,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], @@ -406,9 +406,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], @@ -423,9 +423,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], @@ -440,9 +440,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "cpu": [ "x64" ], @@ -457,9 +457,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], @@ -474,9 +474,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], @@ -491,9 +491,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], @@ -508,9 +508,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], @@ -525,9 +525,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], @@ -709,9 +709,9 @@ } }, "node_modules/@microsoft/api-extractor/node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -877,9 +877,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.2.tgz", + "integrity": "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==", "cpu": [ "arm" ], @@ -891,9 +891,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.2.tgz", + "integrity": "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==", "cpu": [ "arm64" ], @@ -905,9 +905,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.2.tgz", + "integrity": "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==", "cpu": [ "arm64" ], @@ -919,9 +919,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.2.tgz", + "integrity": "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==", "cpu": [ "x64" ], @@ -933,9 +933,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.2.tgz", + "integrity": "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==", "cpu": [ "arm64" ], @@ -947,9 +947,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.2.tgz", + "integrity": "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==", "cpu": [ "x64" ], @@ -961,9 +961,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.2.tgz", + "integrity": "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==", "cpu": [ "arm" ], @@ -975,9 +975,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.2.tgz", + "integrity": "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==", "cpu": [ "arm" ], @@ -989,9 +989,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.2.tgz", + "integrity": "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==", "cpu": [ "arm64" ], @@ -1003,9 +1003,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.2.tgz", + "integrity": "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==", "cpu": [ "arm64" ], @@ -1017,9 +1017,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.2.tgz", + "integrity": "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.2.tgz", + "integrity": "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==", "cpu": [ "loong64" ], @@ -1031,9 +1045,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.2.tgz", + "integrity": "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.2.tgz", + "integrity": "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==", "cpu": [ "ppc64" ], @@ -1045,9 +1073,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.2.tgz", + "integrity": "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==", "cpu": [ "riscv64" ], @@ -1059,9 +1087,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.2.tgz", + "integrity": "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==", "cpu": [ "riscv64" ], @@ -1073,9 +1101,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.2.tgz", + "integrity": "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==", "cpu": [ "s390x" ], @@ -1087,9 +1115,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.2.tgz", + "integrity": "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==", "cpu": [ "x64" ], @@ -1101,9 +1129,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.2.tgz", + "integrity": "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==", "cpu": [ "x64" ], @@ -1114,10 +1142,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.2.tgz", + "integrity": "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.2.tgz", + "integrity": "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==", "cpu": [ "arm64" ], @@ -1129,9 +1171,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.2.tgz", + "integrity": "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==", "cpu": [ "arm64" ], @@ -1143,9 +1185,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.2.tgz", + "integrity": "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==", "cpu": [ "ia32" ], @@ -1157,9 +1199,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.2.tgz", + "integrity": "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==", "cpu": [ "x64" ], @@ -1171,9 +1213,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.2.tgz", + "integrity": "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==", "cpu": [ "x64" ], @@ -1384,9 +1426,9 @@ "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", "license": "MIT" }, "node_modules/@types/lodash-es": { @@ -1399,9 +1441,9 @@ } }, "node_modules/@types/node": { - "version": "25.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.1.tgz", - "integrity": "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==", + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", + "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", "dev": true, "license": "MIT", "peer": true, @@ -1466,28 +1508,28 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.25", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", - "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.25", - "entities": "^4.5.0", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.25", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz", - "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.25", - "@vue/shared": "3.5.25" + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" } }, "node_modules/@vue/compiler-vue2": { @@ -1527,9 +1569,9 @@ } }, "node_modules/@vue/shared": { - "version": "3.5.25", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz", - "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", "dev": true, "license": "MIT" }, @@ -2104,9 +2146,9 @@ } }, "node_modules/codsen-utils": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/codsen-utils/-/codsen-utils-1.7.0.tgz", - "integrity": "sha512-J+fnmscIPihyeZGsMsy0wWHXDiA8+51KySw5uGqhKI+iwNSzOwe+sjU4J/BrQajMEBO6BPVx7qDq0cQHnUbrOw==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/codsen-utils/-/codsen-utils-1.7.3.tgz", + "integrity": "sha512-YIFQQ1n2NSgwoB3sCe7RpkZzsrPxTMek6jc7wC9fXOm1wwfWAKja9gLOMEjlXOUd3LKV3o6Jci7n9BoHs5Z8Sg==", "license": "MIT", "dependencies": { "rfdc": "^1.4.1" @@ -2342,9 +2384,9 @@ "license": "MIT" }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -2388,9 +2430,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2401,32 +2443,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/escalade": { @@ -2556,9 +2598,9 @@ } }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -2671,9 +2713,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", - "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", "dev": true, "license": "MIT", "dependencies": { @@ -3379,9 +3421,9 @@ "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", "license": "MIT" }, "node_modules/lru-cache": { @@ -3981,9 +4023,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4035,12 +4077,12 @@ "license": "MIT" }, "node_modules/ranges-apply": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/ranges-apply/-/ranges-apply-7.1.0.tgz", - "integrity": "sha512-rtAdRodLlwASQlECefgqYPfyCIRKSE4CJjqIltn4UXwqNvhysR1a2db+U49nU8+5N1L6R71LlVPReCRjf3Henw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/ranges-apply/-/ranges-apply-7.1.3.tgz", + "integrity": "sha512-+dpc801TK6qUoMKU9dnwD0wH6XORtZicXLVm4p43jEp+te7Q+Tw0Pa4cHX9Mj1W0zshVYh1S4RIYI38ZUPglqQ==", "license": "MIT", "dependencies": { - "ranges-merge": "^9.1.0", + "ranges-merge": "^9.1.3", "tiny-invariant": "^1.3.3" }, "engines": { @@ -4048,37 +4090,37 @@ } }, "node_modules/ranges-merge": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/ranges-merge/-/ranges-merge-9.1.0.tgz", - "integrity": "sha512-6jJKvNfscpCga3oEMBlZKbPz/jLwOTRdnpiyaHm/qtl57sWI99ld9qupII3YscbkNcSbt1sfePYC837M2IYf0Q==", + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/ranges-merge/-/ranges-merge-9.1.3.tgz", + "integrity": "sha512-dPS11e7AHD1tnuzrboYa+n07JqbHccMqt94C3cSlJ9tTC/RN6P+iGePhTyvCPpoNDWVOEVX4JGyGH0oHjwas0g==", "license": "MIT", "dependencies": { - "ranges-push": "^7.1.0", - "ranges-sort": "^6.1.0" + "ranges-push": "^7.1.3", + "ranges-sort": "^6.1.3" }, "engines": { "node": ">=14.18.0" } }, "node_modules/ranges-push": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/ranges-push/-/ranges-push-7.1.0.tgz", - "integrity": "sha512-5PiLj4BHiG56CTsLGtvdaukgTRTFrzLpET2eAEx8dsJzigOh8phtzjE7zSlYhaUcnVGMmAqWkfTjcWIQhqjpJg==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/ranges-push/-/ranges-push-7.1.3.tgz", + "integrity": "sha512-3laGXNa4CW1vt6e2RqC35xRmxXpcWwLNpepTQJ+dkftc/7rH+d9pQ/UQgV6/peN6DsDcpJuu+PjLjWprz2UjDA==", "license": "MIT", "dependencies": { - "codsen-utils": "^1.7.0", - "ranges-sort": "^6.1.0", - "string-collapse-leading-whitespace": "^7.1.0", - "string-trim-spaces-only": "^5.1.0" + "codsen-utils": "^1.7.3", + "ranges-sort": "^6.1.3", + "string-collapse-leading-whitespace": "^7.1.3", + "string-trim-spaces-only": "^5.1.3" }, "engines": { "node": ">=14.18.0" } }, "node_modules/ranges-sort": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ranges-sort/-/ranges-sort-6.1.0.tgz", - "integrity": "sha512-esvEBNDhydnuojWhXkiZnHv4infMKaeD4NsCqce++uYxnRIAXIS6R3iAMNVLqxaPZn+4+h5dhEPXCuBgpExakg==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/ranges-sort/-/ranges-sort-6.1.3.tgz", + "integrity": "sha512-JsTMmurWEOPYowp1OwUbjcfXyDfaTB+AWoiZwjV+3qOv0uUI7A1CExGwyFwfXEO5HYThOTDC96qC9a6BYqFbyg==", "license": "MIT", "engines": { "node": ">=14.18.0" @@ -4254,9 +4296,9 @@ } }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz", + "integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==", "dev": true, "license": "MIT", "dependencies": { @@ -4270,28 +4312,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.55.2", + "@rollup/rollup-android-arm64": "4.55.2", + "@rollup/rollup-darwin-arm64": "4.55.2", + "@rollup/rollup-darwin-x64": "4.55.2", + "@rollup/rollup-freebsd-arm64": "4.55.2", + "@rollup/rollup-freebsd-x64": "4.55.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.2", + "@rollup/rollup-linux-arm-musleabihf": "4.55.2", + "@rollup/rollup-linux-arm64-gnu": "4.55.2", + "@rollup/rollup-linux-arm64-musl": "4.55.2", + "@rollup/rollup-linux-loong64-gnu": "4.55.2", + "@rollup/rollup-linux-loong64-musl": "4.55.2", + "@rollup/rollup-linux-ppc64-gnu": "4.55.2", + "@rollup/rollup-linux-ppc64-musl": "4.55.2", + "@rollup/rollup-linux-riscv64-gnu": "4.55.2", + "@rollup/rollup-linux-riscv64-musl": "4.55.2", + "@rollup/rollup-linux-s390x-gnu": "4.55.2", + "@rollup/rollup-linux-x64-gnu": "4.55.2", + "@rollup/rollup-linux-x64-musl": "4.55.2", + "@rollup/rollup-openbsd-x64": "4.55.2", + "@rollup/rollup-openharmony-arm64": "4.55.2", + "@rollup/rollup-win32-arm64-msvc": "4.55.2", + "@rollup/rollup-win32-ia32-msvc": "4.55.2", + "@rollup/rollup-win32-x64-gnu": "4.55.2", + "@rollup/rollup-win32-x64-msvc": "4.55.2", "fsevents": "~2.3.2" } }, @@ -4572,21 +4617,21 @@ } }, "node_modules/string-collapse-leading-whitespace": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/string-collapse-leading-whitespace/-/string-collapse-leading-whitespace-7.1.0.tgz", - "integrity": "sha512-VDQaY0zGeD+S36xwreMWw64C+fl31FoS4txHScuUoUw6B760P63Q00FVdcF7SU1qXD5FKG1ptMWrtV65l+kvcw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/string-collapse-leading-whitespace/-/string-collapse-leading-whitespace-7.1.3.tgz", + "integrity": "sha512-bCgNODedM9daANnnNPOHuz++qUeVHgsPMghNPznbLLnBviNyuKt9vzHIa6OGTSpWzJUO4Rlffu4+RD47pSTq3g==", "license": "MIT", "engines": { "node": ">=14.18.0" } }, "node_modules/string-left-right": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/string-left-right/-/string-left-right-6.1.0.tgz", - "integrity": "sha512-Y+QrkHzY7S8/UuArnhJkStKdHfQI4dJv9K3qWDJ2W0WVQXFkG5Zh+YbxMVssGdk84FLwhg5yxg9/y9AORaqbRA==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/string-left-right/-/string-left-right-6.1.3.tgz", + "integrity": "sha512-XPqLphMTTbPMRs4DPX0flLtVFsDWZ+Og3tyku1FiiE2b/+SUHmiO1KRg7NBlb5e9UrojE5F3ryXORL0VYhwwgA==", "license": "MIT", "dependencies": { - "codsen-utils": "^1.7.0", + "codsen-utils": "^1.7.3", "rfdc": "^1.4.1" }, "engines": { @@ -4594,27 +4639,27 @@ } }, "node_modules/string-strip-html": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/string-strip-html/-/string-strip-html-13.5.0.tgz", - "integrity": "sha512-U2ZnVRhqLuCvczaZEyk7yz4Mu91VfNHGOKtulm2Y5m8I69mp2Epr7NeoDaBxrscAQAX/gNuUQEikzaPXBWH/5g==", + "version": "13.5.3", + "resolved": "https://registry.npmjs.org/string-strip-html/-/string-strip-html-13.5.3.tgz", + "integrity": "sha512-MjrGYjcyOfY8VLXpbEJRtiHLFkWRRIdRt7hcBxGUo2GGM/YSyIfiRQ3q0y2pb+RwYHUfVbw3euuyLzxqyxhyIA==", "license": "MIT", "dependencies": { "@types/lodash-es": "^4.17.12", - "codsen-utils": "^1.7.0", + "codsen-utils": "^1.7.3", "html-entities": "^2.6.0", - "lodash-es": "^4.17.21", - "ranges-apply": "^7.1.0", - "ranges-push": "^7.1.0", - "string-left-right": "^6.1.0" + "lodash-es": "^4.17.22", + "ranges-apply": "^7.1.3", + "ranges-push": "^7.1.3", + "string-left-right": "^6.1.3" }, "engines": { "node": ">=14.18.0" } }, "node_modules/string-trim-spaces-only": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/string-trim-spaces-only/-/string-trim-spaces-only-5.1.0.tgz", - "integrity": "sha512-632znq4SGCNM7Vw7QITbx05oej+Xly2s7OtDxN9jvNbOoWcQuA5fq14CAS5TlpiiB04LDETzJj9fk851PnWLgg==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/string-trim-spaces-only/-/string-trim-spaces-only-5.1.3.tgz", + "integrity": "sha512-Flb782YX49j4GkLv/M2rqJik/lUq+OJnT/q7kQ6byZtgknAo1wb1XFBN3b+7UMW5I6VB1QdNAoh9oiUa9izBpA==", "license": "MIT", "engines": { "node": ">=14.18.0" @@ -4806,9 +4851,9 @@ } }, "node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.4.tgz", + "integrity": "sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -4957,9 +5002,9 @@ } }, "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "dev": true, "license": "MIT" }, @@ -5031,13 +5076,13 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", - "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -5160,6 +5205,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6a43ef8..a131224 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,26 @@ { "name": "@readium/speech", "version": "0.1.0", - "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.", + "description": "A TypeScript library for implementing read aloud features with Web technologies, following best practices for digital publishing.", + "author": "Readium Foundation", + "keywords": [ + "readium", + "speech", + "tts", + "text-to-speech", + "read-aloud", + "accessibility" + ], + "homepage": "https://readium.org/speech/", + "repository": { + "type": "git", + "url": "https://github.com/readium/speech.git" + }, + "bugs": { + "url": "https://github.com/readium/speech/issues" + }, + "license": "BSD-3-Clause", + "type": "module", "main": "./build/index.cjs", "module": "./build/index.js", "types": "./build/index.d.ts", @@ -21,13 +40,9 @@ "generate-metadata": "node scripts/generate-metadata.js", "clean": "rimraf ./build", "build": "vite build", - "start": "node build/index.js", - "serve": "http-server ./", + "start": "http-server ./", "watch": "tsc -w" }, - "author": "", - "license": "BSD-3-Clause", - "type": "module", "devDependencies": { "@ava/typescript": "^6.0.0", "ava": "^6.4.0", diff --git a/src/index.ts b/src/index.ts index 8e342c6..e437893 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,10 @@ export * from "./WebSpeech"; // Data exports -export * from "./voices/languages"; +export { chineseVariantMap } from "./voices/languages"; // Other exports +export * from "./voices/types"; export * from "./engine"; export * from "./navigator"; export * from "./provider";