Add local TTS/STT wrapper servers (chatterbox, faster-whisper) as openai_compatible providers

Wraps the locally installed chatterbox-tts and faster-whisper packages in thin
FastAPI servers implementing OpenAI's audio API shape, since neither Ollama nor
OpenRouter support speech. Pinned to GPU 2 to avoid contending with Ollama's
resident models on GPU 1. Requires UFW rules for 8901/8902 (same pattern as
the existing 11434 rule) since UFW defaults to deny-incoming.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-07-10 21:41:44 +02:00
commit fb1fbcfabe
4 changed files with 198 additions and 0 deletions

64
services/stt_server.py Normal file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""OpenAI-compatible STT wrapper around faster-whisper, for Open Notebook.
Implements exactly the endpoint esperanto's OpenAICompatibleSpeechToTextModel calls:
POST /audio/transcriptions (multipart: file, model, language?, prompt?) -> {"text": ...}
Start:
python3 stt_server.py
Env vars:
STT_HOST (default 0.0.0.0), STT_PORT (default 8902)
WHISPER_MODEL (default large-v3), WHISPER_COMPUTE_TYPE (default float16)
CUDA_VISIBLE_DEVICES should be set by the caller (e.g. "1,2") to keep GPU 0 free.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from faster_whisper import WhisperModel
from fastapi import FastAPI, File, Form, UploadFile
app = FastAPI(title="faster-whisper STT (OpenAI-compatible)", version="1.0")
_MODEL_SIZE = os.environ.get("WHISPER_MODEL", "large-v3")
_COMPUTE_TYPE = os.environ.get("WHISPER_COMPUTE_TYPE", "float16")
_model = WhisperModel(_MODEL_SIZE, device="cuda", compute_type=_COMPUTE_TYPE)
@app.get("/health")
def health():
return {"status": "ok", "model": _MODEL_SIZE}
@app.post("/audio/transcriptions")
async def transcriptions(
file: UploadFile = File(...),
model: str = Form(default="whisper-1"),
language: str | None = Form(default=None),
prompt: str | None = Form(default=None),
):
suffix = Path(file.filename or "audio").suffix or ".wav"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(await file.read())
tmp_path = tmp.name
try:
segments, _info = _model.transcribe(
tmp_path,
language=language,
initial_prompt=prompt,
)
text = "".join(segment.text for segment in segments).strip()
finally:
os.unlink(tmp_path)
return {"text": text}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=os.environ.get("STT_HOST", "0.0.0.0"),
port=int(os.environ.get("STT_PORT", "8902")))