Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
22 changes: 19 additions & 3 deletions app/api/routes/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
},
}
],
}
}
}
Expand All @@ -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()
]
Expand Down
11 changes: 8 additions & 3 deletions app/schemas/service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from pydantic import BaseModel

from app.domain.enums import AudioFormat
from app.domain.enums import AudioFormat, Quality


class OutputCapabilities(BaseModel):
Expand All @@ -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):
Expand Down
11 changes: 11 additions & 0 deletions app/schemas/voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
24 changes: 16 additions & 8 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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).

---

Expand Down
18 changes: 10 additions & 8 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/providers/elevenlabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading