feat: add voiceclone support (app.py)

This commit is contained in:
sascha 2026-08-13 12:58:55 +02:00
parent e600fada5a
commit 6de61cc631

96
app.py
View file

@ -1,13 +1,13 @@
"""Homelab Butler v2.1 Unified API proxy for Pfannkuchen homelab. """Homelab Butler v2.1 Unified API proxy for Pfannkuchen homelab.
Reads service config from butler.yaml, credentials from Vaultwarden cache with flat-file fallback.""" 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 from datetime import datetime, timezone
import httpx, yaml import httpx, yaml
from typing import Literal from typing import Literal
from pydantic import BaseModel from pydantic import BaseModel, Field
from fastapi import FastAPI, Request, HTTPException, Depends, Query 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 from contextlib import asynccontextmanager
log = logging.getLogger("butler") log = logging.getLogger("butler")
@ -273,6 +273,7 @@ async def root():
"inventory_add": "POST /inventory/host {name, ip, group?}", "inventory_add": "POST /inventory/host {name, ip, group?}",
"ansible_run": "POST /ansible/run {hostname}", "ansible_run": "POST /ansible/run {hostname}",
"tts_speak": "POST /tts/speak {text, target: speaker|telegram}", "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_voices": "GET /tts/voices",
"tts_health": "GET /tts/health", "tts_health": "GET /tts/health",
"status": "GET /status - health of all backends", "status": "GET /status - health of all backends",
@ -1834,9 +1835,98 @@ class TTSRequest(BaseModel):
voice: str = "deep_thought.mp3" voice: str = "deep_thought.mp3"
language: str = "de" 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" 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" 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") @app.post("/tts/speak")
async def tts_speak(req: TTSRequest, _=Depends(_verify)): async def tts_speak(req: TTSRequest, _=Depends(_verify)):
if req.target == "speaker": if req.target == "speaker":