"""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), hotwords: 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, hotwords=hotwords, ) 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