Compare commits
No commits in common. "15271f58c0a7d64e2fa83a5dbd6d9ec4dc95f065" and "6b17decf13c9d57a3ba188e04a9e08376ea07c50" have entirely different histories.
15271f58c0
...
6b17decf13
5 changed files with 1 additions and 129 deletions
|
|
@ -1,10 +0,0 @@
|
||||||
FROM python:3.13-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY requirements.tts-bridge.txt ./requirements.txt
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
COPY tts_bridge.py ./app.py
|
|
||||||
RUN useradd --system --uid 10001 --home /nonexistent --shell /usr/sbin/nologin ttsbridge
|
|
||||||
USER 10001:10001
|
|
||||||
EXPOSE 8099
|
|
||||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8099", "--proxy-headers", "--no-server-header"]
|
|
||||||
10
README.md
10
README.md
|
|
@ -1,11 +1,3 @@
|
||||||
# automation1 services
|
# automation1 services
|
||||||
|
|
||||||
Git-managed Docker Compose stack for the existing automation1 services and the authenticated TTS bridge.
|
Git-managed Compose stack for Vaultwarden and Uptime Kuma on `automation1`. Runtime variables are stored in Dockhand and are not committed.
|
||||||
|
|
||||||
## TTS bridge
|
|
||||||
|
|
||||||
- Listen address: `0.0.0.0:8099`
|
|
||||||
- Health: `GET /health`
|
|
||||||
- Voiceclone: `POST /v1/tts` with bearer authentication
|
|
||||||
- Default voice: `deep_thought.mp3`
|
|
||||||
- Secrets stay host-local under `/app-config/tts-bridge/` and are never committed.
|
|
||||||
|
|
|
||||||
31
compose.yaml
31
compose.yaml
|
|
@ -32,34 +32,3 @@ services:
|
||||||
timeout: 30s
|
timeout: 30s
|
||||||
start_period: 180s
|
start_period: 180s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
tts-bridge:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.tts-bridge
|
|
||||||
image: local/automation1-tts-bridge:1.0.0
|
|
||||||
container_name: tts-bridge
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "0.0.0.0:8099:8099"
|
|
||||||
environment:
|
|
||||||
BUTLER_URL: http://10.4.1.116:8888
|
|
||||||
BUTLER_TOKEN_FILE: /run/secrets/butler_token
|
|
||||||
CLIENT_TOKEN_FILE: /run/secrets/client_token
|
|
||||||
DEFAULT_VOICE: deep_thought.mp3
|
|
||||||
volumes:
|
|
||||||
- /app-config/tts-bridge/butler-token:/run/secrets/butler_token:ro
|
|
||||||
- /app-config/tts-bridge/client-token:/run/secrets/client_token:ro
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:size=16m,mode=1777
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8099/health', timeout=5)"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 20s
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
fastapi==0.116.1
|
|
||||||
httpx==0.28.1
|
|
||||||
uvicorn==0.35.0
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hmac
|
|
||||||
import os
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
|
||||||
from fastapi.responses import Response
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
BUTLER_URL = os.environ.get("BUTLER_URL", "http://10.4.1.116:8888").rstrip("/")
|
|
||||||
BUTLER_TOKEN_FILE = Path(os.environ.get("BUTLER_TOKEN_FILE", "/run/secrets/butler_token"))
|
|
||||||
CLIENT_TOKEN_FILE = Path(os.environ.get("CLIENT_TOKEN_FILE", "/run/secrets/client_token"))
|
|
||||||
DEFAULT_VOICE = os.environ.get("DEFAULT_VOICE", "deep_thought.mp3")
|
|
||||||
|
|
||||||
|
|
||||||
def _read_secret(path: Path, label: str) -> str:
|
|
||||||
try:
|
|
||||||
value = path.read_text().strip()
|
|
||||||
except OSError as exc:
|
|
||||||
raise RuntimeError(f"{label} secret is unavailable") from exc
|
|
||||||
if not value:
|
|
||||||
raise RuntimeError(f"{label} secret is empty")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
app.state.butler_token = _read_secret(BUTLER_TOKEN_FILE, "Butler")
|
|
||||||
app.state.client_token = _read_secret(CLIENT_TOKEN_FILE, "Client")
|
|
||||||
app.state.http = httpx.AsyncClient(timeout=httpx.Timeout(190.0, connect=10.0))
|
|
||||||
yield
|
|
||||||
await app.state.http.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Pfannkuchen TTS Bridge", version="1.0.0", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateRequest(BaseModel):
|
|
||||||
text: str = Field(min_length=1, max_length=2000)
|
|
||||||
voice: str = Field(default=DEFAULT_VOICE, pattern=r"^[A-Za-z0-9_.-]+$")
|
|
||||||
language: str = Field(default="de", pattern=r"^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{2,8})?$")
|
|
||||||
|
|
||||||
|
|
||||||
def verify_client(authorization: str | None = Header(default=None)) -> None:
|
|
||||||
if not authorization or not authorization.startswith("Bearer "):
|
|
||||||
raise HTTPException(status_code=401, detail="Missing bearer token")
|
|
||||||
supplied = authorization.removeprefix("Bearer ").strip()
|
|
||||||
if not hmac.compare_digest(supplied, app.state.client_token):
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid bearer token")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health():
|
|
||||||
try:
|
|
||||||
response = await app.state.http.get(f"{BUTLER_URL}/tts/health", headers={"Authorization": f"Bearer {app.state.butler_token}"})
|
|
||||||
response.raise_for_status()
|
|
||||||
chatterbox_ok = response.json().get("chatterbox") == "ok"
|
|
||||||
except (httpx.HTTPError, ValueError):
|
|
||||||
chatterbox_ok = False
|
|
||||||
return {"status": "ok" if chatterbox_ok else "degraded", "chatterbox": chatterbox_ok}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/tts", response_class=Response, dependencies=[Depends(verify_client)])
|
|
||||||
async def generate(req: GenerateRequest):
|
|
||||||
try:
|
|
||||||
upstream = await app.state.http.post(f"{BUTLER_URL}/tts/generate", headers={"Authorization": f"Bearer {app.state.butler_token}"}, json=req.model_dump())
|
|
||||||
except httpx.RequestError:
|
|
||||||
raise HTTPException(status_code=502, detail="Butler is unavailable")
|
|
||||||
if upstream.status_code != 200:
|
|
||||||
raise HTTPException(status_code=502, detail="Speech generation failed")
|
|
||||||
if not upstream.content.startswith(b"RIFF"):
|
|
||||||
raise HTTPException(status_code=502, detail="Invalid audio received")
|
|
||||||
return Response(content=upstream.content, media_type="audio/wav", headers={"Content-Disposition": 'inline; filename="voiceclone.wav"', "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"})
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue