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
17 changes: 12 additions & 5 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,19 @@ jobs:
- name: Deploy to GCP VM
uses: appleboy/ssh-action@v1
env:
# base64-encoded contents of .env and pocket-tts.env, stored as GitHub
# secrets (see docs/deployment.md). base64 avoids newline/quoting issues
# over SSH. This is the single source of truth for prod config — never
# edit the files on the VM by hand.
# base64-encoded contents of .env / pocket-tts.env / elevenlabs.env, stored as
# GitHub secrets (see docs/deployment.md). base64 avoids newline/quoting issues
# over SSH. This is the single source of truth for prod config — never edit the
# files on the VM by hand. ELEVENLABS_ENV_B64 is optional (only when ElevenLabs
# is enabled); an empty secret leaves elevenlabs.env absent, which is fine.
DOTENV_B64: ${{ secrets.DOTENV_B64 }}
POCKET_ENV_B64: ${{ secrets.POCKET_TTS_ENV_B64 }}
ELEVENLABS_ENV_B64: ${{ secrets.ELEVENLABS_ENV_B64 }}
with:
host: ${{ secrets.GCP_VM_HOST }}
username: ${{ secrets.GCP_VM_USER }}
key: ${{ secrets.GCP_SSH_PRIVATE_KEY }}
envs: DOTENV_B64,POCKET_ENV_B64
envs: DOTENV_B64,POCKET_ENV_B64,ELEVENLABS_ENV_B64
script: |
set -euo pipefail
cd ~/speech-server
Expand All @@ -64,6 +66,11 @@ jobs:
printf '%s' "$DOTENV_B64" | base64 -d > .env
printf '%s' "$POCKET_ENV_B64" | base64 -d > pocket-tts.env
chmod 600 .env pocket-tts.env
# elevenlabs.env only when the secret is set (provider optional).
if [ -n "$ELEVENLABS_ENV_B64" ]; then
printf '%s' "$ELEVENLABS_ENV_B64" | base64 -d > elevenlabs.env
chmod 600 elevenlabs.env
fi
docker compose pull app
docker compose up -d --no-build app
docker compose restart nginx
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ activemq-data/
# Environments
.env
pocket-tts.env
elevenlabs.env
.envrc
.venv
env/
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ Designed to pair with [Readium Speech](https://github.com/readium/speech), Readi
| | |
|---|---|
| **API** | `GET /service` · `GET /voices` · `POST /synthesize` |
| **Providers** | PocketTTS · ElevenLabs (planned) |
| **Providers** | [PocketTTS](docs/providers/pocket.md) (local) · [ElevenLabs](docs/providers/elevenlabs.md) (hosted) |
| **Languages** | English · French · Italian · German · Spanish · Portuguese |
| **Formats** | WAV (default) · MP3 · Opus |
| **Word boundaries** | Planned (ElevenLabs) |
| **Word boundaries** | ElevenLabs (word-level timing marks) |
| **Deployment** | Docker · CPU-only · Single named volume for model weights |

---
Expand Down Expand Up @@ -55,6 +55,7 @@ curl -s -X POST http://localhost:8000/synthesize \
- [API Reference](docs/API.md)
- [Voices](docs/voices.md)
- [Configuration](docs/configuration.md)
- Providers: [PocketTTS](docs/providers/pocket.md) · [ElevenLabs](docs/providers/elevenlabs.md)
- [Development](docs/development.md)
- [Deployment](docs/deployment.md)

Expand Down
30 changes: 27 additions & 3 deletions app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
class Settings(BaseSettings):
model_config = SettingsConfigDict(
# .env: server/auth/concurrency/circuit-breaker — universal, provider-agnostic.
# pocket-tts.env: PocketTTS-scoped install config. Optional — later providers get
# their own file the same way; a missing file is silently skipped, not an error.
env_file=(".env", "pocket-tts.env"),
# pocket-tts.env / elevenlabs.env: provider-scoped config. Optional — each
# provider gets its own file the same way; a missing file is silently skipped.
env_file=(".env", "pocket-tts.env", "elevenlabs.env"),
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
Expand Down Expand Up @@ -50,6 +50,26 @@ def language_list(self) -> list[str]:
# PocketTTS. Empty = no server-side default voice; a request must name one.
pocket_default_voice: str = ""

# ElevenLabs (hosted HTTP provider). Only used when "elevenlabs" is in ENABLED_PROVIDERS.
elevenlabs_api_key: str = ""
elevenlabs_model_id: str = "eleven_multilingual_v2"
# Languages are PROVIDER-SCOPED: ElevenLabs has its own set in elevenlabs.env, independent of
# pocket's LANGUAGES. Empty = ElevenLabs serves no voices (like pocket's LANGUAGES unset).
elevenlabs_languages: str = ""

@property
def elevenlabs_language_list(self) -> list[str]:
return [v.strip().lower() for v in self.elevenlabs_languages.split(",") if v.strip()]

elevenlabs_base_url: str = "https://api.elevenlabs.io" # overridable for tests
# Per-day usage cap so users can't spam the ElevenLabs API and burn credits. Counts CHARACTERS
# sent, resets at UTC midnight. 0 = unlimited. Operators set ELEVENLABS_DAILY_CHAR_LIMIT in
# elevenlabs.env — pick a value per the model's rate (https://elevenlabs.io/pricing/api;
# flash/turbo bill half, so allow ~2× the characters for the same cost). Backed by a small JSON
# file shared across workers (see app/core/daily_limit.py) so the cap is host-wide.
elevenlabs_daily_char_limit: int = Field(default=0, ge=0)
elevenlabs_usage_file: str = "/tmp/elevenlabs_usage.json" # noqa: S108 — ephemeral counter; set a volume path to persist

# Per-voice cross-language install config. Generic across providers. Format:
# comma-separated "originalName:lang" pairs, "-" prefix to remove instead of add
# (e.g. "alba:fr,alba:de,-javert:es"). Additions still bounded by `languages` (never
Expand Down Expand Up @@ -112,6 +132,10 @@ def validate_auth_and_providers(self) -> "Settings":
providers = [p.strip() for p in self.enabled_providers.split(",")]
if self.default_provider not in providers:
raise ValueError(f"DEFAULT_PROVIDER '{self.default_provider}' not in ENABLED_PROVIDERS")
if "elevenlabs" in providers and not self.elevenlabs_api_key:
raise ValueError(
"ELEVENLABS_API_KEY must be set when 'elevenlabs' is in ENABLED_PROVIDERS"
)
if self.app_env == "production" and not self.domain:
raise ValueError("DOMAIN must be set when APP_ENV=production")
return self
Expand Down
58 changes: 58 additions & 0 deletions app/core/daily_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Host-wide daily usage budget, backed by a small fcntl-locked JSON file.

Shared across all uvicorn workers on the host and persists across restarts within the
same day, so a per-day cap can't be sidestepped by parallel workers or a quick restart.
Unix only (fcntl) — the server runs on Linux. `{"date": "YYYY-MM-DD", "used": N}`.
"""

from __future__ import annotations

import fcntl
import json
from datetime import UTC, datetime
from pathlib import Path


def reserve(path: str, amount: int, limit: int) -> tuple[bool, int]:
"""Atomically reserve `amount` units against today's (UTC) budget.

Returns (allowed, used_after_reservation). `limit <= 0` = unlimited (always allowed;
usage is NOT tracked, to avoid needless file I/O). When the reservation would push
usage over `limit`, nothing is added and `allowed` is False.
"""
if limit <= 0:
return (True, 0)
today = datetime.now(UTC).date().isoformat()
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with open(p, "a+", encoding="utf-8") as f:
fcntl.flock(f, fcntl.LOCK_EX)
try:
f.seek(0)
raw = f.read()
data = json.loads(raw) if raw.strip() else {}
if data.get("date") != today:
data = {"date": today, "used": 0}
used = int(data.get("used", 0))
if used + amount > limit:
return (False, used)
used += amount
f.seek(0)
f.truncate()
f.write(json.dumps({"date": today, "used": used}))
return (True, used)
finally:
fcntl.flock(f, fcntl.LOCK_UN)


if __name__ == "__main__": # self-check
import tempfile

fp = str(Path(tempfile.mkdtemp()) / "u.json")
assert reserve(fp, 100, 250) == (True, 100)
assert reserve(fp, 100, 250) == (True, 200)
assert reserve(fp, 100, 250) == (False, 200) # would exceed → rejected, not added
assert reserve(fp, 50, 250) == (True, 250) # exact fit ok
assert reserve(fp, 1, 250)[0] is False # full
assert reserve(fp, 10_000, 0) == (True, 0) # limit 0 = unlimited, untracked
print("daily_limit self-check ok")
23 changes: 21 additions & 2 deletions app/core/synthesizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ async def synthesize(
voice_uri=voice.identifier,
speed=out.speed,
pitch=out.pitch,
audio_format=out.format,
bitrate=out.bitrate,
prev_utterance=request.prev_utterance,
next_utterance=request.next_utterance,
)
Expand All @@ -94,15 +96,32 @@ async def synthesize(
t0 = time.monotonic()
try:
result: AudioResult = await provider.synthesize(params)
except AppError:
if breaker is not None:
except AppError as exc:
# Only provider OUTAGES (5xx) trip the breaker. Client/quota errors — 429 rate
# limit (incl. the daily budget), 404 unsupported voice — are not provider failures
# and must not open the breaker for everyone.
if breaker is not None and exc.status_code >= 500:
breaker.record_failure()
raise
else:
if breaker is not None:
breaker.record_success()
gen_ms = round((time.monotonic() - t0) * 1000, 1)

# Provider already encoded server-side (e.g. ElevenLabs native output) —
# return as-is, no local WAV/ffmpeg step.
if result.encoded is not None:
content_type = result.content_type or ffmpeg_driver.content_type(out.format.value)
logger.info(
"synth done voice=%s fmt=%s (native) gen_ms=%.1f total_ms=%.1f bytes=%d",
voice.identifier,
out.format.value,
gen_ms,
round((time.monotonic() - t0) * 1000, 1),
len(result.encoded),
)
return (result.encoded, content_type, result.boundaries, boundaries_supported)

if out.format == AudioFormat.WAV:
audio = _pcm_to_wav(result.pcm, result.sample_rate)
logger.info(
Expand Down
47 changes: 47 additions & 0 deletions app/data/voices/elevenlabs/voices.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
[
{
"name": "George",
"originalName": "george",
"identifier": "urn:readium:tts:elevenlabs:george",
"language": "en-GB",
"otherLanguages": ["ar", "bg", "cs", "da", "de", "el", "es", "fi", "fil", "fr", "hi", "hr", "id", "it", "ja", "ko", "ms", "nl", "pl", "pt", "ro", "ru", "sk", "sv", "ta", "tr", "uk", "zh"],
"gender": "male",
"quality": "high"
},
{
"name": "Alice",
"originalName": "alice",
"identifier": "urn:readium:tts:elevenlabs:alice",
"language": "en-GB",
"otherLanguages": ["ar", "bg", "cs", "da", "de", "el", "es", "fi", "fil", "fr", "hi", "hr", "id", "it", "ja", "ko", "ms", "nl", "pl", "pt", "ro", "ru", "sk", "sv", "ta", "tr", "uk", "zh"],
"gender": "female",
"quality": "high"
},
{
"name": "Brian",
"originalName": "brian",
"identifier": "urn:readium:tts:elevenlabs:brian",
"language": "en-US",
"otherLanguages": ["ar", "bg", "cs", "da", "de", "el", "es", "fi", "fil", "fr", "hi", "hr", "id", "it", "ja", "ko", "ms", "nl", "pl", "pt", "ro", "ru", "sk", "sv", "ta", "tr", "uk", "zh"],
"gender": "male",
"quality": "high"
},
{
"name": "Sarah",
"originalName": "sarah",
"identifier": "urn:readium:tts:elevenlabs:sarah",
"language": "en-US",
"otherLanguages": ["ar", "bg", "cs", "da", "de", "el", "es", "fi", "fil", "fr", "hi", "hr", "id", "it", "ja", "ko", "ms", "nl", "pl", "pt", "ro", "ru", "sk", "sv", "ta", "tr", "uk", "zh"],
"gender": "female",
"quality": "high"
},
{
"name": "River",
"originalName": "river",
"identifier": "urn:readium:tts:elevenlabs:river",
"language": "en-US",
"otherLanguages": ["ar", "bg", "cs", "da", "de", "el", "es", "fi", "fil", "fr", "hi", "hr", "id", "it", "ja", "ko", "ms", "nl", "pl", "pt", "ro", "ru", "sk", "sv", "ta", "tr", "uk", "zh"],
"gender": "female",
"quality": "high"
}
]
7 changes: 6 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ def _build_registry() -> ProviderRegistry:
enabled = {p.strip() for p in settings.enabled_providers.split(",")}
if "pocket" in enabled:
registry.register(PocketTTSProvider())
if "elevenlabs" in enabled:
from app.providers.elevenlabs import ElevenLabsProvider

registry.register(ElevenLabsProvider())
return registry


Expand Down Expand Up @@ -107,7 +111,8 @@ def create_app() -> FastAPI:

@app.get("/demo", include_in_schema=False)
async def demo() -> FileResponse:
return FileResponse("app/static/demo.html")
# no-cache so the browser always revalidates — avoids serving a stale demo after edits.
return FileResponse("app/static/demo.html", headers={"Cache-Control": "no-cache"})

return app

Expand Down
Loading
Loading