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 DemoTalks to GET /voices and POST /synthesize on this same origin.
Talks to GET /service, GET /voices and POST /synthesize on this same origin.
Fields the selected voice doesn't support are disabled.
(nothing yet)