diff --git a/.gitignore b/.gitignore index 2ccb6c0..7bf4350 100644 --- a/.gitignore +++ b/.gitignore @@ -149,6 +149,7 @@ activemq-data/ # Environments .env +pocket-tts.env .envrc .venv env/ diff --git a/Dockerfile b/Dockerfile index cde976b..8db4601 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,9 @@ WORKDIR /app COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv COPY --from=builder --chown=appuser:appuser /app/app /app/app COPY --from=builder --chown=appuser:appuser /app/pyproject.toml /app/pyproject.toml -COPY --chown=appuser:appuser scripts/entrypoint.sh /app/scripts/entrypoint.sh +# entrypoint.sh + prune_weights.py both run inside the container (the latter via +# `make prune-models`); configure.sh/pocket_plan.py are host-only but harmless here. +COPY --chown=appuser:appuser scripts/ /app/scripts/ RUN chmod +x /app/scripts/entrypoint.sh diff --git a/Makefile b/Makefile index cc365d1..04437f6 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ DC_RUN = $(DC) run --rm --no-deps app .PHONY: help build dev-docker dev-docker-build test-docker test-integration-docker \ lint-docker fmt-docker fmt-check-docker typecheck-docker ci-docker \ - logs start run stop clean configure \ + logs start run stop clean configure prune-models prune-models-apply \ sync test lint fmt typecheck ci help: ## Show this help @@ -55,6 +55,12 @@ stop: ## Stop the stack configure: ## Run the interactive setup script bash scripts/configure.sh +prune-models: ## Report weight files that don't match the current env config (dry-run) + docker compose exec app python scripts/prune_weights.py + +prune-models-apply: ## Delete weight files that don't match the current env config (frees disk) + docker compose exec app python scripts/prune_weights.py --apply + clean: ## Remove __pycache__ and .pyc files find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find . -name '*.pyc' -delete 2>/dev/null || true diff --git a/README.md b/README.md index e0e0ee6..499edcb 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ Designed to pair with [Readium Speech](https://github.com/readium/speech), Readi | | | |---|---| -| **API** | `GET /voices` · `POST /synthesize` | +| **API** | `GET /service` · `GET /voices` · `POST /synthesize` | | **Providers** | PocketTTS · ElevenLabs (planned) | | **Languages** | English · French · Italian · German · Spanish · Portuguese | -| **Formats** | MP3 · WAV · Opus | +| **Formats** | WAV (default) · MP3 · Opus | | **Word boundaries** | Planned (ElevenLabs) | | **Deployment** | Docker · CPU-only · Single named volume for model weights | @@ -42,8 +42,8 @@ Server: `http://localhost:8000` · Interactive docs: `http://localhost:8000/docs ```bash curl -s -X POST http://localhost:8000/synthesize \ -H 'Content-Type: application/json' \ - -d '{"text":"Hello world","voice":"urn:readium:tts:pocket:en-alba"}' \ - -o /tmp/speech.mp3 && open /tmp/speech.mp3 + -d '{"text":"Hello world","voice":"urn:readium:tts:pocket:alba"}' \ + -o /tmp/speech.wav && open /tmp/speech.wav ``` > **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. @@ -56,6 +56,7 @@ curl -s -X POST http://localhost:8000/synthesize \ - [Voices](docs/voices.md) - [Configuration](docs/configuration.md) - [Development](docs/development.md) +- [Deployment](docs/deployment.md) --- diff --git a/app/api/deps.py b/app/api/deps.py index 1a4ed81..4f6e764 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -2,11 +2,21 @@ from fastapi import Depends, Request +from app.api.errors import ServiceNotReady +from app.core.circuit_breaker import CircuitBreakerRegistry from app.core.registry import ProviderRegistry from app.core.synthesizer import Synthesizer from app.core.voice_catalog import VoiceCatalog +def require_ready(request: Request) -> None: + """503 until the background warmup (model loading) has finished. Lets the server + accept connections immediately at startup instead of blocking, so /healthz and + /readyz stay responsive while models load.""" + if not getattr(request.app.state, "ready", False): + raise ServiceNotReady("Service is starting up — models are still loading.") + + def get_registry(request: Request) -> ProviderRegistry: registry: ProviderRegistry = request.app.state.registry return registry @@ -17,9 +27,18 @@ def get_voice_catalog(request: Request) -> VoiceCatalog: return catalog -def get_synthesizer(catalog: Annotated[VoiceCatalog, Depends(get_voice_catalog)]) -> Synthesizer: - return Synthesizer(catalog) +def get_circuit_breakers(request: Request) -> CircuitBreakerRegistry: + breakers: CircuitBreakerRegistry = request.app.state.circuit_breakers + return breakers + + +def get_synthesizer( + catalog: Annotated[VoiceCatalog, Depends(get_voice_catalog)], + breakers: Annotated[CircuitBreakerRegistry, Depends(get_circuit_breakers)], +) -> Synthesizer: + return Synthesizer(catalog, breakers) VoiceCatalogDep = Annotated[VoiceCatalog, Depends(get_voice_catalog)] SynthesizerDep = Annotated[Synthesizer, Depends(get_synthesizer)] +RegistryDep = Annotated[ProviderRegistry, Depends(get_registry)] diff --git a/app/api/errors.py b/app/api/errors.py index 93ec2b6..c20400a 100644 --- a/app/api/errors.py +++ b/app/api/errors.py @@ -57,6 +57,14 @@ class UnsupportedFormat(AppError): title = "Unsupported Format" +class VoiceLanguageUnsupported(AppError): + # Requested voice (or voice+language) isn't served here. Deliberately neutral — + # it never reveals *why* (install/config state), only that it's unsupported. + status_code = 404 + code = "voice_language_unsupported" + title = "Voice or Language Not Supported" + + class PayloadTooLarge(AppError): status_code = 413 code = "payload_too_large" diff --git a/app/api/routes/service.py b/app/api/routes/service.py new file mode 100644 index 0000000..8a6a8a7 --- /dev/null +++ b/app/api/routes/service.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter + +from app.api.deps import RegistryDep +from app.config.settings import settings +from app.domain.enums import AudioFormat +from app.schemas.service import ( + Limits, + OutputCapabilities, + ProviderCapabilities, + ServiceCapabilities, +) + +router = APIRouter(tags=["service"]) + + +@router.get( + "/service", + response_model=ServiceCapabilities, + summary="Server-wide capabilities", + description=( + "Server-wide, per-provider **capabilities** — kept separate from `/voices` so this isn't " + "repeated on every voice: supported output formats + default, request limits, and per " + "provider the installed-language summary. The voices themselves are on `GET /voices`; " + "per-provider model details (quality, controls, output specs) live under `docs/providers/`." + ), + responses={ + 200: { + "content": { + "application/json": { + "example": { + "output": {"formats": ["wav", "mp3", "opus"], "default": "wav"}, + "limits": {"maxTextLength": 2000, "maxConcurrentSyntheses": 2}, + "providers": [{"id": "pocket", "installedLanguages": ["en", "fr"]}], + } + } + } + } + }, +) +async def get_service_capabilities(registry: RegistryDep) -> ServiceCapabilities: + providers = [ + ProviderCapabilities( + id=provider.id, + installedLanguages=sorted(provider.active_languages()), + ) + for provider in registry.all() + ] + return ServiceCapabilities( + output=OutputCapabilities(formats=list(AudioFormat), default=AudioFormat.WAV), + limits=Limits( + maxTextLength=settings.max_text_length, + maxConcurrentSyntheses=settings.max_concurrent_syntheses, + ), + providers=providers, + ) diff --git a/app/api/routes/synthesize.py b/app/api/routes/synthesize.py index dd82725..6cd6758 100644 --- a/app/api/routes/synthesize.py +++ b/app/api/routes/synthesize.py @@ -1,9 +1,9 @@ import base64 -from fastapi import APIRouter +from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse, StreamingResponse -from app.api.deps import SynthesizerDep +from app.api.deps import SynthesizerDep, require_ready from app.api.errors import problem_response from app.schemas.utterance import SynthesizeRequest @@ -13,12 +13,13 @@ @router.post( "/synthesize", response_model=None, + dependencies=[Depends(require_ready)], summary="Synthesize speech from text or SSML", description=( "Accepts an utterance and returns an audio file. " "Set `boundary: true` to receive a JSON response with base64-encoded audio " "and word-level timing marks. " - "Supported formats: `mp3` (default), `wav`, `opus`." + "Supported formats: `wav` (default), `mp3`, `opus`." ), responses={ 200: { @@ -27,11 +28,12 @@ "or `application/json` with base64 audio + boundaries (`boundary: true`)." ), }, - 400: problem_response("Empty or whitespace text"), - 404: problem_response("Voice URI not found"), + 400: problem_response("Empty text, or no voice given and no default configured"), + 404: problem_response("Voice not found, or voice/language not supported here"), 413: problem_response("Text exceeds max length"), 415: problem_response("Unsupported audio format"), 422: problem_response("Request schema validation error"), + 503: problem_response("Service starting up (models loading), or provider unavailable"), }, openapi_extra={ "requestBody": { @@ -39,12 +41,12 @@ "application/json": { "examples": { "basic": { - "summary": "Basic request (mp3 default)", + "summary": "Basic request (wav default)", "value": { "id": "urn:uuid:019f1784-d800-7cc5-9f39-39c1e1ca6fdd", "text": "Hello, this is a test.", "language": "en", - "voice": "urn:readium:tts:pocket:en-alba", + "voice": "urn:readium:tts:pocket:alba", }, }, "opus_32kbps": { @@ -53,7 +55,7 @@ "id": "urn:uuid:019f1784-d800-7cc5-9f39-39c1e1ca6fdd", "text": "Hello, this is a test.", "language": "en", - "voice": "urn:readium:tts:pocket:en-alba", + "voice": "urn:readium:tts:pocket:alba", "output": {"format": "opus", "bitrate": 32}, }, }, @@ -63,7 +65,7 @@ "id": "urn:uuid:019f178c-cc7c-7bb3-a39b-d185f43d3cc4", "text": "Ceci est un test.", "language": "fr", - "voice": "urn:readium:tts:pocket:fr-estelle", + "voice": "urn:readium:tts:pocket:estelle", "boundary": True, }, }, @@ -72,7 +74,7 @@ "value": { "text": "She opened the door.", "language": "en", - "voice": "urn:readium:tts:pocket:en-alba", + "voice": "urn:readium:tts:pocket:alba", "prev_utterance": "It was a dark night.", "next_utterance": "The room was cold.", "output": {"format": "mp3", "speed": 0.9}, diff --git a/app/api/routes/voices.py b/app/api/routes/voices.py index 7f4f820..b5eaaa3 100644 --- a/app/api/routes/voices.py +++ b/app/api/routes/voices.py @@ -1,6 +1,6 @@ -from fastapi import APIRouter, Query, Response +from fastapi import APIRouter, Depends, Query, Response -from app.api.deps import VoiceCatalogDep +from app.api.deps import VoiceCatalogDep, require_ready from app.api.errors import problem_response from app.schemas.voice import Voice @@ -11,14 +11,18 @@ "/voices", response_model=list[Voice], response_model_exclude_none=True, + dependencies=[Depends(require_ready)], summary="List available TTS voices", description=( - "Returns voices registered across enabled providers, " - "optionally filtered by language or provider. " - "Supports pagination via `offset` and `limit`. " - "Response headers `X-Total-Count`, `X-Offset`, `X-Limit` reflect the full result set size." + "The voices **actually installed** on this deployment (realtime) — each voice's " + "`language` and `otherLanguages` reflect what's loaded now, bounded by `LANGUAGES` + " + "`VOICE_LANGUAGES`. Model-level `quality`/`controls` are merged in per voice; `controls` " + "lists only the enabled ones. Optionally filtered by language or provider; supports " + "pagination via `offset` and `limit`. Response headers `X-Total-Count`, `X-Offset`, " + "`X-Limit` reflect the full result set size." ), responses={ + 503: problem_response("Service starting up (models loading)"), 502: problem_response("Provider unavailable"), }, openapi_extra={ @@ -31,21 +35,20 @@ "summary": "All voices", "value": [ { - "source": "json", - "label": "Alba (English)", - "name": "pocket-en-alba", + "name": "Alba", "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:en-alba", - "language": "en", - "gender": "female", - "quality": "normal", - "pitchControl": False, - "preloaded": True, "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": ["audio/mpeg", "audio/wav", "audio/ogg"], - "boundary": False, + "identifier": "urn:readium:tts:pocket:alba", + "language": "en-US", + "otherLanguages": [], + "gender": "male", + "quality": "veryHigh", + "controls": { + "pitch": False, + "speed": False, + "ssml": False, + "boundary": False, + }, } ], } diff --git a/app/config/settings.py b/app/config/settings.py index 204c054..ef56abd 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -4,7 +4,10 @@ class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", + # .env: server/auth/concurrency/circuit-breaker — universal, provider-agnostic. + # pocket-tts.env: PocketTTS-scoped install config. Optional — later providers get + # their own file the same way; a missing file is silently skipped, not an error. + env_file=(".env", "pocket-tts.env"), env_file_encoding="utf-8", case_sensitive=False, extra="ignore", @@ -31,21 +34,74 @@ class Settings(BaseSettings): # Languages (global; providers filter to their supported subset via active_languages()) # Stored as comma-separated string to avoid pydantic-settings JSON-parsing list fields from env. - languages: str = "en" + # No hardcoded fallback: config is env-file driven (written by scripts/configure.sh). Unset = + # empty = the provider loads no language models (no voices served), rather than silently + # defaulting to English. + languages: str = "" hf_token: str = "" @property def language_list(self) -> list[str]: - langs = [v.strip().lower() for v in self.languages.split(",") if v.strip()] - return langs or ["en"] + return [v.strip().lower() for v in self.languages.split(",") if v.strip()] - # PocketTTS - pocket_default_voice: str = "alba" + # PocketTTS. Empty = no server-side default voice; a request must name one. + pocket_default_voice: str = "" + + # Per-voice cross-language install config. Generic across providers. Format: + # comma-separated "originalName:lang" pairs, "-" prefix to remove instead of add + # (e.g. "alba:fr,alba:de,-javert:es"). Additions still bounded by `languages` (never + # trigger a new base-model download) and must be a language that voice already + # declares in its own otherLanguages. A bare "*:*" token means "every voice, every + # declared otherLanguage that's enabled" (old install_mode="all"); its absence means + # "primary only" (old install_mode="primary") — either way, explicit pairs in the + # same list still layer on top as cherry-picks/prunes. + voice_languages: str = "" + + @property + def voice_install_all(self) -> bool: + return any(p.strip() == "*:*" for p in self.voice_languages.split(",")) + + @property + def voice_language_add_map(self) -> dict[str, frozenset[str]]: + return self._parse_voice_language_overrides()[0] + + @property + def voice_language_remove_map(self) -> dict[str, frozenset[str]]: + return self._parse_voice_language_overrides()[1] + + def _parse_voice_language_overrides( + self, + ) -> tuple[dict[str, frozenset[str]], dict[str, frozenset[str]]]: + add: dict[str, set[str]] = {} + remove: dict[str, set[str]] = {} + for pair in self.voice_languages.split(","): + pair = pair.strip() + if not pair or pair == "*:*": + continue + target = add + if pair.startswith("-"): + target = remove + pair = pair[1:] + if ":" not in pair: + continue + voice, lang = pair.split(":", 1) + voice, lang = voice.strip().lower(), lang.strip().lower() + if voice and lang: + target.setdefault(voice, set()).add(lang) + return ( + {k: frozenset(v) for k, v in add.items()}, + {k: frozenset(v) for k, v in remove.items()}, + ) # Audio / ffmpeg max_text_length: int = Field(default=2000, ge=1) ffmpeg_bin: str = "ffmpeg" + # Circuit breaker (per provider, wraps synthesize() calls) + circuit_breaker_enabled: bool = True + circuit_breaker_failure_threshold: int = Field(default=5, ge=1) + circuit_breaker_recovery_seconds: int = Field(default=30, ge=1) + @model_validator(mode="after") def validate_auth_and_providers(self) -> "Settings": if self.api_key_enabled and not self.api_key: diff --git a/app/core/circuit_breaker.py b/app/core/circuit_breaker.py new file mode 100644 index 0000000..c9dbe2b --- /dev/null +++ b/app/core/circuit_breaker.py @@ -0,0 +1,52 @@ +import time +from enum import Enum + + +class _State(Enum): + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + + +class CircuitBreaker: + """Per-provider failure gate. Trips OPEN after `failure_threshold` consecutive + failures; rejects calls until `recovery_seconds` has elapsed, then allows one + HALF_OPEN trial call — a success closes it, a failure re-opens it.""" + + def __init__(self, failure_threshold: int, recovery_seconds: float) -> None: + self._failure_threshold = failure_threshold + self._recovery_seconds = recovery_seconds + self._state = _State.CLOSED + self._failures = 0 + self._opened_at = 0.0 + + def allow(self) -> bool: + if self._state is _State.OPEN: + if time.monotonic() - self._opened_at >= self._recovery_seconds: + self._state = _State.HALF_OPEN + return True + return False + return True + + def record_success(self) -> None: + self._failures = 0 + self._state = _State.CLOSED + + def record_failure(self) -> None: + self._failures += 1 + if self._state is _State.HALF_OPEN or self._failures >= self._failure_threshold: + self._state = _State.OPEN + self._opened_at = time.monotonic() + + +class CircuitBreakerRegistry: + def __init__( + self, provider_ids: list[str], failure_threshold: int, recovery_seconds: float + ) -> None: + self._breakers = { + provider_id: CircuitBreaker(failure_threshold, recovery_seconds) + for provider_id in provider_ids + } + + def get(self, provider_id: str) -> CircuitBreaker: + return self._breakers[provider_id] diff --git a/app/core/synthesizer.py b/app/core/synthesizer.py index 53d6cdc..3cda899 100644 --- a/app/core/synthesizer.py +++ b/app/core/synthesizer.py @@ -1,8 +1,9 @@ import logging import struct -from app.api.errors import PayloadTooLarge, RequestValidationFailed +from app.api.errors import AppError, PayloadTooLarge, RequestValidationFailed, ServiceNotReady from app.config.settings import settings +from app.core.circuit_breaker import CircuitBreakerRegistry from app.core.voice_catalog import VoiceCatalog from app.domain.enums import AudioFormat from app.drivers import ffmpeg as ffmpeg_driver @@ -38,8 +39,9 @@ def _pcm_to_wav(pcm: bytes, sample_rate: int) -> bytes: class Synthesizer: - def __init__(self, catalog: VoiceCatalog) -> None: + def __init__(self, catalog: VoiceCatalog, breakers: CircuitBreakerRegistry) -> None: self._catalog = catalog + self._breakers = breakers async def synthesize( self, request: SynthesizeRequest @@ -54,13 +56,21 @@ async def synthesize( detail=f"received {len(text)}, limit is {settings.max_text_length}", ) - provider, voice_uri = self._catalog.resolve(request.voice) - boundaries_supported = provider.supports_boundaries + # POCKET_DEFAULT_VOICE is pocket-scoped by name but the only server-wide + # default today; a request may omit voice and inherit it. + voice_ref = request.voice or settings.pocket_default_voice + if not voice_ref: + raise RequestValidationFailed( + "no voice specified and no default voice configured (POCKET_DEFAULT_VOICE)" + ) + + provider, voice = self._catalog.resolve(voice_ref) + boundaries_supported = voice.controls.boundary out = request.output logger.info( "synthesize voice=%s provider=%s format=%s chars=%d boundary=%s", - voice_uri, + voice.identifier, provider.id, out.format.value, len(text), @@ -70,14 +80,26 @@ async def synthesize( text=text, ssml=request.ssml, language=request.language, - voice_uri=voice_uri, + voice_uri=voice.identifier, speed=out.speed, pitch=out.pitch, prev_utterance=request.prev_utterance, next_utterance=request.next_utterance, ) - result: AudioResult = await provider.synthesize(params) + breaker = self._breakers.get(provider.id) if settings.circuit_breaker_enabled else None + if breaker is not None and not breaker.allow(): + raise ServiceNotReady(f"Provider '{provider.id}' is temporarily unavailable") + + try: + result: AudioResult = await provider.synthesize(params) + except AppError: + if breaker is not None: + breaker.record_failure() + raise + else: + if breaker is not None: + breaker.record_success() if out.format == AudioFormat.WAV: audio = _pcm_to_wav(result.pcm, result.sample_rate) diff --git a/app/core/voice_catalog.py b/app/core/voice_catalog.py index 66b1f1b..60798a1 100644 --- a/app/core/voice_catalog.py +++ b/app/core/voice_catalog.py @@ -1,36 +1,46 @@ from app.api.errors import VoiceNotFound from app.core.registry import ProviderRegistry from app.providers.base import TTSProvider -from app.schemas.voice import Voice +from app.schemas.voice import Voice, voice_language_prefixes class VoiceCatalog: def __init__(self, registry: ProviderRegistry) -> None: self._registry = registry self._voices: list[Voice] = [] - self._index: dict[str, tuple[TTSProvider, str]] = {} + self._index: dict[str, tuple[TTSProvider, Voice]] = {} + self._name_index: dict[str, tuple[TTSProvider, Voice]] = {} async def load(self) -> None: + # The catalog serves GET /voices — the voices actually INSTALLED on this + # deployment (realtime: language + otherLanguages reflect what's loaded). + # resolve() maps a known voice to its provider for synthesis. voices: list[Voice] = [] - index: dict[str, tuple[TTSProvider, str]] = {} + index: dict[str, tuple[TTSProvider, Voice]] = {} + # provider exists today; revisit if a second one collides on a name. + name_index: dict[str, tuple[TTSProvider, Voice]] = {} for provider in self._registry.all(): for voice in await provider.list_voices(): voices.append(voice) - index[voice.voiceURI] = (provider, voice.voiceURI) + index[voice.identifier] = (provider, voice) + name_index[voice.originalName.lower()] = (provider, voice) self._voices = voices self._index = index + self._name_index = name_index def list(self, language: str | None = None, provider: str | None = None) -> list[Voice]: result = self._voices if language: lang_prefix = language.split("-")[0].lower() - result = [v for v in result if v.language.split("-")[0].lower() == lang_prefix] + result = [v for v in result if lang_prefix in voice_language_prefixes(v)] if provider: result = [v for v in result if v.provider == provider] return result - def resolve(self, voice_uri: str) -> tuple[TTSProvider, str]: - entry = self._index.get(voice_uri) + def resolve(self, identifier: str) -> tuple[TTSProvider, Voice]: + # Match the full identifier URI first, then fall back to originalName + # (case-insensitive) — lets POCKET_DEFAULT_VOICE=alba resolve without a URI. + entry = self._index.get(identifier) or self._name_index.get(identifier.lower()) if entry is None: - raise VoiceNotFound(f"Voice '{voice_uri}' not found.") + raise VoiceNotFound(f"Voice '{identifier}' not found.") return entry diff --git a/app/data/voices/pocket/voices.json b/app/data/voices/pocket/voices.json index 1e92e0b..776464f 100644 --- a/app/data/voices/pocket/voices.json +++ b/app/data/voices/pocket/voices.json @@ -1,3122 +1,236 @@ [ { - "source": "json", - "label": "Alba (English)", - "name": "pocket-en-alba", + "name": "Alba", "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:en-alba", - "language": "en", + "identifier": "urn:readium:tts:pocket:alba", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Anna (English)", - "name": "pocket-en-anna", + "name": "Anna", "originalName": "anna", - "voiceURI": "urn:readium:tts:pocket:en-anna", + "identifier": "urn:readium:tts:pocket:anna", "language": "en", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "anna", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Azelma (English)", - "name": "pocket-en-azelma", + "name": "Azelma", "originalName": "azelma", - "voiceURI": "urn:readium:tts:pocket:en-azelma", - "language": "en", + "identifier": "urn:readium:tts:pocket:azelma", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "azelma", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Bill Boerst (English)", - "name": "pocket-en-bill_boerst", + "name": "Bill Boerst", "originalName": "bill_boerst", - "voiceURI": "urn:readium:tts:pocket:en-bill_boerst", - "language": "en", + "identifier": "urn:readium:tts:pocket:bill-boerst", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "bill_boerst", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Caro Davy (English)", - "name": "pocket-en-caro_davy", + "name": "Caro Davy", "originalName": "caro_davy", - "voiceURI": "urn:readium:tts:pocket:en-caro_davy", - "language": "en", + "identifier": "urn:readium:tts:pocket:caro-davy", + "language": "en-GB", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "caro_davy", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Charles (English)", - "name": "pocket-en-charles", + "name": "Charles", "originalName": "charles", - "voiceURI": "urn:readium:tts:pocket:en-charles", + "identifier": "urn:readium:tts:pocket:charles", "language": "en", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "charles", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Cosette (English)", - "name": "pocket-en-cosette", + "name": "Cosette", "originalName": "cosette", - "voiceURI": "urn:readium:tts:pocket:en-cosette", - "language": "en", + "identifier": "urn:readium:tts:pocket:cosette", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "cosette", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Eponine (English)", - "name": "pocket-en-eponine", + "name": "Eponine", "originalName": "eponine", - "voiceURI": "urn:readium:tts:pocket:en-eponine", - "language": "en", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eponine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Estelle (English)", - "name": "pocket-en-estelle", - "originalName": "estelle", - "voiceURI": "urn:readium:tts:pocket:en-estelle", - "language": "en", + "identifier": "urn:readium:tts:pocket:eponine", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "estelle", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Eve (English)", - "name": "pocket-en-eve", + "name": "Eve", "originalName": "eve", - "voiceURI": "urn:readium:tts:pocket:en-eve", - "language": "en", + "identifier": "urn:readium:tts:pocket:eve", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eve", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Fantine (English)", - "name": "pocket-en-fantine", + "name": "Fantine", "originalName": "fantine", - "voiceURI": "urn:readium:tts:pocket:en-fantine", + "identifier": "urn:readium:tts:pocket:fantine", "language": "en", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "fantine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "George (English)", - "name": "pocket-en-george", + "name": "George", "originalName": "george", - "voiceURI": "urn:readium:tts:pocket:en-george", - "language": "en", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "george", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Giovanni (English)", - "name": "pocket-en-giovanni", - "originalName": "giovanni", - "voiceURI": "urn:readium:tts:pocket:en-giovanni", - "language": "en", + "identifier": "urn:readium:tts:pocket:george", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "giovanni", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Jane (English)", - "name": "pocket-en-jane", + "name": "Jane", "originalName": "jane", - "voiceURI": "urn:readium:tts:pocket:en-jane", - "language": "en", + "identifier": "urn:readium:tts:pocket:jane", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jane", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Javert (English)", - "name": "pocket-en-javert", + "name": "Javert", "originalName": "javert", - "voiceURI": "urn:readium:tts:pocket:en-javert", - "language": "en", + "identifier": "urn:readium:tts:pocket:javert", + "language": "en-US", + "otherLanguages": ["fr-CA", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "javert", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Jean (English)", - "name": "pocket-en-jean", + "name": "Jean", "originalName": "jean", - "voiceURI": "urn:readium:tts:pocket:en-jean", - "language": "en", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jean", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Juergen (English)", - "name": "pocket-en-juergen", - "originalName": "juergen", - "voiceURI": "urn:readium:tts:pocket:en-juergen", - "language": "en", + "identifier": "urn:readium:tts:pocket:jean", + "language": "en-US", + "otherLanguages": ["fr-CA", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "juergen", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Lola (English)", - "name": "pocket-en-lola", - "originalName": "lola", - "voiceURI": "urn:readium:tts:pocket:en-lola", - "language": "en", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "lola", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Marius (English)", - "name": "pocket-en-marius", + "name": "Marius", "originalName": "marius", - "voiceURI": "urn:readium:tts:pocket:en-marius", - "language": "en", + "identifier": "urn:readium:tts:pocket:marius", + "language": "en-US", + "otherLanguages": ["fr-CA", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "marius", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Mary (English)", - "name": "pocket-en-mary", + "name": "Mary", "originalName": "mary", - "voiceURI": "urn:readium:tts:pocket:en-mary", - "language": "en", + "identifier": "urn:readium:tts:pocket:mary", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "mary", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Michael (English)", - "name": "pocket-en-michael", + "name": "Michael", "originalName": "michael", - "voiceURI": "urn:readium:tts:pocket:en-michael", - "language": "en", + "identifier": "urn:readium:tts:pocket:michael", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "michael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Paul (English)", - "name": "pocket-en-paul", + "name": "Paul", "originalName": "paul", - "voiceURI": "urn:readium:tts:pocket:en-paul", - "language": "en", + "identifier": "urn:readium:tts:pocket:paul", + "language": "en-GB", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "paul", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Peter Yearsley (English)", - "name": "pocket-en-peter_yearsley", + "name": "Peter Yearsley", "originalName": "peter_yearsley", - "voiceURI": "urn:readium:tts:pocket:en-peter_yearsley", - "language": "en", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "peter_yearsley", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Rafael (English)", - "name": "pocket-en-rafael", - "originalName": "rafael", - "voiceURI": "urn:readium:tts:pocket:en-rafael", + "identifier": "urn:readium:tts:pocket:peter-yearsley", "language": "en", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "rafael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Stuart Bell (English)", - "name": "pocket-en-stuart_bell", + "name": "Stuart Bell", "originalName": "stuart_bell", - "voiceURI": "urn:readium:tts:pocket:en-stuart_bell", - "language": "en", + "identifier": "urn:readium:tts:pocket:stuart-bell", + "language": "en-GB", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "stuart_bell", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Vera (English)", - "name": "pocket-en-vera", + "name": "Vera", "originalName": "vera", - "voiceURI": "urn:readium:tts:pocket:en-vera", + "identifier": "urn:readium:tts:pocket:vera", "language": "en", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "vera", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Alba (French)", - "name": "pocket-fr-alba", - "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:fr-alba", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Anna (French)", - "name": "pocket-fr-anna", - "originalName": "anna", - "voiceURI": "urn:readium:tts:pocket:fr-anna", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "anna", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Azelma (French)", - "name": "pocket-fr-azelma", - "originalName": "azelma", - "voiceURI": "urn:readium:tts:pocket:fr-azelma", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "azelma", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Bill Boerst (French)", - "name": "pocket-fr-bill_boerst", - "originalName": "bill_boerst", - "voiceURI": "urn:readium:tts:pocket:fr-bill_boerst", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "bill_boerst", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Caro Davy (French)", - "name": "pocket-fr-caro_davy", - "originalName": "caro_davy", - "voiceURI": "urn:readium:tts:pocket:fr-caro_davy", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "caro_davy", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Charles (French)", - "name": "pocket-fr-charles", - "originalName": "charles", - "voiceURI": "urn:readium:tts:pocket:fr-charles", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "charles", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Cosette (French)", - "name": "pocket-fr-cosette", - "originalName": "cosette", - "voiceURI": "urn:readium:tts:pocket:fr-cosette", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "cosette", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eponine (French)", - "name": "pocket-fr-eponine", - "originalName": "eponine", - "voiceURI": "urn:readium:tts:pocket:fr-eponine", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eponine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Estelle (French)", - "name": "pocket-fr-estelle", - "originalName": "estelle", - "voiceURI": "urn:readium:tts:pocket:fr-estelle", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "estelle", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eve (French)", - "name": "pocket-fr-eve", - "originalName": "eve", - "voiceURI": "urn:readium:tts:pocket:fr-eve", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eve", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Fantine (French)", - "name": "pocket-fr-fantine", - "originalName": "fantine", - "voiceURI": "urn:readium:tts:pocket:fr-fantine", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "fantine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "George (French)", - "name": "pocket-fr-george", - "originalName": "george", - "voiceURI": "urn:readium:tts:pocket:fr-george", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "george", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Giovanni (French)", - "name": "pocket-fr-giovanni", - "originalName": "giovanni", - "voiceURI": "urn:readium:tts:pocket:fr-giovanni", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "giovanni", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jane (French)", - "name": "pocket-fr-jane", - "originalName": "jane", - "voiceURI": "urn:readium:tts:pocket:fr-jane", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jane", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Javert (French)", - "name": "pocket-fr-javert", - "originalName": "javert", - "voiceURI": "urn:readium:tts:pocket:fr-javert", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "javert", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jean (French)", - "name": "pocket-fr-jean", - "originalName": "jean", - "voiceURI": "urn:readium:tts:pocket:fr-jean", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jean", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Juergen (French)", - "name": "pocket-fr-juergen", - "originalName": "juergen", - "voiceURI": "urn:readium:tts:pocket:fr-juergen", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "juergen", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Lola (French)", - "name": "pocket-fr-lola", - "originalName": "lola", - "voiceURI": "urn:readium:tts:pocket:fr-lola", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "lola", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Marius (French)", - "name": "pocket-fr-marius", - "originalName": "marius", - "voiceURI": "urn:readium:tts:pocket:fr-marius", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "marius", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Mary (French)", - "name": "pocket-fr-mary", - "originalName": "mary", - "voiceURI": "urn:readium:tts:pocket:fr-mary", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "mary", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Michael (French)", - "name": "pocket-fr-michael", - "originalName": "michael", - "voiceURI": "urn:readium:tts:pocket:fr-michael", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "michael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Paul (French)", - "name": "pocket-fr-paul", - "originalName": "paul", - "voiceURI": "urn:readium:tts:pocket:fr-paul", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "paul", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Peter Yearsley (French)", - "name": "pocket-fr-peter_yearsley", - "originalName": "peter_yearsley", - "voiceURI": "urn:readium:tts:pocket:fr-peter_yearsley", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "peter_yearsley", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Rafael (French)", - "name": "pocket-fr-rafael", - "originalName": "rafael", - "voiceURI": "urn:readium:tts:pocket:fr-rafael", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "rafael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Stuart Bell (French)", - "name": "pocket-fr-stuart_bell", - "originalName": "stuart_bell", - "voiceURI": "urn:readium:tts:pocket:fr-stuart_bell", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "stuart_bell", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Vera (French)", - "name": "pocket-fr-vera", - "originalName": "vera", - "voiceURI": "urn:readium:tts:pocket:fr-vera", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "vera", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Alba (Italian)", - "name": "pocket-it-alba", - "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:it-alba", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Anna (Italian)", - "name": "pocket-it-anna", - "originalName": "anna", - "voiceURI": "urn:readium:tts:pocket:it-anna", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "anna", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Azelma (Italian)", - "name": "pocket-it-azelma", - "originalName": "azelma", - "voiceURI": "urn:readium:tts:pocket:it-azelma", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "azelma", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Bill Boerst (Italian)", - "name": "pocket-it-bill_boerst", - "originalName": "bill_boerst", - "voiceURI": "urn:readium:tts:pocket:it-bill_boerst", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "bill_boerst", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Caro Davy (Italian)", - "name": "pocket-it-caro_davy", - "originalName": "caro_davy", - "voiceURI": "urn:readium:tts:pocket:it-caro_davy", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "caro_davy", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Charles (Italian)", - "name": "pocket-it-charles", - "originalName": "charles", - "voiceURI": "urn:readium:tts:pocket:it-charles", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "charles", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Cosette (Italian)", - "name": "pocket-it-cosette", - "originalName": "cosette", - "voiceURI": "urn:readium:tts:pocket:it-cosette", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "cosette", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eponine (Italian)", - "name": "pocket-it-eponine", - "originalName": "eponine", - "voiceURI": "urn:readium:tts:pocket:it-eponine", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eponine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Estelle (Italian)", - "name": "pocket-it-estelle", + "name": "Estelle", "originalName": "estelle", - "voiceURI": "urn:readium:tts:pocket:it-estelle", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "estelle", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eve (Italian)", - "name": "pocket-it-eve", - "originalName": "eve", - "voiceURI": "urn:readium:tts:pocket:it-eve", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eve", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Fantine (Italian)", - "name": "pocket-it-fantine", - "originalName": "fantine", - "voiceURI": "urn:readium:tts:pocket:it-fantine", - "language": "it", + "identifier": "urn:readium:tts:pocket:estelle", + "language": "fr-FR", + "otherLanguages": ["en", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "fantine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "George (Italian)", - "name": "pocket-it-george", - "originalName": "george", - "voiceURI": "urn:readium:tts:pocket:it-george", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "george", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Giovanni (Italian)", - "name": "pocket-it-giovanni", + "name": "Giovanni", "originalName": "giovanni", - "voiceURI": "urn:readium:tts:pocket:it-giovanni", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "giovanni", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jane (Italian)", - "name": "pocket-it-jane", - "originalName": "jane", - "voiceURI": "urn:readium:tts:pocket:it-jane", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jane", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Javert (Italian)", - "name": "pocket-it-javert", - "originalName": "javert", - "voiceURI": "urn:readium:tts:pocket:it-javert", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "javert", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jean (Italian)", - "name": "pocket-it-jean", - "originalName": "jean", - "voiceURI": "urn:readium:tts:pocket:it-jean", + "identifier": "urn:readium:tts:pocket:giovanni", "language": "it", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jean", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Juergen (Italian)", - "name": "pocket-it-juergen", + "name": "Juergen", "originalName": "juergen", - "voiceURI": "urn:readium:tts:pocket:it-juergen", - "language": "it", + "identifier": "urn:readium:tts:pocket:juergen", + "language": "de", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "juergen", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Lola (Italian)", - "name": "pocket-it-lola", + "name": "Lola", "originalName": "lola", - "voiceURI": "urn:readium:tts:pocket:it-lola", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "lola", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Marius (Italian)", - "name": "pocket-it-marius", - "originalName": "marius", - "voiceURI": "urn:readium:tts:pocket:it-marius", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "marius", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Mary (Italian)", - "name": "pocket-it-mary", - "originalName": "mary", - "voiceURI": "urn:readium:tts:pocket:it-mary", - "language": "it", + "identifier": "urn:readium:tts:pocket:lola", + "language": "es", + "otherLanguages": ["fr", "en", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "mary", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Michael (Italian)", - "name": "pocket-it-michael", - "originalName": "michael", - "voiceURI": "urn:readium:tts:pocket:it-michael", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "michael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Paul (Italian)", - "name": "pocket-it-paul", - "originalName": "paul", - "voiceURI": "urn:readium:tts:pocket:it-paul", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "paul", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" }, { - "source": "json", - "label": "Peter Yearsley (Italian)", - "name": "pocket-it-peter_yearsley", - "originalName": "peter_yearsley", - "voiceURI": "urn:readium:tts:pocket:it-peter_yearsley", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "peter_yearsley", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Rafael (Italian)", - "name": "pocket-it-rafael", + "name": "Rafael", "originalName": "rafael", - "voiceURI": "urn:readium:tts:pocket:it-rafael", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "rafael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Stuart Bell (Italian)", - "name": "pocket-it-stuart_bell", - "originalName": "stuart_bell", - "voiceURI": "urn:readium:tts:pocket:it-stuart_bell", - "language": "it", + "identifier": "urn:readium:tts:pocket:rafael", + "language": "pt-PT", + "otherLanguages": ["fr", "en", "de", "it", "es"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "stuart_bell", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Vera (Italian)", - "name": "pocket-it-vera", - "originalName": "vera", - "voiceURI": "urn:readium:tts:pocket:it-vera", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "vera", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Alba (German)", - "name": "pocket-de-alba", - "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:de-alba", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Anna (German)", - "name": "pocket-de-anna", - "originalName": "anna", - "voiceURI": "urn:readium:tts:pocket:de-anna", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "anna", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Azelma (German)", - "name": "pocket-de-azelma", - "originalName": "azelma", - "voiceURI": "urn:readium:tts:pocket:de-azelma", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "azelma", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Bill Boerst (German)", - "name": "pocket-de-bill_boerst", - "originalName": "bill_boerst", - "voiceURI": "urn:readium:tts:pocket:de-bill_boerst", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "bill_boerst", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Caro Davy (German)", - "name": "pocket-de-caro_davy", - "originalName": "caro_davy", - "voiceURI": "urn:readium:tts:pocket:de-caro_davy", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "caro_davy", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Charles (German)", - "name": "pocket-de-charles", - "originalName": "charles", - "voiceURI": "urn:readium:tts:pocket:de-charles", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "charles", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Cosette (German)", - "name": "pocket-de-cosette", - "originalName": "cosette", - "voiceURI": "urn:readium:tts:pocket:de-cosette", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "cosette", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eponine (German)", - "name": "pocket-de-eponine", - "originalName": "eponine", - "voiceURI": "urn:readium:tts:pocket:de-eponine", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eponine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Estelle (German)", - "name": "pocket-de-estelle", - "originalName": "estelle", - "voiceURI": "urn:readium:tts:pocket:de-estelle", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "estelle", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eve (German)", - "name": "pocket-de-eve", - "originalName": "eve", - "voiceURI": "urn:readium:tts:pocket:de-eve", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eve", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Fantine (German)", - "name": "pocket-de-fantine", - "originalName": "fantine", - "voiceURI": "urn:readium:tts:pocket:de-fantine", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "fantine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "George (German)", - "name": "pocket-de-george", - "originalName": "george", - "voiceURI": "urn:readium:tts:pocket:de-george", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "george", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Giovanni (German)", - "name": "pocket-de-giovanni", - "originalName": "giovanni", - "voiceURI": "urn:readium:tts:pocket:de-giovanni", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "giovanni", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jane (German)", - "name": "pocket-de-jane", - "originalName": "jane", - "voiceURI": "urn:readium:tts:pocket:de-jane", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jane", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Javert (German)", - "name": "pocket-de-javert", - "originalName": "javert", - "voiceURI": "urn:readium:tts:pocket:de-javert", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "javert", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jean (German)", - "name": "pocket-de-jean", - "originalName": "jean", - "voiceURI": "urn:readium:tts:pocket:de-jean", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jean", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Juergen (German)", - "name": "pocket-de-juergen", - "originalName": "juergen", - "voiceURI": "urn:readium:tts:pocket:de-juergen", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "juergen", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Lola (German)", - "name": "pocket-de-lola", - "originalName": "lola", - "voiceURI": "urn:readium:tts:pocket:de-lola", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "lola", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Marius (German)", - "name": "pocket-de-marius", - "originalName": "marius", - "voiceURI": "urn:readium:tts:pocket:de-marius", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "marius", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Mary (German)", - "name": "pocket-de-mary", - "originalName": "mary", - "voiceURI": "urn:readium:tts:pocket:de-mary", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "mary", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Michael (German)", - "name": "pocket-de-michael", - "originalName": "michael", - "voiceURI": "urn:readium:tts:pocket:de-michael", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "michael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Paul (German)", - "name": "pocket-de-paul", - "originalName": "paul", - "voiceURI": "urn:readium:tts:pocket:de-paul", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "paul", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Peter Yearsley (German)", - "name": "pocket-de-peter_yearsley", - "originalName": "peter_yearsley", - "voiceURI": "urn:readium:tts:pocket:de-peter_yearsley", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "peter_yearsley", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Rafael (German)", - "name": "pocket-de-rafael", - "originalName": "rafael", - "voiceURI": "urn:readium:tts:pocket:de-rafael", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "rafael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Stuart Bell (German)", - "name": "pocket-de-stuart_bell", - "originalName": "stuart_bell", - "voiceURI": "urn:readium:tts:pocket:de-stuart_bell", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "stuart_bell", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Vera (German)", - "name": "pocket-de-vera", - "originalName": "vera", - "voiceURI": "urn:readium:tts:pocket:de-vera", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "vera", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Alba (Spanish)", - "name": "pocket-es-alba", - "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:es-alba", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Anna (Spanish)", - "name": "pocket-es-anna", - "originalName": "anna", - "voiceURI": "urn:readium:tts:pocket:es-anna", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "anna", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Azelma (Spanish)", - "name": "pocket-es-azelma", - "originalName": "azelma", - "voiceURI": "urn:readium:tts:pocket:es-azelma", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "azelma", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Bill Boerst (Spanish)", - "name": "pocket-es-bill_boerst", - "originalName": "bill_boerst", - "voiceURI": "urn:readium:tts:pocket:es-bill_boerst", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "bill_boerst", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Caro Davy (Spanish)", - "name": "pocket-es-caro_davy", - "originalName": "caro_davy", - "voiceURI": "urn:readium:tts:pocket:es-caro_davy", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "caro_davy", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Charles (Spanish)", - "name": "pocket-es-charles", - "originalName": "charles", - "voiceURI": "urn:readium:tts:pocket:es-charles", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "charles", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Cosette (Spanish)", - "name": "pocket-es-cosette", - "originalName": "cosette", - "voiceURI": "urn:readium:tts:pocket:es-cosette", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "cosette", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eponine (Spanish)", - "name": "pocket-es-eponine", - "originalName": "eponine", - "voiceURI": "urn:readium:tts:pocket:es-eponine", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eponine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Estelle (Spanish)", - "name": "pocket-es-estelle", - "originalName": "estelle", - "voiceURI": "urn:readium:tts:pocket:es-estelle", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "estelle", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eve (Spanish)", - "name": "pocket-es-eve", - "originalName": "eve", - "voiceURI": "urn:readium:tts:pocket:es-eve", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eve", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Fantine (Spanish)", - "name": "pocket-es-fantine", - "originalName": "fantine", - "voiceURI": "urn:readium:tts:pocket:es-fantine", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "fantine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "George (Spanish)", - "name": "pocket-es-george", - "originalName": "george", - "voiceURI": "urn:readium:tts:pocket:es-george", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "george", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Giovanni (Spanish)", - "name": "pocket-es-giovanni", - "originalName": "giovanni", - "voiceURI": "urn:readium:tts:pocket:es-giovanni", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "giovanni", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jane (Spanish)", - "name": "pocket-es-jane", - "originalName": "jane", - "voiceURI": "urn:readium:tts:pocket:es-jane", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jane", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Javert (Spanish)", - "name": "pocket-es-javert", - "originalName": "javert", - "voiceURI": "urn:readium:tts:pocket:es-javert", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "javert", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jean (Spanish)", - "name": "pocket-es-jean", - "originalName": "jean", - "voiceURI": "urn:readium:tts:pocket:es-jean", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jean", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Juergen (Spanish)", - "name": "pocket-es-juergen", - "originalName": "juergen", - "voiceURI": "urn:readium:tts:pocket:es-juergen", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "juergen", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Lola (Spanish)", - "name": "pocket-es-lola", - "originalName": "lola", - "voiceURI": "urn:readium:tts:pocket:es-lola", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "lola", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Marius (Spanish)", - "name": "pocket-es-marius", - "originalName": "marius", - "voiceURI": "urn:readium:tts:pocket:es-marius", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "marius", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Mary (Spanish)", - "name": "pocket-es-mary", - "originalName": "mary", - "voiceURI": "urn:readium:tts:pocket:es-mary", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "mary", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Michael (Spanish)", - "name": "pocket-es-michael", - "originalName": "michael", - "voiceURI": "urn:readium:tts:pocket:es-michael", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "michael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Paul (Spanish)", - "name": "pocket-es-paul", - "originalName": "paul", - "voiceURI": "urn:readium:tts:pocket:es-paul", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "paul", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Peter Yearsley (Spanish)", - "name": "pocket-es-peter_yearsley", - "originalName": "peter_yearsley", - "voiceURI": "urn:readium:tts:pocket:es-peter_yearsley", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "peter_yearsley", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Rafael (Spanish)", - "name": "pocket-es-rafael", - "originalName": "rafael", - "voiceURI": "urn:readium:tts:pocket:es-rafael", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "rafael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Stuart Bell (Spanish)", - "name": "pocket-es-stuart_bell", - "originalName": "stuart_bell", - "voiceURI": "urn:readium:tts:pocket:es-stuart_bell", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "stuart_bell", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Vera (Spanish)", - "name": "pocket-es-vera", - "originalName": "vera", - "voiceURI": "urn:readium:tts:pocket:es-vera", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "vera", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Alba (Portuguese)", - "name": "pocket-pt-alba", - "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:pt-alba", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Anna (Portuguese)", - "name": "pocket-pt-anna", - "originalName": "anna", - "voiceURI": "urn:readium:tts:pocket:pt-anna", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "anna", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Azelma (Portuguese)", - "name": "pocket-pt-azelma", - "originalName": "azelma", - "voiceURI": "urn:readium:tts:pocket:pt-azelma", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "azelma", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Bill Boerst (Portuguese)", - "name": "pocket-pt-bill_boerst", - "originalName": "bill_boerst", - "voiceURI": "urn:readium:tts:pocket:pt-bill_boerst", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "bill_boerst", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Caro Davy (Portuguese)", - "name": "pocket-pt-caro_davy", - "originalName": "caro_davy", - "voiceURI": "urn:readium:tts:pocket:pt-caro_davy", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "caro_davy", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Charles (Portuguese)", - "name": "pocket-pt-charles", - "originalName": "charles", - "voiceURI": "urn:readium:tts:pocket:pt-charles", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "charles", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Cosette (Portuguese)", - "name": "pocket-pt-cosette", - "originalName": "cosette", - "voiceURI": "urn:readium:tts:pocket:pt-cosette", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "cosette", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eponine (Portuguese)", - "name": "pocket-pt-eponine", - "originalName": "eponine", - "voiceURI": "urn:readium:tts:pocket:pt-eponine", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eponine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Estelle (Portuguese)", - "name": "pocket-pt-estelle", - "originalName": "estelle", - "voiceURI": "urn:readium:tts:pocket:pt-estelle", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "estelle", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eve (Portuguese)", - "name": "pocket-pt-eve", - "originalName": "eve", - "voiceURI": "urn:readium:tts:pocket:pt-eve", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eve", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Fantine (Portuguese)", - "name": "pocket-pt-fantine", - "originalName": "fantine", - "voiceURI": "urn:readium:tts:pocket:pt-fantine", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "fantine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "George (Portuguese)", - "name": "pocket-pt-george", - "originalName": "george", - "voiceURI": "urn:readium:tts:pocket:pt-george", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "george", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Giovanni (Portuguese)", - "name": "pocket-pt-giovanni", - "originalName": "giovanni", - "voiceURI": "urn:readium:tts:pocket:pt-giovanni", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "giovanni", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jane (Portuguese)", - "name": "pocket-pt-jane", - "originalName": "jane", - "voiceURI": "urn:readium:tts:pocket:pt-jane", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jane", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Javert (Portuguese)", - "name": "pocket-pt-javert", - "originalName": "javert", - "voiceURI": "urn:readium:tts:pocket:pt-javert", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "javert", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jean (Portuguese)", - "name": "pocket-pt-jean", - "originalName": "jean", - "voiceURI": "urn:readium:tts:pocket:pt-jean", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jean", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Juergen (Portuguese)", - "name": "pocket-pt-juergen", - "originalName": "juergen", - "voiceURI": "urn:readium:tts:pocket:pt-juergen", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "juergen", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Lola (Portuguese)", - "name": "pocket-pt-lola", - "originalName": "lola", - "voiceURI": "urn:readium:tts:pocket:pt-lola", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "lola", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Marius (Portuguese)", - "name": "pocket-pt-marius", - "originalName": "marius", - "voiceURI": "urn:readium:tts:pocket:pt-marius", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "marius", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Mary (Portuguese)", - "name": "pocket-pt-mary", - "originalName": "mary", - "voiceURI": "urn:readium:tts:pocket:pt-mary", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "mary", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Michael (Portuguese)", - "name": "pocket-pt-michael", - "originalName": "michael", - "voiceURI": "urn:readium:tts:pocket:pt-michael", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "michael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Paul (Portuguese)", - "name": "pocket-pt-paul", - "originalName": "paul", - "voiceURI": "urn:readium:tts:pocket:pt-paul", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "paul", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Peter Yearsley (Portuguese)", - "name": "pocket-pt-peter_yearsley", - "originalName": "peter_yearsley", - "voiceURI": "urn:readium:tts:pocket:pt-peter_yearsley", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "peter_yearsley", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Rafael (Portuguese)", - "name": "pocket-pt-rafael", - "originalName": "rafael", - "voiceURI": "urn:readium:tts:pocket:pt-rafael", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "rafael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Stuart Bell (Portuguese)", - "name": "pocket-pt-stuart_bell", - "originalName": "stuart_bell", - "voiceURI": "urn:readium:tts:pocket:pt-stuart_bell", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "stuart_bell", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Vera (Portuguese)", - "name": "pocket-pt-vera", - "originalName": "vera", - "voiceURI": "urn:readium:tts:pocket:pt-vera", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "vera", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false + "quality": "veryHigh" } ] diff --git a/app/main.py b/app/main.py index f1a4d95..c021546 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,7 @@ +import asyncio +import logging from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from fastapi import FastAPI from fastapi.responses import FileResponse @@ -8,13 +10,17 @@ from app.api.errors import register_error_handlers from app.api.router import router from app.api.routes.health import router as health_router +from app.api.routes.service import router as service_router from app.config.settings import settings +from app.core.circuit_breaker import CircuitBreakerRegistry from app.core.concurrency import init_semaphore from app.core.registry import ProviderRegistry from app.core.voice_catalog import VoiceCatalog from app.logging.config import RequestLoggingMiddleware, configure_logging from app.providers.pocket_tts import PocketTTSProvider +logger = logging.getLogger(__name__) + def _build_registry() -> ProviderRegistry: registry = ProviderRegistry() @@ -30,19 +36,41 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: init_semaphore(settings.max_concurrent_syntheses) registry = _build_registry() - - for provider in registry.all(): - await provider.load() - catalog = VoiceCatalog(registry) - await catalog.load() + breakers = CircuitBreakerRegistry( + [p.id for p in registry.all()], + settings.circuit_breaker_failure_threshold, + settings.circuit_breaker_recovery_seconds, + ) app.state.registry = registry app.state.voice_catalog = catalog - app.state.ready = True - yield + app.state.circuit_breakers = breakers app.state.ready = False - log_listener.stop() # flush remaining records before process exits + + # Load models OFF the startup path — otherwise ASGI startup blocks and the + # server accepts no connections (not even /healthz, /readyz) until models are + # ready. Warmup runs in the background; readiness flips to True when it finishes. + # A failure here leaves the server up reporting 503 on /readyz, not crashed. + async def _warmup() -> None: + try: + for provider in registry.all(): + await provider.load() + await catalog.load() + app.state.ready = True + logger.info("Startup warmup complete — service ready") + except Exception: + logger.exception("Startup warmup failed — service will report not ready") + + warmup_task = asyncio.create_task(_warmup()) + try: + yield + finally: + app.state.ready = False + warmup_task.cancel() + with suppress(asyncio.CancelledError): + await warmup_task + log_listener.stop() # flush remaining records before process exits def create_app() -> FastAPI: @@ -55,6 +83,7 @@ def create_app() -> FastAPI: ), openapi_tags=[ {"name": "health", "description": "Liveness and readiness probes."}, + {"name": "service", "description": "Server-wide capabilities."}, {"name": "voices", "description": "List available TTS voices."}, {"name": "synthesize", "description": "Convert text/SSML to audio."}, ], @@ -73,6 +102,7 @@ def create_app() -> FastAPI: register_error_handlers(app) app.include_router(health_router) + app.include_router(service_router) app.include_router(router) @app.get("/demo", include_in_schema=False) diff --git a/app/providers/base.py b/app/providers/base.py index a94a888..a4fb2e2 100644 --- a/app/providers/base.py +++ b/app/providers/base.py @@ -2,8 +2,9 @@ from collections.abc import Sequence from typing import ClassVar +from app.domain.enums import Quality from app.schemas.audio import AudioResult, SynthesisParams -from app.schemas.voice import Voice +from app.schemas.voice import Controls, Voice, voice_language_prefixes class TTSProvider(ABC): @@ -15,8 +16,10 @@ class TTSProvider(ABC): # Empty (default) = language-agnostic; list_voices() returns all voices unfiltered. supported_languages: ClassVar[frozenset[str]] = frozenset() - # True when synthesize() populates AudioResult.boundaries with word timing marks. - supports_boundaries: ClassVar[bool] = False + # Model-level defaults merged into every voice this provider serves, unless a + # voice overrides them (see app/providers/voice_loading.py). + default_quality: ClassVar[Quality | None] = None + default_controls: ClassVar[Controls] = Controls() def active_languages(self) -> frozenset[str]: """Intersection of global LANGUAGES config and this provider's supported set. @@ -34,12 +37,14 @@ async def _all_voices(self) -> Sequence[Voice]: """All voices this provider knows about, regardless of language config.""" async def list_voices(self) -> Sequence[Voice]: - """Voices filtered to active_languages(). Providers do NOT override this.""" + """Voices actually INSTALLED (filtered to active_languages()), reflecting the + realtime install state — this is what `GET /voices` serves. Providers do NOT + override this.""" voices = await self._all_voices() if not self.supported_languages: return voices # language-agnostic provider: return all active = self.active_languages() - return [v for v in voices if v.language.split("-")[0].lower() in active] + return [v for v in voices if voice_language_prefixes(v) & active] @abstractmethod async def synthesize(self, params: SynthesisParams) -> AudioResult: diff --git a/app/providers/fake.py b/app/providers/fake.py index d56496f..6359e08 100644 --- a/app/providers/fake.py +++ b/app/providers/fake.py @@ -2,46 +2,35 @@ import math import struct from collections.abc import Sequence +from typing import ClassVar from app.core.concurrency import run_inference from app.domain.enums import Gender, Quality from app.providers.base import TTSProvider from app.schemas.audio import AudioResult, SynthesisParams -from app.schemas.voice import Voice +from app.schemas.voice import Controls, Voice _SAMPLE_RATE = 24000 # matches PocketTTS native output _FREQ = 440.0 # Hz _VOICES: list[Voice] = [ Voice( - source="json", - label="Fake Voice (English)", name="fake-en-US", originalName="fake-en-US", - voiceURI="urn:readium:tts:fake:en-US-standard", + provider="fake", + identifier="urn:readium:tts:fake:en-US-standard", language="en-US", gender=Gender.NEUTRAL, quality=Quality.NORMAL, - preloaded=True, - provider="fake", - engineVoiceId="fake-en", - sampleRate=_SAMPLE_RATE, - mimeTypes=["audio/mpeg", "audio/wav", "audio/ogg"], ), Voice( - source="json", - label="Fake Voice (French)", name="fake-fr-FR", originalName="fake-fr-FR", - voiceURI="urn:readium:tts:fake:fr-FR-standard", + provider="fake", + identifier="urn:readium:tts:fake:fr-FR-standard", language="fr-FR", gender=Gender.NEUTRAL, quality=Quality.NORMAL, - preloaded=True, - provider="fake", - engineVoiceId="fake-fr", - sampleRate=_SAMPLE_RATE, - mimeTypes=["audio/mpeg", "audio/wav", "audio/ogg"], ), ] @@ -54,6 +43,8 @@ def _generate_tone(sample_rate: int, duration: float) -> bytes: class FakeProvider(TTSProvider): id = "fake" + default_quality: ClassVar[Quality] = Quality.NORMAL + default_controls: ClassVar[Controls] = Controls() async def _all_voices(self) -> Sequence[Voice]: return _VOICES diff --git a/app/providers/pocket_tts.py b/app/providers/pocket_tts.py index 09e5d13..9e3a469 100644 --- a/app/providers/pocket_tts.py +++ b/app/providers/pocket_tts.py @@ -10,11 +10,14 @@ import anyio -from app.api.errors import ProviderError +from app.api.errors import ProviderError, VoiceLanguageUnsupported +from app.config.settings import settings from app.core.concurrency import run_inference +from app.domain.enums import Quality from app.providers.base import TTSProvider +from app.providers.voice_loading import VoiceEntry, build_voice, plan_install from app.schemas.audio import AudioResult, SynthesisParams -from app.schemas.voice import Voice +from app.schemas.voice import Controls, Voice logger = logging.getLogger(__name__) @@ -38,12 +41,17 @@ def _strip_ssml(text: str) -> str: class PocketTTSProvider(TTSProvider): id = "pocket" supported_languages: ClassVar[frozenset[str]] = frozenset(_LANG_MODEL.keys()) + default_quality: ClassVar[Quality] = Quality.VERY_HIGH + default_controls: ClassVar[Controls] = Controls( + pitch=False, speed=False, ssml=False, boundary=False + ) def __init__(self) -> None: self._models: dict[str, Any] = {} # lang_key → TTSModel self._model_locks: dict[str, threading.Lock] = {} # per-model lock; not thread-safe - self._voice_states: dict[str, Any] = {} # voiceURI → voice state - self._voice_lang: dict[str, str] = {} # voiceURI → lang_key + self._voice_states: dict[tuple[str, str], Any] = {} # (identifier, lang_key) → voice state + self._voice_default_lang: dict[str, str] = {} # identifier → default lang (request omits) + self._voice_langs: dict[str, frozenset[str]] = {} # identifier → all warmed lang_keys self._voices: list[Voice] = [] self._ready = False @@ -62,19 +70,63 @@ def _load_sync(self) -> bool: return False raw = json.loads(_VOICES_PATH.read_text()) - self._voices = [ - Voice(**{**v, "boundary": self.supports_boundaries}) - for v in raw - if v["language"].split("-")[0].lower() in enabled - ] - - lang_voices: dict[str, list[Voice]] = {} - for voice in self._voices: - key = voice.language.split("-")[0].lower() - lang_voices.setdefault(key, []).append(voice) - self._voice_lang[voice.voiceURI] = key - - for lang_key, voices in lang_voices.items(): + entries = [VoiceEntry.model_validate(v) for v in raw] + + install_all = settings.voice_install_all + add_map = settings.voice_language_add_map + remove_map = settings.voice_language_remove_map + known_keys = {e.originalName.lower() for e in entries} + for unknown in (set(add_map) | set(remove_map)) - known_keys: + logger.warning( + "VOICE_LANGUAGES references unknown voice '%s' — check spelling " + "against voices.json originalName", + unknown, + ) + + lang_voices: dict[str, list[tuple[VoiceEntry, str]]] = {} + self._voices = [] + for entry in entries: + key = entry.originalName.lower() + primary = entry.language.split("-")[0].lower() + no_op_add = add_map.get(key, frozenset()) & {primary} + if no_op_add: + logger.warning( + "VOICE_LANGUAGES add for '%s' includes %s, its own primary " + "language — no effect, already installed", + key, + sorted(no_op_add), + ) + plan = plan_install( + entry, + enabled, + install_all, + add_map.get(key, frozenset()), + remove_map.get(key, frozenset()), + ) + # A voice runs in any enabled language it has an embedding for, using ONLY + # that language's base model — its primary model is NOT required (see + # plan_install / pocket_tts get_state_for_audio_prompt). None = no enabled + # language applies; skip. + if plan is None: + if key in add_map or key in remove_map: + logger.warning( + "VOICE_LANGUAGES override for '%s' resolves to no enabled " + "language — nothing to install for it", + key, + ) + continue + default_lang, installed = plan + other_langs = installed - frozenset({primary}) + voice = build_voice( + entry, self.id, other_langs, self.default_quality, self.default_controls + ) + self._voices.append(voice) + self._voice_default_lang[voice.identifier] = default_lang + self._voice_langs[voice.identifier] = installed + for lang_key in installed: + lang_voices.setdefault(lang_key, []).append((entry, lang_key)) + + for lang_key, pairs in lang_voices.items(): model_id = _LANG_MODEL[lang_key] logger.info("Loading PocketTTS model: %s ...", model_id) model: Any = TTSModel.load_model(language=model_id) @@ -85,12 +137,25 @@ def _load_sync(self) -> bool: "Loaded %s (sample_rate=%d Hz); warming %d voice states...", model_id, model.sample_rate, - len(voices), + len(pairs), ) - for voice in voices: - self._voice_states[voice.voiceURI] = model.get_state_for_audio_prompt( - voice.engineVoiceId - ) + for entry, _ in pairs: + identifier = entry.identifier + # Warm each voice defensively — one voice that fails to load its + # embedding (bad name, missing file, network) must not abort startup + # for every other voice. The (voice, lang) simply stays uninstalled; + # synthesize() then returns a clean "not installed" error for it. + try: + self._voice_states[(identifier, lang_key)] = model.get_state_for_audio_prompt( + entry.originalName + ) + except Exception as exc: # noqa: BLE001 — never let one voice crash load + logger.warning( + "Skipping voice '%s' for '%s' — failed to warm: %s", + entry.originalName, + lang_key, + exc, + ) logger.info( "PocketTTS ready — %d voices across %d language models", @@ -102,9 +167,37 @@ def _load_sync(self) -> bool: async def _all_voices(self) -> Sequence[Voice]: return self._voices + def _resolve_lang_key(self, voice_uri: str, requested_language: str | None) -> str | None: + """The warmed language to synthesize in. A requested language the voice is + NOT installed for returns None — we never silently fall back to a different + language (that would speak the wrong language for a (voice, lang) that was + never downloaded). An omitted language uses the voice's default installed one.""" + warmed = self._voice_langs.get(voice_uri, frozenset()) + if requested_language: + requested = requested_language.split("-")[0].lower() + return requested if requested in warmed else None + return self._voice_default_lang[voice_uri] + async def synthesize(self, params: SynthesisParams) -> AudioResult: - if params.voice_uri not in self._voice_states: - raise ProviderError(f"Unknown PocketTTS voice: {params.voice_uri}") + # Neutral "unsupported" responses throughout — never reveal install/config + # state (whether a voice/language was downloaded), only that it isn't served. + if params.voice_uri not in self._voice_langs: + raise VoiceLanguageUnsupported(f"Voice '{params.voice_uri}' is not supported.") + + lang_key = self._resolve_lang_key(params.voice_uri, params.language) + if lang_key is None: + # Requested a language this voice isn't served in — never silently fall + # back to a different language. + raise VoiceLanguageUnsupported( + f"Voice '{params.voice_uri}' is not supported for language '{params.language}'." + ) + state_key = (params.voice_uri, lang_key) + if state_key not in self._voice_states: + # Safety net: language is in the voice's set but its embedding never warmed + # (a load-time failure that was logged and skipped). Still unsupported here. + raise VoiceLanguageUnsupported( + f"Voice '{params.voice_uri}' is not supported for language '{lang_key}'." + ) text = _strip_ssml(params.text) if params.ssml else params.text if not text: @@ -117,10 +210,9 @@ async def synthesize(self, params: SynthesisParams) -> AudioResult: params.pitch, ) - lang_key = self._voice_lang[params.voice_uri] model = self._models[lang_key] lock = self._model_locks[lang_key] - state = self._voice_states[params.voice_uri] + state = self._voice_states[state_key] sample_rate: int = model.sample_rate def _infer() -> bytes: diff --git a/app/providers/voice_loading.py b/app/providers/voice_loading.py new file mode 100644 index 0000000..512c496 --- /dev/null +++ b/app/providers/voice_loading.py @@ -0,0 +1,117 @@ +from pydantic import BaseModel + +from app.domain.enums import Gender, Quality +from app.schemas.voice import Controls, Voice + + +class ControlsOverride(BaseModel): + """Partial per-voice override of a provider's default Controls. Unset (None) + fields fall back to the provider default — see merge_controls().""" + + pitch: bool | None = None + speed: bool | None = None + ssml: bool | None = None + boundary: bool | None = None + + +class VoiceEntry(BaseModel): + """Raw per-voice shape read from a provider's voices.json — documents what's + POSSIBLE. otherLanguages here is aspirational, not what's actually installed.""" + + name: str + originalName: str + identifier: str + language: str + otherLanguages: list[str] = [] + gender: Gender | None = None + quality: Quality | None = None + controls: ControlsOverride | None = None + + +def _lang_prefix(lang: str) -> str: + return lang.split("-")[0].lower() + + +def resolve_install_languages( + entry: VoiceEntry, + enabled: frozenset[str], + install_all: bool, + add_langs: frozenset[str] = frozenset(), + remove_langs: frozenset[str] = frozenset(), +) -> tuple[str, frozenset[str]]: + """Returns (primary_lang_key, other_lang_keys) actually to install for this + voice — always bounded by `enabled` (the operator's configured LANGUAGES), so + this never triggers a new base-model download on its own. install_all=False + -> other_lang_keys starts empty; install_all=True -> starts as every declared + otherLanguage that's enabled (Settings.voice_install_all, the "*:*" wildcard). + + add_langs / remove_langs (Settings.voice_language_add_map/remove_map, looked up + per voice) then adjust that base set: add_langs can only pull in languages this + voice already declares in otherLanguages (still bounded by `enabled` — it can + promote a declared-but-not-installed language, never invent support voices.json + doesn't claim); remove_langs prunes any pair the base mode would have included. + The primary language is never affected by either.""" + primary = _lang_prefix(entry.language) + declared_other = frozenset(_lang_prefix(lang) for lang in entry.otherLanguages) + base_other = declared_other & enabled if install_all else frozenset() + other = (base_other | (add_langs & declared_other & enabled)) - remove_langs - {primary} + return primary, other + + +def plan_install( + entry: VoiceEntry, + enabled: frozenset[str], + install_all: bool, + add_langs: frozenset[str] = frozenset(), + remove_langs: frozenset[str] = frozenset(), +) -> tuple[str, frozenset[str]] | None: + """Decide what a voice actually installs. Unlike a naive "primary must be + enabled" rule, a voice may run in a non-primary language WITHOUT its primary + base model — pocket_tts loads a voice's speaker embedding from the *target* + language's model dir, not the primary's (see models/tts_model.py), so any + (voice, language) with an embedding works using only that language's model. + + Returns (default_lang, installed_langs), or None when no enabled language + applies (voice not installed at all). installed_langs is every enabled language + among {primary} ∪ resolved others. default_lang — used when a synth request + omits `language` — is the primary if it's installed, else the first installed + language (deterministic).""" + primary, other = resolve_install_languages(entry, enabled, install_all, add_langs, remove_langs) + installed = (frozenset({primary}) & enabled) | other + if not installed: + return None + default_lang = primary if primary in installed else min(installed) + return default_lang, installed + + +def merge_controls(default: Controls, override: ControlsOverride | None) -> Controls: + if override is None: + return default + return Controls( + pitch=override.pitch if override.pitch is not None else default.pitch, + speed=override.speed if override.speed is not None else default.speed, + ssml=override.ssml if override.ssml is not None else default.ssml, + boundary=override.boundary if override.boundary is not None else default.boundary, + ) + + +def build_voice( + entry: VoiceEntry, + provider_id: str, + installed_other: frozenset[str], + default_quality: Quality | None, + default_controls: Controls, +) -> Voice: + """The merge point where 'possible' (voices.json) becomes 'installed' + (served via /voices): otherLanguages is installed_other, not entry.otherLanguages.""" + return Voice( + name=entry.name, + originalName=entry.originalName, + provider=provider_id, + identifier=entry.identifier, + language=entry.language, + otherLanguages=sorted(installed_other), + gender=entry.gender, + quality=entry.quality or default_quality, + controls=merge_controls(default_controls, entry.controls), + ) diff --git a/app/schemas/service.py b/app/schemas/service.py new file mode 100644 index 0000000..f30121d --- /dev/null +++ b/app/schemas/service.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel + +from app.domain.enums import AudioFormat + + +class OutputCapabilities(BaseModel): + formats: list[AudioFormat] + default: AudioFormat + + +class Limits(BaseModel): + maxTextLength: int + maxConcurrentSyntheses: int + + +class ProviderCapabilities(BaseModel): + id: str + installedLanguages: list[str] + # Model-level quality/controls are NOT repeated here — they're merged into each + # voice on GET /voices and documented per provider under docs/providers/. + + +class ServiceCapabilities(BaseModel): + output: OutputCapabilities + limits: Limits + providers: list[ProviderCapabilities] diff --git a/app/schemas/utterance.py b/app/schemas/utterance.py index 74ebb3c..8643a4b 100644 --- a/app/schemas/utterance.py +++ b/app/schemas/utterance.py @@ -6,7 +6,7 @@ class OutputConfig(BaseModel): """Requested output parameters — nested under 'output' in the API body.""" - format: AudioFormat = AudioFormat.MP3 + format: AudioFormat = AudioFormat.WAV bitrate: int | None = None # kbps; only meaningful for mp3/opus sample_rate: int | None = None speed: float = 1.0 @@ -23,7 +23,9 @@ class Utterance(BaseModel): class SynthesizeRequest(Utterance): - voice: str # voiceURI — required + # Voice identifier (URI) or originalName. Optional: when omitted, the server + # falls back to POCKET_DEFAULT_VOICE; if that's unset too, the request is rejected. + voice: str | None = None prev_utterance: str | None = None # preceding text/ID for prosody context next_utterance: str | None = None # following text/ID for prosody context boundary: bool = False # if true, response is JSON with base64 audio + timing marks diff --git a/app/schemas/voice.py b/app/schemas/voice.py index d01e950..bd36e48 100644 --- a/app/schemas/voice.py +++ b/app/schemas/voice.py @@ -1,36 +1,42 @@ -from typing import Literal - -from pydantic import BaseModel +from pydantic import BaseModel, model_serializer from app.domain.enums import Gender, Quality +class Controls(BaseModel): + """Which prosody/format controls a voice accepts. Provider-level defaults, + overridable per voice (see app/providers/voice_loading.py). + + Serializes only the controls that are ENABLED — a control the voice doesn't + support is simply absent, not `false`. Keeps `/voices` and `/service` lean and + works the same for any provider (pocket → `{}`, an SSML voice → `{"ssml": true}`). + Internal Python access (e.g. `voice.controls.boundary`) still sees all fields.""" + + pitch: bool = False + speed: bool = False + ssml: bool = False + boundary: bool = False # true when the provider returns word-level timing marks + + @model_serializer + def _serialize_enabled_only(self) -> dict[str, bool]: + return {k: True for k, v in self.__dict__.items() if v} + + class Voice(BaseModel): # --- Readium ReadiumSpeechVoice-aligned fields --- - source: Literal["json", "browser"] = "json" - label: str name: str originalName: str - voiceURI: str - language: str # BCP-47 - localizedName: str | None = None # "android" | "apple" - altNames: list[str] | None = None - altLanguage: str | None = None - otherLanguages: list[str] | None = None - multiLingual: bool | None = None + identifier: str + language: str # BCP-47, primary + otherLanguages: list[str] = [] # ACTUALLY INSTALLED cross-language support, not the + # aspirational full list from voices.json (see app/providers/voice_loading.py) gender: Gender | None = None - children: bool | None = None quality: Quality | None = None - pitchControl: bool = False - pitch: float | None = None - rate: float | None = None - preloaded: bool = False - nativeID: str | list[str] | None = None - note: str | None = None # --- server extensions (not in ReadiumSpeechVoice) --- provider: str - engineVoiceId: str - sampleRate: int - mimeTypes: list[str] - boundary: bool = False # true when provider supports word-level timing marks + controls: Controls = Controls() + + +def voice_language_prefixes(voice: Voice) -> frozenset[str]: + return frozenset(lang.split("-")[0].lower() for lang in [voice.language, *voice.otherLanguages]) diff --git a/app/static/demo.html b/app/static/demo.html index 3b96441..e65dfb4 100644 --- a/app/static/demo.html +++ b/app/static/demo.html @@ -17,6 +17,7 @@ --mono-bg: #f2f2f7; --mono-text: #1c1c1e; --error: #ff3b30; + --ok: #34c759; } * { box-sizing: border-box; -webkit-font-smoothing: antialiased; } body { @@ -75,6 +76,10 @@ outline: 2px solid var(--accent); outline-offset: 1px; } + input:disabled, select:disabled { + opacity: 0.4; + cursor: not-allowed; + } textarea { resize: vertical; } button { margin-top: 1.4rem; @@ -89,10 +94,11 @@ cursor: pointer; transition: background 0.15s ease, transform 0.1s ease; } - button:hover { background: var(--accent-hover); } - button:active { transform: scale(0.97); } + button:hover:not(:disabled) { background: var(--accent-hover); } + button:active:not(:disabled) { transform: scale(0.97); } + button:disabled { opacity: 0.5; cursor: not-allowed; } #openBtn { background: var(--field); color: var(--accent); } - #openBtn:hover { background: var(--border); } + #openBtn:hover:not(:disabled) { background: var(--border); } pre { background: var(--mono-bg); color: var(--mono-text); @@ -112,9 +118,11 @@ details { margin-top: 1.4rem; border-top: 1px solid var(--border); padding-top: 0.9rem; } summary { cursor: pointer; font-weight: 600; font-size: 0.85rem; color: var(--muted); } details label { font-weight: 600; } + .hint { font-size: 0.75rem; color: var(--muted); font-weight: 400; margin-left: 0.35rem; } .checkbox-row { display: flex; align-items: center; gap: 0.6rem; margin-top: 1.1rem; } .checkbox-row input { width: auto; margin: 0; accent-color: var(--accent); } .checkbox-row label { margin: 0; font-weight: 500; color: var(--text); } + .checkbox-row.disabled label { opacity: 0.4; } .section-head { display: flex; align-items: baseline; justify-content: space-between; } .copy-btn { margin: 0; @@ -138,49 +146,67 @@ color: var(--accent); } .status-pill.error { background: color-mix(in srgb, var(--error) 14%, transparent); color: var(--error); } + /* loading indicator (replaces the player while synthesizing) */ + .loading { + display: none; + align-items: center; + gap: 0.6rem; + margin-top: 1.25rem; + color: var(--muted); + font-size: 0.9rem; + } + .loading.active { display: flex; } + .spinner { + width: 18px; height: 18px; + border: 2.5px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + /* metrics badges */ + .metrics { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.7rem; } + .metric { + font-size: 0.75rem; font-weight: 600; + padding: 0.15rem 0.55rem; border-radius: 999px; + background: var(--field); color: var(--mono-text); + } + .metric strong { color: var(--accent); } + .metric.rt strong { color: var(--ok); } TTS API Demo

