From f6342eef33d0a24957b654de3a91e17f2cd72bec Mon Sep 17 00:00:00 2001 From: Roni bhakta <77425964+ronibhakta1@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:09:14 +0530 Subject: [PATCH] service configure upgrade & API simplification --- README.md | 4 ++++ app/api/routes/service.py | 22 +++++++++++++++++++--- app/schemas/service.py | 11 ++++++++--- app/schemas/voice.py | 11 +++++++++++ docs/API.md | 24 ++++++++++++++++-------- docs/configuration.md | 18 ++++++++++-------- docs/providers/elevenlabs.md | 2 +- tests/test_service.py | 8 +++++--- 8 files changed, 74 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 473ce0d..12c12a2 100644 --- a/README.md +++ b/README.md @@ -66,3 +66,7 @@ curl -s -X POST http://localhost:8000/synthesize \ - [readium/speech](https://github.com/readium/speech) — TypeScript read-aloud library this server is designed to pair with - [HadrienGardeur/web-speech-recommended-voices](https://github.com/HadrienGardeur/web-speech-recommended-voices) — voice catalog schema reference (CC0) - [pocket-tts](https://github.com/pocket-tts/pocket-tts) — the underlying CPU TTS engine + +## License + +BSD 3-Clause — see [LICENSE](LICENSE). diff --git a/app/api/routes/service.py b/app/api/routes/service.py index 8a6a8a7..a7db154 100644 --- a/app/api/routes/service.py +++ b/app/api/routes/service.py @@ -20,8 +20,10 @@ 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/`." + "provider the installed-language summary plus default quality/controls (what the " + "provider CAN do — `controls` includes `false` entries, unlike the enabled-only shape " + "on `/voices`). The voices themselves are on `GET /voices`; full per-provider details " + "(output specs, voice notes) live under `docs/providers/`." ), responses={ 200: { @@ -30,7 +32,19 @@ "example": { "output": {"formats": ["wav", "mp3", "opus"], "default": "wav"}, "limits": {"maxTextLength": 2000, "maxConcurrentSyntheses": 2}, - "providers": [{"id": "pocket", "installedLanguages": ["en", "fr"]}], + "providers": [ + { + "id": "pocket", + "installedLanguages": ["en", "fr"], + "quality": "veryHigh", + "controls": { + "pitch": False, + "speed": False, + "ssml": False, + "boundary": False, + }, + } + ], } } } @@ -42,6 +56,8 @@ async def get_service_capabilities(registry: RegistryDep) -> ServiceCapabilities ProviderCapabilities( id=provider.id, installedLanguages=sorted(provider.active_languages()), + quality=provider.default_quality, + controls=provider.default_controls.as_dict(), ) for provider in registry.all() ] diff --git a/app/schemas/service.py b/app/schemas/service.py index f30121d..287cb0d 100644 --- a/app/schemas/service.py +++ b/app/schemas/service.py @@ -1,6 +1,6 @@ from pydantic import BaseModel -from app.domain.enums import AudioFormat +from app.domain.enums import AudioFormat, Quality class OutputCapabilities(BaseModel): @@ -16,8 +16,13 @@ class Limits(BaseModel): 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/. + # Provider-level defaults — same values merged into each voice on GET /voices, + # surfaced here too so a client can see what a provider supports at all without + # inspecting a voice. controls includes disabled entries as `false` (unlike the + # per-voice enabled-only shape on /voices) since this is "what's possible", not + # "what's on". + quality: Quality | None = None + controls: dict[str, bool] = {} class ServiceCapabilities(BaseModel): diff --git a/app/schemas/voice.py b/app/schemas/voice.py index bd36e48..81595eb 100644 --- a/app/schemas/voice.py +++ b/app/schemas/voice.py @@ -21,6 +21,17 @@ class Controls(BaseModel): def _serialize_enabled_only(self) -> dict[str, bool]: return {k: True for k, v in self.__dict__.items() if v} + def as_dict(self) -> dict[str, bool]: + """Full booleans, including disabled ones — unlike the enabled-only JSON + serialization above. Used by GET /service to show what a provider CAN do, + not just what a given voice has turned on.""" + return { + "pitch": self.pitch, + "speed": self.speed, + "ssml": self.ssml, + "boundary": self.boundary, + } + class Voice(BaseModel): # --- Readium ReadiumSpeechVoice-aligned fields --- diff --git a/docs/API.md b/docs/API.md index 85db0c8..b8b3124 100644 --- a/docs/API.md +++ b/docs/API.md @@ -52,7 +52,7 @@ Pydantic schema errors (`422`) additionally carry an `errors` array (raw Pydanti | 413 | `payload_too_large` | `text` exceeds `MAX_TEXT_LENGTH` | | 422 | `validation_failed` | Request body fails schema validation (wrong type, missing required field, invalid enum value) | | 502 | `provider_error` | Provider or ffmpeg failed (bad voice state, generation error, encode failure) | -| 503 | `service_not_ready` | **During startup** the server accepts connections immediately and loads models in the background — `/synthesize` and `/voices` return 503 until warmup finishes (`/healthz` and `/service` stay up throughout; `/readyz` flips to 200 when ready). Also 503 from `/readyz` if ffmpeg is missing or a provider is unhealthy, and from `/synthesize` when a provider's circuit breaker is open — see [configuration](configuration.md) for `CIRCUIT_BREAKER_*` | +| 503 | `service_not_ready` | Three causes: (1) **startup** — the server accepts connections immediately and loads models in the background, so `/synthesize` and `/voices` return 503 until warmup finishes (`/healthz` and `/service` stay up throughout; `/readyz` flips to 200 when ready); (2) `/readyz` — also 503 if ffmpeg is missing or a provider is unhealthy; (3) `/synthesize` — also 503 when a provider's circuit breaker is open, see [configuration](configuration.md) for `CIRCUIT_BREAKER_*` | `unsupported_format` (415), `rate_limited` (429), and `provider_timeout` (504) are declared in `app/api/errors.py` for future providers but no current code path raises them — a `429`/`503` seen in production is nginx's own rate/connection limit, a plain nginx error page, not this JSON shape. @@ -74,9 +74,9 @@ GET /service ``` Server-wide, per-provider **capabilities** — kept separate from `/voices` so this isn't repeated on -every voice: supported output formats + default, request limits, and per provider the model-level -`quality`/`controls` and the installed-language summary. The voices themselves are on -[`GET /voices`](#get-voices). Per-provider details (output specs, voice notes) live in each +every voice: supported output formats + default, request limits, and per provider the +installed-language summary plus the provider's default `quality`/`controls`. The voices themselves +are on [`GET /voices`](#get-voices). Per-provider details (output specs, voice notes) live in each provider's README under `docs/providers/`. ```json @@ -86,16 +86,24 @@ provider's README under `docs/providers/`. "providers": [ { "id": "pocket", - "installedLanguages": ["en"] + "installedLanguages": ["en"], + "quality": "veryHigh", + "controls": {"pitch": false, "speed": false, "ssml": false, "boundary": false} } ] } ``` `providers[].installedLanguages` reflects `LANGUAGES` + `VOICE_LANGUAGES` as actually configured. -Model-level `quality`/`controls` aren't repeated here — they're merged into each voice on -[`GET /voices`](#get-voices) and documented per provider under `docs/providers/`. See -[configuration](configuration.md). + +`providers[].quality` / `providers[].controls` are the provider's **defaults** — the same values +merged into every voice on [`GET /voices`](#get-voices) unless a voice overrides them. Unlike +`Voice.controls` (which only lists **enabled** controls, so an unsupported one is simply absent), +`providers[].controls` always lists all four keys — `pitch`, `speed`, `ssml`, `boundary` — with +`false` where the provider doesn't support it. That's the point of putting it on `/service`: a +client can see what a provider **can** do at all, including the `false`s, without inspecting a +voice. A voice-level override (rare — see per-provider docs) can still diverge from this default. +See [configuration](configuration.md). --- diff --git a/docs/configuration.md b/docs/configuration.md index c697d44..96cc2cc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -143,14 +143,16 @@ Spanish"). Rules, applied on top of the `*:*` base: language model by itself; the language must already be enabled. 3. If the same pair appears both with and without `-`, **remove wins** — it's always the final word for the exact pair it names. -4. A voice installs for whichever enabled languages apply — its primary if that's in - `LANGUAGES`, plus any added/`*:*` cross-languages. A voice can even run in a **non-primary** - language *without* its primary base model (e.g. `VOICE_LANGUAGES=estelle:en` with - `LANGUAGES=en` installs estelle in English only, no French model): pocket_tts loads a voice's - embedding from the target language's model, not the primary's. When a request omits `language`, - the voice's default is its primary if installed, else its first installed language. `/voices` - still reports the voice's true primary `language` (from `voices.json`); `/service` reports which - languages are actually installed. +4. A voice installs for whichever enabled languages apply: its primary (if that's in `LANGUAGES`) + plus any added/`*:*` cross-languages. + - It can even run in a **non-primary** language *without* its primary base model — e.g. + `VOICE_LANGUAGES=estelle:en` with `LANGUAGES=en` installs estelle in English only, no French + model. pocket_tts loads a voice's embedding from the target language's model, not the + primary's. + - When a request omits `language`, the voice defaults to its primary if installed, else its + first installed language. + - `/voices` still reports the voice's true primary `language` (from `voices.json`); `/service` + reports which languages are actually installed. ## Model sizes & RAM diff --git a/docs/providers/elevenlabs.md b/docs/providers/elevenlabs.md index 70078f5..8fb553e 100644 --- a/docs/providers/elevenlabs.md +++ b/docs/providers/elevenlabs.md @@ -17,7 +17,7 @@ Source: [ElevenLabs Text-to-Speech](https://elevenlabs.io/docs/capabilities/text --- > [!NOTE] -> Currently for the **Proof-of-Concept**, we have listed below are only intersection of voices, language's & Audio format as per all tier of models i.e free - pro tier. Remained supported to subscription tier wise voices, language's & Audio format will be added later. +> This is a proof of concept: the voices, languages, and audio formats listed below are the intersection supported across every tier (free through Pro). Tier-specific extras aren't modeled yet — they may be added later. ## Configuration diff --git a/tests/test_service.py b/tests/test_service.py index dfffe38..369fd0c 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -19,9 +19,11 @@ async def test_service_provider_capabilities_shape(client: AsyncClient) -> None: resp = await client.get("/service") provider = next(p for p in resp.json()["providers"] if p["id"] == "fake") assert "installedLanguages" in provider - # model-level quality/controls aren't repeated here — they're on /voices per voice - assert "controls" not in provider - assert "quality" not in provider + # provider-level defaults, mirrored from /voices — but controls shows every key, + # including disabled ones, since this is "what's possible" not "what's on" + assert set(provider["controls"]) == {"pitch", "speed", "ssml", "boundary"} + assert all(isinstance(v, bool) for v in provider["controls"].values()) + assert "quality" in provider @pytest.mark.route