Files
CRM_Montzar/apps/stt/main.py
T

289 lines
9.1 KiB
Python
Raw Normal View History

2026-09-22 11:37:59 +03:30
"""Speech-to-text sidecar — local Faster-Whisper (Pipecat stack), no GapGPT."""
from __future__ import annotations
import io
2026-09-22 11:37:59 +03:30
import logging
import os
import shutil
import subprocess
import tempfile
2026-09-22 11:37:59 +03:30
import threading
import wave
2026-09-22 11:37:59 +03:30
from contextlib import asynccontextmanager
from pathlib import Path
import numpy as np
from dotenv import load_dotenv
from fastapi import FastAPI, File, Header, HTTPException, UploadFile
from fastapi.responses import JSONResponse
2026-09-22 11:37:59 +03:30
from faster_whisper import WhisperModel
load_dotenv(Path(__file__).resolve().parent / ".env")
2026-09-22 11:37:59 +03:30
logger = logging.getLogger("mento-stt")
logging.basicConfig(level=logging.INFO)
MAX_BYTES = 8 * 1024 * 1024
WAV_MAGIC = (b"RIFF", b"WAVE")
2026-09-22 11:37:59 +03:30
_model: WhisperModel | None = None
_model_lock = threading.Lock()
_model_error: str | None = None
def _env(name: str, default: str = "") -> str:
return (os.getenv(name) or default).strip()
def _require_token(header_token: str | None) -> None:
expected = _env("STT_INTERNAL_TOKEN")
if not expected:
return
if (header_token or "").strip() != expected:
raise HTTPException(status_code=401, detail="توکن سرویس تبدیل صوت نامعتبر است")
def _model_name() -> str:
2026-09-22 11:30:07 +03:30
"""Map env / legacy cloud names to Faster-Whisper local sizes."""
2026-09-22 11:37:59 +03:30
raw = (_env("STT_MODEL", "base") or "base").lower()
2026-09-22 11:30:07 +03:30
legacy = {
2026-09-22 11:37:59 +03:30
"whisper-1": "base",
"whisper": "base",
2026-09-22 11:30:07 +03:30
"gpt-4o-transcribe": "small",
"gpt-4o-mini-transcribe": "base",
}
if raw in legacy:
return legacy[raw]
allowed = {
"tiny",
"tiny.en",
"base",
"base.en",
"small",
"small.en",
"medium",
"medium.en",
"large",
"large-v1",
"large-v2",
"large-v3",
"distil-large-v2",
"distil-medium.en",
"distil-large-v3",
"large-v3-turbo",
}
2026-09-22 11:37:59 +03:30
return raw if raw in allowed else "base"
2026-09-22 11:37:59 +03:30
def _get_model() -> WhisperModel:
"""Load once, thread-safe. Only cache after success."""
global _model, _model_error
if _model is not None:
return _model
2026-09-22 11:37:59 +03:30
with _model_lock:
if _model is not None:
return _model
if _model_error:
2026-09-22 11:16:17 +03:30
raise HTTPException(
status_code=503,
2026-09-22 11:37:59 +03:30
detail=f"مدل Whisper آماده نیست: {_model_error}",
)
2026-09-22 11:37:59 +03:30
name = _model_name()
device = _env("STT_DEVICE", "cpu") or "cpu"
compute = _env("STT_COMPUTE_TYPE", "int8") or "int8"
logger.info("Loading Faster-Whisper model=%s device=%s compute=%s", name, device, compute)
try:
2026-09-22 11:37:59 +03:30
loaded = WhisperModel(name, device=device, compute_type=compute)
except Exception as exc:
2026-09-22 11:37:59 +03:30
_model_error = str(exc)
logger.exception("Whisper model load failed")
raise HTTPException(
status_code=503,
detail=(
2026-09-22 11:37:59 +03:30
"بارگذاری مدل Whisper محلی ناموفق بود. "
"اگر اولین بار است، دانلود طول می‌کشد؛ یا STT_MODEL=tiny/base بگذارید. "
f"جزئیات: {exc}"
),
) from exc
2026-09-22 11:37:59 +03:30
_model = loaded
_model_error = None
logger.info("Whisper model ready: %s", name)
return _model
def _is_wav(data: bytes) -> bool:
return len(data) >= 12 and data[:4] == WAV_MAGIC[0] and data[8:12] == WAV_MAGIC[1]
def _to_wav(data: bytes, filename: str) -> bytes:
if _is_wav(data):
return data
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
raise HTTPException(
status_code=400,
detail="فرمت صوت پشتیبانی نشد. فایل WAV ارسال کنید یا ffmpeg را نصب کنید.",
)
suffix = Path(filename or "audio.webm").suffix or ".webm"
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / f"in{suffix}"
dst = Path(tmp) / "out.wav"
src.write_bytes(data)
result = subprocess.run(
[
ffmpeg,
"-y",
"-i",
str(src),
"-ac",
"1",
"-ar",
"16000",
"-f",
"wav",
str(dst),
],
capture_output=True,
check=False,
)
if result.returncode != 0 or not dst.exists():
raise HTTPException(status_code=400, detail="تبدیل فایل صوتی ناموفق بود")
return dst.read_bytes()
def _wav_to_float32(wav_bytes: bytes) -> tuple[np.ndarray, int]:
with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
channels = wf.getnchannels()
sample_width = wf.getsampwidth()
rate = wf.getframerate()
frames = wf.readframes(wf.getnframes())
if sample_width == 2:
audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
elif sample_width == 4:
audio = np.frombuffer(frames, dtype=np.int32).astype(np.float32) / 2147483648.0
else:
raise HTTPException(status_code=400, detail="فرمت نمونهٔ صوتی پشتیبانی نمی‌شود")
if channels > 1:
audio = audio.reshape(-1, channels).mean(axis=1)
return audio, rate
2026-09-22 11:37:59 +03:30
def _ensure_16k(wav_bytes: bytes) -> np.ndarray:
audio, rate = _wav_to_float32(wav_bytes)
2026-09-22 11:37:59 +03:30
if rate == 16000:
return audio
2026-09-22 11:37:59 +03:30
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
raise HTTPException(status_code=400, detail="نرخ نمونه‌برداری صوت پشتیبانی نشد")
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "in.wav"
dst = Path(tmp) / "out.wav"
src.write_bytes(wav_bytes)
result = subprocess.run(
[ffmpeg, "-y", "-i", str(src), "-ac", "1", "-ar", "16000", "-f", "wav", str(dst)],
capture_output=True,
check=False,
)
if result.returncode != 0 or not dst.exists():
raise HTTPException(status_code=400, detail="نرمال‌سازی صوت ناموفق بود")
audio, _ = _wav_to_float32(dst.read_bytes())
return audio
2026-09-22 11:16:17 +03:30
2026-09-22 11:37:59 +03:30
def _transcribe_local(wav_bytes: bytes) -> str:
model = _get_model()
audio = _ensure_16k(wav_bytes)
2026-09-22 11:16:17 +03:30
initial_prompt = _env(
"STT_PROMPT",
"یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس.",
) or None
segments, _info = model.transcribe(
audio,
2026-09-22 11:37:59 +03:30
language="fa",
initial_prompt=initial_prompt,
vad_filter=True,
)
parts = [seg.text.strip() for seg in segments if getattr(seg, "text", None)]
return " ".join(p for p in parts if p).strip()
def _friendly_stt_error(exc: BaseException) -> tuple[int, str]:
text = str(exc)
lowered = text.lower()
if "timeout" in lowered or "timed out" in lowered:
return 504, "زمان تبدیل صوت به پایان رسید. دوباره با صدای کوتاه‌تر تلاش کنید."
if "memory" in lowered or "oom" in lowered:
2026-09-22 11:37:59 +03:30
return 503, "حافظهٔ سرور برای مدل Whisper کافی نیست. STT_MODEL=tiny یا base بگذارید."
if len(text) > 220:
return 502, "تبدیل صوت به متن محلی ناموفق بود. لاگ mento-stt را بررسی کنید."
return 502, f"تبدیل صوت به متن ناموفق بود: {text}"
2026-09-22 11:37:59 +03:30
@asynccontextmanager
async def lifespan(_app: FastAPI):
# Warm model in background thread so first user request is faster
def warm() -> None:
try:
_get_model()
except Exception:
logger.exception("Startup model warm failed (will retry on first request)")
threading.Thread(target=warm, name="stt-warm", daemon=True).start()
yield
app = FastAPI(title="Mento STT", docs_url=None, redoc_url=None, lifespan=lifespan)
@app.get("/health")
async def health() -> dict[str, object]:
return {
"ok": True,
2026-09-22 11:37:59 +03:30
"engine": "faster-whisper",
"service": "local",
"mode": "local",
2026-09-22 11:37:59 +03:30
"model": _model_name(),
"device": _env("STT_DEVICE", "cpu") or "cpu",
2026-09-22 11:37:59 +03:30
"ready": _model is not None,
"cloud": False,
"gapgpt": False,
2026-09-22 11:37:59 +03:30
"lastError": _model_error,
}
@app.post("/transcribe")
async def transcribe(
audio: UploadFile = File(...),
x_stt_token: str | None = Header(default=None, alias="X-STT-Token"),
) -> JSONResponse:
_require_token(x_stt_token)
data = await audio.read()
if not data:
raise HTTPException(status_code=400, detail="فایل صوتی خالی است")
if len(data) > MAX_BYTES:
raise HTTPException(status_code=400, detail="حجم فایل صوتی بیش از حد مجاز است")
wav = _to_wav(data, audio.filename or "audio.wav")
try:
text = _transcribe_local(wav)
except HTTPException:
raise
except Exception as exc:
status, detail = _friendly_stt_error(exc)
raise HTTPException(status_code=status, detail=detail) from exc
return JSONResponse({"text": text})