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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ activemq-data/

# Environments
.env
pocket-tts.env
.envrc
.venv
env/
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

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

---

Expand Down
23 changes: 21 additions & 2 deletions app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)]
8 changes: 8 additions & 0 deletions app/api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
55 changes: 55 additions & 0 deletions app/api/routes/service.py
Original file line number Diff line number Diff line change
@@ -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,
)
22 changes: 12 additions & 10 deletions app/api/routes/synthesize.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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: {
Expand All @@ -27,24 +28,25 @@
"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": {
"content": {
"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": {
Expand All @@ -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},
},
},
Expand All @@ -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,
},
},
Expand All @@ -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},
Expand Down
41 changes: 22 additions & 19 deletions app/api/routes/voices.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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={
Expand All @@ -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,
},
}
],
}
Expand Down
Loading
Loading