diff --git a/README.md b/README.md index 7803f4f..4454586 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ # Readium Speech Server -A remote text-to-speech HTTP service for the [Readium](https://readium.org) ecosystem. Exposes a uniform API for listing voices and synthesizing speech, backed by open neural TTS models running on CPU — no GPU required. +> [!IMPORTANT] +> Readium Speech Server is a proof of concept exploring how TTS (Text to Speech) models can be either hosted or proxied through a single Web service. +> +> This project is not currently production-ready and it's missing key features such as caching or long term storage options. -Designed to pair with [Readium Speech](https://github.com/readium/speech) and any Readium-compatible reading application. +A remote text-to-speech HTTP service for the [Readium](https://readium.org) ecosystem. Exposes a uniform API for listing voices and synthesizing speech, backed by open-source models and proxied commercial models. + +Designed to pair with [Readium Speech](https://github.com/readium/speech), Readium toolkits or any other application. --- @@ -10,12 +15,12 @@ Designed to pair with [Readium Speech](https://github.com/readium/speech) and an | | | |---|---| -| **API** | `GET /v1/voices` · `POST /v1/synthesize` | -| **Providers** | PocketTTS (v1) · Kokoro, ElevenLabs, Azure (planned) | +| **API** | `GET /voices` · `POST /synthesize` | +| **Providers** | PocketTTS · ElevenLabs (planned) | | **Languages** | English · French · Italian · German · Spanish · Portuguese | | **Formats** | MP3 · WAV · Opus | -| **Word boundaries** | Schema ready, not yet populated by any provider | -| **Deployment** | Docker · CPU-only · single named volume for model weights | +| **Word boundaries** | Planned (ElevenLabs) | +| **Deployment** | Docker · CPU-only · Single named volume for model weights | --- @@ -29,10 +34,9 @@ make build # build the image (~2 min) make dev-docker # start server — downloads models on first run ``` -Server: `http://localhost:8000` -Interactive API docs: `http://localhost:8000/docs` +Server: `http://localhost:8000` · Interactive docs: `http://localhost:8000/docs` · Demo: `http://localhost:8000/demo` -**Quick test — synthesize and play:** +**Quick test:** ```bash curl -s -X POST http://localhost:8000/v1/synthesize \ -H 'Content-Type: application/json' \ @@ -42,305 +46,14 @@ curl -s -X POST http://localhost:8000/v1/synthesize \ > **First start** downloads the selected language models (~240 MB each) into a persistent Docker volume. Every restart after that is instant — models are already cached. -**Browser demo:** `http://localhost:8000/demo` (or `:8080` behind nginx in production). A single static page, served same-origin by the app itself — no build step, no extra dependency. Lets you pick a voice (with language/provider/gender filters), type an utterance, and hear it synthesized in-browser, with the equivalent `curl` command and raw response shown alongside. A "Developer tools" panel exposes the rest of the request body (format, bitrate, speed, pitch, sample rate, SSML, word-boundary mode) — fields confirmed to actually affect output are called out separately from ones the current provider silently ignores (see [`docs/API.md`](docs/API.md) "Not implemented"). - -### Production - -```bash -make start # detached — docker compose --profile nginx up -d -make stop # stop all containers -make logs # tail app logs (nginx logs: docker compose logs -f nginx) -``` - -Reachable at `http://:8080` — nginx publishes host port `8080`, plain HTTP only, no TLS termination. Put a TLS-terminating load balancer in front for a real deployment. - -`restart: unless-stopped` is set on the **app** container only; the nginx sidecar has no restart policy (`docker-compose.yml`), so it won't come back on its own after a crash or host reboot. - -nginx also rate-limits `/v1/synthesize` (2 req/s, burst 4) and caps connections per IP — a `503` from behind nginx under load is a plain nginx error page, not the app's Problem Details JSON. - ---- - -## Setup wizard - -`make configure` opens an interactive wizard. It handles both first-time setup and ongoing management: - -``` -Readium Speech Server - - Current: languages=en workers=1 - - 1) Show full config - 2) Add a language - 3) Remove a language - 4) Change workers - 5) Update HF token - 6) First-time setup (re-run / overwrite) - 7) Reset - q) Quit -``` - -Adding a language updates `.env` in place — only the new model is downloaded on next restart. Removing a language preserves the model files in the Docker volume; disk is reclaimed only if you choose to purge the volume. - -> **Production by default.** First-time setup writes `APP_ENV=production` and prompts for `DOMAIN` (required in production — FastAPI uses it for `TrustedHostMiddleware` and the OpenAPI base URL). `make start` puts nginx in front of the app as a reverse proxy; the app container has no published port, so it's only reachable through nginx. For local dev, edit `.env` and set `APP_ENV=development` (`DOMAIN` becomes optional again), and use `make dev-docker` instead — it exposes `:8000` directly with no nginx. - ---- - -## Voices - -156 voices across 7 language variants (26 voice identities × 6 languages). Every voice is available in every language — `alba` speaking English, `alba` speaking French, `alba` speaking German, etc. Only languages listed in `LANGUAGES` are loaded at startup (~240 MB RAM per language per worker). - -The 26 voice identities, sourced from [kyutai/tts-voices](https://huggingface.co/kyutai/tts-voices): - -| Voice | Gender | Origin | -|---|---|---| -| alba | female | Alba MacKenna (CC BY 4.0) | -| anna, vera, fantine, charles, paul, eponine, azelma, george, mary, jane, michael, eve | mixed | VCTK dataset (CC BY 4.0) | -| bill_boerst, peter_yearsley, stuart_bell, caro_davy | mixed | Voice Zero / LibriVox (CC0) | -| marius, javert | male | Voice donations (CC0) | -| cosette | female | Expresso dataset (CC BY-NC 4.0) | -| jean | male | EARS dataset (CC BY-NC 4.0) | -| estelle | female | Unmute production voices | -| giovanni, lola, juergen, rafael | mixed | Kyutai (language reference voices) | - -Voice URIs are language-scoped — the same speaker in different languages gets a distinct URI: - -``` -urn:readium:tts:pocket:en-alba # Alba speaking English -urn:readium:tts:pocket:fr-alba # Alba speaking French -urn:readium:tts:pocket:de-alba # Alba speaking German -``` - -Supported language codes: `en`, `fr`, `it`, `de`, `es`, `pt` — derived directly from PocketTTS model names. - -> **How it works:** PocketTTS pre-computes voice embeddings for every voice × language combination (stored as `.safetensors` files in `kyutai/pocket-tts-without-voice-cloning`). The voice sample is encoded once at model-load time — no per-request cloning overhead. - ---- - -## API reference - -### Health - -| Method | Path | Description | -|---|---|---| -| `GET` | `/healthz` | Liveness — 200 when the process is running | -| `GET` | `/readyz` | Readiness — 503 until models are loaded and ffmpeg is available | - -### Voices - -``` -GET /v1/voices -GET /v1/voices?language=fr -GET /v1/voices?provider=pocket -GET /v1/voices?offset=0&limit=20 -``` - -Returns an array of voice objects. Null-valued optional fields are omitted. Each voice includes a `boundary` field indicating whether that provider supports word-level timing marks. - -**Pagination query params:** - -| Param | Type | Description | -|---|---|---| -| `language` | string | Filter by BCP-47 language prefix (e.g. `en`, `fr`) | -| `provider` | string | Filter by provider id (e.g. `pocket`) | -| `offset` | int ≥ 0 | Voices to skip (default: 0) | -| `limit` | int ≥ 1 | Max voices to return (default: all) | - -**Response headers:** - -| Header | Description | -|---|---| -| `X-Total-Count` | Total matching voices before pagination | -| `X-Offset` | Applied offset | -| `X-Limit` | Applied limit (omitted when no limit set) | - -### Synthesize - -``` -POST /v1/synthesize -Content-Type: application/json -``` - -**Minimal request — returns binary MP3:** - -```json -{ - "text": "Hello, world!", - "voice": "urn:readium:tts:pocket:en-alba" -} -``` - -**Full request:** - -```json -{ - "id": "urn:uuid:019f178c-cc7c-7bb3-a39b-d185f43d3cc4", - "text": "Ceci est un test.", - "language": "fr", - "voice": "urn:readium:tts:pocket:fr-estelle", - "ssml": false, - "prev_utterance": "La nuit était sombre.", - "next_utterance": "La pièce était froide.", - "publication_id": "urn:isbn:9780000000000", - "boundary": false, - "output": { - "format": "mp3", - "bitrate": 64, - "speed": 1.0, - "pitch": null - } -} -``` - -**Response (default, `boundary: false`):** - -Binary audio with `Content-Type: audio/mpeg` (or `audio/wav`, `audio/ogg`). - -**Response (`boundary: true`):** - -```json -{ - "audio": "", - "format": "mp3", - "boundaries": [ - { "name": "word", "charIndex": 0, "charLength": 5, "elapsedTime": 0.0 }, - { "name": "word", "charIndex": 6, "charLength": 3, "elapsedTime": 0.38 } - ] -} -``` - -`boundaries` is always `null` today — no provider populates timing marks yet. Every voice reports `boundary: false`; check it before setting `boundary: true` to skip a wasted round trip. - -Word boundary fields mirror the [Web Speech API `boundary` event](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance/boundary_event): `charIndex` and `charLength` index into the original `text`; `elapsedTime` is seconds from audio start. - -**Output formats:** - -| `format` | `Content-Type` | Notes | -|---|---|---| -| `mp3` | `audio/mpeg` | Default | -| `wav` | `audio/wav` | No transcoding — fastest | -| `opus` | `audio/ogg` | Smallest file size | - -**Errors:** - -All errors are [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457) (`Content-Type: application/problem+json`): - -```json -{ "type": "https://readium.org/speech-server/error#voice_not_found", "title": "Voice Not Found", "status": 404, "detail": "Voice 'urn:unknown' not found." } -``` - -| Status | Type suffix | Cause | -|---|---|---| -| 400 | `validation_failed` | Empty or whitespace text | -| 404 | `voice_not_found` | Voice URI not registered | -| 413 | `payload_too_large` | Text exceeds `MAX_TEXT_LENGTH` (default 2000 chars) | -| 422 | `validation_failed` | Request schema invalid (Pydantic detail) | -| 502 | `provider_error` | Provider or ffmpeg failed | -| 503 | `service_not_ready` | `/readyz` only — models not loaded, ffmpeg missing, or a provider unhealthy | - -Behind production nginx, a `503`/`429` can also come from nginx's own rate/connection limits — those are plain nginx error pages, not `application/problem+json`. - -Full field-by-field reference, including every request/response field and what's not implemented yet: [`docs/API.md`](docs/API.md). - ---- - -## Configuration - -Run `make configure` to generate `.env`, or run `bash scripts/configure.sh` directly. - -| Variable | Default | Description | -|---|---|---| -| `LANGUAGES` | `en` | Comma-separated BCP-47 language codes to load. Supported: `en fr it de es pt` | -| `HF_TOKEN` | _(empty)_ | HuggingFace token. Optional — prevents rate-limiting on first-run model downloads | -| `WORKERS` | `1` | Uvicorn worker processes. Each loads a full copy of every active language model | -| `MAX_CONCURRENT_SYNTHESES` | `2` | Max parallel CPU inference jobs per worker | -| `API_KEY_ENABLED` | `false` | Reserved — validated at startup but **not yet enforced** on any route | -| `API_KEY` | _(empty)_ | Reserved, same caveat | -| `LOG_LEVEL` | `INFO` | `DEBUG` · `INFO` · `WARNING` · `ERROR` | -| `PORT` | `8000` | Listen port | -| `MAX_TEXT_LENGTH` | `2000` | Maximum characters per synthesis request | -| `FFMPEG_BIN` | `ffmpeg` | Path to ffmpeg binary (bundled in the Docker image) | -| `POCKET_DEFAULT_VOICE` | `alba` | Default voice when none is specified | -| `ENABLED_PROVIDERS` | `pocket` | Comma-separated provider ids to register at startup. Only `pocket` exists today | -| `DEFAULT_PROVIDER` | `pocket` | Must be one of `ENABLED_PROVIDERS` — validated at startup | -| `DOMAIN` | _(empty)_ | Required when `APP_ENV=production` — used for `TrustedHostMiddleware` and nginx `server_name` | - -**RAM estimate:** `WORKERS × active languages × ~240 MB` - -Example: 2 workers, English + French = `2 × 2 × 240 MB ≈ 960 MB` - ---- - -## Development - -### Commands - -| Command | Description | -|---|---| -| `make configure` | Run setup wizard | -| `make build` | Build Docker image | -| `make dev-docker` | Start dev server with hot-reload | -| `make dev-docker-build` | Build then start | -| `make start` | Start production stack (detached) | -| `make stop` | Stop containers | -| `make logs` | Tail app logs | -| `make test-docker` | Fast test suite — no models needed | -| `make test-integration-docker` | Integration tests — requires models in volume | -| `make ci-docker` | Lint + format check + typecheck + tests | -| `make lint-docker` | `ruff check` | -| `make fmt-docker` | `ruff format` | -| `make typecheck-docker` | `mypy` | -| `make test` | Fast tests via `uv` (no Docker) | -| `make ci` | Full local CI | -| `make clean` | Remove `__pycache__` and `.pyc` files | - -### Running tests locally - -The fast suite requires no models and no ffmpeg — everything is mocked: - -```bash -uv sync -uv run pytest tests/ -m 'not integration and not slow' -v -``` - -### Adding a provider - -1. Create `app/providers/.py` implementing `TTSProvider` -2. Declare `id`, `supported_languages`, and `supports_boundaries` as class variables -3. Implement `_all_voices()` and `synthesize()` -4. Register in `app/main.py` `_build_registry()` - -No changes to routes, synthesizer, or voice catalog. Language filtering and boundary capability are inherited automatically from the base class. - ---- - -## Architecture - -``` -Client - └─ POST /v1/synthesize - └─ Synthesizer - ├─ validate text length + content - ├─ resolve voiceURI → (provider, voiceURI) (VoiceCatalog) - ├─ provider.synthesize() ← runs in thread pool, bounded by semaphore - │ └─ TTSModel.generate_audio() — CPU inference - └─ encode PCM → mp3/opus (ffmpeg driver) or wrap → wav -``` - -- Routes are `async`; all CPU-bound inference runs off the event loop via `anyio.to_thread.run_sync` -- A semaphore (`MAX_CONCURRENT_SYNTHESES`) prevents model thrashing under concurrent load -- Model throughput scales by adding worker processes (`WORKERS`), not threads -- Model weights live in a named Docker volume — downloaded once, instant on every subsequent start - --- -## Provider roadmap +## Documentation -| Provider | Status | Notes | -|---|---|---| -| PocketTTS | Current| CPU · 6 languages · 156 voices (26 identities × 6 languages) | -| Kokoro | Planned | Referenced, not vendored (IP cleanliness) | -| ElevenLabs | Planned | Proxied · word boundaries supported | -| Azure Speech | Planned | Proxied · word boundaries supported | +- [API Reference](docs/API.md) +- [Voices](docs/voices.md) +- [Configuration](docs/configuration.md) +- [Development](docs/development.md) --- diff --git a/docs/API.md b/docs/API.md index 579b7fc..18bc986 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,7 +2,7 @@ HTTP API for the Readium Speech Server: list voices, synthesize speech. Implements the [Readium Speech](https://github.com/readium/speech) `ReadiumSpeechUtterance` / `ReadiumSpeechVoice` contract plus server-specific extensions, called out below. -Base path: `/v1`. All bodies are `application/json` unless noted. +Base path: `/`. All bodies are `application/json` unless noted. --- @@ -11,23 +11,23 @@ Base path: `/v1`. All bodies are `application/json` unless noted. - [Authentication](#authentication) - [Errors](#errors) - [Health](#health) -- [`GET /v1/voices`](#get-v1voices) -- [`POST /v1/synthesize`](#post-v1synthesize) +- [`GET /voices`](#get-v1voices) +- [`POST /synthesize`](#post-v1synthesize) - [Not implemented](#not-implemented) --- ## Authentication -None enforced today. `API_KEY_ENABLED` / `API_KEY` exist in [config](../README.md#configuration) but no middleware checks them yet — every route is open regardless of the setting. Don't rely on it. +None enforced today. `API_KEY_ENABLED` / `API_KEY` exist in [configuration](configuration.md) but no middleware checks them yet — every route is open regardless of the setting. Don't rely on it. -In production, nginx sits in front (see [README → Production](../README.md#production)) and rate-limits `/v1/synthesize` (2 req/s, burst 4) plus per-IP connection caps — unrelated to app auth, but the only request throttling that exists. +In production, nginx sits in front (see [configuration](configuration.md#production-vs-development)) and rate-limits `/synthesize` (2 req/s, burst 4) plus per-IP connection caps — unrelated to app auth, but the only request throttling that exists. --- ## Errors -All errors are [RFC 7807 Problem Details](https://datatracker.ietf.org/doc/html/rfc7807) (`Content-Type: application/problem+json`): +All errors are [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457) (`Content-Type: application/problem+json`): ```json { @@ -68,10 +68,10 @@ Pydantic schema errors (`422`) additionally carry an `errors` array (raw Pydanti ## `GET /v1/voices` ``` -GET /v1/voices -GET /v1/voices?language=fr -GET /v1/voices?provider=pocket -GET /v1/voices?offset=0&limit=20 +GET /voices +GET /voices?language=fr +GET /voices?provider=pocket +GET /voices?offset=0&limit=20 ``` | Param | Type | Default | Description | @@ -136,7 +136,7 @@ Headers: `X-Total-Count` (matches before pagination), `X-Offset`, `X-Limit` (omi --- -## `POST /v1/synthesize` +## `POST /synthesize` ```json { @@ -227,4 +227,4 @@ Things the schema or config surfaces but the server doesn't actually do yet: - **`id` / `publication_id`** — parsed, not used for caching, idempotency, or dedup. - **SSML** — tags are stripped, not interpreted. No prosody control. - **MathML** — passed through as plain text; equations get spoken as raw markup. -- **Providers beyond PocketTTS** — Kokoro, ElevenLabs, Azure are referenced in the provider interface but not wired into `_build_registry()`. See [README → provider roadmap](../README.md#provider-roadmap). +- **Providers beyond PocketTTS** — ElevenLabs is planned but not yet wired into `_build_registry()`. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..12a6aa5 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,59 @@ +# Configuration + +Run `make configure` to generate `.env` via an interactive wizard, or run `bash scripts/configure.sh` directly. + +## Setup wizard + +`make configure` handles both first-time setup and ongoing management: + +``` +Readium Speech Server + + Current: languages=en workers=1 + + 1) Show full config + 2) Add a language + 3) Remove a language + 4) Change workers + 5) Update HF token + 6) First-time setup (re-run / overwrite) + 7) Reset + q) Quit +``` + +Adding a language updates `.env` in place — only the new model is downloaded on next restart. Removing a language preserves the model files in the Docker volume; disk is reclaimed only if you choose to purge the volume. + +> **macOS only.** The configure script auto-downloads [gum](https://github.com/charmbracelet/gum) for its menus. On Linux, install gum manually before running it. + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `LANGUAGES` | `en` | Comma-separated BCP-47 language codes to load. Supported: `en fr it de es pt` | +| `HF_TOKEN` | _(empty)_ | HuggingFace token. Optional — prevents rate-limiting on first-run model downloads | +| `WORKERS` | `1` | Uvicorn worker processes. Each loads a full copy of every active language model | +| `MAX_CONCURRENT_SYNTHESES` | `2` | Max parallel CPU inference jobs per worker | +| `API_KEY_ENABLED` | `false` | Reserved — validated at startup but **not yet enforced** on any route | +| `API_KEY` | _(empty)_ | Reserved, same caveat | +| `LOG_LEVEL` | `INFO` | `DEBUG` · `INFO` · `WARNING` · `ERROR` | +| `PORT` | `8000` | Listen port | +| `MAX_TEXT_LENGTH` | `2000` | Maximum characters per synthesis request | +| `FFMPEG_BIN` | `ffmpeg` | Path to ffmpeg binary (bundled in the Docker image) | +| `POCKET_DEFAULT_VOICE` | `alba` | Default voice when none is specified | +| `ENABLED_PROVIDERS` | `pocket` | Comma-separated provider ids to register at startup. Only `pocket` exists today | +| `DEFAULT_PROVIDER` | `pocket` | Must be one of `ENABLED_PROVIDERS` — validated at startup | +| `DOMAIN` | _(empty)_ | Required when `APP_ENV=production` — used for `TrustedHostMiddleware` and nginx `server_name` | + +## RAM estimate + +`WORKERS × active languages × ~240 MB` + +Example: 2 workers, English + French = `2 × 2 × 240 MB ≈ 960 MB` + +## Production vs development + +First-time setup writes `APP_ENV=production` and prompts for `DOMAIN`. `make start` puts nginx in front as a reverse proxy; the app container has no published port. + +For local dev, set `APP_ENV=development` in `.env` (`DOMAIN` becomes optional) and use `make dev-docker` — exposes `:8000` directly with no nginx. + +nginx rate-limits `/v1/synthesize` (2 req/s, burst 4) and caps connections per IP. A `503` from behind nginx is a plain nginx error page, not the app's Problem Details JSON. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..7111340 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,58 @@ +# Development + +## Commands + +| Command | Description | +|---|---| +| `make configure` | Run setup wizard | +| `make build` | Build Docker image | +| `make dev-docker` | Start dev server with hot-reload | +| `make dev-docker-build` | Build then start | +| `make start` | Start production stack (detached) | +| `make stop` | Stop containers | +| `make logs` | Tail app logs | +| `make test-docker` | Fast test suite — no models needed | +| `make test-integration-docker` | Integration tests — requires models in volume | +| `make ci-docker` | Lint + format check + typecheck + tests | +| `make lint-docker` | `ruff check` | +| `make fmt-docker` | `ruff format` | +| `make typecheck-docker` | `mypy` | +| `make test` | Fast tests via `uv` (no Docker) | +| `make ci` | Full local CI | +| `make clean` | Remove `__pycache__` and `.pyc` files | + +## Running tests locally + +The fast suite requires no models and no ffmpeg — everything is mocked: + +```bash +uv sync +uv run pytest tests/ -m 'not integration and not slow' -v +``` + +## Adding a provider + +1. Create `app/providers/.py` implementing `TTSProvider` +2. Declare `id`, `supported_languages`, and `supports_boundaries` as class variables +3. Implement `_all_voices()` and `synthesize()` +4. Register in `app/main.py` `_build_registry()` + +No changes to routes, synthesizer, or voice catalog. Language filtering and boundary capability are inherited automatically from the base class. + +## Architecture + +``` +Client + └─ POST /v1/synthesize + └─ Synthesizer + ├─ validate text length + content + ├─ resolve voiceURI → (provider, voiceURI) (VoiceCatalog) + ├─ provider.synthesize() ← runs in thread pool, bounded by semaphore + │ └─ TTSModel.generate_audio() — CPU inference + └─ encode PCM → mp3/opus (ffmpeg driver) or wrap → wav +``` + +- Routes are `async`; all CPU-bound inference runs off the event loop via `anyio.to_thread.run_sync` +- A semaphore (`MAX_CONCURRENT_SYNTHESES`) prevents model thrashing under concurrent load +- Model throughput scales by adding worker processes (`WORKERS`), not threads +- Model weights live in a named Docker volume — downloaded once, instant on every subsequent start diff --git a/docs/voices.md b/docs/voices.md new file mode 100644 index 0000000..024589c --- /dev/null +++ b/docs/voices.md @@ -0,0 +1,32 @@ +# Voices + +156 voices across 7 language variants (26 voice identities × 6 languages). Every voice is available in every language — `alba` speaking English, `alba` speaking French, `alba` speaking German, etc. Only languages listed in `LANGUAGES` are loaded at startup (~240 MB RAM per language per worker). + +The 26 voice identities, sourced from [kyutai/tts-voices](https://huggingface.co/kyutai/tts-voices): + +| Voice | Gender | Origin | +|---|---|---| +| alba | female | Alba MacKenna (CC BY 4.0) | +| anna, vera, fantine, charles, paul, eponine, azelma, george, mary, jane, michael, eve | mixed | VCTK dataset (CC BY 4.0) | +| bill_boerst, peter_yearsley, stuart_bell, caro_davy | mixed | Voice Zero / LibriVox (CC0) | +| marius, javert | male | Voice donations (CC0) | +| cosette | female | Expresso dataset (CC BY-NC 4.0) | +| jean | male | EARS dataset (CC BY-NC 4.0) | +| estelle | female | Unmute production voices | +| giovanni, lola, juergen, rafael | mixed | Kyutai (language reference voices) | + +## Voice URIs + +Voice URIs are language-scoped — the same speaker in different languages gets a distinct URI: + +``` +urn:readium:tts:pocket:en-alba # Alba speaking English +urn:readium:tts:pocket:fr-alba # Alba speaking French +urn:readium:tts:pocket:de-alba # Alba speaking German +``` + +Supported language codes: `en`, `fr`, `it`, `de`, `es`, `pt` — derived directly from PocketTTS model names. + +## How it works + +PocketTTS pre-computes voice embeddings for every voice × language combination (stored as `.safetensors` files in `kyutai/pocket-tts-without-voice-cloning`). The voice sample is encoded once at model-load time — no per-request cloning overhead.