Files
voiceUI/app/main.py
T
2026-10-29 16:29:24 -04:00

488 lines
19 KiB
Python

from __future__ import annotations
import asyncio
import os
import shutil
import sqlite3
import subprocess
import tempfile
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
import httpx
import msgpack
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
DATA_DIR = Path(os.getenv("DATA_DIR", "./data")).resolve()
REFERENCES_DIR = DATA_DIR / "references"
GENERATIONS_DIR = DATA_DIR / "generations"
DB_PATH = DATA_DIR / "studio.sqlite3"
FISH_SPEECH_URL = os.getenv("FISH_SPEECH_URL", "http://host.docker.internal:8080/v1/tts")
FISH_SPEECH_API_KEY = os.getenv("FISH_SPEECH_API_KEY", "YOUR_API_KEY")
MAX_REFERENCE_BYTES = int(os.getenv("MAX_REFERENCE_BYTES", str(25 * 1024 * 1024)))
PAGE_SIZE = 50
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def connect_db() -> sqlite3.Connection:
DATA_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
REFERENCES_DIR.mkdir(parents=True, exist_ok=True)
GENERATIONS_DIR.mkdir(parents=True, exist_ok=True)
with connect_db() as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS references_audio (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
transcript TEXT NOT NULL,
filename TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS generations (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
reference_id TEXT NOT NULL,
tts_url TEXT,
status TEXT NOT NULL,
filename TEXT,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(reference_id) REFERENCES references_audio(id)
);
CREATE INDEX IF NOT EXISTS generations_created_at_idx
ON generations(created_at DESC);
CREATE TABLE IF NOT EXISTS tts_endpoints (
id TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
)
generation_columns = {row["name"] for row in db.execute("PRAGMA table_info(generations)")}
if "tts_url" not in generation_columns:
db.execute("ALTER TABLE generations ADD COLUMN tts_url TEXT")
db.execute("UPDATE generations SET tts_url=? WHERE tts_url IS NULL OR tts_url=' '", (FISH_SPEECH_URL,))
# A running request cannot be resumed after the app process restarts.
db.execute(
"UPDATE generations SET status='failed', error='App restarted while this job was running', updated_at=? WHERE status='running'",
(now_iso(),),
)
def get_reference(reference_id: str) -> sqlite3.Row | None:
with connect_db() as db:
return db.execute("SELECT * FROM references_audio WHERE id=?", (reference_id,)).fetchone()
def get_generation(job_id: str) -> sqlite3.Row | None:
with connect_db() as db:
return db.execute("SELECT * FROM generations WHERE id=?", (job_id,)).fetchone()
def normalize_tts_url(value: str) -> str:
raw = value.strip()
try:
parsed = urlsplit(raw)
_ = parsed.port # Validate malformed ports before the request is made.
except ValueError as exc:
raise HTTPException(status_code=422, detail="Enter a valid Fish Speech URL") from exc
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
raise HTTPException(status_code=422, detail="Use an HTTP or HTTPS Fish Speech URL without embedded credentials")
if parsed.query or parsed.fragment:
raise HTTPException(status_code=422, detail="Fish Speech URL cannot contain a query string or fragment")
path = parsed.path.rstrip("/")
if path.endswith("/v1/tts"):
tts_path = path
else:
tts_path = f"{path}/v1/tts" if path else "/v1/tts"
return urlunsplit((parsed.scheme, parsed.netloc, tts_path, "", ""))
def active_tts_url() -> str:
with connect_db() as db:
row = db.execute("SELECT value FROM app_settings WHERE key='active_tts_url'").fetchone()
return row["value"] if row else normalize_tts_url(FISH_SPEECH_URL)
async def probe_tts_url(value: str) -> tuple[str, str]:
tts_url = normalize_tts_url(value)
parsed = urlsplit(tts_url)
endpoint_path = parsed.path
prefix = endpoint_path[:-len("/v1/tts")] if endpoint_path.endswith("/v1/tts") else ""
health_url = urlunsplit((parsed.scheme, parsed.netloc, f"{prefix}/v1/health", "", ""))
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client:
response = await client.get(health_url, headers={"Authorization": f"Bearer {FISH_SPEECH_API_KEY}"})
if response.is_error:
raise HTTPException(status_code=502, detail=f"Fish Speech health check returned HTTP {response.status_code} at {health_url}")
health = response.json()
if not isinstance(health, dict) or health.get("status") != "ok":
raise HTTPException(status_code=502, detail="Server responded, but did not return a healthy Fish Speech status")
except HTTPException:
raise
except (httpx.TimeoutException, httpx.HTTPError) as exc:
raise HTTPException(status_code=502, detail=f"Could not reach Fish Speech at {tts_url}: {exc}") from exc
except ValueError as exc:
raise HTTPException(status_code=502, detail="Fish Speech returned an unreadable health response") from exc
return tts_url, "Fish Speech server is reachable and healthy."
def reference_json(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"name": row["name"],
"transcript": row["transcript"],
"created_at": row["created_at"],
"audio_url": f"/api/references/{row['id']}/audio",
}
def generation_json(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"text": row["text"],
"reference_id": row["reference_id"],
"status": row["status"],
"error": row["error"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"audio_url": f"/api/generations/{row['id']}/audio" if row["filename"] else None,
}
class CreateGeneration(BaseModel):
text: str = Field(min_length=1, max_length=10000)
reference_id: str
class TtsUrlRequest(BaseModel):
url: str = Field(min_length=1, max_length=500)
class JobQueue:
def __init__(self) -> None:
self.queue: asyncio.Queue[str] = asyncio.Queue()
self.worker_task: asyncio.Task[None] | None = None
self.http: httpx.AsyncClient | None = None
async def start(self) -> None:
init_db()
self.http = httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=10.0))
with connect_db() as db:
pending = db.execute("SELECT id FROM generations WHERE status='queued' ORDER BY created_at").fetchall()
for row in pending:
self.queue.put_nowait(row["id"])
self.worker_task = asyncio.create_task(self.run())
async def close(self) -> None:
if self.worker_task:
self.worker_task.cancel()
try:
await self.worker_task
except asyncio.CancelledError:
pass
if self.http:
await self.http.aclose()
async def run(self) -> None:
while True:
job_id = await self.queue.get()
try:
await self.process(job_id)
finally:
self.queue.task_done()
async def process(self, job_id: str) -> None:
with connect_db() as db:
changed = db.execute(
"UPDATE generations SET status='running', updated_at=? WHERE id=? AND status='queued'",
(now_iso(), job_id),
).rowcount
if not changed:
return
job = db.execute("SELECT * FROM generations WHERE id=?", (job_id,)).fetchone()
ref = db.execute("SELECT * FROM references_audio WHERE id=?", (job["reference_id"],)).fetchone()
if not ref:
self.fail(job_id, "Selected reference audio no longer exists")
return
audio_bytes = (REFERENCES_DIR / ref["filename"]).read_bytes()
request_body = {
"text": job["text"],
"references": [{"audio": audio_bytes, "text": ref["transcript"]}],
"format": "mp3",
}
try:
assert self.http is not None
response = await self.http.post(
job["tts_url"] or active_tts_url(),
params={"format": "msgpack"},
content=msgpack.packb(request_body, use_bin_type=True),
headers={
"Authorization": f"Bearer {FISH_SPEECH_API_KEY}",
"Content-Type": "application/msgpack",
},
)
if response.is_error:
detail = response.text.strip()
self.fail(job_id, f"Fish Speech returned HTTP {response.status_code}" + (f": {detail[:500]}" if detail else ""))
return
if not response.content:
self.fail(job_id, "Fish Speech returned an empty audio response")
return
with connect_db() as db:
filename = f"{job_id}.mp3"
changed = db.execute(
"UPDATE generations SET status='completed', filename=?, error=NULL, updated_at=? WHERE id=? AND status='running'",
(filename, now_iso(), job_id),
).rowcount
if not changed:
return
(GENERATIONS_DIR / filename).write_bytes(response.content)
except httpx.TimeoutException:
self.fail(job_id, "Fish Speech request timed out")
except httpx.HTTPError as exc:
self.fail(job_id, f"Could not reach Fish Speech: {exc}")
except Exception as exc:
self.fail(job_id, f"Generation failed: {exc}")
@staticmethod
def fail(job_id: str, message: str) -> None:
with connect_db() as db:
db.execute(
"UPDATE generations SET status='failed', error=?, updated_at=? WHERE id=? AND status='running'",
(message[:1000], now_iso(), job_id),
)
job_queue = JobQueue()
@asynccontextmanager
async def lifespan(_: FastAPI):
await job_queue.start()
try:
yield
finally:
await job_queue.close()
app = FastAPI(title="Fish Speech Studio", lifespan=lifespan)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/tts-endpoints")
def list_tts_endpoints() -> dict[str, Any]:
current = active_tts_url()
with connect_db() as db:
rows = db.execute("SELECT * FROM tts_endpoints ORDER BY created_at DESC").fetchall()
return {
"items": [{"id": row["id"], "url": row["url"], "created_at": row["created_at"], "active": row["url"] == current} for row in rows],
"active_url": current,
}
@app.post("/api/tts-endpoints/test")
async def test_tts_endpoint(request: TtsUrlRequest) -> dict[str, str | bool]:
url, message = await probe_tts_url(request.url)
return {"ok": True, "url": url, "message": message}
@app.post("/api/tts-endpoints", status_code=201)
async def save_tts_endpoint(request: TtsUrlRequest) -> dict[str, Any]:
url, message = await probe_tts_url(request.url)
endpoint_id = str(uuid.uuid4())
with connect_db() as db:
db.execute(
"INSERT INTO tts_endpoints(id,url,created_at) VALUES(?,?,?) ON CONFLICT(url) DO NOTHING",
(endpoint_id, url, now_iso()),
)
row = db.execute("SELECT * FROM tts_endpoints WHERE url=?", (url,)).fetchone()
db.execute(
"INSERT INTO app_settings(key,value) VALUES('active_tts_url',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(url,),
)
return {"id": row["id"], "url": row["url"], "created_at": row["created_at"], "active": True, "message": message}
@app.post("/api/tts-endpoints/{endpoint_id}/activate")
def activate_tts_endpoint(endpoint_id: str) -> dict[str, Any]:
with connect_db() as db:
row = db.execute("SELECT * FROM tts_endpoints WHERE id=?", (endpoint_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Saved Fish Speech URL not found")
db.execute(
"INSERT INTO app_settings(key,value) VALUES('active_tts_url',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(row["url"],),
)
return {"id": row["id"], "url": row["url"], "created_at": row["created_at"], "active": True}
@app.get("/api/references")
def list_references() -> list[dict[str, Any]]:
with connect_db() as db:
rows = db.execute("SELECT * FROM references_audio ORDER BY created_at DESC").fetchall()
return [reference_json(row) for row in rows]
def normalize_recording(audio: bytes, suffix: str) -> bytes:
if not shutil.which("ffmpeg"):
raise HTTPException(status_code=503, detail="ffmpeg is required to normalize browser recordings")
with tempfile.TemporaryDirectory(dir=DATA_DIR) as temp_dir:
source = Path(temp_dir) / f"recording{suffix}"
target = Path(temp_dir) / "reference.wav"
source.write_bytes(audio)
result = subprocess.run(
["ffmpeg", "-nostdin", "-y", "-i", str(source), "-ac", "1", "-ar", "44100", "-c:a", "pcm_s16le", str(target)],
capture_output=True,
timeout=60,
)
if result.returncode != 0 or not target.exists():
raise HTTPException(status_code=400, detail="Could not decode the browser recording")
return target.read_bytes()
@app.post("/api/references", status_code=201)
async def create_reference(
file: UploadFile = File(...),
transcript: str = Form(...),
name: str = Form(""),
recording: bool = Form(False),
) -> dict[str, Any]:
transcript = transcript.strip()
if not transcript:
raise HTTPException(status_code=422, detail="Reference transcript is required")
audio = await file.read(MAX_REFERENCE_BYTES + 1)
if len(audio) > MAX_REFERENCE_BYTES:
raise HTTPException(status_code=413, detail=f"Audio must be at most {MAX_REFERENCE_BYTES // (1024 * 1024)} MiB")
if recording:
suffix = ".webm" if "webm" in (file.content_type or "") else ".ogg"
audio = normalize_recording(audio, suffix)
elif not (audio.startswith(b"RIFF") and audio[8:12] == b"WAVE"):
raise HTTPException(status_code=415, detail="Upload a WAV file")
if len(audio) > MAX_REFERENCE_BYTES:
raise HTTPException(status_code=413, detail="Normalized recording is too large")
reference_id = str(uuid.uuid4())
filename = f"{reference_id}.wav"
(REFERENCES_DIR / filename).write_bytes(audio)
display_name = name.strip() or Path(file.filename or "Reference").stem or "Reference"
created_at = now_iso()
with connect_db() as db:
db.execute(
"INSERT INTO references_audio(id,name,transcript,filename,created_at) VALUES(?,?,?,?,?)",
(reference_id, display_name[:120], transcript, filename, created_at),
)
row = db.execute("SELECT * FROM references_audio WHERE id=?", (reference_id,)).fetchone()
return reference_json(row)
@app.get("/api/references/{reference_id}/audio")
def reference_audio(reference_id: str) -> FileResponse:
row = get_reference(reference_id)
if not row:
raise HTTPException(status_code=404, detail="Reference not found")
return FileResponse(REFERENCES_DIR / row["filename"], media_type="audio/wav", filename=row["filename"])
@app.post("/api/generations", status_code=202)
def create_generation(request: CreateGeneration) -> dict[str, Any]:
text_value = request.text.strip()
if not text_value:
raise HTTPException(status_code=422, detail="Text to generate is required")
if not get_reference(request.reference_id):
raise HTTPException(status_code=404, detail="Selected reference was not found")
job_id = str(uuid.uuid4())
timestamp = now_iso()
tts_url = active_tts_url()
with connect_db() as db:
db.execute(
"INSERT INTO generations(id,text,reference_id,tts_url,status,created_at,updated_at) VALUES(?,?,?,?,'queued',?,?)",
(job_id, text_value, request.reference_id, tts_url, timestamp, timestamp),
)
job_queue.queue.put_nowait(job_id)
return generation_json(get_generation(job_id))
@app.get("/api/generations")
def list_generations(page: int = 1, page_size: int = PAGE_SIZE) -> dict[str, Any]:
if page < 1:
raise HTTPException(status_code=422, detail="Page must be at least 1")
if page_size != PAGE_SIZE:
raise HTTPException(status_code=422, detail=f"Page size is fixed at {PAGE_SIZE}")
offset = (page - 1) * PAGE_SIZE
with connect_db() as db:
total = db.execute("SELECT COUNT(*) FROM generations").fetchone()[0]
rows = db.execute(
"SELECT * FROM generations ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?",
(PAGE_SIZE, offset),
).fetchall()
return {"items": [generation_json(row) for row in rows], "page": page, "page_size": PAGE_SIZE, "total": total, "pages": (total + PAGE_SIZE - 1) // PAGE_SIZE}
@app.get("/api/generations/{job_id}")
def generation_status(job_id: str) -> dict[str, Any]:
row = get_generation(job_id)
if not row:
raise HTTPException(status_code=404, detail="Generation not found")
return generation_json(row)
@app.post("/api/generations/{job_id}/cancel")
def cancel_generation(job_id: str) -> dict[str, Any]:
row = get_generation(job_id)
if not row:
raise HTTPException(status_code=404, detail="Generation not found")
if row["status"] == "cancelled":
return generation_json(row)
if row["status"] in {"completed", "failed"}:
raise HTTPException(status_code=409, detail=f"Cannot cancel a {row['status']} generation")
with connect_db() as db:
changed = db.execute(
"UPDATE generations SET status='cancelled', updated_at=? WHERE id=? AND status IN ('queued','running')",
(now_iso(), job_id),
).rowcount
updated = get_generation(job_id)
if not changed and updated["status"] in {"completed", "failed"}:
raise HTTPException(status_code=409, detail=f"Cannot cancel a {updated['status']} generation")
return generation_json(updated)
@app.get("/api/generations/{job_id}/audio")
def generated_audio(job_id: str) -> FileResponse:
row = get_generation(job_id)
if not row or not row["filename"]:
raise HTTPException(status_code=404, detail="Generated audio not found")
return FileResponse(GENERATIONS_DIR / row["filename"], media_type="audio/mpeg", filename=row["filename"])
frontend_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist"
if frontend_dist.exists():
app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="frontend")