stacks/fish-cpp: Phase 1 — s2.cpp + GGML CUDA backend image, FastAPI shim, deploy playbook

New stack scaffolding for the Fish quantized-realtime experiment. Not
deployed yet — this commit lands the canonical files; deploy follows.

Architecture decisions made in Phase 1:
* CUDA backend, NOT Vulkan. s2.cpp's CMakeLists exposes both
  -DS2_VULKAN and -DS2_CUDA; the most recent upstream commit
  (2026-04-12) was specifically about CUDA improvements, and CUDA
  on the A6000 will be substantially faster than Vulkan for ML
  matmul. -DS2_CUDA=ON in the Dockerfile build args.

* Pinned to s2.cpp commit e48ce8e02d8335bd9a0ba94679f605724b31d12
  (2026-04-12 HEAD of main). Repo is alpha software per README;
  pin tightly so future churn doesn't break our build. Bump
  deliberately when wanting upstream improvements.

* Multi-stage Dockerfile: nvidia/cuda:12.6.0-devel for build (needs
  CMake + ninja + git + the CUDA toolchain) → nvidia/cuda:12.6.0-runtime
  for serve (slimmer; just the s2 binary + GGML libs + a small Python
  shim). Cuts image size by ~50% vs single-stage devel.

* FastAPI shim (server.py) wraps s2.cpp CLI in Fish's `/v1/tts`
  contract so the same bench harness + clients work against fish-cpp
  with no changes. Per-request flow: decode optional reference WAV
  from base64 → write to temp → subprocess.run the s2 binary → stream
  resulting WAV back. Adds ~50-100ms per-request fork+exec overhead;
  negligible vs the multi-second generation cost.

* `streaming: true` accepted in request body but IGNORED — s2.cpp
  writes a complete WAV before returning, so chunked output isn't
  available. Unlike fish-s2 (HF wrapper) where streaming drops TTFB
  to 26ms, fish-cpp's TTFB ≈ total wall time. Speed depends entirely
  on raw generation throughput.

* q6_k as default quant — sweet spot per typical GGUF guidance:
  near-bf16 quality at ~5GB. Other variants (q4_k_m, q5_k_m, q8_0,
  f16) selectable via FISH_CPP_MODEL env.

* Pinned to GPU 1 (A6000) by default to share with fish-s2 for
  direct A/B benching. q6_k weights ~5GB + runtime ~3GB ≈ 8GB —
  comfortable on either GPU.

* Port 8199 (next free in the irv-ml1 TTS slate).

