stt-model / app.py
Yash030's picture
fix: remove forced language param - causes hallucination on tiny model
a6c3607
Raw
History Blame Contribute Delete
6.78 kB
"""
RealtimeSTT FastAPI server — deploy to Hugging Face Spaces.
POST /transcribe — upload audio, returns {"text": "..."}
GET /health — health check
GET / — browser recording web UI
"""
import io
import logging
import os
import tempfile
import threading
from pathlib import Path
from fastapi import FastAPI, File, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("realtimestt")
app = FastAPI(title="RealtimeSTT HF Space")
INDEX_HTML = r"""<!DOCTYPE html>
<html>
<head>
<title>RealtimeSTT</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0;
display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.container { max-width: 600px; width: 100%; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 1rem; text-align: center; }
#status { text-align: center; padding: 0.5rem; border-radius: 8px; margin-bottom: 1rem;
background: #1a1a2e; font-size: 0.9rem; }
#record-btn { display: block; width: 80px; height: 80px; border-radius: 50%;
border: 3px solid #e74c3c; background: #1a1a2e; color: #e74c3c;
font-size: 1rem; cursor: pointer; margin: 1rem auto; transition: 0.2s; }
#record-btn.recording { background: #e74c3c; color: white; box-shadow: 0 0 20px #e74c3c88; }
#result { background: #1a1a2e; border-radius: 8px; padding: 1rem; min-height: 60px;
white-space: pre-wrap; word-wrap: break-word; margin-top: 1rem; font-size: 0.95rem; }
</style>
</head>
<body>
<div class="container">
<h1>RealtimeSTT</h1>
<div id="status">Ready</div>
<button id="record-btn">&#127908;</button>
<div id="result"></div>
</div>
<script>
const btn = document.getElementById('record-btn');
const status = document.getElementById('status');
const result = document.getElementById('result');
let recording = false, mediaRecorder = null, chunks = [], stream = null;
btn.addEventListener('click', async () => {
if (recording) {
mediaRecorder.stop();
stream.getTracks().forEach(t => t.stop());
btn.classList.remove('recording');
btn.textContent = '\u{1F3A4}';
status.textContent = 'Transcribing...';
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => chunks.push(e.data);
mediaRecorder.onstop = async () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
const fd = new FormData();
fd.append('file', blob, 'audio.webm');
try {
const res = await fetch('/transcribe', { method: 'POST', body: fd });
const data = await res.json();
result.textContent = data.text || '(no speech detected)';
status.textContent = 'Done';
} catch(e) { status.textContent = 'Error: ' + e.message; }
};
mediaRecorder.start();
recording = true;
btn.classList.add('recording');
btn.textContent = '■';
status.textContent = 'Recording... click to stop';
} catch(e) { status.textContent = 'Mic denied: ' + e.message; }
});
</script>
</body>
</html>"""
# --- Model management ---
_model = None
_model_lock = threading.Lock()
def get_model():
global _model
if _model is None:
with _model_lock:
if _model is None:
from faster_whisper import WhisperModel
model_size = os.getenv("STT_MODEL", "tiny")
device = os.getenv("STT_DEVICE", "cpu")
compute_type = os.getenv("STT_COMPUTE", "int8" if device == "cpu" else "float16")
logger.info(f"Loading faster-whisper {model_size} ({device}/{compute_type})...")
_model = WhisperModel(model_size, device=device, compute_type=compute_type)
logger.info("Model loaded.")
return _model
# --- Routes ---
@app.get("/")
async def index():
return HTMLResponse(INDEX_HTML)
@app.get("/health")
async def health():
return JSONResponse({"ok": True, "ready": _model is not None})
@app.post("/transcribe")
async def transcribe(file: UploadFile = File(...)):
import soundfile as sf
import numpy as np
model = get_model()
contents = await file.read()
try:
# Try to decode as WAV or other soundfile-supported format
try:
data, sr = sf.read(io.BytesIO(contents))
except Exception:
# WebM/Opus — convert via ffmpeg pipe
import subprocess
proc = subprocess.run(
["ffmpeg", "-y", "-i", "pipe:0", "-ar", "16000", "-ac", "1",
"-sample_fmt", "s16", "-f", "wav", "pipe:1"],
input=contents, capture_output=True, timeout=30, check=True
)
data, sr = sf.read(io.BytesIO(proc.stdout))
if len(data) == 0:
return JSONResponse({"text": ""})
# Ensure mono, float32, normalized to [-1, 1]
if data.ndim > 1:
data = data.mean(axis=1)
data = data.astype(np.float32)
max_val = data.max()
if max_val > 1.0:
data /= 32768.0
# Resample to 16kHz if needed (Whisper expects 16kHz)
if sr != 16000:
from scipy.signal import resample_poly
data = resample_poly(data, 16000, sr)
sr = 16000
max_val = data.max()
if max_val > 1.0:
data /= 32768.0
logger.info(f"Audio: {len(data)} samples, sr={sr}, "
f"rms={np.sqrt(np.mean(data**2)):.5f}, "
f"max={data.max():.5f}, min={data.min():.5f}")
transcribe_kwargs = {"beam_size": 5, "no_speech_threshold": 0.99, "compression_ratio_threshold": 2.0}
segments, info = model.transcribe(data, **transcribe_kwargs)
seg_texts = []
for seg in segments:
seg_texts.append(seg.text)
logger.info(f"Segment [{seg.start:.2f}-{seg.end:.2f}]: {seg.text.strip()} "
f"(no_speech={seg.no_speech_prob:.2f})")
text = " ".join(seg_texts)
return JSONResponse({"text": text.strip()})
except Exception as e:
logger.error(f"Transcription failed: {e}")
return JSONResponse({"text": "", "error": str(e)}, status_code=500)
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", "7860"))
uvicorn.run(app, host="0.0.0.0", port=port)