feat: add voiceclone audio API and bridge bootstrap #19

Merged
sascha merged 2 commits from feat/tts-generate-20260813125855 into main 2026-08-13 12:58:58 +02:00
2 changed files with 197 additions and 3 deletions

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":

View file

@ -45,6 +45,110 @@ def test_health_exposes_current_version():
assert response.json()["version"] == app.VERSION == "2.3.5" assert response.json()["version"] == app.VERSION == "2.3.5"
def test_tts_generate_returns_cloned_wav(monkeypatch):
captured = {}
class FakeResponse:
status_code = 200
content = b"RIFF" + b"test-wave"
class FakeClient:
def __init__(self, **_kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def post(self, url, json):
captured["url"] = url
captured["json"] = json
return FakeResponse()
monkeypatch.setattr(app.httpx, "AsyncClient", FakeClient)
with TestClient(app.app) as client:
response = client.post(
"/tts/generate",
headers={"Authorization": "Bearer test-token"},
json={"text": "Hallo Sascha", "voice": "deep_thought.mp3", "language": "de"},
)
assert response.status_code == 200
assert response.headers["content-type"].startswith("audio/wav")
assert response.content.startswith(b"RIFF")
assert captured["json"]["voice_mode"] == "clone"
assert captured["json"]["reference_audio_filename"] == "deep_thought.mp3"
def test_tts_generate_validates_text_and_voice_before_backend(monkeypatch):
monkeypatch.setattr(
app.httpx,
"AsyncClient",
lambda **_kwargs: (_ for _ in ()).throw(AssertionError("backend must not be called")),
)
with TestClient(app.app) as client:
empty = client.post(
"/tts/generate",
headers={"Authorization": "Bearer test-token"},
json={"text": ""},
)
traversal = client.post(
"/tts/generate",
headers={"Authorization": "Bearer test-token"},
json={"text": "Hallo", "voice": "../secret.wav"},
)
assert empty.status_code == 422
assert traversal.status_code == 422
def test_tts_generate_rejects_non_wav_backend_response(monkeypatch):
class FakeResponse:
status_code = 200
content = b"not audio"
class FakeClient:
def __init__(self, **_kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def post(self, _url, json):
return FakeResponse()
monkeypatch.setattr(app.httpx, "AsyncClient", FakeClient)
with TestClient(app.app) as client:
response = client.post(
"/tts/generate",
headers={"Authorization": "Bearer test-token"},
json={"text": "Hallo"},
)
assert response.status_code == 502
assert "invalid audio" in response.text
def test_tts_bridge_deploy_installs_secrets_without_logging_them(monkeypatch):
calls = []
monkeypatch.setattr(app, "BUTLER_TOKEN", "butler-secret")
monkeypatch.setattr(app, "_vault_cache", {})
monkeypatch.setattr(app, "_ssh", lambda host, command, timeout=30: (calls.append((host, command, timeout)) or (0, "", "")))
with TestClient(app.app) as client:
response = client.post(
"/tts/bridge/deploy",
headers={"Authorization": "Bearer butler-secret"},
json={"rotate_client_token": True},
)
assert response.status_code == 200
assert response.json()["listen"] == "0.0.0.0:8099"
assert len(response.json()["client_token"]) >= 32
assert calls[0][0] == "sascha@10.5.85.5"
assert "butler-secret" not in calls[0][1]
def test_sysctl_audit_reads_fixed_keys_from_inventory_host(monkeypatch): def test_sysctl_audit_reads_fixed_keys_from_inventory_host(monkeypatch):
payload = { payload = {
"live": {"net.ipv4.tcp_congestion_control": "bbr"}, "live": {"net.ipv4.tcp_congestion_control": "bbr"},