14f052461e
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.
132 lines
4.5 KiB
Python
132 lines
4.5 KiB
Python
"""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,
|
|
)
|