HTTP API for the Readium Speech Server: list voices, synthesize speech. Implements the Readium Speech ReadiumSpeechUtterance / ReadiumSpeechVoice contract plus server-specific extensions, called out below.
Base path: /. All bodies are application/json unless noted.
None enforced today. API_KEY_ENABLED / API_KEY exist in configuration but no middleware checks them yet — every route is open regardless of the setting. Don't rely on it.
In production, nginx sits in front (see configuration) and rate-limits /synthesize (2 req/s, burst 4) plus per-IP connection caps — unrelated to app auth, but the only request throttling that exists.
All errors are RFC 9457 Problem Details (Content-Type: application/problem+json):
{
"type": "https://readium.org/speech-server/error#voice_not_found",
"title": "Voice Not Found",
"status": 404,
"detail": "Voice 'urn:unknown' not found.",
"instance": "urn:uuid:3f7a2e10-..."
}instance is the request's X-Request-Id (also returned as a response header on every request, success or failure — use it to correlate with server logs).
Pydantic schema errors (422) additionally carry an errors array (raw Pydantic error list).
| Status | type suffix |
Raised when |
|---|---|---|
| 400 | validation_failed |
text is empty or whitespace-only |
| 404 | voice_not_found |
voice identifier not in the registry |
| 404 | voice_language_unsupported |
The requested voice (or voice + language) isn't served here. Deliberately neutral — it does not reveal why (whether a voice/language is installed), only that it's unsupported. Ask /voices for what this deployment actually serves |
| 413 | payload_too_large |
text exceeds MAX_TEXT_LENGTH |
| 422 | validation_failed |
Request body fails schema validation (wrong type, missing required field, invalid enum value) |
| 502 | provider_error |
Provider or ffmpeg failed (bad voice state, generation error, encode failure) |
| 503 | service_not_ready |
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 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.
| Method | Path | Description |
|---|---|---|
GET |
/healthz |
Liveness. Always 200 {"status": "ok"} once the process is up. |
GET |
/readyz |
Readiness. 200 once models are loaded, ffmpeg is on PATH, and every registered provider reports healthy; 503 otherwise. |
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. Per-provider details (output specs, voice notes) live in each
provider's README under docs/providers/.
{
"output": {"formats": ["wav", "mp3", "opus"], "default": "wav"},
"limits": {"maxTextLength": 2000, "maxConcurrentSyntheses": 2},
"providers": [
{
"id": "pocket",
"installedLanguages": ["en"]
}
]
}providers[].installedLanguages reflects LANGUAGES + VOICE_LANGUAGES as actually configured.
Model-level quality/controls aren't repeated here — they're merged into each voice on
GET /voices and documented per provider under docs/providers/. See
configuration.
GET /voices
GET /voices?language=fr
GET /voices?provider=pocket
GET /voices?offset=0&limit=20
| Param | Type | Default | Description |
|---|---|---|---|
language |
string | — | Filter by BCP-47 language prefix, matched against a voice's primary language or otherLanguages (en, fr, ...) |
provider |
string | — | Filter by provider id (pocket) |
offset |
int ≥ 0 | 0 |
Voices to skip |
limit |
int ≥ 1 | none | Max voices to return |
The voices actually installed on this deployment (realtime): each voice's language (primary)
and otherLanguages reflect what's loaded now, bounded by LANGUAGES + VOICE_LANGUAGES. Model-level
quality/controls are merged in per voice. A voice not in this list can't be synthesized here.
Response: 200, Voice[].
Headers: X-Total-Count (matches before pagination), X-Offset, X-Limit (omitted when limit unset).
Model-level info (quality default, control support) is declared once per provider and merged
into every voice it serves; a voice only carries a field in voices.json when it's voice-specific
or needs to override that default. otherLanguages reflects languages actually installed for
that voice on this deployment, not every language the voice could theoretically support — see
configuration.
{
"name": "Alba",
"originalName": "alba",
"provider": "pocket",
"identifier": "urn:readium:tts:pocket:alba",
"language": "en-US",
"otherLanguages": [],
"gender": "male",
"quality": "veryHigh",
"controls": {}
}controls lists only the enabled controls — a control the voice doesn't support is absent
(pocket supports none, so {}). A voice that supported SSML would show "controls": {"ssml": true}.
ReadiumSpeechVoice-aligned:
| Field | Type | Notes |
|---|---|---|
name |
string | Display name |
originalName |
string | Raw engine voice id |
language |
string | BCP-47, primary |
otherLanguages |
string[] | Additional languages this voice is actually installed for — empty by default (VOICE_LANGUAGES unset) |
gender |
"male" | "female" | "neutral" | null |
|
quality |
"veryLow"…"veryHigh" | null |
Provider default unless a voice overrides it. PocketTTS voices are always "veryHigh" |
Server extensions (not in ReadiumSpeechVoice):
| Field | Type | Notes |
|---|---|---|
provider |
string | Backend serving this voice — "pocket" today |
identifier |
string | Send this as SynthesizeRequest.voice |
controls |
object | {pitch, speed, ssml, boundary} booleans — what this voice accepts, merged from the provider's defaults with any voice-specific override. PocketTTS: all false |
{
"id": "urn:uuid:019f178c-cc7c-7bb3-a39b-d185f43d3cc4",
"text": "Ceci est un test.",
"ssml": false,
"language": "fr",
"voice": "urn:readium:tts:pocket:estelle",
"prev_utterance": "La nuit était sombre.",
"next_utterance": "La pièce était froide.",
"publication_id": "urn:isbn:9780000000000",
"boundary": true,
"output": {
"format": "wav",
"bitrate": 64,
"sample_rate": null,
"speed": 1.0,
"pitch": null
}
}Only text is required; everything else defaults as shown (voice falls back to POCKET_DEFAULT_VOICE).
| Field | Type | Default | Notes |
|---|---|---|---|
id |
string | null | null |
Client-generated UUID v7 URN. Parsed, logged, not otherwise used — no caching or idempotency yet (roadmap) |
text |
string | — | Max MAX_TEXT_LENGTH chars (2000 default). Rejected if empty/whitespace after trim |
ssml |
bool | false |
PocketTTS strips tags before synthesis (regex <[^>]+> removal) — no SSML-aware prosody |
language |
string | null | null |
For voices installed across more than one language (VOICE_LANGUAGES=*:* or an explicit override), picks which installed language to synthesize in; falls back to the voice's primary language if unset or not installed for that voice |
voice |
string | null | null |
A voice identifier from /voices, or a raw originalName. 404 if not found. When omitted, falls back to POCKET_DEFAULT_VOICE; if that's unset too, 400 |
prev_utterance / next_utterance |
string | null | null |
Accepted, passed into SynthesisParams; PocketTTS ignores both |
publication_id |
string | null | null |
Accepted, currently unused (reserved for future cache scoping) |
boundary |
bool | false |
true → JSON response with base64 audio + timing marks instead of raw binary |
output.format |
"wav" | "mp3" | "opus" |
"wav" |
wav bypasses ffmpeg and is highest quality (fastest, no lossy encoding); mp3/opus are ffmpeg-encoded on request — see GET /service for what's available |
output.bitrate |
int | null | null |
kbps for mp3/opus; ffmpeg default (~128 mp3, ~64 opus) if unset; ignored for wav |
output.sample_rate |
int | null | null |
Accepted but not applied — output is always the model's native rate (24000 Hz for PocketTTS). No resampling happens today |
output.speed |
float | 1.0 |
Accepted but ignored by PocketTTS (logged at debug level) |
output.pitch |
float | null | null |
Accepted but ignored by PocketTTS |
200, binary body, Content-Type: audio/mpeg | audio/wav | audio/ogg, Content-Disposition: inline; filename=speech.<ext>.
200, JSON regardless of output.format:
{
"audio": "UklGRiQAAABXQVZFZm10IBAAAA...",
"format": "mp3",
"boundaries": [
{ "name": "word", "charIndex": 0, "charLength": 4, "elapsedTime": 0.0 },
{ "name": "word", "charIndex": 5, "charLength": 3, "elapsedTime": 0.31 }
]
}| Field | Type | Notes |
|---|---|---|
audio |
string | Base64, encoded in output.format |
format |
string | Echoes the requested/default format |
boundaries |
TimingMark[] | null |
null = this voice doesn't support timing (Voice.controls.boundary == false) — currently true for every voice, since PocketTTS never populates this |
Mirrors the Web Speech API boundary event field-for-field, so a client that already handles native SpeechSynthesis events needs no translation layer.
| Field | Type | Meaning |
|---|---|---|
name |
"word" | "sentence" |
Always "word" today |
charIndex |
int | Offset of the word's first char in the request's text |
charLength |
int | Word length — text[charIndex:charIndex+charLength] |
elapsedTime |
float | Seconds from audio start |
No end field (next mark's elapsedTime, or total duration for the last word, covers it) and no text field (client already has the source text).
Things the schema or config surfaces but the server doesn't actually do yet:
- Auth —
API_KEY_ENABLEDis validated at startup but never enforced on requests. - Word boundaries — no provider populates
TimingMarks. Every voice reportscontrols.boundary: false. output.speed/output.pitch/output.sample_rate— accepted, validated, silently ignored by PocketTTS.id/publication_id— parsed, not used for caching, idempotency, or dedup.- SSML — tags are stripped, not interpreted. No prosody control.
- MathML — passed through as plain text; equations get spoken as raw markup.
- Curated cross-language voice quality —
otherLanguagesinvoices.jsondocuments what a voice is capable of, not whether it's a good fit (e.g. an English voice speaking French). No ranking/curation of this is implemented yet — deliberately conservative, pending a listening review. - Providers beyond PocketTTS — ElevenLabs is planned but not yet wired into
_build_registry().