Build Fish Speech web studio
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
FISH_SPEECH_URL=http://192.168.1.42:8080/v1/tts
|
||||||
|
FISH_SPEECH_API_KEY=YOUR_API_KEY
|
||||||
|
DATA_DIR=/data
|
||||||
|
MAX_REFERENCE_BYTES=26214400
|
||||||
|
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/test-results/
|
||||||
|
frontend/playwright-report/
|
||||||
|
frontend/tsconfig.tsbuildinfo
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-alpine AS frontend-build
|
||||||
|
WORKDIR /web
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 DATA_DIR=/data
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY app ./app
|
||||||
|
COPY --from=frontend-build /web/dist ./frontend/dist
|
||||||
|
RUN mkdir -p /data
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Fish Speech Studio
|
||||||
|
|
||||||
|
A small Dockerized web app for saving voice references and generating speech through a separate Fish Speech API server.
|
||||||
|
|
||||||
|
## Run with Docker
|
||||||
|
|
||||||
|
1. Run `docker compose up --build`.
|
||||||
|
2. Open `http://localhost:8000`, enter the CUDA PC's reachable Fish Speech address in the **TTS Host** control, and select **Test connection**.
|
||||||
|
3. After the server check passes, select **Save & use**. Saved hosts remain available in the URL dropdown.
|
||||||
|
|
||||||
|
The app writes its SQLite database and audio files under `/data`; Compose mounts this directory as a persistent volume. For browser microphone recording from another device, serve the app through HTTPS (for example, with a TLS reverse proxy). `localhost` works without HTTPS.
|
||||||
|
|
||||||
|
Queued jobs can be cancelled before they reach Fish Speech. Cancelling a running job hides and discards its eventual result, but the Fish Speech request may continue using the GPU until it returns; the app waits for it to finish before starting the next queued job.
|
||||||
|
|
||||||
|
The Fish Speech host must listen on a network interface reachable by the Docker host, and its firewall must permit the configured API port. If the app container is on a different machine, use the CUDA PC's LAN/VPN hostname or IP, not `localhost`.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
Backend: `python -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt && uvicorn app.main:app --reload`
|
||||||
|
|
||||||
|
Frontend: `cd frontend && npm install && npm run dev`. Vite proxies `/api` requests to `http://localhost:8000`.
|
||||||
|
|
||||||
|
For recorded browser audio, the backend uses `ffmpeg` to normalize the browser's recording format to WAV. Docker includes it; install it locally for development.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- `FISH_SPEECH_URL`: Fish Speech `/v1/tts` endpoint (default `http://host.docker.internal:8080/v1/tts`).
|
||||||
|
- `FISH_SPEECH_API_KEY`: bearer key (default `YOUR_API_KEY`, matching the Fish Speech client default).
|
||||||
|
- `DATA_DIR`: persistent app data directory (default `/data` in Docker).
|
||||||
|
- `MAX_REFERENCE_BYTES`: maximum upload size (default 25 MiB).
|
||||||
|
|
||||||
|
The UI accepts either a base host such as `http://192.168.1.42:8080` or a full endpoint such as `http://192.168.1.42:8080/v1/tts`. It checks the Fish Speech `/v1/health` endpoint before saving a URL. The saved URL becomes active for new jobs; queued jobs keep the URL they were created with.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Backend tests: `.venv/bin/python -m pytest`
|
||||||
|
|
||||||
|
Frontend tests: `cd frontend && npm test`
|
||||||
|
|
||||||
|
Browser smoke tests: install Playwright Chromium with `cd frontend && npx playwright install chromium`, then run `npm run test:e2e`.
|
||||||
|
|
||||||
|
Production frontend build: `cd frontend && npm run build`
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
fish-speech-studio:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
DATA_DIR: /data
|
||||||
|
FISH_SPEECH_URL: ${FISH_SPEECH_URL:-http://host.docker.internal:8080/v1/tts}
|
||||||
|
FISH_SPEECH_API_KEY: ${FISH_SPEECH_API_KEY:-YOUR_API_KEY}
|
||||||
|
MAX_REFERENCE_BYTES: ${MAX_REFERENCE_BYTES:-26214400}
|
||||||
|
volumes:
|
||||||
|
- fish_speech_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
fish_speech_data:
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
test('shows the generation workspace and handles an empty library', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Generated audio' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Your next favorite take starts here.')).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: /record/i })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('records a browser microphone sample and offers a preview', async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, 'mediaDevices', {
|
||||||
|
configurable: true,
|
||||||
|
value: { getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }) },
|
||||||
|
})
|
||||||
|
class TestMediaRecorder {
|
||||||
|
state = 'inactive'
|
||||||
|
mimeType = 'audio/webm'
|
||||||
|
ondataavailable: ((event: { data: Blob }) => void) | null = null
|
||||||
|
onstop: (() => void) | null = null
|
||||||
|
static isTypeSupported() { return true }
|
||||||
|
constructor(_: unknown) {}
|
||||||
|
start() { this.state = 'recording' }
|
||||||
|
stop() {
|
||||||
|
this.state = 'inactive'
|
||||||
|
this.ondataavailable?.({ data: new Blob(['fake audio'], { type: this.mimeType }) })
|
||||||
|
this.onstop?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.defineProperty(window, 'MediaRecorder', { configurable: true, value: TestMediaRecorder })
|
||||||
|
})
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByRole('tab', { name: /record/i }).click()
|
||||||
|
await page.getByRole('button', { name: /start recording/i }).click()
|
||||||
|
await expect(page.getByText('Recording in progress')).toBeVisible()
|
||||||
|
await page.waitForTimeout(700)
|
||||||
|
await page.getByRole('button', { name: /stop recording/i }).click()
|
||||||
|
await expect(page.getByLabel('Recording preview')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancels a queued generation from the library', async ({ page }) => {
|
||||||
|
const job = { id: 'job-1', text: 'A queued sample', reference_id: 'ref-1', status: 'queued', error: null, created_at: '2026-10-27T12:00:00Z', updated_at: '2026-10-27T12:00:00Z', audio_url: null }
|
||||||
|
await page.route('**/api/references', (route) => route.fulfill({ json: [] }))
|
||||||
|
await page.route('**/api/generations*', (route) => route.fulfill({ json: { items: [job], page: 1, page_size: 50, total: 1, pages: 1 } }))
|
||||||
|
await page.route('**/api/generations/job-1/cancel', async (route) => route.fulfill({ json: { ...job, status: 'cancelled' } }))
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByText('A queued sample')).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: /cancel generation/i }).click()
|
||||||
|
await expect(page.getByText('CANCELLED', { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#f3f0e9" />
|
||||||
|
<meta name="description" content="A private workspace for Fish Speech voice references and generated audio." />
|
||||||
|
<title>Fish Speech Studio</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
Generated
+3129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "fish-speech-studio-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.51.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"jsdom": "^26.0.0",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.1.0",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
use: { baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8000', permissions: ['microphone'], launchOptions: { args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] } },
|
||||||
|
webServer: { command: 'cd .. && python -m uvicorn app.main:app --port 8000', url: 'http://127.0.0.1:8000/api/health', reuseExistingServer: !process.env.CI, timeout: 30_000, env: { DATA_DIR: './data' } },
|
||||||
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
vi.mock('./api', () => ({
|
||||||
|
getReferences: vi.fn().mockResolvedValue([{ id: 'r1', name: 'Studio voice', transcript: 'Hello there.', created_at: '2026-10-27T14:00:00Z', audio_url: '/ref.wav' }]),
|
||||||
|
getGenerations: vi.fn().mockResolvedValue({ items: [], page: 1, page_size: 50, total: 0, pages: 0 }),
|
||||||
|
getGeneration: vi.fn(),
|
||||||
|
getTtsEndpoints: vi.fn().mockResolvedValue({ items: [], active_url: 'http://host.docker.internal:8080/v1/tts' }),
|
||||||
|
testTtsEndpoint: vi.fn().mockResolvedValue({ ok: true, url: 'http://192.168.1.42:8080/v1/tts', message: 'Fish Speech server is reachable and exposes the /v1/tts endpoint.' }),
|
||||||
|
saveTtsEndpoint: vi.fn().mockResolvedValue({ id: 'tts-1', url: 'http://192.168.1.42:8080/v1/tts', created_at: '2026-10-28T14:00:00Z', active: true }),
|
||||||
|
activateTtsEndpoint: vi.fn(),
|
||||||
|
saveReference: vi.fn().mockResolvedValue({ id: 'r2', name: 'New voice', transcript: 'sample words', created_at: '2026-10-27T14:00:00Z', audio_url: '/ref2.wav' }),
|
||||||
|
createGeneration: vi.fn().mockResolvedValue({ id: 'g1', status: 'queued' }),
|
||||||
|
cancelGeneration: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('Fish Speech Studio', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('loads saved references and lets the user submit text to generate', async () => {
|
||||||
|
const api = await import('./api')
|
||||||
|
render(<App />)
|
||||||
|
expect(await screen.findByRole('button', { name: /studio voice/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Play reference audio: Studio voice')).toHaveAttribute('src', '/ref.wav')
|
||||||
|
fireEvent.change(screen.getByLabelText('TEXT TO GENERATE'), { target: { value: 'Good morning.' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /generate audio/i }))
|
||||||
|
await waitFor(() => expect(api.createGeneration).toHaveBeenCalledWith('Good morning.', 'r1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires a transcript before saving an uploaded reference', async () => {
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /save reference/i }))
|
||||||
|
expect(await screen.findByText('Enter the words spoken in the reference recording.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers upload and recording reference modes', async () => {
|
||||||
|
render(<App />)
|
||||||
|
expect(await screen.findByLabelText('Reference WAV file')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /record/i }))
|
||||||
|
expect(screen.getByRole('button', { name: /start recording/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets a user cancel an active job', async () => {
|
||||||
|
const api = await import('./api')
|
||||||
|
vi.mocked(api.getGenerations).mockResolvedValueOnce({
|
||||||
|
items: [{ id: 'g-running', text: 'A running take', reference_id: 'r1', status: 'running', error: null, created_at: '2026-10-27T14:01:00Z', updated_at: '2026-10-27T14:01:00Z', audio_url: null }],
|
||||||
|
page: 1, page_size: 50, total: 1, pages: 1,
|
||||||
|
})
|
||||||
|
vi.mocked(api.cancelGeneration).mockResolvedValue({
|
||||||
|
id: 'g-running', text: 'A running take', reference_id: 'r1', status: 'cancelled', error: null, created_at: '2026-10-27T14:01:00Z', updated_at: '2026-10-27T14:02:00Z', audio_url: null,
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /cancel generation: a running take/i }))
|
||||||
|
await waitFor(() => expect(api.cancelGeneration).toHaveBeenCalledWith('g-running'))
|
||||||
|
expect(await screen.findByText('CANCELLED')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tests a TTS URL before enabling save and use', async () => {
|
||||||
|
const api = await import('./api')
|
||||||
|
render(<App />)
|
||||||
|
const input = await screen.findByLabelText('Fish Speech API URL')
|
||||||
|
fireEvent.change(input, { target: { value: 'http://192.168.1.42:8080' } })
|
||||||
|
expect(screen.getByRole('button', { name: /save & use/i })).toBeDisabled()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
await waitFor(() => expect(api.testTtsEndpoint).toHaveBeenCalledWith('http://192.168.1.42:8080'))
|
||||||
|
expect(await screen.findByText('Fish Speech server is reachable and exposes the /v1/tts endpoint.')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save & use/i }))
|
||||||
|
await waitFor(() => expect(api.saveTtsEndpoint).toHaveBeenCalledWith('http://192.168.1.42:8080/v1/tts'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { activateTtsEndpoint, cancelGeneration, createGeneration, getGeneration, getGenerations, getReferences, getTtsEndpoints, saveReference, saveTtsEndpoint, testTtsEndpoint } from './api'
|
||||||
|
import type { Generation, GenerationPage, Reference, TtsEndpoint } from './types'
|
||||||
|
|
||||||
|
function Icon({ name, size = 18 }: { name: string; size?: number }) {
|
||||||
|
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const }
|
||||||
|
const paths: Record<string, React.ReactNode> = {
|
||||||
|
wave: <><path d="M2 10v4M6 6v12M10 3v18M14 8v8M18 5v14M22 10v4" /></>,
|
||||||
|
mic: <><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></>,
|
||||||
|
upload: <><path d="M12 16V4m-4 4 4-4 4 4"/><path d="M4 16v4h16v-4"/></>,
|
||||||
|
play: <path d="m9 6 10 6-10 6z" fill="currentColor" stroke="none"/>,
|
||||||
|
stop: <><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" stroke="none"/></>,
|
||||||
|
arrow: <><path d="M5 12h14m-6-6 6 6-6 6"/></>,
|
||||||
|
plus: <><path d="M12 5v14M5 12h14"/></>,
|
||||||
|
spark: <><path d="m12 3 1.9 5.8L20 11l-6.1 2.2L12 19l-1.9-5.8L4 11l6.1-2.2L12 3Z"/><path d="m19 14 1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3Z"/></>,
|
||||||
|
cancel: <><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6m0-6-6 6"/></>,
|
||||||
|
chevron: <><path d="m9 18 6-6-6-6"/></>,
|
||||||
|
}
|
||||||
|
return <svg {...common} aria-hidden="true">{paths[name]}</svg>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(new Date(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [references, setReferences] = useState<Reference[]>([])
|
||||||
|
const [selectedReference, setSelectedReference] = useState('')
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [history, setHistory] = useState<GenerationPage | null>(null)
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [savingReference, setSavingReference] = useState(false)
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [mode, setMode] = useState<'upload' | 'record'>('upload')
|
||||||
|
const [file, setFile] = useState<File | null>(null)
|
||||||
|
const [transcript, setTranscript] = useState('')
|
||||||
|
const [referenceName, setReferenceName] = useState('')
|
||||||
|
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
||||||
|
const [recording, setRecording] = useState(false)
|
||||||
|
const [recordError, setRecordError] = useState('')
|
||||||
|
const [recordSeconds, setRecordSeconds] = useState(0)
|
||||||
|
const [recordPreviewUrl, setRecordPreviewUrl] = useState('')
|
||||||
|
const [ttsEndpoints, setTtsEndpoints] = useState<TtsEndpoint[]>([])
|
||||||
|
const [ttsUrl, setTtsUrl] = useState('')
|
||||||
|
const [testedTtsUrl, setTestedTtsUrl] = useState('')
|
||||||
|
const [ttsTestState, setTtsTestState] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle')
|
||||||
|
const [ttsTestMessage, setTtsTestMessage] = useState('')
|
||||||
|
const [savingTtsUrl, setSavingTtsUrl] = useState(false)
|
||||||
|
const recorder = useRef<MediaRecorder | null>(null)
|
||||||
|
const stream = useRef<MediaStream | null>(null)
|
||||||
|
const chunks = useRef<Blob[]>([])
|
||||||
|
|
||||||
|
const refreshReferences = useCallback(async () => {
|
||||||
|
const next = await getReferences()
|
||||||
|
setReferences(next)
|
||||||
|
setSelectedReference((current) => current || next[0]?.id || '')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshHistory = useCallback(async (requestedPage = page) => {
|
||||||
|
const result = await getGenerations(requestedPage)
|
||||||
|
setHistory(result)
|
||||||
|
if (result.pages > 0 && requestedPage > result.pages) {
|
||||||
|
setPage(result.pages)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getReferences().then((next) => {
|
||||||
|
setReferences(next)
|
||||||
|
setSelectedReference(next[0]?.id || '')
|
||||||
|
}).catch((err: Error) => setError(err.message))
|
||||||
|
getGenerations(1).then(setHistory).catch((err: Error) => setError(err.message))
|
||||||
|
getTtsEndpoints().then((result) => {
|
||||||
|
setTtsEndpoints(result.items)
|
||||||
|
setTtsUrl(result.active_url)
|
||||||
|
}).catch((err: Error) => setTtsTestMessage(err.message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const active = history?.items.filter((job) => job.status === 'queued' || job.status === 'running') ?? []
|
||||||
|
if (!active.length) return
|
||||||
|
const timer = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const updates = await Promise.all(active.map((job) => getGeneration(job.id)))
|
||||||
|
setHistory((current) => current ? ({ ...current, items: current.items.map((job) => updates.find((u) => u.id === job.id) ?? job) }) : current)
|
||||||
|
} catch (err) { setError((err as Error).message) }
|
||||||
|
}, 1800)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [history])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recording) return
|
||||||
|
const timer = window.setInterval(() => setRecordSeconds((seconds) => seconds + 1), 1000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [recording])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recordedBlob) { setRecordPreviewUrl(''); return }
|
||||||
|
const url = URL.createObjectURL(recordedBlob)
|
||||||
|
setRecordPreviewUrl(url)
|
||||||
|
return () => URL.revokeObjectURL(url)
|
||||||
|
}, [recordedBlob])
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
stream.current?.getTracks().forEach((track) => track.stop())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const beginRecording = async () => {
|
||||||
|
setRecordError('')
|
||||||
|
setRecordedBlob(null)
|
||||||
|
setRecordSeconds(0)
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
||||||
|
setRecordError('This browser does not support microphone recording. Try uploading a WAV file instead.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
stream.current = mediaStream
|
||||||
|
const preferred = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'].find((type) => MediaRecorder.isTypeSupported(type))
|
||||||
|
const activeRecorder = new MediaRecorder(mediaStream, preferred ? { mimeType: preferred } : undefined)
|
||||||
|
recorder.current = activeRecorder
|
||||||
|
chunks.current = []
|
||||||
|
activeRecorder.ondataavailable = (event) => { if (event.data.size) chunks.current.push(event.data) }
|
||||||
|
activeRecorder.onstop = () => {
|
||||||
|
setRecordedBlob(new Blob(chunks.current, { type: activeRecorder.mimeType || 'audio/webm' }))
|
||||||
|
mediaStream.getTracks().forEach((track) => track.stop())
|
||||||
|
}
|
||||||
|
activeRecorder.start()
|
||||||
|
setRecording(true)
|
||||||
|
} catch (err) {
|
||||||
|
setRecordError(err instanceof DOMException && err.name === 'NotAllowedError'
|
||||||
|
? 'Microphone access was denied. Allow microphone access in your browser, or upload a WAV file.'
|
||||||
|
: 'Could not start the microphone. Check that one is connected and available.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
if (recorder.current?.state === 'recording') recorder.current.stop()
|
||||||
|
setRecording(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveReferenceFromForm = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (!transcript.trim()) { setError('Enter the words spoken in the reference recording.'); return }
|
||||||
|
const source = mode === 'upload' ? file : recordedBlob
|
||||||
|
if (!source) { setError(mode === 'upload' ? 'Choose a WAV file first.' : 'Record a reference before saving.'); return }
|
||||||
|
setSavingReference(true)
|
||||||
|
try {
|
||||||
|
const filename = mode === 'upload' ? (file?.name || 'reference.wav') : `recording.${recordedBlob?.type.includes('ogg') ? 'ogg' : 'webm'}`
|
||||||
|
const created = await saveReference(source, filename, transcript.trim(), referenceName.trim(), mode === 'record')
|
||||||
|
await refreshReferences()
|
||||||
|
setSelectedReference(created.id)
|
||||||
|
setFile(null); setRecordedBlob(null); setTranscript(''); setReferenceName('')
|
||||||
|
setNotice('Reference saved and ready to use.')
|
||||||
|
} catch (err) { setError((err as Error).message) }
|
||||||
|
finally { setSavingReference(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const generate = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!text.trim() || !selectedReference) return
|
||||||
|
setError(''); setGenerating(true)
|
||||||
|
try {
|
||||||
|
await createGeneration(text.trim(), selectedReference)
|
||||||
|
setText('')
|
||||||
|
setPage(1)
|
||||||
|
await refreshHistory(1)
|
||||||
|
setNotice('Generation added to the queue.')
|
||||||
|
} catch (err) { setError((err as Error).message) }
|
||||||
|
finally { setGenerating(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = async (job: Generation) => {
|
||||||
|
try {
|
||||||
|
const updated = await cancelGeneration(job.id)
|
||||||
|
setHistory((current) => current ? { ...current, items: current.items.map((item) => item.id === job.id ? updated : item) } : current)
|
||||||
|
setNotice('Generation cancelled. Fish Speech may finish its current GPU work in the background.')
|
||||||
|
} catch (err) { setError((err as Error).message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const testTtsHost = async () => {
|
||||||
|
setTtsTestState('testing')
|
||||||
|
setTtsTestMessage('Checking the server and its /v1/tts API…')
|
||||||
|
setTestedTtsUrl('')
|
||||||
|
try {
|
||||||
|
const result = await testTtsEndpoint(ttsUrl)
|
||||||
|
setTtsUrl(result.url)
|
||||||
|
setTestedTtsUrl(result.url)
|
||||||
|
setTtsTestState('ok')
|
||||||
|
setTtsTestMessage(result.message)
|
||||||
|
} catch (err) {
|
||||||
|
setTtsTestState('error')
|
||||||
|
setTtsTestMessage((err as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveAndUseTtsHost = async () => {
|
||||||
|
if (!testedTtsUrl || testedTtsUrl !== ttsUrl) return
|
||||||
|
setSavingTtsUrl(true)
|
||||||
|
try {
|
||||||
|
const saved = await saveTtsEndpoint(ttsUrl)
|
||||||
|
setTtsEndpoints((current) => [saved, ...current.filter((item) => item.url !== saved.url)])
|
||||||
|
setTtsUrl(saved.url)
|
||||||
|
setTestedTtsUrl(saved.url)
|
||||||
|
setTtsTestState('ok')
|
||||||
|
setTtsTestMessage('Connection saved and now active for new generations.')
|
||||||
|
} catch (err) {
|
||||||
|
setTtsTestState('error')
|
||||||
|
setTtsTestMessage((err as Error).message)
|
||||||
|
} finally { setSavingTtsUrl(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSavedTtsHost = async (endpoint: TtsEndpoint) => {
|
||||||
|
setSavingTtsUrl(true)
|
||||||
|
try {
|
||||||
|
const active = await activateTtsEndpoint(endpoint.id)
|
||||||
|
setTtsEndpoints((current) => current.map((item) => ({ ...item, active: item.id === active.id })))
|
||||||
|
setTtsUrl(active.url)
|
||||||
|
setTestedTtsUrl(active.url)
|
||||||
|
setTtsTestState('ok')
|
||||||
|
setTtsTestMessage('Saved connection is now active for new generations.')
|
||||||
|
} catch (err) {
|
||||||
|
setTtsTestState('error')
|
||||||
|
setTtsTestMessage((err as Error).message)
|
||||||
|
} finally { setSavingTtsUrl(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCount = history?.items.filter((job) => job.status === 'queued' || job.status === 'running').length ?? 0
|
||||||
|
const selected = references.find((item) => item.id === selectedReference)
|
||||||
|
const savedTtsEndpoint = ttsEndpoints.find((endpoint) => endpoint.url === ttsUrl)
|
||||||
|
|
||||||
|
return <main className="app-shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<a className="brand" href="#top" aria-label="Fish Speech Studio home"><span className="brand-mark"><Icon name="wave" size={20}/></span><span>fish<span className="brand-light">speech</span><small>LOCAL STUDIO</small></span></a>
|
||||||
|
<div className="topbar-right"><span className="server-indicator"><i /> FISH SPEECH SERVER</span><span className="avatar">FS</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="content" id="top">
|
||||||
|
<section className="intro">
|
||||||
|
<div><div className="eyebrow">VOICE WORKSPACE <span>•</span> S2 PRO</div><h1>Make something <em>heard.</em></h1><p>Shape a voice, give it words, and keep every take close at hand.</p></div>
|
||||||
|
<div className="intro-wave" aria-hidden="true">▁ ▃ ▅ ▂ ▆ ▃ ▇ ▂ ▅ ▄ ▆ ▂ ▄ ▇ ▃ ▅</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="connection-bar" aria-label="Fish Speech connection">
|
||||||
|
<div className="connection-title"><span className="connection-icon"><Icon name="wave" size={17}/></span><div><strong>TTS HOST</strong><small>{ttsEndpoints.find((endpoint) => endpoint.active)?.url === ttsUrl ? 'SAVED CONNECTION' : 'CONFIGURE SERVER'}</small></div></div>
|
||||||
|
<div className="connection-control"><label className="visually-hidden" htmlFor="tts-host">Fish Speech API URL</label><input id="tts-host" list="saved-tts-hosts" value={ttsUrl} onChange={(event) => { setTtsUrl(event.target.value); setTestedTtsUrl(''); setTtsTestState('idle'); setTtsTestMessage('') }} placeholder="http://192.168.1.42:8080/v1/tts"/><datalist id="saved-tts-hosts">{ttsEndpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.url}/>)}</datalist></div>
|
||||||
|
<button className="connection-button test-connection" onClick={testTtsHost} disabled={!ttsUrl.trim() || ttsTestState === 'testing' || savingTtsUrl}>{ttsTestState === 'testing' ? <><span className="spinner small"/> Testing</> : 'Test connection'}</button>
|
||||||
|
{savedTtsEndpoint ? <button className="connection-button use-connection" onClick={() => useSavedTtsHost(savedTtsEndpoint)} disabled={savedTtsEndpoint.active || savingTtsUrl}>{savedTtsEndpoint.active ? 'Active' : 'Use saved'}</button> : <button className="connection-button save-connection" onClick={saveAndUseTtsHost} disabled={ttsTestState !== 'ok' || testedTtsUrl !== ttsUrl || savingTtsUrl}>{savingTtsUrl ? 'Saving…' : 'Save & use'}</button>}
|
||||||
|
<div className={`connection-feedback connection-${ttsTestState}`} role={ttsTestState === 'error' ? 'alert' : 'status'}>{ttsTestState === 'ok' && <span className="connection-ok-dot"/>}{ttsTestMessage || 'Choose a saved host or enter a Fish Speech API URL.'}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{(error || notice) && <div className={`toast ${error ? 'toast-error' : ''}`} role={error ? 'alert' : 'status'}>{error || notice}<button onClick={() => { setError(''); setNotice('') }} aria-label="Dismiss">×</button></div>}
|
||||||
|
|
||||||
|
<section className="work-grid">
|
||||||
|
<article className="panel reference-panel">
|
||||||
|
<div className="panel-head"><div><div className="step-label">01 <span>VOICE SOURCE</span></div><h2>Reference audio</h2></div><span className="count-pill">{references.length} SAVED</span></div>
|
||||||
|
<div className="reference-list">
|
||||||
|
{references.length ? references.map((reference) => <div key={reference.id} className={`reference-option ${selectedReference === reference.id ? 'is-selected' : ''}`}>
|
||||||
|
<button type="button" className="reference-select" aria-pressed={selectedReference === reference.id} onClick={() => setSelectedReference(reference.id)}>
|
||||||
|
<span className="ref-icon"><Icon name="wave" size={17}/></span><span className="ref-copy"><strong>{reference.name}</strong><small>{reference.transcript}</small></span>{selectedReference === reference.id && <span className="selected-mark">✓</span>}
|
||||||
|
</button>
|
||||||
|
<audio controls preload="none" src={reference.audio_url} aria-label={`Play reference audio: ${reference.name}`}/>
|
||||||
|
</div>) : <div className="empty-reference"><span className="empty-wave"><Icon name="wave" size={20}/></span><strong>No voices saved yet</strong><span>Upload a WAV or record a sample to start.</span></div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="new-reference">
|
||||||
|
<div className="subhead"><span><Icon name="plus" size={15}/> ADD A REFERENCE</span><span className="subhead-line"/></div>
|
||||||
|
<div className="segmented" role="tablist" aria-label="Reference source">
|
||||||
|
<button role="tab" aria-selected={mode === 'upload'} className={mode === 'upload' ? 'active' : ''} onClick={() => { setMode('upload'); setError('') }}><Icon name="upload" size={15}/> Upload WAV</button>
|
||||||
|
<button role="tab" aria-selected={mode === 'record'} className={mode === 'record' ? 'active' : ''} onClick={() => { setMode('record'); setError('') }}><Icon name="mic" size={15}/> Record</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={saveReferenceFromForm} className="reference-form">
|
||||||
|
{mode === 'upload' ? <label className="dropzone">
|
||||||
|
<input aria-label="Reference WAV file" type="file" accept="audio/wav,.wav" onChange={(event) => setFile(event.target.files?.[0] ?? null)}/>
|
||||||
|
<span className="drop-icon"><Icon name="upload" size={19}/></span><span><strong>{file?.name ?? 'Choose a WAV file'}</strong><small>{file ? `${(file.size / 1024 / 1024).toFixed(1)} MB · WAV` : 'Drop it here or browse · max 25 MB'}</small></span>
|
||||||
|
</label> : <div className={`recorder-box ${recording ? 'recording' : ''}`}>
|
||||||
|
<button type="button" className={`record-button ${recording ? 'recording' : ''}`} onClick={recording ? stopRecording : beginRecording} aria-label={recording ? 'Stop recording' : 'Start recording'}>{recording ? <Icon name="stop" size={16}/> : <Icon name="mic" size={17}/>}</button>
|
||||||
|
<div className="recorder-copy"><strong>{recording ? 'Recording in progress' : recordedBlob ? 'Recording ready to preview' : 'Record a short voice sample'}</strong><small>{recording ? `00:${String(recordSeconds).padStart(2, '0')} · select stop when finished` : 'Allow microphone access when prompted'}</small></div><span className={`recording-dot ${recording ? 'live' : ''}`}/>
|
||||||
|
{recordedBlob && <audio className="record-preview" controls src={recordPreviewUrl} aria-label="Recording preview"/>}
|
||||||
|
{recordError && <div className="record-error" role="alert">{recordError}</div>}
|
||||||
|
</div>}
|
||||||
|
<label className="field-label" htmlFor="reference-name">REFERENCE NAME <span>OPTIONAL</span></label>
|
||||||
|
<input id="reference-name" className="text-input" placeholder="e.g. Warm narrator" value={referenceName} onChange={(event) => setReferenceName(event.target.value)}/>
|
||||||
|
<label className="field-label" htmlFor="transcript">WHAT IS SAID IN THE AUDIO <span>REQUIRED</span></label>
|
||||||
|
<textarea id="transcript" className="text-input transcript-input" placeholder="Type the exact words spoken in your reference…" value={transcript} onChange={(event) => setTranscript(event.target.value)} rows={3}/>
|
||||||
|
<button type="submit" className="secondary-button" disabled={savingReference || recording}>{savingReference ? <><span className="spinner small"/> Saving reference</> : 'Save reference'}<Icon name="arrow" size={16}/></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel generate-panel">
|
||||||
|
<div className="panel-head"><div><div className="step-label">02 <span>NEW GENERATION</span></div><h2>Give it something to say</h2></div><span className="model-tag"><span/> S2 PRO</span></div>
|
||||||
|
<form onSubmit={generate} className="generate-form">
|
||||||
|
<label className="field-label" htmlFor="voice-select">SPEAKING VOICE</label>
|
||||||
|
<div className="select-wrap"><select id="voice-select" value={selectedReference} onChange={(event) => setSelectedReference(event.target.value)} disabled={!references.length}>
|
||||||
|
{!references.length && <option value="">Save a reference to begin</option>}
|
||||||
|
{references.map((reference) => <option value={reference.id} key={reference.id}>{reference.name}</option>)}
|
||||||
|
</select><Icon name="chevron" size={16}/></div>
|
||||||
|
{selected && <div className="selected-transcript"><span>REFERENCE TRANSCRIPT</span><p>“{selected.transcript}”</p></div>}
|
||||||
|
<div className="text-entry-head"><label className="field-label" htmlFor="generation-text">TEXT TO GENERATE</label><span>{text.length.toLocaleString()} / 10,000</span></div>
|
||||||
|
<textarea id="generation-text" className="text-input generation-input" placeholder="Write or paste the words you want spoken…" value={text} onChange={(event) => setText(event.target.value)} maxLength={10000}/>
|
||||||
|
<div className="generate-footer"><span className="format-note"><span className="format-icon">MP3</span> Compressed audio · saved automatically</span><button className="primary-button" type="submit" disabled={!text.trim() || !selectedReference || generating}>{generating ? <><span className="spinner"/> Adding…</> : <><Icon name="spark" size={17}/> Generate audio</>}</button></div>
|
||||||
|
</form>
|
||||||
|
<div className="generate-hint"><span className="hint-light"/><span>{activeCount ? `${activeCount} ${activeCount === 1 ? 'generation' : 'generations'} in progress or queued` : 'Your audio will appear in the library below'}</span></div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="library-section">
|
||||||
|
<div className="library-head"><div><div className="step-label">03 <span>YOUR LIBRARY</span></div><h2>Generated audio <span className="total-count">{history?.total ?? 0}</span></h2></div><div className="sort-label"><span className="sort-dot"/> NEWEST FIRST</div></div>
|
||||||
|
{!history?.items.length ? <div className="empty-library"><div className="empty-library-mark"><Icon name="wave" size={23}/></div><strong>Your next favorite take starts here.</strong><span>Generated clips will be saved here, ready to replay.</span></div> : <>
|
||||||
|
<div className="audio-list">{history.items.map((job, index) => <article className="audio-row" key={job.id}>
|
||||||
|
<span className="row-index">{String((page - 1) * 50 + index + 1).padStart(2, '0')}</span>
|
||||||
|
<div className="audio-meta"><div className="audio-title">{job.text}</div><div className="audio-subtitle"><span>{formatDate(job.created_at)}</span><span className="meta-divider">·</span><span>{references.find((item) => item.id === job.reference_id)?.name ?? 'Reference voice'}</span></div>{job.error && <div className="job-error">{job.error}</div>}</div>
|
||||||
|
<div className={`status-chip status-${job.status}`}><span className="status-indicator"/>{job.status === 'running' ? 'GENERATING' : job.status.toUpperCase()}</div>
|
||||||
|
{job.audio_url ? <audio controls preload="none" src={job.audio_url} aria-label={`Play generated audio: ${job.text}`}/> : <div className="audio-placeholder">{job.status === 'running' || job.status === 'queued' ? <><span className="spinner small"/> {job.status === 'queued' ? 'Waiting' : 'Working'}</> : '—'}</div>}
|
||||||
|
{(job.status === 'queued' || job.status === 'running') && <button className="cancel-button" aria-label={`Cancel generation: ${job.text}`} onClick={() => cancel(job)}><Icon name="cancel" size={17}/></button>}
|
||||||
|
</article>)}</div>
|
||||||
|
<div className="pagination"><span>Showing <strong>{(page - 1) * 50 + 1}–{Math.min(page * 50, history.total)}</strong> of <strong>{history.total}</strong></span><div className="page-controls"><button aria-label="Previous page" disabled={page <= 1} onClick={() => { const next = page - 1; setPage(next); refreshHistory(next).catch((err) => setError(err.message)) }}>←</button><span>PAGE <strong>{page}</strong> OF <strong>{Math.max(1, history.pages)}</strong></span><button aria-label="Next page" disabled={page >= history.pages} onClick={() => { const next = page + 1; setPage(next); refreshHistory(next).catch((err) => setError(err.message)) }}>→</button></div></div>
|
||||||
|
</>}
|
||||||
|
</section>
|
||||||
|
<footer className="footer"><span>FISH SPEECH STUDIO <i>·</i> PRIVATE VOICE WORKSPACE</span><span>YOUR FILES STAY ON YOUR SERVER</span></footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Generation, GenerationPage, Reference, TtsEndpoint, TtsEndpointList } from './types'
|
||||||
|
|
||||||
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(url, init)
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `Request failed (${response.status})`
|
||||||
|
try {
|
||||||
|
const body = await response.json()
|
||||||
|
message = body.detail || message
|
||||||
|
} catch { /* keep status message */ }
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReferences(): Promise<Reference[]> {
|
||||||
|
return request('/api/references')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveReference(file: File | Blob, filename: string, transcript: string, name: string, recording = false): Promise<Reference> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file, filename)
|
||||||
|
form.append('transcript', transcript)
|
||||||
|
form.append('name', name)
|
||||||
|
form.append('recording', String(recording))
|
||||||
|
return request('/api/references', { method: 'POST', body: form })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGeneration(text: string, reference_id: string): Promise<Generation> {
|
||||||
|
return request('/api/generations', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text, reference_id }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGeneration(id: string): Promise<Generation> {
|
||||||
|
return request(`/api/generations/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelGeneration(id: string): Promise<Generation> {
|
||||||
|
return request(`/api/generations/${id}/cancel`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGenerations(page: number): Promise<GenerationPage> {
|
||||||
|
return request(`/api/generations?page=${page}&page_size=50`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTtsEndpoints(): Promise<TtsEndpointList> {
|
||||||
|
return request('/api/tts-endpoints')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testTtsEndpoint(url: string): Promise<{ ok: boolean; url: string; message: string }> {
|
||||||
|
return request('/api/tts-endpoints/test', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTtsEndpoint(url: string): Promise<TtsEndpoint> {
|
||||||
|
return request('/api/tts-endpoints', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateTtsEndpoint(id: string): Promise<TtsEndpoint> {
|
||||||
|
return request(`/api/tts-endpoints/${id}/activate`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode><App /></React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
import { cleanup } from '@testing-library/react'
|
||||||
|
import { afterEach } from 'vitest'
|
||||||
|
|
||||||
|
afterEach(() => cleanup())
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export type Reference = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
transcript: string
|
||||||
|
created_at: string
|
||||||
|
audio_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Generation = {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
reference_id: string
|
||||||
|
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||||
|
error: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
audio_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerationPage = {
|
||||||
|
items: Generation[]
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total: number
|
||||||
|
pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TtsEndpoint = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
created_at: string
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TtsEndpointList = {
|
||||||
|
items: TtsEndpoint[]
|
||||||
|
active_url: string
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { proxy: { '/api': 'http://localhost:8000' } },
|
||||||
|
test: { environment: 'jsdom', setupFiles: './src/test-setup.ts', include: ['src/**/*.test.{ts,tsx}'] },
|
||||||
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi>=0.115,<1
|
||||||
|
uvicorn[standard]>=0.30,<1
|
||||||
|
httpx>=0.27,<1
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
msgpack>=1.0
|
||||||
|
pytest>=8,<9
|
||||||
|
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import main
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
data_dir = tmp_path / "data"
|
||||||
|
monkeypatch.setattr(main, "DATA_DIR", data_dir)
|
||||||
|
monkeypatch.setattr(main, "REFERENCES_DIR", data_dir / "references")
|
||||||
|
monkeypatch.setattr(main, "GENERATIONS_DIR", data_dir / "generations")
|
||||||
|
monkeypatch.setattr(main, "DB_PATH", data_dir / "studio.sqlite3")
|
||||||
|
queue = main.JobQueue()
|
||||||
|
async def start_without_worker():
|
||||||
|
main.init_db()
|
||||||
|
monkeypatch.setattr(queue, "start", start_without_worker)
|
||||||
|
monkeypatch.setattr(main, "job_queue", queue)
|
||||||
|
with TestClient(main.app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
def wav_bytes() -> bytes:
|
||||||
|
# Minimal RIFF/WAVE-shaped fixture; endpoint validates the container header.
|
||||||
|
return b"RIFF\x24\x00\x00\x00WAVEfmt " + b"\x10\x00\x00\x00\x01\x00\x01\x00\x44\xac\x00\x00\x88\x58\x01\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
|
||||||
|
|
||||||
|
|
||||||
|
def create_reference(client: TestClient) -> dict:
|
||||||
|
response = client.post(
|
||||||
|
"/api/references",
|
||||||
|
files={"file": ("voice.wav", wav_bytes(), "audio/wav")},
|
||||||
|
data={"transcript": "Hello, this is a sample.", "name": "Studio voice"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_upload_and_transcript_required(client: TestClient):
|
||||||
|
response = client.post(
|
||||||
|
"/api/references",
|
||||||
|
files={"file": ("voice.wav", wav_bytes(), "audio/wav")},
|
||||||
|
data={"transcript": " ", "name": "Empty"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
reference = create_reference(client)
|
||||||
|
assert reference["transcript"] == "Hello, this is a sample."
|
||||||
|
assert client.get(reference["audio_url"]).content == wav_bytes()
|
||||||
|
assert client.get("/api/references").json()[0]["id"] == reference["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_non_wav_upload(client: TestClient):
|
||||||
|
response = client.post(
|
||||||
|
"/api/references",
|
||||||
|
files={"file": ("voice.mp3", b"not wav", "audio/mpeg")},
|
||||||
|
data={"transcript": "sample"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
|
def test_generations_are_queued_and_can_be_cancelled(client: TestClient):
|
||||||
|
reference = create_reference(client)
|
||||||
|
created = client.post("/api/generations", json={"text": "Read this aloud", "reference_id": reference["id"]})
|
||||||
|
assert created.status_code == 202
|
||||||
|
job = created.json()
|
||||||
|
assert job["status"] in {"queued", "running"}
|
||||||
|
# Cancellation is valid regardless of whether the single worker has picked it up.
|
||||||
|
cancelled = client.post(f"/api/generations/{job['id']}/cancel")
|
||||||
|
assert cancelled.status_code == 200
|
||||||
|
assert cancelled.json()["status"] == "cancelled"
|
||||||
|
assert client.post(f"/api/generations/{job['id']}/cancel").json()["status"] == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_jobs_cancel_and_completed_jobs_cannot(client: TestClient):
|
||||||
|
reference = create_reference(client)
|
||||||
|
running = client.post("/api/generations", json={"text": "Active request", "reference_id": reference["id"]}).json()
|
||||||
|
completed = client.post("/api/generations", json={"text": "Finished request", "reference_id": reference["id"]}).json()
|
||||||
|
with main.connect_db() as db:
|
||||||
|
db.execute("UPDATE generations SET status='running' WHERE id=?", (running["id"],))
|
||||||
|
db.execute("UPDATE generations SET status='completed', filename='done.mp3' WHERE id=?", (completed["id"],))
|
||||||
|
cancelled = client.post(f"/api/generations/{running['id']}/cancel")
|
||||||
|
assert cancelled.status_code == 200
|
||||||
|
assert cancelled.json()["status"] == "cancelled"
|
||||||
|
cannot_cancel = client.post(f"/api/generations/{completed['id']}/cancel")
|
||||||
|
assert cannot_cancel.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_history_paginates_at_fifty(client: TestClient):
|
||||||
|
reference = create_reference(client)
|
||||||
|
with main.connect_db() as db:
|
||||||
|
for index in range(52):
|
||||||
|
stamp = f"2026-10-27T12:{index:02}:00+00:00"
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO generations(id,text,reference_id,status,created_at,updated_at) VALUES(?,?,?,'failed',?,?)",
|
||||||
|
(f"job-{index:02}", f"Take {index}", reference["id"], stamp, stamp),
|
||||||
|
)
|
||||||
|
page_one = client.get("/api/generations?page=1&page_size=50").json()
|
||||||
|
page_two = client.get("/api/generations?page=2&page_size=50").json()
|
||||||
|
assert page_one["total"] == 52
|
||||||
|
assert len(page_one["items"]) == 50
|
||||||
|
assert len(page_two["items"]) == 2
|
||||||
|
assert page_one["items"][0]["text"] == "Take 51"
|
||||||
|
assert client.get("/api/generations?page=1&page_size=20").status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_fish_speech_mock_request_persists_mp3_and_respects_cancel(client: TestClient):
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
import msgpack
|
||||||
|
|
||||||
|
reference = create_reference(client)
|
||||||
|
job = client.post("/api/generations", json={"text": "A test take", "reference_id": reference["id"]}).json()
|
||||||
|
outgoing: list[dict] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
outgoing.append(msgpack.unpackb(request.content, raw=False))
|
||||||
|
return httpx.Response(200, content=b"fake-mp3")
|
||||||
|
|
||||||
|
queue = main.JobQueue()
|
||||||
|
queue.http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
asyncio.run(queue.process(job["id"]))
|
||||||
|
asyncio.run(queue.http.aclose())
|
||||||
|
assert outgoing[0]["text"] == "A test take"
|
||||||
|
assert outgoing[0]["format"] == "mp3"
|
||||||
|
assert outgoing[0]["references"][0]["text"] == "Hello, this is a sample."
|
||||||
|
result = client.get(f"/api/generations/{job['id']}").json()
|
||||||
|
assert result["status"] == "completed"
|
||||||
|
assert client.get(result["audio_url"]).content == b"fake-mp3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancelled_queued_job_never_calls_fish_speech(client: TestClient):
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
reference = create_reference(client)
|
||||||
|
job = client.post("/api/generations", json={"text": "A cancelled take", "reference_id": reference["id"]}).json()
|
||||||
|
client.post(f"/api/generations/{job['id']}/cancel")
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return httpx.Response(200, content=b"unexpected")
|
||||||
|
|
||||||
|
queue = main.JobQueue()
|
||||||
|
queue.http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
asyncio.run(queue.process(job["id"]))
|
||||||
|
asyncio.run(queue.http.aclose())
|
||||||
|
assert calls == 0
|
||||||
|
assert client.get(f"/api/generations/{job['id']}").json()["status"] == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_cancellation_discards_upstream_result(client: TestClient):
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
reference = create_reference(client)
|
||||||
|
job = client.post("/api/generations", json={"text": "Cancel while running", "reference_id": reference["id"]}).json()
|
||||||
|
started = asyncio.Event()
|
||||||
|
release = asyncio.Event()
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
started.set()
|
||||||
|
await release.wait()
|
||||||
|
return httpx.Response(200, content=b"discard this mp3")
|
||||||
|
|
||||||
|
queue = main.JobQueue()
|
||||||
|
queue.http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
work = asyncio.create_task(queue.process(job["id"]))
|
||||||
|
await started.wait()
|
||||||
|
response = client.post(f"/api/generations/{job['id']}/cancel")
|
||||||
|
assert response.json()["status"] == "cancelled"
|
||||||
|
release.set()
|
||||||
|
await work
|
||||||
|
await queue.http.aclose()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
result = client.get(f"/api/generations/{job['id']}").json()
|
||||||
|
assert result["status"] == "cancelled"
|
||||||
|
assert result["audio_url"] is None
|
||||||
|
assert not (main.GENERATIONS_DIR / f"{job['id']}.mp3").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fish_speech_errors_are_saved_on_the_job(client: TestClient):
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
reference = create_reference(client)
|
||||||
|
job = client.post("/api/generations", json={"text": "An upstream error", "reference_id": reference["id"]}).json()
|
||||||
|
|
||||||
|
async def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(503, text="model unavailable")
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
queue = main.JobQueue()
|
||||||
|
queue.http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
await queue.process(job["id"])
|
||||||
|
await queue.http.aclose()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
result = client.get(f"/api/generations/{job['id']}").json()
|
||||||
|
assert result["status"] == "failed"
|
||||||
|
assert "503" in result["error"]
|
||||||
|
assert "model unavailable" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fish_speech_connection_failure_is_saved_on_the_job(client: TestClient):
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
reference = create_reference(client)
|
||||||
|
job = client.post("/api/generations", json={"text": "A disconnected request", "reference_id": reference["id"]}).json()
|
||||||
|
|
||||||
|
async def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ConnectError("server is offline")
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
queue = main.JobQueue()
|
||||||
|
queue.http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
await queue.process(job["id"])
|
||||||
|
await queue.http.aclose()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
result = client.get(f"/api/generations/{job['id']}").json()
|
||||||
|
assert result["status"] == "failed"
|
||||||
|
assert "Could not reach Fish Speech" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tts_url_normalization_and_validation():
|
||||||
|
assert main.normalize_tts_url(" http://192.168.1.42:8080/ ") == "http://192.168.1.42:8080/v1/tts"
|
||||||
|
assert main.normalize_tts_url("http://192.168.1.42:8080/v1/tts") == "http://192.168.1.42:8080/v1/tts"
|
||||||
|
with pytest.raises(main.HTTPException):
|
||||||
|
main.normalize_tts_url("file:///etc/passwd")
|
||||||
|
|
||||||
|
|
||||||
|
def test_tts_url_probe_uses_fish_health_endpoint(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
requests = []
|
||||||
|
async def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
return main.httpx.Response(200, json={"status": "ok"})
|
||||||
|
|
||||||
|
client_class = main.httpx.AsyncClient
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main.httpx,
|
||||||
|
"AsyncClient",
|
||||||
|
lambda **kwargs: client_class(transport=main.httpx.MockTransport(handler), **kwargs),
|
||||||
|
)
|
||||||
|
url, message = asyncio.run(main.probe_tts_url("http://192.168.1.42:8080"))
|
||||||
|
assert url == "http://192.168.1.42:8080/v1/tts"
|
||||||
|
assert "healthy" in message
|
||||||
|
assert requests[0].url.path == "/v1/health"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tts_urls_must_pass_probe_before_saving_and_can_be_activated(client: TestClient, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
async def passing_probe(url: str) -> tuple[str, str]:
|
||||||
|
return main.normalize_tts_url(url), "Probe passed"
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "probe_tts_url", passing_probe)
|
||||||
|
test_result = client.post("/api/tts-endpoints/test", json={"url": "http://192.168.1.42:8080"})
|
||||||
|
assert test_result.status_code == 200
|
||||||
|
assert test_result.json()["url"] == "http://192.168.1.42:8080/v1/tts"
|
||||||
|
|
||||||
|
saved = client.post("/api/tts-endpoints", json={"url": "http://192.168.1.42:8080"})
|
||||||
|
assert saved.status_code == 201
|
||||||
|
endpoint = saved.json()
|
||||||
|
assert endpoint["active"] is True
|
||||||
|
assert main.active_tts_url() == endpoint["url"]
|
||||||
|
|
||||||
|
reference = create_reference(client)
|
||||||
|
generation = client.post("/api/generations", json={"text": "Use the selected host", "reference_id": reference["id"]}).json()
|
||||||
|
assert main.get_generation(generation["id"])["tts_url"] == endpoint["url"]
|
||||||
|
|
||||||
|
second = client.post("/api/tts-endpoints", json={"url": "http://192.168.1.43:8080/v1/tts"}).json()
|
||||||
|
activated = client.post(f"/api/tts-endpoints/{endpoint['id']}/activate")
|
||||||
|
assert activated.status_code == 200
|
||||||
|
assert activated.json()["url"] == endpoint["url"]
|
||||||
|
assert main.active_tts_url() == endpoint["url"]
|
||||||
|
assert client.get("/api/tts-endpoints").json()["items"]
|
||||||
|
assert second["url"] != endpoint["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_marks_interrupted_jobs_failed(client: TestClient):
|
||||||
|
reference = create_reference(client)
|
||||||
|
with main.connect_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO generations(id,text,reference_id,status,created_at,updated_at) VALUES('interrupted','hello',?,'running','2026-10-27T12:00:00+00:00','2026-10-27T12:00:00+00:00')",
|
||||||
|
(reference["id"],),
|
||||||
|
)
|
||||||
|
main.init_db()
|
||||||
|
row = main.get_generation("interrupted")
|
||||||
|
assert row["status"] == "failed"
|
||||||
|
assert "restarted" in row["error"].lower()
|
||||||
|
|
||||||
Reference in New Issue
Block a user