Build Fish Speech web studio
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
+487
@@ -0,0 +1,487 @@
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user