Build Fish Speech web studio
This commit is contained in:
@@ -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