Readium Speech Server

-

Talks to GET /voices and POST /synthesize on this same origin.

+

Talks to GET /service, GET /voices and POST /synthesize on this same origin.

- - -
- - -
-
- - + +
- - + +
- - + + - - + + - +
Synthesizing…
+
Developer tools — advanced request fields +

Fields the selected voice doesn't support are disabled.

@@ -188,26 +214,38 @@

Readium Speech Server

- +
- - + +
- - + +
-
+
- +
-
+
- +
@@ -215,6 +253,7 @@

Readium Speech Server

Response

+
(nothing yet)
@@ -227,39 +266,42 @@

diff --git a/docker-compose.yml b/docker-compose.yml index 57c152e..7d40c9d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: image: ghcr.io/readium/speech-server:latest env_file: - .env + - pocket-tts.env environment: - HF_TOKEN=${HF_TOKEN:-} volumes: diff --git a/docs/API.md b/docs/API.md index c58efa9..687413e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -11,6 +11,7 @@ Base path: `/`. All bodies are `application/json` unless noted. - [Authentication](#authentication) - [Errors](#errors) - [Health](#health) +- [`GET /service`](#get-service) - [`GET /voices`](#get-voices) - [`POST /synthesize`](#post-synthesize) - [Not implemented](#not-implemented) @@ -46,11 +47,12 @@ Pydantic schema errors (`422`) additionally carry an `errors` array (raw Pydanti | Status | `type` suffix | Raised when | |---|---|---| | 400 | `validation_failed` | `text` is empty or whitespace-only | -| 404 | `voice_not_found` | `voice` URI not in the registry | +| 404 | `voice_not_found` | `voice` identifier not in the registry | +| 404 | `voice_language_unsupported` | The requested `voice` (or `voice` + `language`) isn't served here. Deliberately neutral — it does not reveal *why* (whether a voice/language is installed), only that it's unsupported. Ask `/voices` for what this deployment actually serves | | 413 | `payload_too_large` | `text` exceeds `MAX_TEXT_LENGTH` | | 422 | `validation_failed` | Request body fails schema validation (wrong type, missing required field, invalid enum value) | | 502 | `provider_error` | Provider or ffmpeg failed (bad voice state, generation error, encode failure) | -| 503 | `service_not_ready` | `/readyz` only — models not loaded, ffmpeg missing, or a provider reports unhealthy | +| 503 | `service_not_ready` | **During startup** the server accepts connections immediately and loads models in the background — `/synthesize` and `/voices` return 503 until warmup finishes (`/healthz` and `/service` stay up throughout; `/readyz` flips to 200 when ready). Also 503 from `/readyz` if ffmpeg is missing or a provider is unhealthy, and from `/synthesize` when a provider's circuit breaker is open — see [configuration](configuration.md) for `CIRCUIT_BREAKER_*` | `unsupported_format` (415), `rate_limited` (429), and `provider_timeout` (504) are declared in `app/api/errors.py` for future providers but no current code path raises them — a `429`/`503` seen in production is nginx's own rate/connection limit, a plain nginx error page, not this JSON shape. @@ -65,6 +67,38 @@ Pydantic schema errors (`422`) additionally carry an `errors` array (raw Pydanti --- +## `GET /service` + +``` +GET /service +``` + +Server-wide, per-provider **capabilities** — kept separate from `/voices` so this isn't repeated on +every voice: supported output formats + default, request limits, and per provider the model-level +`quality`/`controls` and the installed-language summary. The voices themselves are on +[`GET /voices`](#get-voices). Per-provider details (output specs, voice notes) live in each +provider's README under `docs/providers/`. + +```json +{ + "output": {"formats": ["wav", "mp3", "opus"], "default": "wav"}, + "limits": {"maxTextLength": 2000, "maxConcurrentSyntheses": 2}, + "providers": [ + { + "id": "pocket", + "installedLanguages": ["en"] + } + ] +} +``` + +`providers[].installedLanguages` reflects `LANGUAGES` + `VOICE_LANGUAGES` as actually configured. +Model-level `quality`/`controls` aren't repeated here — they're merged into each voice on +[`GET /voices`](#get-voices) and documented per provider under `docs/providers/`. See +[configuration](configuration.md). + +--- + ## `GET /voices` ``` @@ -76,63 +110,62 @@ GET /voices?offset=0&limit=20 | Param | Type | Default | Description | |---|---|---|---| -| `language` | string | — | Filter by BCP-47 language prefix (`en`, `fr`, ...) | +| `language` | string | — | Filter by BCP-47 language prefix, matched against a voice's primary `language` **or** `otherLanguages` (`en`, `fr`, ...) | | `provider` | string | — | Filter by provider id (`pocket`) | | `offset` | int ≥ 0 | `0` | Voices to skip | | `limit` | int ≥ 1 | none | Max voices to return | -Response: `200`, `Voice[]`. Null-valued optional fields are omitted (`response_model_exclude_none`). +The voices **actually installed** on this deployment (realtime): each voice's `language` (primary) +and `otherLanguages` reflect what's loaded now, bounded by `LANGUAGES` + `VOICE_LANGUAGES`. Model-level +`quality`/`controls` are merged in per voice. A voice not in this list can't be synthesized here. + +Response: `200`, `Voice[]`. Headers: `X-Total-Count` (matches before pagination), `X-Offset`, `X-Limit` (omitted when `limit` unset). ### `Voice` +Model-level info (quality default, control support) is declared once per provider and **merged** +into every voice it serves; a voice only carries a field in `voices.json` when it's voice-specific +or needs to override that default. `otherLanguages` reflects languages **actually installed** for +that voice on this deployment, not every language the voice could theoretically support — see +[configuration](configuration.md). + ```json { - "source": "json", - "label": "Alba (English)", - "name": "pocket-en-alba", + "name": "Alba", "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:en-alba", - "language": "en", - "gender": "female", - "quality": "normal", - "pitchControl": false, - "preloaded": true, "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": ["audio/mpeg", "audio/wav", "audio/ogg"], - "boundary": false + "identifier": "urn:readium:tts:pocket:alba", + "language": "en-US", + "otherLanguages": [], + "gender": "male", + "quality": "veryHigh", + "controls": {} } ``` +`controls` lists only the **enabled** controls — a control the voice doesn't support is absent +(pocket supports none, so `{}`). A voice that supported SSML would show `"controls": {"ssml": true}`. + **`ReadiumSpeechVoice`-aligned:** | Field | Type | Notes | |---|---|---| -| `source` | `"json" \| "browser"` | Always `"json"` — every voice here is server-hosted | -| `label` | string | Display name | -| `name` | string | Unique identifier within the Readium ecosystem | +| `name` | string | Display name | | `originalName` | string | Raw engine voice id | -| `voiceURI` | string | Send this as `SynthesizeRequest.voice` | -| `language` | string | BCP-47 | -| `localizedName`, `altNames`, `altLanguage`, `otherLanguages`, `multiLingual`, `children`, `nativeID`, `note` | — | Not populated by any current provider — always omitted | +| `language` | string | BCP-47, primary | +| `otherLanguages` | string[] | Additional languages this voice is actually installed for — empty by default (`VOICE_LANGUAGES` unset) | | `gender` | `"male" \| "female" \| "neutral" \| null` | | -| `quality` | `"veryLow"…"veryHigh" \| null` | PocketTTS voices are always `"normal"` | -| `pitchControl` | bool | `true` = provider accepts `output.pitch`. PocketTTS = `false` | -| `pitch`, `rate` | float or null | Recommended defaults, if the engine specifies any — currently always `null` | -| `preloaded` | bool | `true` = model weights already resident, ready without a cold-start delay | +| `quality` | `"veryLow"…"veryHigh" \| null` | Provider default unless a voice overrides it. PocketTTS voices are always `"veryHigh"` | **Server extensions (not in `ReadiumSpeechVoice`):** | Field | Type | Notes | |---|---|---| | `provider` | string | Backend serving this voice — `"pocket"` today | -| `engineVoiceId` | string | Opaque, internal — not for client use | -| `sampleRate` | int | Native PCM rate in Hz (`24000` for PocketTTS) | -| `mimeTypes` | string[] | Always `["audio/mpeg", "audio/wav", "audio/ogg"]` | -| `boundary` | bool | `true` = this voice's provider fills `boundaries` on synthesis. Check before setting `boundary: true` on the request — a `false` voice always gets `boundaries: null` back | +| `identifier` | string | Send this as `SynthesizeRequest.voice` | +| `controls` | object | `{pitch, speed, ssml, boundary}` booleans — what this voice accepts, merged from the provider's defaults with any voice-specific override. PocketTTS: all `false` | --- @@ -144,13 +177,13 @@ Headers: `X-Total-Count` (matches before pagination), `X-Offset`, `X-Limit` (omi "text": "Ceci est un test.", "ssml": false, "language": "fr", - "voice": "urn:readium:tts:pocket:fr-estelle", + "voice": "urn:readium:tts:pocket:estelle", "prev_utterance": "La nuit était sombre.", "next_utterance": "La pièce était froide.", "publication_id": "urn:isbn:9780000000000", "boundary": true, "output": { - "format": "mp3", + "format": "wav", "bitrate": 64, "sample_rate": null, "speed": 1.0, @@ -159,19 +192,19 @@ Headers: `X-Total-Count` (matches before pagination), `X-Offset`, `X-Limit` (omi } ``` -Only `text` and `voice` are required; everything else defaults as shown. +Only `text` is required; everything else defaults as shown (`voice` falls back to `POCKET_DEFAULT_VOICE`). | Field | Type | Default | Notes | |---|---|---|---| | `id` | string \| null | `null` | Client-generated UUID v7 URN. Parsed, logged, **not otherwise used** — no caching or idempotency yet ([roadmap](#not-implemented)) | | `text` | string | — | Max `MAX_TEXT_LENGTH` chars (2000 default). Rejected if empty/whitespace after trim | | `ssml` | bool | `false` | PocketTTS strips tags before synthesis (regex `<[^>]+>` removal) — no SSML-aware prosody | -| `language` | string \| null | `null` | Hint only; voice resolution is by `voiceURI`, not `language` | -| `voice` | string | — | Must exactly match a `voiceURI` from `/voices`. 404 if not found | +| `language` | string \| null | `null` | For voices installed across more than one language (`VOICE_LANGUAGES=*:*` or an explicit override), picks which installed language to synthesize in; falls back to the voice's primary language if unset or not installed for that voice | +| `voice` | string \| null | `null` | A voice `identifier` from `/voices`, or a raw `originalName`. 404 if not found. When omitted, falls back to `POCKET_DEFAULT_VOICE`; if that's unset too, `400` | | `prev_utterance` / `next_utterance` | string \| null | `null` | Accepted, passed into `SynthesisParams`; PocketTTS ignores both | | `publication_id` | string \| null | `null` | Accepted, currently unused (reserved for future cache scoping) | | `boundary` | bool | `false` | `true` → JSON response with base64 audio + timing marks instead of raw binary | -| `output.format` | `"mp3" \| "wav" \| "opus"` | `"mp3"` | `wav` bypasses ffmpeg (fastest); `mp3`/`opus` are ffmpeg-encoded | +| `output.format` | `"wav" \| "mp3" \| "opus"` | `"wav"` | `wav` bypasses ffmpeg and is highest quality (fastest, no lossy encoding); `mp3`/`opus` are ffmpeg-encoded on request — see [`GET /service`](#get-service) for what's available | | `output.bitrate` | int \| null | `null` | kbps for `mp3`/`opus`; ffmpeg default (~128 mp3, ~64 opus) if unset; ignored for `wav` | | `output.sample_rate` | int \| null | `null` | **Accepted but not applied** — output is always the model's native rate (24000 Hz for PocketTTS). No resampling happens today | | `output.speed` | float | `1.0` | **Accepted but ignored** by PocketTTS (logged at debug level) | @@ -200,7 +233,7 @@ Only `text` and `voice` are required; everything else defaults as shown. |---|---|---| | `audio` | string | Base64, encoded in `output.format` | | `format` | string | Echoes the requested/default format | -| `boundaries` | `TimingMark[] \| null` | `null` = voice's provider doesn't support timing (`Voice.boundary == false`) — currently true for **every** voice, since PocketTTS never populates this | +| `boundaries` | `TimingMark[] \| null` | `null` = this voice doesn't support timing (`Voice.controls.boundary == false`) — currently true for **every** voice, since PocketTTS never populates this | ### `TimingMark` @@ -222,9 +255,10 @@ No `end` field (next mark's `elapsedTime`, or total duration for the last word, Things the schema or config surfaces but the server doesn't actually do yet: - **Auth** — `API_KEY_ENABLED` is validated at startup but never enforced on requests. -- **Word boundaries** — no provider populates `TimingMark`s. Every voice reports `boundary: false`. +- **Word boundaries** — no provider populates `TimingMark`s. Every voice reports `controls.boundary: false`. - **`output.speed` / `output.pitch` / `output.sample_rate`** — accepted, validated, silently ignored by PocketTTS. - **`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. +- **Curated cross-language voice quality** — `otherLanguages` in `voices.json` documents what a voice is *capable* of, not whether it's a *good fit* (e.g. an English voice speaking French). No ranking/curation of this is implemented yet — deliberately conservative, pending a listening review. - **Providers beyond PocketTTS** — ElevenLabs is planned but not yet wired into `_build_registry()`. diff --git a/docs/configuration.md b/docs/configuration.md index 9fa75f2..bbb9931 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,10 +1,24 @@ # Configuration -Run `make configure` to generate `.env` via an interactive wizard, or run `bash scripts/configure.sh` directly. +Run `make configure` to generate config via an interactive wizard, or run `bash scripts/configure.sh` directly. + +Config is split across two files, both written by the wizard: + +- **`.env`** — server/auth/concurrency/circuit-breaker. Universal, provider-agnostic. +- **`pocket-tts.env`** — PocketTTS-scoped install config (which languages, which voices in which + languages). A later provider (Kokoro, etc.) would get its own equivalent file the same way, + since "which languages/voices does this provider install" is inherently per-provider, not + global. `pydantic-settings` reads both automatically (`app/config/settings.py`); `pocket-tts.env` + simply being absent is not an error. Existing installs are migrated automatically the next time + the wizard runs (it moves `LANGUAGES`/`VOICE_INSTALL_MODE`/`POCKET_DEFAULT_VOICE` out of `.env` + and into a newly created `pocket-tts.env`, once, silently). ## Setup wizard -`make configure` handles both first-time setup and ongoing management: +`make configure` handles both first-time setup and ongoing management. The top-level menu only +has universal actions; anything provider-scoped lives behind **Manage provider**, which lists +registered providers (just `pocket` today) and drops into a provider-specific submenu — the same +shape a second provider (Kokoro, etc.) would slot into later: ``` Readium Speech Server @@ -12,43 +26,162 @@ 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 + 2) Manage provider + 3) Change workers + 4) Update HF token + 5) First-time setup (re-run / overwrite) + 6) 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. +**Manage provider → pocket:** (the live install plan is shown above the menu each time) + +``` +1) Languages +2) Voice coverage +3) Back +``` + +A linear two-step flow: pick languages first, then choose coverage over them. + +**Languages** re-runs the same multi-select as first-time setup — whatever you check becomes +`LANGUAGES` (the ceiling: the base models that load). Newly-added languages download once on next +restart (~219 MB for en/it/pt, ~672 MB for fr/de/es); removed languages keep their model files in +the Docker volume — see [Disk space](#disk-space) to reclaim selectively. + +**Voice coverage** chooses what installs on top of the native defaults (see +[Per-voice language overrides](#per-voice-language-overrides)). The native voices of your selected +languages are **always** installed — they're the best-matched model for each voice, so coverage +only ever *adds* cross-language support: + +- **default** — native voices only, each in its own language (`VOICE_LANGUAGES=`). +- **all** — every voice also speaks every selected language, including non-native ones + (`VOICE_LANGUAGES=*:*`). +- **custom** — defaults **plus** cross-language pairs you hand-pick. Pick any voice, then choose any + of the languages it can speak; each pick adds an `originalName:lang` pair and, if needed, pulls + that language into `LANGUAGES`. `VOICE_LANGUAGES` lists only the *extra* pairs — the defaults stay + implied by `LANGUAGES`. > **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 +## Environment variables — `.env` | 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 | +| `HF_TOKEN` | _(empty)_ | HuggingFace token. Optional — prevents rate-limiting on first-run model downloads. Shared across any provider that pulls models from HF | | `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 | +| `CIRCUIT_BREAKER_ENABLED` | `true` | Trip a provider's circuit breaker after repeated `synthesize()` failures, returning `503` immediately instead of hammering a broken provider | +| `CIRCUIT_BREAKER_FAILURE_THRESHOLD` | `5` | Consecutive failures before a provider's breaker opens | +| `CIRCUIT_BREAKER_RECOVERY_SECONDS` | `30` | How long an open breaker waits before allowing one trial call | | `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 +## Environment variables — `pocket-tts.env` -`WORKERS × active languages × ~240 MB` +| Variable | Default | Description | +|---|---|---| +| `LANGUAGES` | _(empty)_ | Comma-separated BCP-47 language codes to **load as base models**. No hardcoded fallback — env-driven; unset means no base models load (no voices served). Size per language is *not* uniform: the 6-layer `en`/`it`/`pt` models are ~219 MB download / ~438 MB RAM; the 24-layer `fr`/`de`/`es` models are ~672 MB download / ~1344 MB RAM (loaded size ~2x the download, measured at startup). Supported: `en fr it de es pt`. This is the ceiling — nothing below can exceed it | +| `VOICE_LANGUAGES` | _(empty)_ | Which voices get warmed against which of those loaded models, beyond each voice's own primary — see [Per-voice language overrides](#per-voice-language-overrides) | +| `POCKET_DEFAULT_VOICE` | _(empty)_ | Default voice when none is specified. No hardcoded fallback — env-driven; empty means the setting is unset | + +`LANGUAGES` and `VOICE_LANGUAGES` operate at different levels and both matter: `LANGUAGES` +decides which base language models physically load into RAM at all — any voice whose *primary* +language isn't in this set is skipped entirely, no matter what `VOICE_LANGUAGES` says. +`VOICE_LANGUAGES` only fine-tunes which *already-loaded* models a voice also gets warmed +against. + +## Per-voice language overrides + +`VOICE_LANGUAGES` is one setting that does two things: + +- A bare `*:*` token = install every voice's declared `otherLanguages` that are in `LANGUAGES` + (old `VOICE_INSTALL_MODE=all`). Its absence = primary-only (old `VOICE_INSTALL_MODE=primary`, + still the default). +- Explicit `originalName:lang` pairs cherry-pick or prune on top of that base — e.g. install + French for Alba specifically without turning on `*:*` for every voice, or turn on `*:*` but + exclude one voice's Portuguese support. Use the wizard's "Voice coverage → custom", or set + directly: + +```dotenv +VOICE_LANGUAGES=alba:fr,alba:de,-javert:es +``` + +Comma-separated `originalName:lang` pairs (voice's `originalName` from `voices.json`, not its +`identifier`). A pair adds by default; a leading `-` removes instead. `*:*` can appear in the +same list as explicit pairs (`VOICE_LANGUAGES=*:*,-javert:es` = "everything except javert's +Spanish"). Rules, applied on top of the `*:*` base: + +1. An **add** pair can only promote a language the voice already declares in its own + `otherLanguages` in `voices.json` — it can't invent support voices.json doesn't claim. +2. Both directions are bounded by `LANGUAGES` — neither ever triggers downloading a new base + language model by itself; the language must already be enabled. +3. If the same pair appears both with and without `-`, **remove wins** — it's always the final + word for the exact pair it names. +4. A voice installs for whichever enabled languages apply — its primary if that's in + `LANGUAGES`, plus any added/`*:*` cross-languages. A voice can even run in a **non-primary** + language *without* its primary base model (e.g. `VOICE_LANGUAGES=estelle:en` with + `LANGUAGES=en` installs estelle in English only, no French model): pocket_tts loads a voice's + embedding from the target language's model, not the primary's. When a request omits `language`, + the voice's default is its primary if installed, else its first installed language. `/voices` + still reports the voice's true primary `language` (from `voices.json`); `/service` reports which + languages are actually installed. + +## Model sizes & RAM + +The wizard deliberately keeps sizes out of its prompts — they live here. All figures are for the +`kyutai/pocket-tts-without-voice-cloning` models (what the server loads without gated access) and +are approximate. + +**Base language model** — one per selected language, loaded once per worker. Two size classes: + +| Languages | Layers | Download (disk, once) | RAM (per worker) | +|---|---|---|---| +| `en` `it` `pt` | 6 | ~219 MB | ~438 MB | +| `fr` `de` `es` | 24 (`_24l`) | ~672 MB | ~1344 MB | + +RAM ≈ 2× the download (measured at startup). This is the dominant cost. + +**Voice embedding** — one per installed `(voice, language)`, small: ~6 MB into a 6-layer language, +~25–33 MB into a 24-layer language. Native voices carry one; each cross-language pair adds one more. + +**RAM formula:** `WORKERS × (sum of each active language's RAM size)`. +Example: 1 worker, English + French = `438 + 1344 ≈ 1782 MB` RAM (download ~891 MB base + a few +hundred MB of embeddings). Add a language, and its whole base model is added per worker. + +To see the exact **voice/pair count** for your config (not sizes), the wizard's config view uses +`scripts/pocket_plan.py`. + +## Disk space + +Changing `LANGUAGES` or `VOICE_LANGUAGES` doesn't free disk on its own — weights stay cached in the +`weights_cache` volume in case you re-add them later (and re-download automatically from the HF +cache on next start if you do). To reclaim space for what you no longer use, without purging +everything: + +```bash +make prune-models # dry-run report +make prune-models-apply # actually delete +``` -Example: 2 workers, English + French = `2 × 2 × 240 MB ≈ 960 MB` +It prunes to the **exact install plan** of your current `LANGUAGES` + `VOICE_LANGUAGES` (reusing +the same `plan_install` the server loads with, so it can't drift): it drops base models for +languages no longer enabled **and**, within kept languages, individual voice embeddings no longer +used (e.g. a removed cross-language pair). Empty `VOICE_LANGUAGES` (default coverage) correctly +keeps just the native voices. It only deletes a file's underlying blob once nothing else references +it — safe to re-run anytime. Under the hood it's `docker compose exec app python +scripts/prune_weights.py [--apply]`; pass `--keep en,fr` for a language-level-only override that +preserves every embedding. + +To reclaim everything at once instead, use the wizard's "Reset" → "purge all downloaded models" +option, which removes the whole `weights_cache` volume. ## Production vs development diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..780ed8f --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,75 @@ +# Deployment + +Automated: CI passes on `main` → `deploy.yml` builds + pushes the image to GHCR and +SSHes into the GCP VM to pull + restart. **Config (`.env`, `pocket-tts.env`) is +provisioned by the pipeline from GitHub Secrets — you never edit it on the VM by +hand, and you never run the wizard in production.** `configure.sh` is a *local / +first-time* convenience only. + +## How config flows + +``` +local .env / pocket-tts.env ──base64──▶ GitHub Secrets ──deploy.yml writes──▶ VM files ──▶ containers +``` + +`.env` and `pocket-tts.env` are gitignored (they hold secrets). The two GitHub +secrets `DOTENV_B64` / `POCKET_TTS_ENV_B64` are the single source of truth; the +deploy job decodes them onto the VM before `docker compose up`. + +## One-time setup + +### 1. Local — produce the env files +Generate them however you like (the wizard is easiest): +```bash +./scripts/configure.sh # or hand-edit .env and pocket-tts.env +``` +Then base64-encode each (copy the output): +```bash +base64 -i .env | pbcopy # macOS; or: base64 -w0 .env (Linux) +base64 -i pocket-tts.env | pbcopy +``` + +### 2. GitHub — create the secrets +Repo → **Settings → Secrets and variables → Actions → New repository secret**: + +| Secret | Value | +|---|---| +| `DOTENV_B64` | base64 of your `.env` | +| `POCKET_TTS_ENV_B64` | base64 of your `pocket-tts.env` | +| `GCP_VM_HOST` / `GCP_VM_USER` / `GCP_SSH_PRIVATE_KEY` | VM SSH access (already set) | + +### 3. VM — prepare once (no config edits) +```bash +git clone https://github.com/readium/speech-server ~/speech-server +# install docker + docker compose plugin +# if the GHCR image is private, authenticate so `docker compose pull` works: +echo "$GHCR_PAT" | docker login ghcr.io -u --password-stdin +``` +The `weights_cache` Docker volume persists across deploys, so models download only +once. Nginx serves TLS (see `nginx/` + `DOMAIN`). + +## Changing config (any env var, add or edit) — the whole point + +**No SSH, no wizard on the VM.** Update the source and redeploy: + +1. Edit `.env` / `pocket-tts.env` locally (or re-run the wizard). +2. Re-base64 and update the corresponding GitHub secret (`DOTENV_B64` / + `POCKET_TTS_ENV_B64`). +3. Push to `main` (or re-run the **Build & Deploy** workflow). The deploy job + rewrites the VM's env files and restarts. + +Adding a *new* var is the same flow — it's just part of the file. `entrypoint.sh` +validates required vars at container start and fails fast if one is missing, so a +bad/incomplete secret is caught immediately (the old container keeps running). + +## Notes + +- **Model/voice config** (`LANGUAGES`, `VOICE_LANGUAGES`) lives in `pocket-tts.env`. + Changing it and redeploying triggers new downloads on next start (cached after); + reclaim disk for removed ones with `make prune-models-apply`. +- **Secrets vs non-secrets:** bundling both files as base64 secrets is the pragmatic + choice for a single VM. To review config in PRs instead, graduate to a + SOPS/age-encrypted env committed to the repo and decrypted at deploy. +- **Rollback:** images are tagged by commit SHA (`ghcr.io/readium/speech-server:`); + pin that tag in compose and redeploy to roll back the app. Config rolls back by + restoring the previous secret value. diff --git a/docs/development.md b/docs/development.md index 0dee475..eee2797 100644 --- a/docs/development.md +++ b/docs/development.md @@ -33,11 +33,15 @@ 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()` +2. Declare `id`, `supported_languages`, `default_quality`, and `default_controls` as class variables +3. If voices come from a JSON file, reuse `app/providers/voice_loading.py` (`VoiceEntry`, + `resolve_install_languages`, `build_voice`) — the same install-mode bounding and + quality/controls merge PocketTTS uses, so it doesn't get reimplemented per provider +4. Implement `_all_voices()` and `synthesize()` +5. 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. +No changes to routes, synthesizer, or voice catalog. Language filtering, quality/controls +merging, and circuit-breaking are inherited automatically from the base class and core layer. ## Architecture @@ -46,13 +50,15 @@ Client └─ POST /synthesize └─ Synthesizer ├─ validate text length + content - ├─ resolve voiceURI → (provider, voiceURI) (VoiceCatalog) + ├─ resolve voice identifier → (provider, Voice) (VoiceCatalog) + ├─ circuit breaker check ← per-provider, 503 fast if open ├─ provider.synthesize() ← runs in thread pool, bounded by semaphore │ └─ TTSModel.generate_audio() — CPU inference - └─ encode PCM → mp3/opus (ffmpeg driver) or wrap → wav + └─ encode PCM → mp3/opus (ffmpeg driver) or wrap → wav (default) ``` - 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 +- A per-provider circuit breaker (`CIRCUIT_BREAKER_*`) fails fast instead of repeatedly hitting a broken provider - 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/providers/pocket.md b/docs/providers/pocket.md new file mode 100644 index 0000000..785ffad --- /dev/null +++ b/docs/providers/pocket.md @@ -0,0 +1,55 @@ +# Provider: PocketTTS + +Model-level information for the `pocket` provider — the things that are true for the whole model, +so they're documented here once instead of repeated on every voice in `/voices`. The API still +merges the model-level defaults below into each voice (a voice may override them). + +Source: [kyutai/pocket-tts](https://github.com/kyutai-labs/pocket-tts) — a CPU TTS (flow-based LM + +Mimi neural codec). + +## Identity + +- **Provider id:** `pocket` +- **Voice identifier:** `urn:readium:tts:pocket:` (e.g. `urn:readium:tts:pocket:estelle`). + Requests combine the `identifier` with a `language` — voices are declared **once**, not per + language. The server picks the right language model behind the scenes. + +## Model-level defaults (merged into every voice) + +| Property | Value | Notes | +|---|---|---| +| `quality` | `veryHigh` | Quality of the **voice**, not the audio output. All pocket voices are `veryHigh`. | +| `controls.pitch` | `false` | Not supported. | +| `controls.speed` | `false` | Not supported (no native speed control). | +| `controls.ssml` | `false` | SSML not supported; tags are stripped, not rendered. | +| `controls.boundary` | `false` | No word-level timing marks. | + +A future voice that overrides one of these (e.g. `controls.ssml: true`) would carry that field in +`voices.json`; otherwise the value above applies. + +## Audio output + +PocketTTS renders **24 kHz · mono · 16-bit PCM**, returned as a standard **WAV** file with no +re-encoding (the default, and fastest — no ffmpeg). The server can transcode on request: + +- `wav` — native, lossless. **Default.** +- `opus` — compressed, high quality. Strong second choice. +- `mp3` — lossy; noticeably worse, not a good default. + +`output.sample_rate` is accepted but not applied — output is always the model's native 24 kHz. + +## Languages & voices + +- **Native languages:** `en` `fr` `it` `de` `es` `pt`. English ships many voices; each other + language ships one native voice. +- **Cross-language:** any voice can also speak the languages it declares in `otherLanguages`, + *if* that `(voice, language)` pair is installed (`VOICE_LANGUAGES`). A voice needs the target + language's base model **and** its per-language speaker embedding — see + [configuration → model sizes](../configuration.md#model-sizes--ram). +- What's **actually installed** on a deployment is what `GET /voices` returns (realtime). The full + declared list of possibilities lives in `app/data/voices/pocket/voices.json`. + +## Constraints + +- CPU only; torch ≥ 2.5 CPU build. `en`/`it`/`pt` are 6-layer models; `fr`/`de`/`es` are larger + 24-layer models (slower, higher quality). Throughput scales by worker processes. diff --git a/docs/voices.md b/docs/voices.md index 024589c..2241e32 100644 --- a/docs/voices.md +++ b/docs/voices.md @@ -1,6 +1,13 @@ # 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). +26 voice identities, each declared **once** — not duplicated per language. A voice has one +primary `language` and, optionally, `otherLanguages` it's also capable of speaking. The +identifier has no language segment: it's always `urn:readium:tts:pocket:`, and which +language to speak is chosen at request time via `SynthesizeRequest.language`. + +``` +urn:readium:tts:pocket:alba # one identifier for Alba, in any installed language +``` The 26 voice identities, sourced from [kyutai/tts-voices](https://huggingface.co/kyutai/tts-voices): @@ -15,18 +22,37 @@ The 26 voice identities, sourced from [kyutai/tts-voices](https://huggingface.co | estelle | female | Unmute production voices | | giovanni, lola, juergen, rafael | mixed | Kyutai (language reference voices) | -## Voice URIs +Supported language codes: `en`, `fr`, `it`, `de`, `es`, `pt` — derived directly from PocketTTS model names. -Voice URIs are language-scoped — the same speaker in different languages gets a distinct URI: +## Cross-language voices -``` -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 -``` +Every voice's `otherLanguages` in `voices.json` documents what it's *capable* of, not whether +it's a *good fit* — an English voice speaking French isn't necessarily natural-sounding. No +ranking or curation of this exists yet; treat it as aspirational, not a quality signal. -Supported language codes: `en`, `fr`, `it`, `de`, `es`, `pt` — derived directly from PocketTTS model names. +**Which cross-language support actually gets installed is controlled by `VOICE_LANGUAGES`** +(see [Configuration](configuration.md)): + +| Setting | Behavior | +|---|---| +| _(empty, default)_ | Each voice is only installed for its primary `language`. Smallest download/RAM footprint. | +| `*:*` | Every voice is also installed for its `otherLanguages` — but **only** for languages already in `LANGUAGES`. This never triggers downloading a language model you didn't already select; it just lets already-loaded models serve more voices. | + +For exact (voice, language) picks instead of the all-or-nothing `*:*` — e.g. only Alba in French, +or `*:*` minus one voice's Spanish — the same setting takes explicit `voice:lang` pairs +alongside (or instead of) the wildcard; see +[Configuration → per-voice language overrides](configuration.md#per-voice-language-overrides). + +`GET /voices` reflects what's actually **installed** on this deployment — its `otherLanguages` +is the installed subset, not the full aspirational list from `voices.json` (see +[API reference](API.md#get-voices)). `GET /service` summarizes each provider's installed +languages without repeating it per voice. ## 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. +PocketTTS pre-computes voice embeddings per voice per language (stored as `.safetensors` files +under `kyutai/pocket-tts-without-voice-cloning`), sized by the target language's model: +~5–8 MB into the 6-layer `en`/`it`/`pt` models, ~24–33 MB into the 24-layer `fr`/`de`/`es`. The embedding is warmed once at +model-load time — no per-request cloning overhead — but only for the (voice, language) pairs +`VOICE_LANGUAGES` and `LANGUAGES` actually call for. Unused language weights can be reclaimed +from the Docker volume with `scripts/prune_weights.py` — see [Configuration → disk space](configuration.md). diff --git a/nginx/templates/20-speech-server.conf.template b/nginx/templates/20-speech-server.conf.template index 1494c35..521ad6c 100644 --- a/nginx/templates/20-speech-server.conf.template +++ b/nginx/templates/20-speech-server.conf.template @@ -64,6 +64,12 @@ server { limit_conn perip 10; include /etc/nginx/snippets/proxy-pass.conf; + + # TTS inference is CPU-bound and can be slow (24-layer models, long text, + # emulated arch) — give synthesis more than the shared 120s read timeout so + # a legitimately long generation isn't cut off with a 504. This override must + # come AFTER the include, whose 120s would otherwise win. + proxy_read_timeout 300s; } location / { diff --git a/scripts/configure.sh b/scripts/configure.sh index fb3b8a7..0ebed94 100755 --- a/scripts/configure.sh +++ b/scripts/configure.sh @@ -4,6 +4,9 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="$REPO_ROOT/.env" +POCKET_ENV_FILE="$REPO_ROOT/pocket-tts.env" +VOICES_JSON="$REPO_ROOT/app/data/voices/pocket/voices.json" +POCKET_PLAN="$REPO_ROOT/scripts/pocket_plan.py" # ── Colours ─────────────────────────────────────────────────────────────────── B=$'\033[1m' R=$'\033[0m' G=$'\033[32m' Y=$'\033[33m' C=$'\033[36m' RED=$'\033[31m' @@ -14,18 +17,23 @@ err() { printf '%s✗%s %s\n' "$RED" "$R" "$*" >&2; } hdr() { echo; gum style --bold --foreground 212 "── $* ──"; echo; } banner() { gum style --border rounded --border-foreground 212 --bold --padding "0 2" --margin "1 0" "Readium Speech Server"; } -# ── Portable .env read/write ────────────────────────────────────────────────── -get_env() { - grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true +# ── Portable env-file read/write ──────────────────────────────────────────────── +# .env: server/auth/concurrency/circuit-breaker — universal, provider-agnostic. +# pocket-tts.env: PocketTTS-scoped install config (LANGUAGES, VOICE_LANGUAGES, +# POCKET_DEFAULT_VOICE). Future providers get their own file the same way. +# get_env/set_env target .env; get_penv/set_penv target pocket-tts.env — both +# built on the same generic _get_kv/_set_kv. +_get_kv() { + grep -E "^${2}=" "$1" 2>/dev/null | cut -d= -f2- || true } -set_env() { - local key="$1" val="$2" tmp - if [[ ! -f "$ENV_FILE" ]]; then - printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" +_set_kv() { + local file="$1" key="$2" val="$3" tmp + if [[ ! -f "$file" ]]; then + printf '%s=%s\n' "$key" "$val" >> "$file" return fi - if grep -qE "^${key}=" "$ENV_FILE"; then + if grep -qE "^${key}=" "$file"; then tmp=$(mktemp) while IFS= read -r line; do if [[ "$line" == "${key}="* ]]; then @@ -33,15 +41,65 @@ set_env() { else printf '%s\n' "$line" fi - done < "$ENV_FILE" > "$tmp" - mv "$tmp" "$ENV_FILE" + done < "$file" > "$tmp" + mv "$tmp" "$file" else - printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" + printf '%s=%s\n' "$key" "$val" >> "$file" fi } +get_env() { _get_kv "$ENV_FILE" "$1"; } +set_env() { _set_kv "$ENV_FILE" "$1" "$2"; } +get_penv() { _get_kv "$POCKET_ENV_FILE" "$1"; } +set_penv() { _set_kv "$POCKET_ENV_FILE" "$1" "$2"; } + +# ── Migrations (one-time, idempotent — safe to call every run) ───────────────── +_migrate_pocket_env() { + [[ -f "$ENV_FILE" ]] || return 0 + [[ -f "$POCKET_ENV_FILE" ]] && return 0 + local langs mode voice + langs=$(get_env LANGUAGES) + mode=$(get_env VOICE_INSTALL_MODE) + voice=$(get_env POCKET_DEFAULT_VOICE) + [[ -z "$langs" && -z "$mode" && -z "$voice" ]] && return 0 + + cat > "$POCKET_ENV_FILE" << EOF +# PocketTTS — provider-scoped install config. Migrated from .env by configure.sh. +LANGUAGES=${langs} +POCKET_DEFAULT_VOICE=${voice} +VOICE_LANGUAGES=$([ "$mode" = "all" ] && echo '*:*') +EOF + chmod 600 "$POCKET_ENV_FILE" + + local tmp + tmp=$(mktemp) + grep -vE '^(LANGUAGES|VOICE_INSTALL_MODE|POCKET_DEFAULT_VOICE)=' "$ENV_FILE" > "$tmp" + mv "$tmp" "$ENV_FILE" + + ok "Migrated LANGUAGES/VOICE_INSTALL_MODE/POCKET_DEFAULT_VOICE → pocket-tts.env" +} + +# Earlier versions of this script wrote VOICE_INSTALL_MODE into pocket-tts.env +# directly (before it merged into the VOICE_LANGUAGES wildcard). Fold it in. +_migrate_voice_install_mode() { + [[ -f "$POCKET_ENV_FILE" ]] || return 0 + local mode + mode=$(get_penv VOICE_INSTALL_MODE) + [[ -z "$mode" ]] && return 0 + + if [[ "$mode" == "all" ]]; then + set_penv VOICE_LANGUAGES "$(_voicelang_prepend_wildcard "$(get_penv VOICE_LANGUAGES)")" + fi + local tmp + tmp=$(mktemp) + grep -v '^VOICE_INSTALL_MODE=' "$POCKET_ENV_FILE" > "$tmp" + mv "$tmp" "$POCKET_ENV_FILE" + ok "Migrated VOICE_INSTALL_MODE → VOICE_LANGUAGES wildcard" +} + # ── Language helpers ────────────────────────────────────────────────────────── ALL_LANGS=(en fr it de es pt) +BASE_LANGS="" # protected language base while custom coverage is being edited lang_name() { case "$1" in @@ -54,7 +112,7 @@ lang_name() { current_langs() { local raw - raw=$(get_env LANGUAGES) + raw=$(get_penv LANGUAGES) echo "${raw:-en}" } @@ -70,6 +128,164 @@ lang_list_to_set() { echo "$out" } +# Multi-select the language set, with a confirm-and-retry loop. ENTER is easy to +# hit reflexively before pressing SPACE to check anything — the confirm guards +# against a stray ENTER silently locking in an empty/wrong pick. Sets global +# PICKED_LANGS (not echoed — gum/confirm output would pollute a $(...) capture). +# $1 = fallback CSV used when nothing is checked (keep-current / "en"). +_pick_languages() { + PICKED_LANGS="" + local fallback="${1:-en}" + local lang_opts=() code + for code in "${ALL_LANGS[@]}"; do + lang_opts+=("$code — $(lang_name "$code")") + done + while true; do + local choices=() line + while IFS= read -r line; do + [[ -n "$line" ]] && choices+=("$line") + done < <(printf '%s\n' "${lang_opts[@]}" | + gum choose --no-limit \ + --header "SPACE to check, ENTER when done (none checked → keep '$fallback')") + + local langs="" + if [[ ${#choices[@]} -gt 0 ]]; then + for line in "${choices[@]}"; do + langs="${langs:+${langs},}${line%% —*}" + done + fi + [[ -z "$langs" ]] && langs="$fallback" + langs=$(lang_list_to_set "$langs") + + printf '\nSelected:\n' + local c + IFS=',' read -ra _codes <<< "$langs" + for c in "${_codes[@]}"; do + printf ' %s✓%s %s — %s\n' "$G" "$R" "$c" "$(lang_name "$c")" + done + if gum confirm "Use these languages?"; then + PICKED_LANGS="$langs" + return + fi + printf '\n' + done +} + +# ── VOICE_LANGUAGES helpers ────────────────────────────────────────────────── +# Comma-separated "originalName:lang" pairs; a leading "-" on a pair means remove +# instead of add, e.g. "alba:fr,alba:de,-javert:es". A bare "*:*" token means +# "every voice, every declared+enabled otherLanguage" (old install_mode=all). +_voicelang_upsert() { + local list="$1" voice="$2" lang="$3" mode="$4" out="" p bare entry + # bash 3.2 (macOS default) treats "${arr[@]}" on an empty array as unbound + # under set -u — short-circuit rather than expand an array from "". + if [[ -n "$list" ]]; then + IFS=',' read -ra parts <<< "$list" + for p in "${parts[@]}"; do + [[ -z "$p" ]] && continue + bare="${p#-}" + [[ "$bare" != "${voice}:${lang}" ]] && out="${out:+${out},}${p}" + done + fi + entry="${voice}:${lang}" + [[ "$mode" == "remove" ]] && entry="-${entry}" + [[ -z "$out" ]] && { echo "$entry"; return; } + echo "${out},${entry}" +} + +_voicelang_strip_wildcard() { + local list="$1" out="" p + if [[ -n "$list" ]]; then + IFS=',' read -ra parts <<< "$list" + for p in "${parts[@]}"; do + [[ -z "$p" || "$p" == "*:*" ]] && continue + out="${out:+${out},}${p}" + done + fi + echo "$out" +} + +_voicelang_prepend_wildcard() { + local list + list=$(_voicelang_strip_wildcard "$1") + [[ -z "$list" ]] && { echo "*:*"; return; } + echo "*:*,${list}" +} + +# Prints "originalName|Display Name (gender, primaryLang)" per voice, one per +# line — e.g. "estelle|Estelle (female, fr)". Sorted primary-English voices +# first, then by language, matching how PocketTTS's own voice picker groups +# them (native-language voices called out distinctly from the English pool). +list_voice_choices() { + python3 - "$VOICES_JSON" "${1:-}" << 'PY' +import json, sys +data = json.load(open(sys.argv[1])) +# Optional arg2: comma-separated enabled langs — restrict to installable voices +# (primary language selected). Empty/absent = no filter. +raw = sys.argv[2] if len(sys.argv) > 2 else "" +langs = {x.strip().lower() for x in raw.split(",") if x.strip()} or None +def sort_key(v): + lang = v["language"].split("-")[0].lower() + return (0 if lang == "en" else 1, lang, v["name"]) +for v in sorted(data, key=sort_key): + lang = v["language"].split("-")[0].lower() + if langs is not None and lang not in langs: + continue + gender = v.get("gender") or "?" + print(f'{v["originalName"]}|{v["name"]} ({gender}, {lang})') +PY +} + +# Prints "primary|other1,other2,..." for a voice's originalName (case-insensitive +# match), empty if not found. Uses python3 (stdlib json) — more reliable than +# hand-rolled per-object regex extraction from multi-line JSON. +voice_info() { + python3 - "$VOICES_JSON" "$1" << 'PY' +import json, sys +path, name = sys.argv[1], sys.argv[2].lower() +data = json.load(open(path)) +for v in data: + if v["originalName"].lower() == name: + primary = v["language"].split("-")[0].lower() + others = ",".join(l.split("-")[0].lower() for l in v.get("otherLanguages", [])) + print(f"{primary}|{others}") + break +PY +} + +# Sets global PICKED_VOICE (empty if cancelled/none found). Not returned via +# command substitution — warn()/gum output would otherwise get captured too. +# Shows each voice annotated with gender + primary language so the primary +# language is visible right in the picker, not just something you find out +# after adding a now-obviously-redundant override. +_pick_voice() { + PICKED_VOICE="" + local names=() displays=() line orig disp + # All voices — custom can pull in a voice whose primary language isn't selected + # yet (adding a pair for it extends LANGUAGES to include that primary). + while IFS='|' read -r orig disp; do + [[ -z "$orig" ]] && continue + names+=("$orig") + displays+=("$disp") + done < <(list_voice_choices "") + if [[ ${#names[@]} -eq 0 ]]; then + warn "No installable voices for your selected languages ($(current_langs))." + return + fi + local picked + # Explicit exit row so you don't have to guess that ESC backs out. + local back="‹ Back (done)" + picked=$(printf '%s\n' "$back" "${displays[@]}" | gum choose --header "Voice (or Back)") || true + [[ -z "$picked" || "$picked" == "$back" ]] && return + local i + for i in "${!displays[@]}"; do + if [[ "${displays[$i]}" == "$picked" ]]; then + PICKED_VOICE="${names[$i]}" + return + fi + done +} + # ── Docker check ────────────────────────────────────────────────────────────── require_docker() { if ! command -v docker &>/dev/null; then @@ -134,7 +350,7 @@ require_gum() { } # ══════════════════════════════════════════════════════════════════════════════ -# Actions +# First-time setup # ══════════════════════════════════════════════════════════════════════════════ do_first_setup() { @@ -151,58 +367,31 @@ do_first_setup() { esac fi - hdr "Step 1/4 — Languages" - local lang_opts=() - for code in "${ALL_LANGS[@]}"; do - lang_opts+=("$code — $(lang_name "$code")") - done - - # Enter is easy to hit reflexively (it's the "confirm" key in most menus) - # before pressing Space to actually check anything — confirm-and-retry - # loop so a stray Enter doesn't silently lock in the wrong selection. - while true; do - local choices=() - # mapfile is bash4+ only; macOS ships bash 3.2 by default. Portable read loop instead. - while IFS= read -r line; do - [[ -n "$line" ]] && choices+=("$line") - done < <(printf '%s\n' "${lang_opts[@]}" | - gum choose --no-limit \ - --header "SPACE to check a language, then ENTER when done (default: English if none checked)") - - LANGS="" - # bash 3.2 (macOS default) treats "${arr[@]}" on an empty array as unbound - # under set -u — check length first rather than expanding directly. - if [[ ${#choices[@]} -gt 0 ]]; then - for choice in "${choices[@]}"; do - LANGS="${LANGS:+${LANGS},}${choice%% —*}" - done - fi - [[ -z "$LANGS" ]] && LANGS="en" - LANGS=$(lang_list_to_set "$LANGS") + hdr "Step 1/5 — Languages" + printf 'The languages PocketTTS installs voices for. English ships 21 voices; each\n' + printf 'other language ships 1 native voice. Coverage (native vs cross-language)\n' + printf 'is the next step. (Model sizes / RAM: see docs/configuration.md.)\n\n' + _pick_languages "en" + LANGS="$PICKED_LANGS" + ok "Languages: $LANGS" - printf '\nSelected:\n' - IFS=',' read -ra _selected_codes <<< "$LANGS" - for code in "${_selected_codes[@]}"; do - printf ' %s✓%s %s — %s\n' "$G" "$R" "$code" "$(lang_name "$code")" - done + # Persist pocket-tts.env now (with the chosen languages) so Step 2's coverage + # picker can read/write it and compute the plan against real config. + _write_pocket_env "$LANGS" - if gum confirm "Use these languages?"; then - break - fi - printf '\n' - done - ok "Languages: $LANGS" + hdr "Step 2/5 — Voice coverage" + _set_coverage - hdr "Step 2/4 — Workers" + hdr "Step 3/5 — Workers" WORKERS=$(gum input --placeholder "1" \ - --header "RAM per worker = ~240 MB × number of languages loaded") || true + --header "Uvicorn worker processes (throughput; RAM scales with count — see docs)") || true WORKERS="${WORKERS:-1}" case "$WORKERS" in ''|*[!0-9]*|0) warn "Invalid — using 1"; WORKERS=1 ;; esac ok "Workers: $WORKERS" - hdr "Step 3/4 — HuggingFace Token" + hdr "Step 4/5 — HuggingFace Token" HF_TOKEN=$(gum input --password --placeholder "hf_..." \ --header "Get a free read-only token at: https://huggingface.co/settings/tokens") || true if [[ -z "$HF_TOKEN" ]]; then @@ -211,7 +400,7 @@ do_first_setup() { ok "Token recorded" fi - hdr "Step 4/4 — Domain" + hdr "Step 5/5 — Domain" DOMAIN_INPUT=$(gum input --placeholder "tts.example.com" \ --header "Domain name nginx/FastAPI will serve") || true if [[ -z "$DOMAIN_INPUT" ]]; then @@ -223,6 +412,8 @@ do_first_setup() { cat > "$ENV_FILE" << 'EOF' # Readium Speech Server — environment variables # Generated by configure.sh — edit manually or re-run configure.sh +# Provider-specific install config (e.g. PocketTTS) lives in its own env file — +# see pocket-tts.env. # ── Server ── APP_ENV=production @@ -239,114 +430,332 @@ API_KEY= # ── Concurrency ── MAX_CONCURRENT_SYNTHESES=2 -# ── Languages (comma-separated BCP-47 prefixes; PocketTTS: en,fr,it,de,es,pt) ── -# Each language downloads ~240 MB on first start into the weights Docker volume. -LANGUAGES=en -HF_TOKEN= +# ── Circuit breaker (per provider, wraps synthesize() calls) ── +CIRCUIT_BREAKER_ENABLED=true +CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 +CIRCUIT_BREAKER_RECOVERY_SECONDS=30 # ── Providers ── ENABLED_PROVIDERS=pocket DEFAULT_PROVIDER=pocket -# ── PocketTTS ── -POCKET_DEFAULT_VOICE=alba +# ── HuggingFace (shared across any provider that pulls models from HF) ── +HF_TOKEN= # ── Audio / ffmpeg ── FFMPEG_BIN=ffmpeg MAX_TEXT_LENGTH=2000 EOF chmod 600 "$ENV_FILE" - set_env LANGUAGES "$LANGS" - set_env WORKERS "$WORKERS" - set_env HF_TOKEN "$HF_TOKEN" - set_env DOMAIN "$DOMAIN_INPUT" + set_env WORKERS "$WORKERS" + set_env HF_TOKEN "$HF_TOKEN" + set_env DOMAIN "$DOMAIN_INPUT" - ok "Written → .env" + ok "Written → .env, pocket-tts.env" _print_next_steps "$LANGS" } -do_add_language() { - _require_env - local current - current=$(current_langs) +# Writes pocket-tts.env with the given LANGUAGES; coverage (VOICE_LANGUAGES) is +# set separately by _set_coverage. Called early in first-setup so the coverage +# step has a real config file to read/mutate. +_write_pocket_env() { + local langs="$1" + cat > "$POCKET_ENV_FILE" << 'EOF' +# PocketTTS — provider-scoped install config. Full details + model sizes: +# docs/configuration.md. +# LANGUAGES: comma BCP-47 prefixes — the languages to install voices for. +# VOICE_LANGUAGES: cross-language pairs added on top of the native defaults. +# Format: "originalName:lang", "-" prefix to remove (e.g. "alba:fr,-javert:es"); +# a bare "*:*" = every voice in every selected language (the 'all' coverage mode). +LANGUAGES=en +POCKET_DEFAULT_VOICE= +VOICE_LANGUAGES= +EOF + chmod 600 "$POCKET_ENV_FILE" + set_penv LANGUAGES "$langs" +} - hdr "Add Language" - local available=() code choice - for code in "${ALL_LANGS[@]}"; do - if ! echo "$current" | grep -qE "(^|,)${code}(,|$)"; then - available+=("$code — $(lang_name "$code")") - fi - done - if [[ ${#available[@]} -eq 0 ]]; then - warn "All supported languages are already enabled." - return - fi - choice=$(printf '%s\n' "${available[@]}" | gum choose --header "Language to add") || true - if [[ -z "$choice" ]]; then - warn "Cancelled." +# ══════════════════════════════════════════════════════════════════════════════ +# PocketTTS provider actions +# ══════════════════════════════════════════════════════════════════════════════ + +# Live install plan for the current LANGUAGES/VOICE_LANGUAGES/WORKERS. Delegates +# what-installs view via scripts/pocket_plan.py, which reuses the provider's own +# resolver — so this can never drift from what the server actually loads. +_show_plan() { + local langs voice_lang + langs=$(current_langs) + voice_lang=$(get_penv VOICE_LANGUAGES) + python3 "$POCKET_PLAN" "$VOICES_JSON" "$langs" "$voice_lang" +} + +do_show_pocket_config() { + hdr "PocketTTS Configuration" + local default_voice + default_voice=$(get_penv POCKET_DEFAULT_VOICE) + printf ' %-24s %s%s%s\n' "Default voice:" "$C" "${default_voice:-(unset)}" "$R" + _show_plan + printf '\n' +} + +# Step 1 of the flow: pick the language set. Whatever you check becomes LANGUAGES — +# the languages PocketTTS installs voices for. Reuses the trap-guarded picker. +_menu_change_languages() { + hdr "Languages" + local current new + current=$(current_langs) + printf 'Languages to install voices for. English has many voices; each other\n' + printf 'language has one. Coverage (native vs cross-language) is the next step.\n' + printf 'This REPLACES the current set. Currently: %s%s%s\n\n' "$C" "$current" "$R" + _pick_languages "$current" + new="$PICKED_LANGS" + if [[ "$new" == "$current" ]]; then + warn "Unchanged ($current)." return fi - code="${choice%% —*}" - local new - new=$(lang_list_to_set "${current},${code}") - set_env LANGUAGES "$new" + set_penv LANGUAGES "$new" ok "LANGUAGES updated → $new" - printf '\n%sRestart the server to load the new language:%s\n' "$B" "$R" - printf ' make stop && make start\n\n' - printf '%sNote:%s first start downloads ~240 MB for %s%s%s. Cached after that.\n\n' \ - "$Y" "$R" "$B" "$code" "$R" + printf '\n%sNote:%s new languages download on next start; removed ones stay on disk\n' "$Y" "$R" + printf '(reclaim: %smake prune-models-apply%s). Restart to apply: %smake stop && make start%s\n\n' \ + "$C" "$R" "$C" "$R" + _show_plan + _show_plan } -do_remove_language() { - _require_env - local current - current=$(current_langs) - IFS=',' read -ra parts <<< "$current" +# Step 2 of the flow: choose how many voices/languages install. default and all +# work over your currently-selected languages; custom can pull in any voice and any +# of its languages, extending LANGUAGES to match (removes prune it back down). +_set_coverage() { + hdr "Voice coverage" + local langs + langs=$(current_langs) + printf 'Selected languages: %s%s%s\n\n' "$C" "$langs" "$R" + printf ' default — one voice per selected language, in its own language only.\n' + printf ' all — every installed voice also speaks every OTHER selected language.\n' + printf ' custom — hand-pick any voice + any of its languages; LANGUAGES follows.\n\n' + + local COV + COV=$(gum choose \ + "default — native voices only" \ + "all — every voice, every selected language" \ + "custom — per-voice, per-language (can add languages)" \ + "Cancel") || true + case "$COV" in + default*) + set_penv VOICE_LANGUAGES "" + ok "Coverage → default (native voices only)" + ;; + all*) + set_penv VOICE_LANGUAGES "*:*" + ok "Coverage → all (every voice, every selected language)" + ;; + custom*) + # Custom derives LANGUAGES from the picks: snapshot the current selection + # as the protected base (native voices you keep) — adds extend LANGUAGES, + # removes prune back to this base. Drop any "*:*" so picks are explicit. + BASE_LANGS=$(current_langs) + set_penv VOICE_LANGUAGES "$(_voicelang_strip_wildcard "$(get_penv VOICE_LANGUAGES)")" + _custom_voice_loop + _custom_sync_languages + ;; + *) warn "Cancelled."; return ;; + esac + printf '\nNow: %sLANGUAGES=%s%s | %sVOICE_LANGUAGES=%s%s\n\n' \ + "$C" "$(current_langs)" "$R" "$C" "$(get_penv VOICE_LANGUAGES)" "$R" + _show_plan + printf '\nRestart to apply: %smake stop && make start%s\n\n' "$C" "$R" +} + +# Prints one BCP-47 lang per line: every language this voice can ALSO speak (its +# declared otherLanguages, minus its own primary). Unbounded by LANGUAGES — custom +# can pull a new language in, and adding one syncs LANGUAGES to match. +_voice_xlang_candidates() { + local voice="$1" info primary others l + info=$(voice_info "$voice") + IFS='|' read -r primary others <<< "$info" + IFS=',' read -ra _o <<< "$others" + for l in "${_o[@]}"; do + [[ -z "$l" || "$l" == "$primary" ]] && continue + echo "$l" + done +} + +# Languages the explicit VOICE_LANGUAGES pairs require: each pair's target language, +# plus the primary language of each pair's voice (a voice can't run without its own +# base model). Wildcard/removal tokens are ignored. +_langs_used_by_pairs() { + local list="$1" p voice lang out="" + [[ -z "$list" ]] && { echo ""; return; } + IFS=',' read -ra _p <<< "$list" + for p in "${_p[@]}"; do + [[ -z "$p" || "$p" == "*:*" || "$p" == -* ]] && continue + voice="${p%%:*}"; lang="${p#*:}" + out="${out:+${out},}${lang},$(voice_info "$voice" | cut -d'|' -f1)" + done + lang_list_to_set "$out" +} + +# Re-derive LANGUAGES = protected base (the selection when custom started) ∪ every +# language the current pairs require. Adds extend it; removes prune back to base. +_custom_sync_languages() { + local base used + base="${BASE_LANGS:-$(current_langs)}" + used=$(_langs_used_by_pairs "$(get_penv VOICE_LANGUAGES)") + set_penv LANGUAGES "$(lang_list_to_set "${base}${used:+,$used}")" +} + +# Space-separated extra langs currently added for a voice (explicit non-removal +# pairs in VOICE_LANGUAGES). Custom mode has no "*:*", so these are the real adds. +_voice_added_langs() { + local voice="$1" list p out="" + list=$(get_penv VOICE_LANGUAGES) + [[ -z "$list" ]] && return + IFS=',' read -ra _p <<< "$list" + for p in "${_p[@]}"; do + [[ -z "$p" || "$p" == -* ]] && continue + [[ "$p" == "${voice}:"* ]] && out="${out:+${out} }${p#*:}" + done + echo "$out" +} - if [[ ${#parts[@]} -eq 1 ]]; then - err "Only one language configured (${current}). Cannot remove — server needs at least one." +# Delete any "voice:lang" pair (add or removal form) from a VOICE_LANGUAGES list. +_voicelang_delete() { + local list="$1" voice="$2" lang="$3" out="" p bare + [[ -z "$list" ]] && return + IFS=',' read -ra _p <<< "$list" + for p in "${_p[@]}"; do + [[ -z "$p" ]] && continue + bare="${p#-}" + [[ "$bare" == "${voice}:${lang}" ]] && continue + out="${out:+${out},}${p}" + done + echo "$out" +} + +# Per-voice cross-language editor. All steps are single-select (ENTER picks the +# highlighted row) — no SPACE-to-check trap, no silent dead-ends. +_custom_voice_loop() { + hdr "Custom cross-language" + printf 'Pick any voice, then add/remove the languages it should speak. Adding a\n' + printf 'language a voice needs pulls it into LANGUAGES automatically; removing it\n' + printf 'prunes back to your base selection when nothing else needs it.\n\n' + while true; do + _pick_voice + local voice="$PICKED_VOICE" + [[ -z "$voice" ]] && { warn "Done."; return; } + _custom_voice_one "$voice" + gum confirm "Configure another voice?" || return + done +} + +_custom_voice_one() { + local voice="$1" added primary ACT + primary=$(voice_info "$voice" | cut -d'|' -f1) + while true; do + added=$(_voice_added_langs "$voice") + printf '\n%s%s%s (native %s) — extra languages: %s%s%s\n' \ + "$B" "$voice" "$R" "$primary" "$C" "${added:-none}" "$R" + ACT=$(gum choose "Add a language" "Remove a language" "Done (this voice)") || true + case "$ACT" in + "Add a language") _custom_add "$voice" ;; + "Remove a language") _custom_remove "$voice" ;; + *) return ;; + esac + done +} + +_custom_add() { + local voice="$1" cand=() added l pick + added=" $(_voice_added_langs "$voice") " + while IFS= read -r l; do + [[ -z "$l" ]] && continue + [[ "$added" == *" $l "* ]] && continue + cand+=("$l — $(lang_name "$l")") + done < <(_voice_xlang_candidates "$voice") + if [[ ${#cand[@]} -eq 0 ]]; then + warn "No more languages to add for '$voice'." return fi + pick=$(printf '%s\n' "${cand[@]}" | gum choose --header "Add language for '$voice'") || true + [[ -z "$pick" ]] && { warn "Cancelled."; return; } + pick="${pick%% —*}" + set_penv VOICE_LANGUAGES "$(_voicelang_upsert "$(get_penv VOICE_LANGUAGES)" "$voice" "$pick" add)" + _custom_sync_languages + ok "Added: $voice → $pick (LANGUAGES now: $(current_langs))" +} - hdr "Remove Language" - local options=() p code choice - for p in "${parts[@]}"; do - options+=("$p — $(lang_name "$p")") +_custom_remove() { + local voice="$1" cand=() l pick + for l in $(_voice_added_langs "$voice"); do + cand+=("$l — $(lang_name "$l")") done - choice=$(printf '%s\n' "${options[@]}" | gum choose --header "Language to remove") || true - if [[ -z "$choice" ]]; then - warn "Cancelled." + if [[ ${#cand[@]} -eq 0 ]]; then + warn "'$voice' has no extra languages to remove." return fi - code="${choice%% —*}" + pick=$(printf '%s\n' "${cand[@]}" | gum choose --header "Remove language for '$voice'") || true + [[ -z "$pick" ]] && { warn "Cancelled."; return; } + pick="${pick%% —*}" + set_penv VOICE_LANGUAGES "$(_voicelang_delete "$(get_penv VOICE_LANGUAGES)" "$voice" "$pick")" + _custom_sync_languages + ok "Removed: $voice → $pick (LANGUAGES now: $(current_langs))" +} - local new="" p2 - for p2 in "${parts[@]}"; do - [[ "$p2" != "$code" ]] && new="${new:+${new},}${p2}" +do_pocket_menu() { + while true; do + clear + banner + hdr "PocketTTS" + + do_show_pocket_config + + printf '\n %sSteps:%s 1) set Languages first 2) pick Voice coverage.\n' "$B" "$R" + printf ' Coverage: %sdefault%s = native voices only · %sall%s = every voice/language ·\n' \ + "$C" "$R" "$C" "$R" + printf ' %scustom%s = default + cross-language pairs you add. Sizes/RAM: docs/configuration.md\n\n' \ + "$C" "$R" + + local OPT + OPT=$(gum choose \ + "Languages" \ + "Voice coverage" \ + "Back") || true + case "$OPT" in + "Languages") _menu_change_languages ;; + "Voice coverage") _set_coverage ;; + *) return ;; + esac + printf '\nPress Enter to continue...'; read -r _ done - set_env LANGUAGES "$new" - ok "LANGUAGES updated → $new" - printf '\n%sNote:%s model files for %s remain in the Docker volume (disk space not freed).\n' \ - "$Y" "$R" "$code" - printf 'To reclaim disk: %smake stop && docker volume rm speech-server_weights_cache && make start%s\n' \ - "$C" "$R" - printf '(Re-downloads active languages: %s)\n\n' "$new" - printf 'Restart to apply: %smake stop && make start%s\n\n' "$C" "$R" } +# ══════════════════════════════════════════════════════════════════════════════ +# Provider picker (ready to extend once a second provider exists) +# ══════════════════════════════════════════════════════════════════════════════ + +do_manage_provider() { + hdr "Providers" + local provider + provider=$(gum choose "pocket" --header "Provider") || true + [[ -z "$provider" ]] && { warn "Cancelled."; return; } + case "$provider" in + pocket) do_pocket_menu ;; + esac +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Universal (.env) actions +# ══════════════════════════════════════════════════════════════════════════════ + do_change_workers() { - _require_env local current current=$(get_env WORKERS) current="${current:-1}" hdr "Change Workers" - local langs - langs=$(current_langs) - local lang_count - lang_count=$(echo "$langs" | tr ',' '\n' | wc -l | tr -d ' ') - printf 'Current: %s%s%s workers | RAM per worker: ~%d MB (%d language(s) × 240 MB)\n\n' \ - "$C" "$current" "$R" $((lang_count * 240)) "$lang_count" + printf 'Current: %s%s%s workers. More workers = more throughput; each loads its own\n' \ + "$C" "$current" "$R" + printf 'copy of the models, so RAM scales with worker count (sizes: see docs).\n\n' WORKERS=$(gum input --placeholder "$current" --header "New WORKERS") || true WORKERS="${WORKERS:-$current}" case "$WORKERS" in @@ -354,15 +763,10 @@ do_change_workers() { esac set_env WORKERS "$WORKERS" ok "WORKERS updated → $WORKERS" - local total_ram=$(( lang_count * 240 * WORKERS )) - printf 'Estimated RAM: ~%d MB (%d workers × %d languages × 240 MB)\n\n' \ - "$total_ram" "$WORKERS" "$lang_count" printf 'Restart to apply: %smake stop && make start%s\n\n' "$C" "$R" } do_update_token() { - _require_env - hdr "Update HF_TOKEN" HF_TOKEN=$(gum input --password --placeholder "hf_..." \ --header "Get a free read-only token at: https://huggingface.co/settings/tokens") || true @@ -373,24 +777,14 @@ do_update_token() { } do_show_config() { - _require_env - hdr "Current Configuration" - local langs workers hf_token key_enabled api_key max_syn - langs=$(current_langs) + local workers hf_token key_enabled max_syn workers=$(get_env WORKERS); workers="${workers:-1}" hf_token=$(get_env HF_TOKEN) key_enabled=$(get_env API_KEY_ENABLED); key_enabled="${key_enabled:-false}" max_syn=$(get_env MAX_CONCURRENT_SYNTHESES); max_syn="${max_syn:-2}" - printf ' %-30s %s%s%s\n' "Languages:" "$C" "$langs" "$R" - - local lang_count - lang_count=$(echo "$langs" | tr ',' '\n' | wc -l | tr -d ' ') - local total_ram=$(( lang_count * 240 * workers )) - - printf ' %-30s %s%s%s (~%d MB total RAM)\n' \ - "Workers:" "$C" "$workers" "$R" "$total_ram" + printf ' %-30s %s%s%s\n' "Workers:" "$C" "$workers" "$R" printf ' %-30s %s%s%s\n' "Max concurrent syntheses:" "$C" "$max_syn" "$R" if [[ -n "$hf_token" ]]; then @@ -401,24 +795,29 @@ do_show_config() { fi printf ' %-30s %s%s%s\n' "API key auth:" "$C" "$key_enabled" "$R" - printf '\n Config file: %s\n\n' "$ENV_FILE" + printf '\n %sSee "Manage provider" for PocketTTS-specific config.%s\n' "$Y" "$R" + printf ' Config files: %s, %s\n\n' "$ENV_FILE" "$POCKET_ENV_FILE" } do_reset() { hdr "Reset" - warn "This will delete .env and stop the server." + warn "This will delete .env / pocket-tts.env and stop the server." CHOICE=$(gum choose \ - "Delete .env only (keep downloaded models)" \ - "Delete .env AND purge all downloaded models (frees disk)" \ + "Delete config only (keep downloaded models)" \ + "Delete config AND purge all downloaded models (frees disk)" \ "Cancel") || true case "$CHOICE" in - "Delete .env only"*) - [[ -f "$ENV_FILE" ]] && rm "$ENV_FILE" && ok ".env deleted" + "Delete config only"*) + [[ -f "$ENV_FILE" ]] && rm "$ENV_FILE" + [[ -f "$POCKET_ENV_FILE" ]] && rm "$POCKET_ENV_FILE" + ok "Config deleted" printf 'Run %sconfigure.sh%s to set up again.\n\n' "$C" "$R" ;; - "Delete .env AND purge"*) + "Delete config AND purge"*) require_docker - [[ -f "$ENV_FILE" ]] && rm "$ENV_FILE" && ok ".env deleted" + [[ -f "$ENV_FILE" ]] && rm "$ENV_FILE" + [[ -f "$POCKET_ENV_FILE" ]] && rm "$POCKET_ENV_FILE" + ok "Config deleted" local vol vol=$(docker volume ls -q | grep -E 'weights_cache$' | head -1 || true) if [[ -n "$vol" ]]; then @@ -436,13 +835,6 @@ do_reset() { } # ── Helpers ─────────────────────────────────────────────────────────────────── -_require_env() { - if [[ ! -f "$ENV_FILE" ]]; then - err ".env not found. Run option 1 (first-time setup) first." - exit 1 - fi -} - _print_next_steps() { local langs="$1" printf '\n%sAvailable commands:%s\n\n' "$B" "$R" @@ -462,6 +854,8 @@ main_menu() { while true; do clear banner + _migrate_pocket_env + _migrate_voice_install_mode if [[ -f "$ENV_FILE" ]]; then local langs workers OPT @@ -471,8 +865,7 @@ main_menu() { "$C" "$langs" "$R" "$C" "$workers" "$R" OPT=$(gum choose \ "Show full config" \ - "Add a language" \ - "Remove a language" \ + "Manage provider" \ "Change workers" \ "Update HF token" \ "First-time setup (re-run / overwrite)" \ @@ -480,8 +873,7 @@ main_menu() { "Quit") || true case "$OPT" in "Show full config") do_show_config ;; - "Add a language") do_add_language ;; - "Remove a language") do_remove_language ;; + "Manage provider") do_manage_provider ;; "Change workers") do_change_workers ;; "Update HF token") do_update_token ;; "First-time setup"*) do_first_setup ;; diff --git a/scripts/pocket_plan.py b/scripts/pocket_plan.py new file mode 100755 index 0000000..bc60c09 --- /dev/null +++ b/scripts/pocket_plan.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Print what the current env config (LANGUAGES + VOICE_LANGUAGES) installs. + +A plain "what will be installed" view for the wizard: the coverage mode +(default / all / custom), native voices per language, and how many cross-language +pairs are added on top. Sizes/RAM live in docs/configuration.md, not here — the +wizard stays readable. + +Reuses app.providers.voice_loading.plan_install so the picture can't drift from +what the server actually loads. + +Usage: pocket_plan.py + pocket_plan.py --selftest +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from app.providers.voice_loading import VoiceEntry, plan_install # noqa: E402 + +SUPPORTED = ("en", "fr", "it", "de", "es", "pt") + + +def _split(csv: str) -> list[str]: + return [p.strip().lower() for p in csv.split(",") if p.strip()] + + +# ponytail: mirrors Settings._parse_voice_language_overrides / voice_install_all +# (settings.py) — that's the source of truth; keep this tokenizer in sync if the +# VOICE_LANGUAGES grammar changes. Kept separate to avoid constructing Settings(). +def _parse_voice_languages( + raw: str, +) -> tuple[bool, dict[str, set[str]], dict[str, set[str]]]: + wildcard = False + add: dict[str, set[str]] = {} + remove: dict[str, set[str]] = {} + for tok in raw.split(","): + tok = tok.strip() + if not tok: + continue + if tok == "*:*": + wildcard = True + continue + target = add + if tok.startswith("-"): + target, tok = remove, tok[1:] + if ":" not in tok: + continue + voice, lang = tok.split(":", 1) + voice, lang = voice.strip().lower(), lang.strip().lower() + if voice and lang: + target.setdefault(voice, set()).add(lang) + return wildcard, add, remove + + +def plan(voices_path: str, langs_csv: str, voice_languages: str) -> dict[str, Any]: + entries = [VoiceEntry.model_validate(v) for v in json.loads(Path(voices_path).read_text())] + enabled = frozenset(_split(langs_csv)) & frozenset(SUPPORTED) + wildcard, add, remove = _parse_voice_languages(voice_languages) + + installed = 0 + cross_pairs = 0 + natives: dict[str, list[str]] = {lang: [] for lang in enabled} + for e in entries: + key = e.originalName.lower() + result = plan_install( + e, enabled, wildcard, + frozenset(add.get(key, set())), frozenset(remove.get(key, set())), + ) + if result is None: + continue + _, langs = result + installed += 1 + primary = e.language.split("-")[0].lower() + cross_pairs += len(langs - {primary}) + if primary in natives: + natives[primary].append(e.originalName) + + if wildcard: + mode = "all" + elif add or remove: + mode = "custom" + else: + mode = "default" + + return { + "mode": mode, + "languages": sorted(enabled), + "installed_voices": installed, + "cross_pairs": cross_pairs, + "natives": natives, + } + + +_MODE_BLURB = { + "default": "native voices of your selected languages only", + "all": "every voice also speaks every other selected language", + "custom": "native voices + your hand-picked cross-language pairs", +} + + +def render(p: dict[str, Any]) -> str: + lines = [ + f" Languages: {', '.join(p['languages']) or '(none)'}", + f" Coverage: {p['mode']} — {_MODE_BLURB[p['mode']]}", + f" Voices installed: {p['installed_voices']}", + ] + if p["cross_pairs"]: + lines.append(f" Cross-language: {p['cross_pairs']} pair(s) added on top of defaults") + lines.append(" Native voices per language:") + for lang in p["languages"]: + names = p["natives"][lang] + if not names: + shown = "(none)" + elif len(names) <= 3: + shown = ", ".join(names) + else: + shown = f"{names[0]} (+{len(names) - 1} more)" + lines.append(f" {lang}: {shown}") + return "\n".join(lines) + + +def _selftest() -> None: + root = Path(__file__).parent.parent + vj = str(root / "app" / "data" / "voices" / "pocket" / "voices.json") + + d = plan(vj, "en", "") + assert d["mode"] == "default" and d["installed_voices"] == 21 and d["cross_pairs"] == 0, d + + d = plan(vj, "en,fr", "") + assert d["installed_voices"] == 22 and d["cross_pairs"] == 0, d + + # all + en,fr: every voice that can speak a selected language installs — incl. + # non-native-primary ones (giovanni/lola/... speaking fr/en cross-language). + a = plan(vj, "en,fr", "*:*") + assert a["mode"] == "all" and a["installed_voices"] == 26 and a["cross_pairs"] == 28, a + + c = plan(vj, "en,fr", "alba:fr") + assert c["mode"] == "custom" and c["cross_pairs"] == 1, c + print("pocket_plan selftest OK") + + +def main() -> int: + if len(sys.argv) == 2 and sys.argv[1] == "--selftest": + _selftest() + return 0 + if len(sys.argv) != 4: + print(__doc__, file=sys.stderr) + return 2 + voices_path, langs, voice_languages = sys.argv[1:4] + print(render(plan(voices_path, langs, voice_languages))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prune_weights.py b/scripts/prune_weights.py new file mode 100644 index 0000000..4e6c587 --- /dev/null +++ b/scripts/prune_weights.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Prune PocketTTS weight files that don't match the current env config. + +By default this reads LANGUAGES + VOICE_LANGUAGES and prunes to the exact install +plan the server would load (reusing plan_install): it drops base language models +for languages no longer enabled AND, within kept languages, individual voice +embeddings no longer used (e.g. a removed cross-language pair). What's still needed +stays; anything re-added later re-downloads from the HF cache on next start. + +PocketTTS caches everything — base model, tokenizer, and per-voice embeddings — +under `languages//` inside two HuggingFace repos (`kyutai/pocket-tts` and +`kyutai/pocket-tts-without-voice-cloning`), sharing one snapshot revision per +repo (confirmed by reading pocket_tts's own config/*.yaml and utils/utils.py — +every language config references the same two revision hashes). That makes +per-language pruning safe: each language's files live under one exclusive path +prefix, distinct from every other language's. + +Content-addressed HF cache layout means each snapshot file is a symlink into +`blobs/`. Before deleting a blob, this script confirms no OTHER +surviving symlink (outside the languages being pruned) points to the same +blob — belt-and-braces, since these files shouldn't collide in practice, but a +wrong deletion here corrupts a live model. + +Default is a dry-run report. Pass --apply to actually delete. + +Usage (inside the container, where HF_HOME=/weights is mounted): + python scripts/prune_weights.py # report only (env config) + python scripts/prune_weights.py --apply # actually delete + python scripts/prune_weights.py --keep en,fr --apply # language-level override, + # keeps all embeddings +Or via make (from the host): `make prune-models` / `make prune-models-apply`. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +_REPOS = ["kyutai/pocket-tts", "kyutai/pocket-tts-without-voice-cloning"] + + +def _repo_cache_dir(hub_dir: Path, repo_id: str) -> Path: + # HF cache naming: "org/name" -> "models--org--name" + return hub_dir / ("models--" + repo_id.replace("/", "--")) + + +def _lang_folder_map() -> dict[str, str]: + """BCP-47 prefix -> PocketTTS language folder name. Reuses the app's own + mapping so this script can't silently drift from what the provider loads.""" + sys.path.insert(0, str(Path(__file__).parent.parent)) + from app.providers.pocket_tts import _LANG_MODEL # noqa: PLC0415 + + return dict(_LANG_MODEL) + + +def _dir_size(path: Path) -> int: + total = 0 + for f in path.rglob("*"): + if f.is_file(): + try: + total += f.stat().st_size + except OSError: + pass + return total + + +def _resolve_blob(symlink: Path) -> Path | None: + try: + target = symlink.resolve() + except OSError: + return None + return target if target.is_file() else None + + +def _blob_has_other_references( + blob: Path, snapshots_dir: Path, exclude_prefixes: list[Path] +) -> bool: + """True if any symlink under snapshots_dir, outside exclude_prefixes, + resolves to the same blob.""" + for f in snapshots_dir.rglob("*"): + if not f.is_symlink(): + continue + if any(str(f).startswith(str(p)) for p in exclude_prefixes): + continue + if _resolve_blob(f) == blob: + return True + return False + + +def _install_plan_from_env() -> tuple[set[str], set[tuple[str, str]]]: + """What to KEEP per the current env config (LANGUAGES + VOICE_LANGUAGES), + exactly matching what the server loads. Returns (language folder names, + {(folder, voice_originalName)} embeddings). Reuses plan_install so it can't + drift; empty VOICE_LANGUAGES = default = native voices only, handled naturally.""" + import json # noqa: PLC0415 + + sys.path.insert(0, str(Path(__file__).parent.parent)) + from app.config.settings import settings # noqa: PLC0415 + from app.providers.pocket_tts import _LANG_MODEL, _VOICES_PATH # noqa: PLC0415 + from app.providers.voice_loading import VoiceEntry, plan_install # noqa: PLC0415 + + enabled = frozenset(settings.language_list) & frozenset(_LANG_MODEL) + install_all = settings.voice_install_all + add = settings.voice_language_add_map + remove = settings.voice_language_remove_map + entries = [VoiceEntry.model_validate(v) for v in json.loads(_VOICES_PATH.read_text())] + + keep_folders: set[str] = set() + keep_emb: set[tuple[str, str]] = set() + for e in entries: + key = e.originalName.lower() + plan = plan_install( + e, enabled, install_all, add.get(key, frozenset()), remove.get(key, frozenset()) + ) + if plan is None: + continue + _, installed = plan + for lang in installed: + folder = _LANG_MODEL[lang] + keep_folders.add(folder) + keep_emb.add((folder, e.originalName)) + return keep_folders, keep_emb + + +def _delete_file_blob_safe(f: Path, snapshots_dir: Path) -> None: + """Delete a snapshot symlink and its blob, unless another symlink still uses it.""" + if f.is_symlink(): + blob = _resolve_blob(f) + if blob is not None and not _blob_has_other_references(blob, snapshots_dir, [f]): + blob.unlink(missing_ok=True) + f.unlink(missing_ok=True) + + +def _prune_embeddings( + lang_dir: Path, keep_emb: set[tuple[str, str]], snapshots_dir: Path, apply: bool +) -> int: + """Within a KEPT language dir, delete voice embeddings not in the install plan. + Returns bytes freed. Base model / tokenizer are never touched here.""" + folder = lang_dir.name + emb_dir = lang_dir / "embeddings" + if not emb_dir.is_dir(): + return 0 + freed = 0 + for emb in sorted(emb_dir.glob("*.safetensors")): + if (folder, emb.stem) in keep_emb: + continue + try: + fsize = emb.resolve().stat().st_size + except OSError: + fsize = 0 + act = "DELETE" if apply else "would-delete" + print(f" {act:12s} {folder:12s} embeddings/{emb.stem} {fsize / 1e6:7.2f} MB") + freed += fsize + if apply: + _delete_file_blob_safe(emb, snapshots_dir) + return freed + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "--apply", action="store_true", help="Actually delete (default: dry-run report only)" + ) + parser.add_argument( + "--keep", + default=None, + help="Comma-separated BCP-47 prefixes to keep (default: LANGUAGES env var)", + ) + args = parser.parse_args() + + hf_home = Path(os.environ.get("HF_HOME", "/weights")) + hub_dir = hf_home / "hub" + if not hub_dir.is_dir(): + print(f"No HF cache found at {hub_dir} — nothing to prune.") + return 0 + + # --keep overrides to a language-level prune (keeps every embedding in kept + # languages). Default reads the full env config and prunes to the exact install + # plan — base models AND per-voice embeddings — matching what the server loads. + keep_emb: set[tuple[str, str]] | None + if args.keep is not None: + lang_map = _lang_folder_map() + keep = {v.strip().lower() for v in args.keep.split(",") if v.strip()} or {"en"} + keep_folders = {lang_map[p] for p in keep if p in lang_map} + keep_emb = None + unknown = keep - set(lang_map) + if unknown: + print(f"Warning: unsupported prefixes in --keep, ignored: {sorted(unknown)}") + print(f"HF cache: {hub_dir}") + print(f"Keeping languages (--keep): {sorted(keep)} -> folders {sorted(keep_folders)}") + else: + keep_folders, keep_emb = _install_plan_from_env() + print(f"HF cache: {hub_dir}") + print( + f"Keeping per env config: {len(keep_folders)} language(s), " + f"{len(keep_emb)} voice embedding(s)" + ) + print(f" languages: {sorted(keep_folders)}") + print() + + total_freed = 0 + total_kept = 0 + any_found = False + + for repo_id in _REPOS: + repo_dir = _repo_cache_dir(hub_dir, repo_id) + snapshots_dir = repo_dir / "snapshots" + if not snapshots_dir.is_dir(): + continue + + for snapshot in snapshots_dir.iterdir(): + langs_dir = snapshot / "languages" + if not langs_dir.is_dir(): + continue + + for lang_dir in sorted(langs_dir.iterdir()): + if not lang_dir.is_dir(): + continue + any_found = True + size = _dir_size(lang_dir) + + where = f"{repo_id:42s} languages/{lang_dir.name:15s}" + if lang_dir.name in keep_folders: + # Kept language — prune only its unused voice embeddings (env mode). + freed = ( + _prune_embeddings(lang_dir, keep_emb, snapshots_dir, args.apply) + if keep_emb is not None + else 0 + ) + kept_size = size - freed + total_freed += freed + total_kept += kept_size + suffix = f" (pruned {freed / 1e6:.1f} MB embeddings)" if freed else "" + print(f" KEEP {where} {kept_size / 1e6:8.1f} MB{suffix}") + continue + + action = "DELETE" if args.apply else "would-delete" + print(f" {action:12s} {where} {size / 1e6:8.1f} MB") + total_freed += size + + if not args.apply: + continue + + # Delete blobs first (while we can still resolve the symlinks), + # skipping any blob still referenced from outside this language dir. + exclude = [lang_dir] + for f in lang_dir.rglob("*"): + if not f.is_symlink(): + continue + blob = _resolve_blob(f) + if blob is None: + continue + if _blob_has_other_references(blob, snapshots_dir, exclude): + continue + blob.unlink(missing_ok=True) + + import shutil # noqa: PLC0415 + + shutil.rmtree(lang_dir, ignore_errors=True) + + print() + if not any_found: + print("No per-language weight directories found — nothing to prune.") + return 0 + + verb = "Freed" if args.apply else "Would free" + print(f"{verb}: {total_freed / 1e6:.1f} MB Kept: {total_kept / 1e6:.1f} MB") + if not args.apply: + print("Dry run — re-run with --apply to actually delete.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index ea424ef..b98d647 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ from fastapi import FastAPI from httpx import ASGITransport, AsyncClient +from app.core.circuit_breaker import CircuitBreakerRegistry from app.core.concurrency import init_semaphore from app.core.registry import ProviderRegistry from app.core.voice_catalog import VoiceCatalog @@ -50,6 +51,7 @@ async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]: await catalog.load() app.state.registry = registry app.state.voice_catalog = catalog + app.state.circuit_breakers = CircuitBreakerRegistry([p.id for p in registry.all()], 5, 30) app.state.ready = True async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: diff --git a/tests/contract/test_provider_contract.py b/tests/contract/test_provider_contract.py index 2b79f21..b7eefba 100644 --- a/tests/contract/test_provider_contract.py +++ b/tests/contract/test_provider_contract.py @@ -62,33 +62,31 @@ async def test_list_voices_nonempty_and_shaped(self, provider: TTSProvider) -> N voices = await provider.list_voices() assert len(voices) >= 1 for v in voices: - assert v.voiceURI and v.name and v.language + assert v.identifier and v.name and v.language assert v.provider == provider.id - assert v.engineVoiceId - assert v.sampleRate > 0 async def test_voiceuri_is_unique(self, provider: TTSProvider) -> None: - uris = [v.voiceURI for v in await provider.list_voices()] - assert len(uris) == len(set(uris)) + ids = [v.identifier for v in await provider.list_voices()] + assert len(ids) == len(set(ids)) async def test_synthesize_returns_valid_audio(self, provider: TTSProvider) -> None: v = (await provider.list_voices())[0] - res = await provider.synthesize(_params(v.voiceURI)) + res = await provider.synthesize(_params(v.identifier)) assert res.sample_rate > 0 assert_valid_pcm(res) async def test_longer_text_yields_longer_audio(self, provider: TTSProvider) -> None: v = (await provider.list_voices())[0] - short = await provider.synthesize(_params(v.voiceURI, "Hi.")) - long_ = await provider.synthesize(_params(v.voiceURI, "Hi. " * 20)) + short = await provider.synthesize(_params(v.identifier, "Hi.")) + long_ = await provider.synthesize(_params(v.identifier, "Hi. " * 20)) assert pcm_duration_seconds(long_) > pcm_duration_seconds(short) async def test_speed_affects_duration(self, provider: TTSProvider) -> None: if provider.id == "pocket": pytest.xfail("PocketTTS has no native speed param; ffmpeg atempo not yet implemented.") v = (await provider.list_voices())[0] - slow = await provider.synthesize(_params(v.voiceURI, speed=0.8)) - fast = await provider.synthesize(_params(v.voiceURI, speed=1.5)) + slow = await provider.synthesize(_params(v.identifier, speed=0.8)) + fast = await provider.synthesize(_params(v.identifier, speed=1.5)) assert pcm_duration_seconds(fast) < pcm_duration_seconds(slow) async def test_synthesize_is_async_non_blocking(self, provider: TTSProvider) -> None: diff --git a/tests/integration/test_pocket_tts.py b/tests/integration/test_pocket_tts.py index 35c2880..5abead5 100644 --- a/tests/integration/test_pocket_tts.py +++ b/tests/integration/test_pocket_tts.py @@ -28,16 +28,15 @@ async def test_list_voices_nonempty_and_shaped(pocket: PocketTTSProvider) -> Non voices = await pocket.list_voices() assert len(voices) >= 1 for v in voices: - assert v.voiceURI.startswith("urn:readium:tts:pocket:") + assert v.identifier.startswith("urn:readium:tts:pocket:") assert v.name and v.language assert v.provider == "pocket" - assert v.engineVoiceId - assert v.sampleRate > 0 + assert v.quality is not None async def test_voiceuri_unique(pocket: PocketTTSProvider) -> None: - uris = [v.voiceURI for v in await pocket.list_voices()] - assert len(uris) == len(set(uris)) + ids = [v.identifier for v in await pocket.list_voices()] + assert len(ids) == len(set(ids)) async def test_synthesize_returns_valid_pcm(pocket: PocketTTSProvider) -> None: @@ -46,7 +45,7 @@ async def test_synthesize_returns_valid_pcm(pocket: PocketTTSProvider) -> None: text="Hello world, this is a test.", ssml=False, language="en-US", - engineVoiceId=v.engineVoiceId, + voice_uri=v.identifier, speed=1.0, pitch=None, ) @@ -63,7 +62,7 @@ def params(text: str) -> SynthesisParams: text=text, ssml=False, language=None, - engineVoiceId=v.engineVoiceId, + voice_uri=v.identifier, speed=1.0, pitch=None, ) diff --git a/tests/test_circuit_breaker.py b/tests/test_circuit_breaker.py new file mode 100644 index 0000000..6baceb2 --- /dev/null +++ b/tests/test_circuit_breaker.py @@ -0,0 +1,77 @@ +import app.core.circuit_breaker as cb_mod +from app.core.circuit_breaker import CircuitBreaker, CircuitBreakerRegistry + + +def test_closed_allows_calls() -> None: + b = CircuitBreaker(failure_threshold=3, recovery_seconds=10) + assert b.allow() is True + + +def test_stays_closed_below_threshold() -> None: + b = CircuitBreaker(failure_threshold=3, recovery_seconds=10) + b.record_failure() + b.record_failure() + assert b.allow() is True + + +def test_opens_after_threshold_failures() -> None: + b = CircuitBreaker(failure_threshold=3, recovery_seconds=10) + b.record_failure() + b.record_failure() + b.record_failure() + assert b.allow() is False + + +def test_success_resets_failure_count() -> None: + b = CircuitBreaker(failure_threshold=3, recovery_seconds=10) + b.record_failure() + b.record_failure() + b.record_success() + b.record_failure() + b.record_failure() + assert b.allow() is True # only 2 consecutive since the reset + + +def test_open_rejects_until_recovery_window_elapses(monkeypatch) -> None: # type: ignore[no-untyped-def] + now = [0.0] + monkeypatch.setattr(cb_mod.time, "monotonic", lambda: now[0]) + b = CircuitBreaker(failure_threshold=1, recovery_seconds=10) + b.record_failure() + assert b.allow() is False + now[0] = 5 + assert b.allow() is False + now[0] = 10 + assert b.allow() is True # half-open trial allowed + + +def test_half_open_success_closes_breaker(monkeypatch) -> None: # type: ignore[no-untyped-def] + now = [0.0] + monkeypatch.setattr(cb_mod.time, "monotonic", lambda: now[0]) + b = CircuitBreaker(failure_threshold=2, recovery_seconds=10) + b.record_failure() + b.record_failure() + now[0] = 10 + assert b.allow() is True # half-open + b.record_success() + assert b.allow() is True + b.record_failure() + assert b.allow() is True # failure count truly reset — 1 < threshold of 2 + + +def test_half_open_failure_reopens_breaker(monkeypatch) -> None: # type: ignore[no-untyped-def] + now = [0.0] + monkeypatch.setattr(cb_mod.time, "monotonic", lambda: now[0]) + b = CircuitBreaker(failure_threshold=1, recovery_seconds=10) + b.record_failure() + now[0] = 10 + assert b.allow() is True # half-open + b.record_failure() + assert b.allow() is False # re-opened immediately, not another threshold count + + +def test_registry_gives_each_provider_an_independent_breaker() -> None: + registry = CircuitBreakerRegistry(["a", "b"], failure_threshold=2, recovery_seconds=10) + registry.get("a").record_failure() + registry.get("a").record_failure() + assert registry.get("a").allow() is False + assert registry.get("b").allow() is True diff --git a/tests/test_errors.py b/tests/test_errors.py index f767cdf..96d2997 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -32,12 +32,12 @@ async def test_unknown_route_returns_about_blank_problem(client: AsyncClient) -> @pytest.mark.route async def test_validation_error_includes_field_errors(client: AsyncClient) -> None: - resp = await client.post(_URL, json={"text": "Hello"}) # missing required "voice" + resp = await client.post(_URL, json={"voice": _VOICE}) # missing required "text" assert resp.status_code == 422 body = resp.json() assert body["type"] == "https://readium.org/speech-server/error#validation_failed" assert body["title"] == "Invalid Request" - assert any(err["loc"] == ["body", "voice"] for err in body["errors"]) + assert any(err["loc"] == ["body", "text"] for err in body["errors"]) @pytest.mark.route diff --git a/tests/test_formats.py b/tests/test_formats.py index 2a988f7..45db77e 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -35,10 +35,10 @@ async def test_opus_returns_audio(client: AsyncClient) -> None: @pytest.mark.route -async def test_default_format_is_mp3(client: AsyncClient) -> None: +async def test_default_format_is_wav(client: AsyncClient) -> None: resp = await client.post(_SYNTH, json={"text": "Hello", "voice": _VOICE}) assert resp.status_code == 200 - assert resp.headers["content-type"] == "audio/mpeg" + assert resp.headers["content-type"] == "audio/wav" @pytest.mark.route diff --git a/tests/test_health.py b/tests/test_health.py index ff3df22..ba191a5 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -25,3 +25,17 @@ async def test_readyz_before_ready(app: FastAPI) -> None: resp = await c.get("/readyz") assert resp.status_code == 503 assert resp.json()["type"] == "https://readium.org/speech-server/error#service_not_ready" + + +@pytest.mark.route +async def test_readiness_gate_on_model_endpoints(app: FastAPI, client: AsyncClient) -> None: + """While models are still loading (ready=False), endpoints that NEED the models + return 503; model-independent ones stay up (the server never blocks at startup).""" + app.state.ready = False + # up regardless — liveness + config-only capabilities + assert (await client.get("/healthz")).status_code == 200 + assert (await client.get("/service")).status_code == 200 + # gated — need loaded models + assert (await client.get("/readyz")).status_code == 503 + assert (await client.get("/voices")).status_code == 503 + assert (await client.post("/synthesize", json={"text": "hi"})).status_code == 503 diff --git a/tests/test_language_filter.py b/tests/test_language_filter.py index a86fdb8..e60e5c8 100644 --- a/tests/test_language_filter.py +++ b/tests/test_language_filter.py @@ -27,6 +27,7 @@ def language_config(*langs: str) -> Generator[None, None, None]: def test_pocket_json_covers_all_supported_languages() -> None: + """Every supported language is some voice's PRIMARY language at least once.""" voices = json.loads(_POCKET_JSON.read_text()) present = {v["language"].split("-")[0].lower() for v in voices} assert present == _SUPPORTED @@ -35,7 +36,8 @@ def test_pocket_json_covers_all_supported_languages() -> None: def test_pocket_json_english_voice_count() -> None: voices = json.loads(_POCKET_JSON.read_text()) en = [v for v in voices if v["language"].split("-")[0].lower() == "en"] - assert len(en) == 26 # all 26 voices under language "en" + assert len(en) == 21 # voices whose PRIMARY language is English (not all 26 — each + # voice appears once now, not duplicated per language) def test_pocket_json_non_english_languages_each_have_voices() -> None: @@ -49,21 +51,25 @@ def test_pocket_json_non_english_languages_each_have_voices() -> None: def test_pocket_json_all_voices_have_required_fields() -> None: - required = { - "source", - "label", - "name", - "originalName", - "voiceURI", - "language", - "provider", - "engineVoiceId", - "sampleRate", - "mimeTypes", - } + required = {"name", "originalName", "identifier", "language"} for v in json.loads(_POCKET_JSON.read_text()): missing = required - v.keys() - assert not missing, f"{v.get('voiceURI')} missing: {missing}" + assert not missing, f"{v.get('identifier')} missing: {missing}" + + +def test_pocket_json_no_duplicate_identifiers() -> None: + voices = json.loads(_POCKET_JSON.read_text()) + ids = [v["identifier"] for v in voices] + assert len(ids) == len(set(ids)) + + +def test_pocket_json_original_names_are_lowercase() -> None: + """PocketTTS predefined-voice ids are lowercase; originalName is looked up + case-sensitively at warm time (get_state_for_audio_prompt). A stray capital + (e.g. "Vera") silently falls through to the unavailable voice-cloning path.""" + for v in json.loads(_POCKET_JSON.read_text()): + name = v["originalName"] + assert name == name.lower(), f"originalName must be lowercase: {name!r}" # ── active_languages() intersection logic ──────────────────────────────────── @@ -98,6 +104,12 @@ def test_active_languages_all_unsupported_returns_empty() -> None: # ── list_voices() filtering (no model load — injects Voice objects directly) ─ +# +# otherLanguages here is voices.json's full aspirational list, standing in for a +# scenario where every declared cross-language pair happens to be installed (e.g. +# VOICE_LANGUAGES=*:* with every supported language enabled). This exercises +# list_voices()'s voice_language_prefixes()-based filter directly, independent of +# install-mode bookkeeping (covered separately in tests/unit/test_voice_loading.py). def _loaded_provider(*langs: str): # type: ignore[no-untyped-def] @@ -106,7 +118,18 @@ def _loaded_provider(*langs: str): # type: ignore[no-untyped-def] from app.schemas.voice import Voice provider = PocketTTSProvider() - provider._voices = [Voice(**v) for v in json.loads(_POCKET_JSON.read_text())] # noqa: SLF001 + provider._voices = [ # noqa: SLF001 + Voice( + name=v["name"], + originalName=v["originalName"], + provider="pocket", + identifier=v["identifier"], + language=v["language"], + otherLanguages=v.get("otherLanguages", []), + gender=v.get("gender"), + ) + for v in json.loads(_POCKET_JSON.read_text()) + ] return provider, langs @@ -115,8 +138,8 @@ async def test_list_voices_filters_to_single_language() -> None: provider, langs = _loaded_provider("fr") with language_config(*langs): result = await provider.list_voices() - assert {v.language.split("-")[0].lower() for v in result} == {"fr"} - assert len(result) == 26 # all 26 voices available in French + assert all("fr" in _prefixes(v) for v in result) + assert len(result) == 26 # every voice declares French as primary or additional @pytest.mark.asyncio @@ -124,9 +147,8 @@ async def test_list_voices_multiple_languages() -> None: provider, langs = _loaded_provider("en", "fr") with language_config(*langs): result = await provider.list_voices() - langs_in_result = {v.language.split("-")[0].lower() for v in result} - assert langs_in_result == {"en", "fr"} - assert len(result) == 52 # 26 en + 26 fr + assert all({"en", "fr"} & _prefixes(v) for v in result) + assert len(result) == 26 # each voice counted once, not per matching language @pytest.mark.asyncio @@ -134,7 +156,7 @@ async def test_list_voices_all_languages_returns_all() -> None: provider, langs = _loaded_provider(*_SUPPORTED) with language_config(*langs): result = await provider.list_voices() - assert len(result) == 156 # 26 voices × 6 languages + assert len(result) == 26 # all voices, each still counted once @pytest.mark.asyncio @@ -144,3 +166,23 @@ async def test_list_voices_unsupported_config_returns_empty() -> None: with language_config(*langs): result = await provider.list_voices() assert list(result) == [] + + +def _prefixes(voice) -> set[str]: # type: ignore[no-untyped-def] + from app.schemas.voice import voice_language_prefixes + + return set(voice_language_prefixes(voice)) + + +def test_resolve_lang_key_never_falls_back_to_uninstalled_language() -> None: + """A requested language a voice ISN'T installed for must NOT silently fall back + to another language — pocket-tts needs the specific (voice, language) embedding, + so an uninstalled combo is genuinely unavailable, not a synonym for the default.""" + from app.providers.pocket_tts import PocketTTSProvider + + p = PocketTTSProvider() + p._voice_langs = {"v": frozenset({"en", "fr"})} # noqa: SLF001 + p._voice_default_lang = {"v": "en"} # noqa: SLF001 + assert p._resolve_lang_key("v", "fr") == "fr" # installed → used # noqa: SLF001 + assert p._resolve_lang_key("v", None) == "en" # omitted → default # noqa: SLF001 + assert p._resolve_lang_key("v", "es") is None # NOT installed → no fallback # noqa: SLF001 diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..dfffe38 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,32 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.route +async def test_service_shape(client: AsyncClient) -> None: + resp = await client.get("/service") + assert resp.status_code == 200 + body = resp.json() + assert set(body["output"]["formats"]) >= {"wav", "mp3", "opus"} + assert body["output"]["default"] == "wav" + assert body["limits"]["maxTextLength"] > 0 + assert body["limits"]["maxConcurrentSyntheses"] > 0 + assert any(p["id"] == "fake" for p in body["providers"]) + + +@pytest.mark.route +async def test_service_provider_capabilities_shape(client: AsyncClient) -> None: + resp = await client.get("/service") + provider = next(p for p in resp.json()["providers"] if p["id"] == "fake") + assert "installedLanguages" in provider + # model-level quality/controls aren't repeated here — they're on /voices per voice + assert "controls" not in provider + assert "quality" not in provider + + +@pytest.mark.route +async def test_service_has_no_per_voice_list(client: AsyncClient) -> None: + """The voice list lives on /voices; /service stays a server-wide summary.""" + resp = await client.get("/service") + provider = next(p for p in resp.json()["providers"] if p["id"] == "fake") + assert "voices" not in provider diff --git a/tests/test_settings.py b/tests/test_settings.py index 7f3cd17..ccce1b5 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3,16 +3,72 @@ from app.config.settings import Settings +def _settings(**overrides: object) -> Settings: + # _env_file=None: don't let this repo's real .env/pocket-tts.env leak into + # tests expecting clean defaults — construct from kwargs only. + return Settings(_env_file=None, **overrides) # type: ignore[arg-type] + + def test_production_requires_domain() -> None: with pytest.raises(ValueError, match="DOMAIN must be set"): - Settings(app_env="production", domain="") + _settings(app_env="production", domain="") def test_production_with_domain_is_valid() -> None: - settings = Settings(app_env="production", domain="tts.example.com") + settings = _settings(app_env="production", domain="tts.example.com") assert settings.domain == "tts.example.com" def test_development_without_domain_is_valid() -> None: - settings = Settings(app_env="development", domain="") + settings = _settings(app_env="development", domain="") assert settings.domain == "" + + +def test_voice_language_add_map_parses_unprefixed_pairs() -> None: + settings = _settings( + app_env="development", domain="", voice_languages="alba:fr,alba:de,Estelle:EN" + ) + assert settings.voice_language_add_map == { + "alba": frozenset({"fr", "de"}), + "estelle": frozenset({"en"}), + } + assert settings.voice_language_remove_map == {} + + +def test_voice_language_remove_map_parses_minus_prefixed_pairs() -> None: + settings = _settings( + app_env="development", domain="", voice_languages="alba:fr,-javert:es,-javert:de" + ) + assert settings.voice_language_add_map == {"alba": frozenset({"fr"})} + assert settings.voice_language_remove_map == {"javert": frozenset({"es", "de"})} + + +def test_voice_language_maps_empty_by_default() -> None: + settings = _settings(app_env="development", domain="") + assert settings.voice_language_add_map == {} + assert settings.voice_language_remove_map == {} + + +def test_voice_language_maps_ignore_malformed_entries() -> None: + settings = _settings( + app_env="development", domain="", voice_languages="alba:fr,malformed,:missing,x:,-" + ) + assert settings.voice_language_add_map == {"alba": frozenset({"fr"})} + assert settings.voice_language_remove_map == {} + + +def test_voice_install_all_false_by_default() -> None: + settings = _settings(app_env="development", domain="") + assert settings.voice_install_all is False + + +def test_voice_install_all_true_with_wildcard() -> None: + settings = _settings(app_env="development", domain="", voice_languages="*:*") + assert settings.voice_install_all is True + + +def test_voice_install_all_wildcard_coexists_with_overrides() -> None: + settings = _settings(app_env="development", domain="", voice_languages="*:*,-javert:es") + assert settings.voice_install_all is True + assert settings.voice_language_remove_map == {"javert": frozenset({"es"})} + assert settings.voice_language_add_map == {} # "*:*" itself is not a voice:lang pair diff --git a/tests/test_synthesize.py b/tests/test_synthesize.py index f15551a..1ce754f 100644 --- a/tests/test_synthesize.py +++ b/tests/test_synthesize.py @@ -53,10 +53,32 @@ async def test_synthesize_invalid_format_returns_422(client: AsyncClient) -> Non @pytest.mark.route async def test_synthesize_schema_error_returns_422(client: AsyncClient) -> None: - resp = await client.post(_URL, json={"text": "Hello", "output": {"format": "wav"}}) + resp = await client.post(_URL, json={"voice": _VOICE, "output": {"format": "wav"}}) # no text assert resp.status_code == 422 +@pytest.mark.route +async def test_synthesize_uses_default_voice_when_omitted( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + from app.config.settings import settings + + monkeypatch.setattr(settings, "pocket_default_voice", "fake-en-US") # originalName, not URI + resp = await client.post(_URL, json={"text": "Hello", **_WAV}) + assert resp.status_code == 200 + + +@pytest.mark.route +async def test_synthesize_no_voice_no_default_returns_400( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + from app.config.settings import settings + + monkeypatch.setattr(settings, "pocket_default_voice", "") + resp = await client.post(_URL, json={"text": "Hello", **_WAV}) + assert resp.status_code == 400 + + @pytest.mark.route async def test_synthesize_content_disposition(client: AsyncClient) -> None: resp = await client.post(_URL, json={"text": "Hello", "voice": _VOICE, **_WAV}) diff --git a/tests/test_voice_loading.py b/tests/test_voice_loading.py new file mode 100644 index 0000000..15ddff6 --- /dev/null +++ b/tests/test_voice_loading.py @@ -0,0 +1,170 @@ +from app.domain.enums import Gender, Quality +from app.providers.voice_loading import ( + ControlsOverride, + VoiceEntry, + build_voice, + plan_install, + resolve_install_languages, +) +from app.schemas.voice import Controls + + +def _entry(**overrides: object) -> VoiceEntry: + base: dict[str, object] = { + "name": "Test", + "originalName": "test", + "identifier": "urn:x:test", + "language": "en-US", + "otherLanguages": ["fr", "es"], + } + base.update(overrides) + return VoiceEntry(**base) # type: ignore[arg-type] + + +def test_resolve_primary_mode_ignores_other_languages() -> None: + primary, other = resolve_install_languages(_entry(), frozenset({"en", "fr", "es"}), False) + assert primary == "en" + assert other == frozenset() + + +def test_resolve_all_mode_bounded_by_enabled_languages() -> None: + """The crux of the install-mode design: cross-language support never exceeds + what's already enabled via LANGUAGES, even if voices.json declares more.""" + primary, other = resolve_install_languages(_entry(), frozenset({"en", "fr"}), True) + assert primary == "en" + assert other == frozenset({"fr"}) # "es" is declared but not enabled — excluded + + +def test_resolve_all_mode_never_installs_beyond_enabled_set() -> None: + entry = _entry(otherLanguages=["fr", "es", "de", "it", "pt"]) + _, other = resolve_install_languages(entry, frozenset({"en"}), True) + assert other == frozenset() # nothing else enabled — no new model gets pulled in + + +def test_resolve_excludes_primary_from_other_set() -> None: + entry = _entry(language="en-US", otherLanguages=["en", "fr"]) + _, other = resolve_install_languages(entry, frozenset({"en", "fr"}), True) + assert other == frozenset({"fr"}) # "en" is primary, not an "other" language + + +def test_resolve_add_promotes_declared_language_when_not_install_all() -> None: + """The per-voice override: cherry-pick an addition without switching install_all + on (which would pull in everything every voice declares).""" + entry = _entry(otherLanguages=["fr", "es"]) + _, other = resolve_install_languages( + entry, frozenset({"en", "fr", "es"}), False, add_langs=frozenset({"fr"}) + ) + assert other == frozenset({"fr"}) + + +def test_resolve_add_bounded_by_enabled_languages() -> None: + """Add can never exceed LANGUAGES either — same invariant as install_all=True.""" + entry = _entry(otherLanguages=["fr", "es"]) + _, other = resolve_install_languages( + entry, frozenset({"en", "fr"}), False, add_langs=frozenset({"fr", "es"}) + ) + assert other == frozenset({"fr"}) # "es" not enabled — dropped despite being in add_langs + + +def test_resolve_add_cannot_invent_undeclared_capability() -> None: + """Add can only promote a language the voice already declares in otherLanguages — + never a language voices.json doesn't claim that voice supports.""" + entry = _entry(otherLanguages=["fr"]) + _, other = resolve_install_languages( + entry, frozenset({"en", "fr", "de"}), False, add_langs=frozenset({"de"}) + ) + assert other == frozenset() # "de" not in this voice's otherLanguages — ignored + + +def test_resolve_remove_prunes_base_install_all() -> None: + entry = _entry(otherLanguages=["fr", "es"]) + _, other = resolve_install_languages( + entry, frozenset({"en", "fr", "es"}), True, remove_langs=frozenset({"fr"}) + ) + assert other == frozenset({"es"}) + + +def test_resolve_remove_wins_when_same_pair_also_added() -> None: + entry = _entry(otherLanguages=["fr", "es"]) + _, other = resolve_install_languages( + entry, + frozenset({"en", "fr", "es"}), + False, + add_langs=frozenset({"fr", "es"}), + remove_langs=frozenset({"fr"}), + ) + assert other == frozenset({"es"}) # fr added then removed — net result excludes it + + +# ── plan_install: a voice can run in a non-primary language without its primary ── + + +def test_plan_install_primary_enabled_default_mode() -> None: + # Normal case: primary enabled, no cross-language → installs primary only. + plan = plan_install(_entry(), frozenset({"en", "fr"}), False) + assert plan == ("en", frozenset({"en"})) + + +def test_plan_install_primary_not_enabled_but_added_other_installs() -> None: + # estelle-style: primary (en here) NOT enabled, but an explicit add pulls the + # voice in for a non-primary language using only that language's model. + entry = _entry(language="en-US", otherLanguages=["fr", "es"]) + plan = plan_install(entry, frozenset({"fr"}), False, add_langs=frozenset({"fr"})) + assert plan is not None + default, installed = plan + assert installed == frozenset({"fr"}) # installed for fr only — no en base model + assert default == "fr" # primary not installed → first installed language + + +def test_plan_install_no_enabled_language_returns_none() -> None: + # Primary not enabled and no cross-language applies → voice not installed. + entry = _entry(language="en-US", otherLanguages=["fr", "es"]) + assert plan_install(entry, frozenset({"de"}), False) is None + + +def test_plan_install_primary_preferred_as_default_when_installed() -> None: + entry = _entry(language="en-US", otherLanguages=["fr"]) + plan = plan_install(entry, frozenset({"en", "fr"}), True) # install_all + assert plan == ("en", frozenset({"en", "fr"})) # primary stays the default + + +def test_controls_serialize_only_enabled() -> None: + """A false control is absent from the payload; only enabled ones appear.""" + assert Controls().model_dump() == {} + assert Controls(ssml=True).model_dump() == {"ssml": True} + assert Controls(ssml=True, boundary=True).model_dump() == {"ssml": True, "boundary": True} + # internal attribute access is unaffected (all fields still exist) + assert Controls().boundary is False + + +def test_build_voice_uses_provider_defaults() -> None: + entry = _entry(gender=Gender.MALE) + voice = build_voice(entry, "pocket", frozenset({"fr"}), Quality.VERY_HIGH, Controls()) + assert voice.quality == Quality.VERY_HIGH + assert voice.otherLanguages == ["fr"] + assert voice.controls == Controls() + assert voice.provider == "pocket" + assert voice.gender == Gender.MALE + + +def test_build_voice_quality_override_wins_over_default() -> None: + entry = _entry(quality=Quality.LOW) + voice = build_voice(entry, "pocket", frozenset(), Quality.VERY_HIGH, Controls()) + assert voice.quality == Quality.LOW + + +def test_build_voice_partial_controls_override() -> None: + entry = _entry(controls=ControlsOverride(ssml=True)) + defaults = Controls(pitch=False, speed=False, ssml=False, boundary=False) + voice = build_voice(entry, "pocket", frozenset(), None, defaults) + assert voice.controls.ssml is True + assert voice.controls.pitch is False # untouched fields fall back to the default + assert voice.controls.boundary is False + + +def test_build_voice_other_languages_reflect_installed_not_aspirational() -> None: + """voices.json declares 2 other languages, but only 1 was actually resolved + for install — the served Voice must reflect what's installed.""" + entry = _entry(otherLanguages=["fr", "es"]) + voice = build_voice(entry, "pocket", frozenset({"fr"}), None, Controls()) + assert voice.otherLanguages == ["fr"] diff --git a/tests/test_voices.py b/tests/test_voices.py index 48da6d9..fe276cc 100644 --- a/tests/test_voices.py +++ b/tests/test_voices.py @@ -8,9 +8,9 @@ async def test_list_voices_returns_all(client: AsyncClient) -> None: assert resp.status_code == 200 voices = resp.json() assert len(voices) == 2 - uris = {v["voiceURI"] for v in voices} - assert "urn:readium:tts:fake:en-US-standard" in uris - assert "urn:readium:tts:fake:fr-FR-standard" in uris + ids = {v["identifier"] for v in voices} + assert "urn:readium:tts:fake:en-US-standard" in ids + assert "urn:readium:tts:fake:fr-FR-standard" in ids @pytest.mark.route @@ -18,16 +18,12 @@ async def test_list_voices_shape(client: AsyncClient) -> None: resp = await client.get("/voices") voice = resp.json()[0] required = ( - "source", - "label", "name", "originalName", - "voiceURI", - "language", "provider", - "engineVoiceId", - "sampleRate", - "mimeTypes", + "identifier", + "language", + "controls", ) for field in required: assert field in voice, f"Missing field: {field}"