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: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ make dev-docker # start server — downloads models on first run

Server: `http://localhost:8000` · Interactive docs: `http://localhost:8000/docs` · Demo: `http://localhost:8000/demo`

**Live demo:** [speech-server.readium.org/demo](https://speech-server.readium.org/demo)

**Quick test:**
```bash
curl -s -X POST http://localhost:8000/v1/synthesize \
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
Expand Down
8 changes: 8 additions & 0 deletions app/api/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import APIRouter

from app.api.routes.synthesize import router as synthesize_router
from app.api.routes.voices import router as voices_router

router = APIRouter()
router.include_router(voices_router)
router.include_router(synthesize_router)
File renamed without changes.
File renamed without changes.
File renamed without changes.
8 changes: 0 additions & 8 deletions app/api/v1/router.py

This file was deleted.

1 change: 0 additions & 1 deletion app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ class Settings(BaseSettings):
log_level: str = "INFO"
host: str = "0.0.0.0"
port: int = Field(default=8000, gt=0, le=65535)
api_v1_prefix: str = "/v1"
workers: int = Field(default=1, ge=1)
domain: str = ""

Expand Down
6 changes: 3 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from starlette.middleware.trustedhost import TrustedHostMiddleware

from app.api.errors import register_error_handlers
from app.api.v1.router import v1_router
from app.api.v1.routes.health import router as health_router
from app.api.router import router
from app.api.routes.health import router as health_router
from app.config.settings import settings
from app.core.concurrency import init_semaphore
from app.core.registry import ProviderRegistry
Expand Down Expand Up @@ -73,7 +73,7 @@ def create_app() -> FastAPI:
register_error_handlers(app)

app.include_router(health_router)
app.include_router(v1_router, prefix=settings.api_v1_prefix)
app.include_router(router)

@app.get("/demo", include_in_schema=False)
async def demo() -> FileResponse:
Expand Down
8 changes: 4 additions & 4 deletions app/static/demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
<body>
<span class="eyebrow">TTS API Demo</span>
<h1>Readium Speech Server</h1>
<p>Talks to <code>GET /v1/voices</code> and <code>POST /v1/synthesize</code> on this same origin.</p>
<p>Talks to <code>GET /voices</code> and <code>POST /synthesize</code> on this same origin.</p>

<div class="card">
<label for="text">Utterance</label>
Expand Down Expand Up @@ -266,7 +266,7 @@ <h2>
function updateCurl() {
const body = JSON.stringify(requestBody());
curlEl.textContent =
`curl -s -X POST ${location.origin}/v1/synthesize \\\n` +
`curl -s -X POST ${location.origin}/synthesize \\\n` +
` -H 'Content-Type: application/json' \\\n` +
` -d '${body}' \\\n` +
` -o speech.${boundaryEl.checked ? 'json' : formatEl.value}`;
Expand Down Expand Up @@ -315,7 +315,7 @@ <h2>
genderFilterEl.addEventListener('change', renderVoiceOptions);

async function loadVoices() {
const resp = await fetch('/v1/voices');
const resp = await fetch('/voices');
allVoices = await resp.json();
populateFilterOptions(langFilterEl, [...new Set(allVoices.map(v => v.language))].sort());
const providers = [...new Set(allVoices.map(v => v.provider))].sort();
Expand All @@ -329,7 +329,7 @@ <h2>
statusPillEl.style.display = 'none';
outputEl.classList.remove('error');
outputEl.textContent = 'Loading...';
const resp = await fetch('/v1/synthesize', {
const resp = await fetch('/synthesize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody()),
Expand Down
8 changes: 4 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ Base path: `/`. All bodies are `application/json` unless noted.
- [Authentication](#authentication)
- [Errors](#errors)
- [Health](#health)
- [`GET /voices`](#get-v1voices)
- [`POST /synthesize`](#post-v1synthesize)
- [`GET /voices`](#get-voices)
- [`POST /synthesize`](#post-synthesize)
- [Not implemented](#not-implemented)

---
Expand Down Expand Up @@ -65,7 +65,7 @@ Pydantic schema errors (`422`) additionally carry an `errors` array (raw Pydanti

---

## `GET /v1/voices`
## `GET /voices`

```
GET /voices
Expand Down Expand Up @@ -167,7 +167,7 @@ Only `text` and `voice` are required; everything else defaults as shown.
| `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` | Hint only; voice resolution is by `voiceURI`, not `language` |
| `voice` | string | — | Must exactly match a `voiceURI` from `/v1/voices`. 404 if not found |
| `voice` | string | — | Must exactly match a `voiceURI` from `/voices`. 404 if not found |
| `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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,4 @@ First-time setup writes `APP_ENV=production` and prompts for `DOMAIN`. `make sta

For local dev, set `APP_ENV=development` in `.env` (`DOMAIN` becomes optional) and use `make dev-docker` — exposes `:8000` directly with no nginx.

nginx rate-limits `/v1/synthesize` (2 req/s, burst 4) and caps connections per IP. A `503` from behind nginx is a plain nginx error page, not the app's Problem Details JSON.
nginx rate-limits `/synthesize` (2 req/s, burst 4) and caps connections per IP. A `503` from behind nginx is a plain nginx error page, not the app's Problem Details JSON.
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ No changes to routes, synthesizer, or voice catalog. Language filtering and boun

```
Client
└─ POST /v1/synthesize
└─ POST /synthesize
└─ Synthesizer
├─ validate text length + content
├─ resolve voiceURI → (provider, voiceURI) (VoiceCatalog)
Expand Down
4 changes: 2 additions & 2 deletions nginx/templates/20-speech-server.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ server {
# meant a normal page load (docs UI, healthz, favicon, multiple parallel
# requests on a hard refresh) could trip nginx's built-in 503 rate-limit
# rejection well before actually threatening the synthesis backend.
location = /v1/synthesize {
location = /synthesize {
limit_except POST {
deny all;
}
Expand All @@ -73,7 +73,7 @@ server {

# Headroom above what a real browser opens per origin (~6-8) — this
# is just a coarse abuse guard for general routes, not the synthesis
# concurrency budget (that's the /v1/synthesize block above).
# concurrency budget (that's the /synthesize block above).
limit_conn perip 20;

include /etc/nginx/snippets/proxy-pass.conf;
Expand Down
1 change: 0 additions & 1 deletion scripts/configure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,6 @@ APP_ENV=production
LOG_LEVEL=INFO
HOST=0.0.0.0
PORT=8000
API_V1_PREFIX=/v1
WORKERS=1
DOMAIN=

Expand Down
4 changes: 2 additions & 2 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from httpx import AsyncClient

_VOICE = "urn:readium:tts:fake:en-US-standard"
_URL = "/v1/synthesize"
_URL = "/synthesize"


@pytest.mark.route
Expand All @@ -21,7 +21,7 @@ async def test_text_too_long_returns_413(client: AsyncClient) -> None:

@pytest.mark.route
async def test_unknown_route_returns_about_blank_problem(client: AsyncClient) -> None:
resp = await client.get("/v1/does-not-exist")
resp = await client.get("/does-not-exist")
assert resp.status_code == 404
assert resp.headers["content-type"] == "application/problem+json"
body = resp.json()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from httpx import AsyncClient

_VOICE = "urn:readium:tts:fake:en-US-standard"
_SYNTH = "/v1/synthesize"
_SYNTH = "/synthesize"


def _body(fmt: str) -> dict: # type: ignore[type-arg]
Expand Down
2 changes: 1 addition & 1 deletion tests/test_synthesize.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

_VOICE = "urn:readium:tts:fake:en-US-standard"
_VOICE_FR = "urn:readium:tts:fake:fr-FR-standard"
_URL = "/v1/synthesize"
_URL = "/synthesize"
_WAV = {"output": {"format": "wav"}}


Expand Down
10 changes: 5 additions & 5 deletions tests/test_voices.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

@pytest.mark.route
async def test_list_voices_returns_all(client: AsyncClient) -> None:
resp = await client.get("/v1/voices")
resp = await client.get("/voices")
assert resp.status_code == 200
voices = resp.json()
assert len(voices) == 2
Expand All @@ -15,7 +15,7 @@ async def test_list_voices_returns_all(client: AsyncClient) -> None:

@pytest.mark.route
async def test_list_voices_shape(client: AsyncClient) -> None:
resp = await client.get("/v1/voices")
resp = await client.get("/voices")
voice = resp.json()[0]
required = (
"source",
Expand All @@ -35,7 +35,7 @@ async def test_list_voices_shape(client: AsyncClient) -> None:

@pytest.mark.route
async def test_filter_by_language(client: AsyncClient) -> None:
resp = await client.get("/v1/voices", params={"language": "en"})
resp = await client.get("/voices", params={"language": "en"})
assert resp.status_code == 200
voices = resp.json()
assert len(voices) == 1
Expand All @@ -44,13 +44,13 @@ async def test_filter_by_language(client: AsyncClient) -> None:

@pytest.mark.route
async def test_filter_by_provider(client: AsyncClient) -> None:
resp = await client.get("/v1/voices", params={"provider": "fake"})
resp = await client.get("/voices", params={"provider": "fake"})
assert resp.status_code == 200
assert len(resp.json()) == 2


@pytest.mark.route
async def test_filter_by_unknown_language_returns_empty(client: AsyncClient) -> None:
resp = await client.get("/v1/voices", params={"language": "zh"})
resp = await client.get("/voices", params={"language": "zh"})
assert resp.status_code == 200
assert resp.json() == []
Loading