| """ |
| 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">🎤</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 = 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 |
|
|
|
|
| |
|
|
| @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: |
| data, sr = sf.read(io.BytesIO(contents)) |
| except Exception: |
| |
| 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": ""}) |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|