Add custom faster-whisper service with anti-hallucination params

Off-the-shelf Whisper wrappers hardcode faster-whisper defaults that
cause cascading hallucinations on real dictations (2/13 runs against
ahmetoner v1.9.1). This thin FastAPI service mirrors the /asr interface
but fixes condition_on_previous_text=False, temperature=0.0, and
vad_filter=True so the Axum server can switch by changing WHISPER_URL.
This commit is contained in:
2026-04-13 19:45:52 +02:00
parent d3ef78796d
commit 6e6cfd77b7
5 changed files with 277 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
FROM nvidia/cuda:12.1.0-cudnn8-runtime-ubuntu22.04
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip ffmpeg && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
ENV WHISPER_MODEL=large-v3-turbo \
WHISPER_COMPUTE_TYPE=float16 \
WHISPER_MODELS_DIR=/models
VOLUME ["/models"]
EXPOSE 9001
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "9001", "--workers", "1"]
+101
View File
@@ -0,0 +1,101 @@
# doctate-whisper
Thin FastAPI wrapper around [faster-whisper](https://github.com/SYSTRAN/faster-whisper).
## Why a custom service?
Existing Whisper HTTP wrappers (`ahmetoner/whisper-asr-webservice`,
`speaches`, `linuxserver/faster-whisper`, `hwdsl2/docker-whisper`) hardcode
the faster-whisper defaults. Those defaults include:
- `condition_on_previous_text=True` — classic cascading-hallucination source
- temperature fallback cascade `[0.0, 0.2, …, 1.0]` — when confidence drops,
Whisper gets "creative"
On real cardiology dictations we saw **2/13 runs with catastrophic
hallucinations** (Russian/Chinese fragments, repetition loops) using
ahmetoner's defaults. None of the available wrappers expose these
parameters as request fields or env vars.
This service fixes them to safe values that never change.
## Anti-hallucination params (fixed)
| Param | Value | Reason |
|---|---|---|
| `temperature` | `0.0` | No fallback cascade; deterministic output |
| `condition_on_previous_text` | `False` | Prevents cascading hallucinations |
| `vad_filter` | `True` | Silence → no transcription (not garbage) |
| `vad_parameters.min_silence_duration_ms` | `500` | VAD sensitivity |
| `no_speech_threshold` | `0.6` | Drop silent segments |
| `log_prob_threshold` | `-1.0` | Drop low-confidence segments |
| `compression_ratio_threshold` | `2.4` | Anti-repetition |
| `beam_size` / `best_of` | `5` / `5` | Default quality beam search |
## Endpoints
- `POST /asr` — multipart `audio_file`, query `output=txt|json`, `language=de`, form `initial_prompt`
- `GET /health` — liveness: `{"status":"ok","model":...,"device":...}`
- `GET /info` — debug: model, device, compute_type, offline flag
Compatible with ahmetoner's `/asr` interface so the Axum server needs no change.
## Env vars
| Var | Default | Purpose |
|---|---|---|
| `WHISPER_MODEL` | `large-v3-turbo` | Any faster-whisper model name |
| `WHISPER_COMPUTE_TYPE` | `float16` | `float16`, `int8_float16`, `int8` (CPU) |
| `WHISPER_DEVICE` | `cuda` | `cuda` or `cpu` |
| `WHISPER_MODELS_DIR` | `/models` | Cache location |
| `WHISPER_OFFLINE` | `0` | `1` → no HuggingFace download |
## Model choice
**Default is `large-v3-turbo`**: same 32-layer encoder as `large-v3`,
only decoder distilled to 4 layers. OpenAI benchmarks: equal quality on
European languages. ~1.6 GB VRAM vs. ~3 GB — leaves room for Ollama
(gemma4, 9 GB) in a 12 GB card.
Switch to `large-v3` at any time via `WHISPER_MODEL=large-v3` + container restart.
## Build
```bash
docker build -t doctate-whisper ./whisper
```
Takes 25 minutes. First run of the container pulls ~1.6 GB from
HuggingFace into `/models` (volume-persisted).
## Deploy to minerva via Dockge
Transport the image without a registry:
```bash
docker save doctate-whisper | ssh minerva 'docker load'
```
Then add the compose file in Dockge and start.
## Integration with Axum server
In `server/.env`:
```
WHISPER_URL=http://minerva.lan:9001
```
No other change. The Axum server speaks ahmetoner's interface; we mirror it.
## Offline mode
```bash
# Pre-pull on a host with internet:
docker run --rm -v /opt/stacks/doctate-whisper/models:/models doctate-whisper \
python3 -c "from faster_whisper import WhisperModel; WhisperModel('large-v3-turbo', download_root='/models')"
# Then set in docker-compose.yml:
environment:
- WHISPER_OFFLINE=1
```
+21
View File
@@ -0,0 +1,21 @@
services:
doctate-whisper:
image: doctate-whisper:latest
container_name: doctate-whisper
environment:
- WHISPER_MODEL=large-v3-turbo
- WHISPER_COMPUTE_TYPE=float16
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
volumes:
- /opt/stacks/doctate-whisper/models:/models
ports:
- "9001:9001"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
restart: unless-stopped
+129
View File
@@ -0,0 +1,129 @@
"""Thin FastAPI wrapper around faster-whisper.
Exposes the same /asr interface as ahmetoner/whisper-asr-webservice so the
Axum server stays unchanged, but hardcodes anti-hallucination parameters
that no off-the-shelf wrapper exposes.
"""
import json
import logging
import os
import tempfile
import time
from typing import Optional
from fastapi import FastAPI, File, Form, Query, UploadFile
from fastapi.responses import JSONResponse, PlainTextResponse
from faster_whisper import WhisperModel
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("whisper")
MODEL_NAME = os.getenv("WHISPER_MODEL", "large-v3-turbo")
COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float16")
DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
MODELS_DIR = os.getenv("WHISPER_MODELS_DIR", "/models")
OFFLINE = os.getenv("WHISPER_OFFLINE", "0") == "1"
log.info("Loading model=%s device=%s compute_type=%s", MODEL_NAME, DEVICE, COMPUTE_TYPE)
model = WhisperModel(
MODEL_NAME,
device=DEVICE,
compute_type=COMPUTE_TYPE,
download_root=MODELS_DIR,
local_files_only=OFFLINE,
)
log.info("Model loaded")
app = FastAPI(title="doctate-whisper", version="0.1.0")
@app.get("/health")
def health():
return {"status": "ok", "model": MODEL_NAME, "device": DEVICE}
@app.get("/info")
def info():
return {
"model": MODEL_NAME,
"device": DEVICE,
"compute_type": COMPUTE_TYPE,
"offline": OFFLINE,
}
@app.post("/asr")
async def asr(
audio_file: UploadFile = File(...),
output: str = Query("txt"),
language: Optional[str] = Query(None),
initial_prompt: Optional[str] = Form(None),
):
suffix = os.path.splitext(audio_file.filename or "")[1] or ".bin"
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
try:
tmp.write(await audio_file.read())
tmp.flush()
tmp.close()
t0 = time.monotonic()
segments_gen, info_obj = model.transcribe(
tmp.name,
language=language,
task="transcribe",
beam_size=5,
best_of=5,
# Fixed anti-hallucination params — the whole point of this service.
temperature=0.0,
condition_on_previous_text=False,
vad_filter=True,
vad_parameters={"min_silence_duration_ms": 500},
no_speech_threshold=0.6,
log_prob_threshold=-1.0,
compression_ratio_threshold=2.4,
initial_prompt=initial_prompt,
)
segments = list(segments_gen)
infer_secs = time.monotonic() - t0
text = "".join(s.text for s in segments).strip()
worst_logprob = min((s.avg_logprob for s in segments), default=0.0)
log.info(
json.dumps({
"event": "transcribe",
"duration_audio": info_obj.duration,
"duration_infer": round(infer_secs, 2),
"segments": len(segments),
"worst_logprob": round(worst_logprob, 3),
"language": info_obj.language,
})
)
if output == "json":
return JSONResponse({
"text": text,
"language": info_obj.language,
"duration": info_obj.duration,
"segments": [
{
"start": s.start,
"end": s.end,
"text": s.text,
"avg_logprob": s.avg_logprob,
"compression_ratio": s.compression_ratio,
"no_speech_prob": s.no_speech_prob,
"temperature": s.temperature,
}
for s in segments
],
})
return PlainTextResponse(text)
finally:
try:
os.unlink(tmp.name)
except OSError:
pass
+5
View File
@@ -0,0 +1,5 @@
faster-whisper==1.0.3
requests==2.32.3
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9