diff --git a/app.py b/app.py index 9f30e11..36afdb7 100644 --- a/app.py +++ b/app.py @@ -1,13 +1,13 @@ """Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab. Reads service config from butler.yaml, credentials from Vaultwarden cache with flat-file fallback.""" -import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress +import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets from datetime import datetime, timezone import httpx, yaml from typing import Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field from fastapi import FastAPI, Request, HTTPException, Depends, Query -from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.responses import JSONResponse, RedirectResponse, Response from contextlib import asynccontextmanager log = logging.getLogger("butler") @@ -273,6 +273,7 @@ async def root(): "inventory_add": "POST /inventory/host {name, ip, group?}", "ansible_run": "POST /ansible/run {hostname}", "tts_speak": "POST /tts/speak {text, target: speaker|telegram}", + "tts_generate": "POST /tts/generate {text, voice?, language?} - return cloned WAV audio", "tts_voices": "GET /tts/voices", "tts_health": "GET /tts/health", "status": "GET /status - health of all backends", @@ -1834,9 +1835,98 @@ class TTSRequest(BaseModel): voice: str = "deep_thought.mp3" language: str = "de" + +class TTSGenerateRequest(BaseModel): + text: str = Field(min_length=1, max_length=2000) + voice: str = Field(default="deep_thought.mp3", pattern=r"^[A-Za-z0-9_.-]+$") + language: str = Field(default="de", pattern=r"^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{2,8})?$") + + +class TTSBridgeDeployRequest(BaseModel): + rotate_client_token: bool = False + + SPEAKER_URL = TTS_CFG.get("speaker_url", "http://10.10.1.166:10800") if TTS_CFG else "http://10.10.1.166:10800" CHATTERBOX_URL = TTS_CFG.get("chatterbox_url", "http://10.2.1.104:8004/tts") if TTS_CFG else "http://10.2.1.104:8004/tts" + +def _chatterbox_payload(text: str, voice: str, language: str) -> dict: + return { + "text": text, + "voice_mode": "clone", + "reference_audio_filename": voice, + "output_format": "wav", + "language": language, + "exaggeration": 0.3, + "cfg_weight": 0.7, + "temperature": 0.6, + } + + +@app.post("/tts/generate", response_class=Response) +async def tts_generate(req: TTSGenerateRequest, _=Depends(_verify)): + """Generate cloned speech and return the WAV bytes to the authenticated caller.""" + async with httpx.AsyncClient(verify=False, timeout=180) as client: + try: + result = await client.post(CHATTERBOX_URL, json=_chatterbox_payload(req.text, req.voice, req.language)) + except httpx.RequestError: + _audit("/tts/generate", "POST", 502, "chatterbox request failed") + raise HTTPException(status_code=502, detail="Chatterbox is unavailable") + + if result.status_code != 200: + _audit("/tts/generate", "POST", 502, f"chatterbox_http={result.status_code}") + raise HTTPException(status_code=502, detail="Chatterbox generation failed") + if not result.content.startswith(b"RIFF"): + _audit("/tts/generate", "POST", 502, "invalid audio response") + raise HTTPException(status_code=502, detail="Chatterbox returned invalid audio") + + _audit("/tts/generate", "POST", 200, f"voice={req.voice} chars={len(req.text)}") + return Response( + content=result.content, + media_type="audio/wav", + headers={ + "Content-Disposition": 'inline; filename="voiceclone.wav"', + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }, + ) + + +@app.post("/tts/bridge/deploy") +async def tts_bridge_deploy(req: TTSBridgeDeployRequest, _=Depends(_verify)): + """Install host-local bridge secrets on automation1 without exposing them.""" + client_token = _vault_cache.get("tts_bridge_client_token", "").strip() + if req.rotate_client_token or not client_token: + client_token = secrets.token_urlsafe(32) + if not BUTLER_TOKEN: + raise HTTPException(status_code=500, detail="Butler token is not configured") + + files = { + "/app-config/tts-bridge/butler-token": BUTLER_TOKEN, + "/app-config/tts-bridge/client-token": client_token, + } + encoded = base64.b64encode(json.dumps(files).encode()).decode() + script = ( + "import base64,json,os,pathlib;" + f"files=json.loads(base64.b64decode('{encoded}'));" + "pathlib.Path('/app-config/tts-bridge').mkdir(parents=True,exist_ok=True);" + "[(pathlib.Path(p).write_text(v),os.chmod(p,0o600)) for p,v in files.items()]" + ) + rc, _out, err = _ssh("sascha@10.5.85.5", f"python3 -c {__import__('shlex').quote(script)}", timeout=30) + if rc != 0: + _audit("/tts/bridge/deploy", "POST", 500, "secret installation failed") + raise HTTPException(status_code=500, detail="Could not install bridge secrets") + + _audit("/tts/bridge/deploy", "POST", 200, f"rotated={req.rotate_client_token or not _vault_cache.get('tts_bridge_client_token')}") + return { + "status": "ready", + "host": "automation1", + "listen": "0.0.0.0:8099", + "client_token": client_token, + "rotated": req.rotate_client_token or not _vault_cache.get("tts_bridge_client_token"), + } + + @app.post("/tts/speak") async def tts_speak(req: TTSRequest, _=Depends(_verify)): if req.target == "speaker":