diff --git a/README.md b/README.md index 4454586..e0e0ee6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/api/router.py b/app/api/router.py new file mode 100644 index 0000000..7e422e4 --- /dev/null +++ b/app/api/router.py @@ -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) diff --git a/app/api/v1/routes/health.py b/app/api/routes/health.py similarity index 100% rename from app/api/v1/routes/health.py rename to app/api/routes/health.py diff --git a/app/api/v1/routes/synthesize.py b/app/api/routes/synthesize.py similarity index 100% rename from app/api/v1/routes/synthesize.py rename to app/api/routes/synthesize.py diff --git a/app/api/v1/routes/voices.py b/app/api/routes/voices.py similarity index 100% rename from app/api/v1/routes/voices.py rename to app/api/routes/voices.py diff --git a/app/api/v1/router.py b/app/api/v1/router.py deleted file mode 100644 index a1c203b..0000000 --- a/app/api/v1/router.py +++ /dev/null @@ -1,8 +0,0 @@ -from fastapi import APIRouter - -from app.api.v1.routes.synthesize import router as synthesize_router -from app.api.v1.routes.voices import router as voices_router - -v1_router = APIRouter() -v1_router.include_router(voices_router) -v1_router.include_router(synthesize_router) diff --git a/app/config/settings.py b/app/config/settings.py index a907562..204c054 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -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 = "" diff --git a/app/main.py b/app/main.py index a1f2169..f1a4d95 100644 --- a/app/main.py +++ b/app/main.py @@ -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 @@ -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: diff --git a/app/static/demo.html b/app/static/demo.html index 47eca6b..3b96441 100644 --- a/app/static/demo.html +++ b/app/static/demo.html @@ -143,7 +143,7 @@ TTS API Demo

Readium Speech Server

-

Talks to GET /v1/voices and POST /v1/synthesize on this same origin.

+

Talks to GET /voices and POST /synthesize on this same origin.

@@ -266,7 +266,7 @@

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}`; @@ -315,7 +315,7 @@

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(); @@ -329,7 +329,7 @@

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()), diff --git a/docs/API.md b/docs/API.md index 18bc986..c58efa9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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) --- @@ -65,7 +65,7 @@ Pydantic schema errors (`422`) additionally carry an `errors` array (raw Pydanti --- -## `GET /v1/voices` +## `GET /voices` ``` GET /voices @@ -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 | diff --git a/docs/configuration.md b/docs/configuration.md index 12a6aa5..9fa75f2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/docs/development.md b/docs/development.md index 7111340..0dee475 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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) diff --git a/nginx/templates/20-speech-server.conf.template b/nginx/templates/20-speech-server.conf.template index 3363733..1494c35 100644 --- a/nginx/templates/20-speech-server.conf.template +++ b/nginx/templates/20-speech-server.conf.template @@ -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; } @@ -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; diff --git a/scripts/configure.sh b/scripts/configure.sh index e23ccca..fb3b8a7 100755 --- a/scripts/configure.sh +++ b/scripts/configure.sh @@ -229,7 +229,6 @@ APP_ENV=production LOG_LEVEL=INFO HOST=0.0.0.0 PORT=8000 -API_V1_PREFIX=/v1 WORKERS=1 DOMAIN= diff --git a/tests/test_errors.py b/tests/test_errors.py index cf99cae..f767cdf 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -2,7 +2,7 @@ from httpx import AsyncClient _VOICE = "urn:readium:tts:fake:en-US-standard" -_URL = "/v1/synthesize" +_URL = "/synthesize" @pytest.mark.route @@ -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() diff --git a/tests/test_formats.py b/tests/test_formats.py index 05016f7..2a988f7 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -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] diff --git a/tests/test_synthesize.py b/tests/test_synthesize.py index d6a7fe8..f15551a 100644 --- a/tests/test_synthesize.py +++ b/tests/test_synthesize.py @@ -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"}} diff --git a/tests/test_voices.py b/tests/test_voices.py index 359abb2..48da6d9 100644 --- a/tests/test_voices.py +++ b/tests/test_voices.py @@ -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 @@ -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", @@ -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 @@ -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() == []