Build Fish Speech web studio

This commit is contained in:
2026-10-29 16:29:24 -04:00
commit a269bda060
23 changed files with 4688 additions and 0 deletions
+5
View File
@@ -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
View File
@@ -0,0 +1,11 @@
node_modules/
frontend/dist/
frontend/test-results/
frontend/playwright-report/
frontend/tsconfig.tsbuildinfo
.venv/
__pycache__/
.pytest_cache/
data/
.env
+19
View File
@@ -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"]
+43
View File
@@ -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`
+1
View File
@@ -0,0 +1 @@
+487
View File
@@ -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")
+17
View File
@@ -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:
+51
View File
@@ -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()
})
+15
View File
@@ -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>
+3129
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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"
}
}
+8
View File
@@ -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' } },
})
+72
View File
@@ -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'))
})
})
+331
View File
@@ -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
+67
View File
@@ -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' })
}
+9
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => cleanup())
+39
View File
@@ -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
}
+21
View File
@@ -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"]
}
+9
View File
@@ -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}'] },
})
+7
View File
@@ -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
+299
View File
@@ -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()