Phase 2 (next) is the actual deploy + first build. Reserved 30-45 min
for cold-cache build + weights pull.
This commit is contained in:
vh
2026-04-28 01:06:14 -07:00
parent 68f3cd05fe
commit 14f052461e
6 changed files with 529 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
# Deploy fish-cpp (Fish s2-pro via s2.cpp + GGML CUDA inference) to irv-ml1.
#
# Builds the image locally — multi-stage CUDA devel base (CMake + s2.cpp
# compile, ~10 min cold) → CUDA runtime base + binary + python shim.
# Pre-pulls rodrigomt/s2-pro-gguf weights (q6_k default, ~5 GB) into
# the bind-mounted weights dir.
#
# Usage:
# scripts/elway irv-ml1 --playbook playbooks/deploy-fish-cpp.yaml
#
# Idempotent — every step is creates-/when-gated; rerun is safe.
vars:
compose_dir: /opt/docker/compose/fish-cpp
references_dir: /worktank/fish-cpp/references
weights_dir: /worktank/fish-cpp/weights
host_port: "8199"
weights_repo: rodrigomt/s2-pro-gguf
default_quant: s2-pro-q6_k.gguf
steps:
# ── host-side dirs ──────────────────────────────────────────────────
- name: Ensure /worktank/fish-cpp root exists (one-time, sudo)
shell: mkdir -p /worktank/fish-cpp
sudo: true
creates: /worktank/fish-cpp
- name: Chown /worktank/fish-cpp to lkraven
shell: chown -R lkraven:lkraven /worktank/fish-cpp
sudo: true
when: "[ \"$(stat -c %U /worktank/fish-cpp)\" != \"lkraven\" ]"
- name: Ensure references dir exists
shell: mkdir -p {{ references_dir }}
creates: "{{ references_dir }}"
- name: Ensure weights dir exists
shell: mkdir -p {{ weights_dir }}
creates: "{{ weights_dir }}"
- name: Ensure compose dir exists
shell: mkdir -p {{ compose_dir }}
creates: "{{ compose_dir }}"
# ── deploy build context ────────────────────────────────────────────
# s2.cpp is built INSIDE the docker image, but the Dockerfile + shim
# need to be present in the compose dir so `docker compose build`
# can find them.
- name: Upload compose.yaml
upload:
src: stacks/fish-cpp/compose.yaml
dest: "{{ compose_dir }}/compose.yaml"
mode: "0644"
- name: Upload Dockerfile
upload:
src: stacks/fish-cpp/Dockerfile
dest: "{{ compose_dir }}/Dockerfile"
mode: "0644"
- name: Upload server.py (FastAPI shim)
upload:
src: stacks/fish-cpp/server.py
dest: "{{ compose_dir }}/server.py"
mode: "0644"
- name: Seed .env from template (only if absent)
upload:
src: stacks/fish-cpp/.env.example
dest: "{{ compose_dir }}/.env"
mode: "0644"
when: "[ ! -f {{ compose_dir }}/.env ]"
# ── pre-pull weights ────────────────────────────────────────────────
# q6_k + tokenizer.json (~5 GB total). Same one-shot
# python:3.12-slim + huggingface_hub.snapshot_download + hf_transfer
# pattern we've used for fish-s2, voxtral, etc. Idempotent on rerun
# via `creates:` on the model file.
- name: Pre-pull rodrigomt/s2-pro-gguf weights (q6_k + tokenizer, ~5 GB)
shell: |
docker run --rm --user 1000:1000 \
-e HOME=/tmp/h -e HF_HUB_ENABLE_HF_TRANSFER=1 \
-v {{ weights_dir }}:/dest \
python:3.12-slim sh -c 'set -e; mkdir -p /tmp/h /tmp/pip /tmp/site; PIP_CACHE_DIR=/tmp/pip pip install --quiet --target /tmp/site huggingface_hub hf_transfer; PYTHONPATH=/tmp/site python -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id=\"{{ weights_repo }}\", local_dir=\"/dest\", allow_patterns=[\"{{ default_quant }}\",\"tokenizer.json\"])"'
creates: "{{ weights_dir }}/{{ default_quant }}"
# ── build + bring up ────────────────────────────────────────────────
- name: docker compose build (~10 min first time; CUDA toolchain + s2.cpp compile)
shell: |
set -o pipefail
cd {{ compose_dir }} && docker compose build 2>&1 \
| grep -vE '^#[0-9]+ |^ => |^=> |Collecting|Downloading|Requirement|Using cached|Installing collected|Successfully (installed|built)|━'
- name: docker compose up -d
shell: cd {{ compose_dir }} && docker compose up -d
- name: Wait for /v1/health to respond
shell: |
for i in $(seq 1 60); do
curl -sf -o /dev/null --max-time 3 http://localhost:{{ host_port }}/v1/health && exit 0
sleep 5
done
exit 1
changed_when: "false"
verify:
- name: /v1/health returns 200 + reports model loaded
shell: |
curl -sf http://localhost:{{ host_port }}/v1/health \
| python3 -c "import json,sys; d=json.load(sys.stdin); assert d.get('status')=='ok' and d.get('model')"
changed_when: "false"
- name: /v1/tts returns a real WAV (POST with text body)
shell: |
out=$(mktemp --suffix=.wav)
curl -sf -X POST http://localhost:{{ host_port }}/v1/tts \
-H 'Content-Type: application/json' \
-d '{"text":"Verify."}' \
-o "$out" --max-time 60
file -b "$out" | grep -q '^RIFF.*WAVE'
rm -f "$out"
changed_when: "false"
- name: Container is running
shell: docker inspect fish-cpp --format '{{.State.Status}}' | grep -q running
changed_when: "false"
+42
View File
@@ -0,0 +1,42 @@
# fish-cpp stack tunables. Copy to `.env` on irv-ml1 before deploying.
# ── build pin ────────────────────────────────────────────────────────
# SHA of rodrigomatta/s2.cpp to build from. The repo is alpha — pin a
# specific SHA so future upstream churn doesn't break our build. Update
# deliberately when you want upstream improvements.
FISH_CPP_S2_SHA=e48ce8e02d8335bd9a0ba94679f605724b31d123
# Local image tag — bump when you change build context.
FISH_CPP_TAG=v1
# ── network ──────────────────────────────────────────────────────────
# Host port. Container listens on 8000 internally.
# Reservations on irv-ml1: 8188 ComfyUI, 8190 CosyVoice, 8191 Qwen3-TTS,
# 8192 IndexTTS-2, 8193 Kokoro, 8194 VibeVoice, 8195 fish-s2 (HF),
# 8196 Chatterbox, 8197 Voxtral, 8765 Parakeet ASR. 8199 picked here.
FISH_CPP_PORT=8199
FISH_CPP_BIND=0.0.0.0
# ── runtime / GPU ────────────────────────────────────────────────────
# GPU pinning. "0" = RTX 3090 (24 GB), "1" = RTX A6000 (48 GB).
# Pinned to GPU 1 to share with fish-s2 (HF) for direct A/B benching;
# q6_k weights need ~5 GB + 3 GB runtime ≈ 8 GB. A6000 has plenty.
FISH_CPP_GPU_DEVICES=1
# Quantization variant to load. Available files in
# rodrigomt/s2-pro-gguf:
# s2-pro-q4_k_m.gguf — smaller / faster, quality drops noticeably
# s2-pro-q5_k_m.gguf — middle ground
# s2-pro-q6_k.gguf — sweet spot (default), near-bf16 quality
# s2-pro-q8_0.gguf — closest to bf16, larger / slower
# s2-pro-f16.gguf — full precision, no quantization win
FISH_CPP_MODEL=s2-pro-q6_k.gguf
# ── persistent storage on the host ───────────────────────────────────
# Weights — pre-pulled by the deploy playbook (~5 GB for q6_k +
# ~12 MB tokenizer.json).
FISH_CPP_WEIGHTS_DIR=/worktank/fish-cpp/weights
# Reference audio for voice cloning. Drop ~5-15 s WAV/MP3/FLAC clips
# here; reference them by basename in the API request body.
FISH_CPP_REFERENCE_DIR=/worktank/fish-cpp/references
+63
View File
@@ -0,0 +1,63 @@
# fish-cpp — s2.cpp (pure C++/GGML inference for Fish s2-pro GGUFs) +
# tiny FastAPI shim exposing Fish's /v1/tts contract.
#
# Two-stage build:
# 1. builder — compiles s2.cpp with CUDA backend
# 2. runtime — slim image with the s2 binary + Python shim
#
# The s2.cpp binary is the actual inference engine; the Python shim is
# just an HTTP-to-CLI bridge so this stack drops into the same fleet
# pattern as the other TTS (POST /v1/tts, Fish-shaped request body).
# ── Stage 1: build s2.cpp with CUDA ────────────────────────────────────
FROM nvidia/cuda:12.6.0-devel-ubuntu24.04 AS builder
ARG S2_CPP_SHA=e48ce8e02d8335bd9a0ba94679f605724b31d123
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
git ca-certificates cmake ninja-build build-essential pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
RUN git clone --recurse-submodules https://github.com/rodrigomatta/s2.cpp.git \
&& cd s2.cpp \
&& git checkout ${S2_CPP_SHA} \
&& git submodule update --init --recursive
WORKDIR /src/s2.cpp
RUN cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release -DS2_CUDA=ON \
&& cmake --build build --parallel $(nproc) --target s2
# ── Stage 2: runtime — slim image with the binary + python shim ────────
FROM nvidia/cuda:12.6.0-runtime-ubuntu24.04
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv tini \
&& rm -rf /var/lib/apt/lists/*
# Pull the shim deps into an isolated venv so we don't fight system pip.
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:${PATH}"
RUN pip install --no-cache-dir 'fastapi>=0.115' 'uvicorn[standard]>=0.30' 'pydantic>=2'
# Copy the s2 binary + GGML runtime libs from the builder stage.
COPY --from=builder /src/s2.cpp/build/s2 /usr/local/bin/s2
COPY --from=builder /src/s2.cpp/build/ggml/src/libggml*.so /usr/local/lib/
COPY --from=builder /src/s2.cpp/build/ggml/src/ggml-cuda/libggml-cuda.so /usr/local/lib/
RUN ldconfig
WORKDIR /app
COPY server.py /app/server.py
# Bind-mounted at runtime: weights at /weights, references at /references.
VOLUME /weights
VOLUME /references
EXPOSE 8000
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
+108
View File
@@ -0,0 +1,108 @@
# fish-cpp
Fish s2-pro served via [s2.cpp](https://github.com/rodrigomatta/s2.cpp)
— a pure C++/GGML inference engine for Fish s2-pro, with weights from
[rodrigomt/s2-pro-gguf](https://huggingface.co/rodrigomt/s2-pro-gguf).
Wrapped by a tiny FastAPI shim exposing Fish's `/v1/tts` HTTP contract
so it slots into the same bench harness + client patterns as `fish-s2`.
## Why this stack alongside `fish-s2`
`fish-s2` (HF transformers wrapper) measured at **0.78× realtime** on
the A6000 — phenomenal quality but sub-realtime, meaning streaming
clients hit buffer underruns on phrases longer than ~3-4 seconds of
audio. `fish-cpp` targets the same s2-pro architecture but runs it
through s2.cpp's C++/GGML/CUDA inference path with q6_k quantization
— typically 2-5× faster than HF transformers for equivalent precision
(GGML is what makes llama.cpp fast).
**Goal**: hit ≥ 1× realtime on the A6000 so streaming actually flows
without stutters, while keeping Fish's quality near-equivalent to BF16.
## Status: alpha
s2.cpp is alpha software per its upstream README. Pin the SHA in
`.env`, don't track main blindly — community alpha projects break
weekly.
## What works (and what doesn't) vs `fish-s2`
| feature | fish-s2 (HF) | fish-cpp (this) |
|---|---|---|
| `/v1/tts` POST endpoint | ✓ | ✓ |
| `text` body field | ✓ | ✓ |
| `references` body field (cloning) | ✓ | ✓ (single ref only) |
| `streaming: true` | ✓ (TTFB → 26 ms) | ✗ accepted but ignored |
| Paralinguistic tags | ✓ | should work (same weights) |
| Quantization | bf16 | q4_k_m / q5_k_m / **q6_k** (default) / q8_0 |
| Realtime factor | 0.78× | targeting 1-1.5× |
The streaming gap matters: fish-s2 with `streaming: true` returns the
first audio chunk in 26 ms (perceived latency feels instant). fish-cpp
returns nothing until generation completes. So fish-cpp's appeal is
RAW THROUGHPUT, not perceived latency. Combined with realtime+
generation, total wall time stays low enough that polling clients
don't notice.
## API
OpenAPI shape mirrors `fish-s2`:
```bash
# Basic — text only, default voice
curl -fsS -X POST http://10.100.79.3:8199/v1/tts \
-H 'Content-Type: application/json' \
-d '{"text":"Hello there."}' \
> out.wav
# With voice cloning — base64-encoded reference audio inline
B64=$(base64 -w 0 /worktank/fish-cpp/references/glados.wav) # on irv-ml1
echo "{\"text\":\"Welcome.\",\"references\":[{\"audio\":\"$B64\",\"text\":\"transcript\"}]}" \
| curl -fsS -X POST http://10.100.79.3:8199/v1/tts \
-H 'Content-Type: application/json' --data-binary @- \
> out.wav
```
Health probe at `GET /v1/health`.
## Deploy
```bash
scripts/elway irv-ml1 --playbook playbooks/deploy-fish-cpp.yaml
```
Cold deploy ~30-45 min: ~10 min image build (CUDA dev toolchain +
CMake + s2.cpp compile), ~3 min weights pull (~5 GB for q6_k +
12 MB tokenizer), ~1 min container boot.
## Hardware footprint
- **VRAM**: ~8 GB practical for q6_k (5 GB weights + 3 GB runtime).
Pinned to GPU 1 (A6000) by default to share with `fish-s2` for
direct A/B comparison. Could also run on GPU 0 (3090) with room
to spare.
- **Disk**: ~5 GB for q6_k checkpoint + tokenizer.
## Bench plan
Same 3-phrase suite as the other TTS:
```
P1 = "Hello, this is a test of the voice synthesis system. The quick brown fox jumps over the lazy dog."
P2 = "Oh my god, I cannot believe what just happened. That was absolutely incredible!"
P3 = "What the hell is going on. This is some bullshit and I am not putting up with it."
```
Compare:
- TTFB / total wall-clock per phrase
- audio-seconds / wall-seconds (realtime factor)
- Quality (ear test) vs `fish-s2` BF16 baseline
If fish-cpp lands ≥ 1× realtime AND the q6_k quality holds up under
ear test, this stack becomes the default Fish path. fish-s2 stays
deployed for paralinguistic tag fidelity reference + streaming
(if that turns out to matter for any specific use case).
## Lessons learned
(Populate after deploy iteration.)
+55
View File
@@ -0,0 +1,55 @@
# fish-cpp — Fish s2-pro served via s2.cpp (pure C++/GGML inference,
# CUDA backend) with a tiny FastAPI shim exposing Fish's /v1/tts
# contract. Built locally from rodrigomatta/s2.cpp + a pinned SHA.
#
# Why this stack alongside fish-s2:
# * fish-s2 (HF transformers wrapper): ~7-8 s TTFB, 0.78× realtime.
# Phenomenal quality but sub-realtime, buffer-underruns on long
# phrases even with streaming.
# * fish-cpp (this): targets ~2-5× speedup from the GGML inference
# path + q6_k quantization. Goal: hit realtime for streaming use.
#
# CAVEATS:
# * s2.cpp is alpha software (per upstream README). Pin the SHA;
# don't track main blindly.
# * Per-request subprocess spawn — each /v1/tts call forks the s2
# binary. Adds ~50-100 ms over a long-running daemon. Negligible
# vs the multi-second generation cost.
# * No streaming — s2.cpp writes a complete WAV before returning,
# so the shim's `streaming: true` field is accepted but ignored.
# TTFB ≈ total wall time (fast generation is the only path to
# low-latency here, not chunked output).
services:
fish-cpp:
image: local/fish-cpp:${FISH_CPP_TAG}
build:
context: .
dockerfile: Dockerfile
args:
S2_CPP_SHA: ${FISH_CPP_S2_SHA:-e48ce8e02d8335bd9a0ba94679f605724b31d123}
container_name: fish-cpp
restart: unless-stopped
runtime: nvidia
ports:
- "${FISH_CPP_BIND:-0.0.0.0}:${FISH_CPP_PORT}:8000"
environment:
- NVIDIA_VISIBLE_DEVICES=${FISH_CPP_GPU_DEVICES:-1}
- FISH_CPP_MODEL=${FISH_CPP_MODEL:-s2-pro-q6_k.gguf}
- FISH_CPP_TOKENIZER=tokenizer.json
- FISH_CPP_DEVICE=0
volumes:
- ${FISH_CPP_WEIGHTS_DIR}:/weights:ro
- ${FISH_CPP_REFERENCE_DIR}:/references:ro
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/v1/health', timeout=5).status==200 else 1)\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
labels:
- homepage.group=AI Systems
- homepage.name=Fish (s2.cpp)
- homepage.icon=mdi-fish
- homepage.description=Fish s2-pro via s2.cpp/GGML — quantized for realtime (irv-ml1)
- homepage.href=http://10.100.79.3:${FISH_CPP_PORT}
+131
View File
@@ -0,0 +1,131 @@
"""fish-cpp — HTTP shim wrapping the s2.cpp CLI in Fish's /v1/tts API contract.
s2.cpp is a pure C++/GGML inference engine for Fish s2-pro GGUFs. It's
CLI-only (alpha), so this shim turns each HTTP POST into a subprocess
invocation that:
1. Writes the optional reference WAV to /tmp/<id>.wav
2. Runs: s2 -m <model.gguf> -t <tokenizer.json>
[-pa /tmp/ref.wav -pt "<transcript>"]
-text "..." -o /tmp/<id>.out.wav
3. Streams the resulting WAV back to the client.
The contract matches Fish's wrapper close enough that bench harnesses
written for fish-s2 work against fish-cpp without changes — same
POST /v1/tts, same {text, references[{audio,text}], format} request body.
What's NOT supported (vs Fish HF):
* `streaming: true` — s2.cpp writes a complete WAV before returning.
The shim accepts the field but ignores it. TTFB ≈ total wall time.
If/when s2.cpp grows incremental output, wire it through here.
* Paralinguistic tags — s2.cpp uses the same model weights, so tags
SHOULD work, but quality may differ from the HF wrapper.
"""
from __future__ import annotations
import base64
import os
import subprocess
import tempfile
import uuid
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
S2_BINARY = os.environ.get("S2_BINARY", "/usr/local/bin/s2")
WEIGHTS_DIR = Path(os.environ.get("WEIGHTS_DIR", "/weights"))
MODEL_FILE = os.environ.get("FISH_CPP_MODEL", "s2-pro-q6_k.gguf")
TOKENIZER_FILE = os.environ.get("FISH_CPP_TOKENIZER", "tokenizer.json")
DEVICE_INDEX = os.environ.get("FISH_CPP_DEVICE", "0") # CUDA device id
MODEL_PATH = WEIGHTS_DIR / MODEL_FILE
TOKENIZER_PATH = WEIGHTS_DIR / TOKENIZER_FILE
app = FastAPI(title="fish-cpp")
class ReferenceAudio(BaseModel):
audio: str # base64-encoded WAV bytes (matches Fish HF schema)
text: str # transcript of the reference clip
class TTSRequest(BaseModel):
text: str
references: list[ReferenceAudio] = []
format: str = "wav"
streaming: bool = False # accepted for contract parity; ignored
@app.get("/v1/health")
def health() -> dict:
if not MODEL_PATH.exists():
raise HTTPException(503, f"model missing: {MODEL_PATH}")
if not TOKENIZER_PATH.exists():
raise HTTPException(503, f"tokenizer missing: {TOKENIZER_PATH}")
return {"status": "ok", "model": MODEL_FILE}
@app.post("/v1/tts")
def tts(req: TTSRequest) -> FileResponse:
if req.format not in ("wav",):
raise HTTPException(400, f"only wav format supported, got {req.format}")
job = uuid.uuid4().hex
out_path = Path(tempfile.gettempdir()) / f"fish-cpp-{job}.wav"
ref_path: Optional[Path] = None
cmd: list[str] = [
S2_BINARY,
"-m", str(MODEL_PATH),
"-t", str(TOKENIZER_PATH),
"-text", req.text,
"-o", str(out_path),
# -v selects voice mode; 0 = default (no preset). Preset voices
# would need s2.cpp to ship a voice library; clone via -pa/-pt.
"-v", "0",
]
if req.references:
# s2.cpp accepts a single reference at a time via -pa/-pt. If the
# client sent multiple, use the first; the upstream API doesn't
# appear to support multi-reference cloning.
ref = req.references[0]
ref_path = Path(tempfile.gettempdir()) / f"fish-cpp-{job}-ref.wav"
ref_path.write_bytes(base64.b64decode(ref.audio))
cmd.extend(["-pa", str(ref_path), "-pt", ref.text])
env = os.environ.copy()
env["CUDA_VISIBLE_DEVICES"] = DEVICE_INDEX
try:
result = subprocess.run(
cmd, env=env, capture_output=True, text=True, timeout=180,
)
except subprocess.TimeoutExpired:
raise HTTPException(504, "s2 binary timed out (180s)")
finally:
if ref_path and ref_path.exists():
ref_path.unlink()
if result.returncode != 0:
raise HTTPException(
500,
f"s2 binary failed (rc={result.returncode}): {result.stderr[-500:]}",
)
if not out_path.exists() or out_path.stat().st_size == 0:
raise HTTPException(500, "s2 binary produced no output")
# FileResponse streams the file + cleans up after sending. Caller
# gets WAV bytes immediately; we reap the temp file via background
# task once the response finishes.
from fastapi import BackgroundTasks
bg = BackgroundTasks()
bg.add_task(out_path.unlink, missing_ok=True)
return FileResponse(
out_path, media_type="audio/wav", filename=f"{job}.wav", background=bg,
)