diff --git a/apps/api/src/ai/ai-stt.service.ts b/apps/api/src/ai/ai-stt.service.ts index a8d27ec..beb7b09 100644 --- a/apps/api/src/ai/ai-stt.service.ts +++ b/apps/api/src/ai/ai-stt.service.ts @@ -38,7 +38,7 @@ export class AiSttService { file.originalname || 'audio.wav', ); - const timeoutMs = Number(this.config.get('STT_TIMEOUT_MS') || 60000); + const timeoutMs = Number(this.config.get('STT_TIMEOUT_MS') || 180000); const headers: Record = {}; const token = this.config.get('STT_INTERNAL_TOKEN')?.trim(); if (token) headers['X-STT-Token'] = token; diff --git a/apps/stt/.env.example b/apps/stt/.env.example index 1766800..5a738af 100644 --- a/apps/stt/.env.example +++ b/apps/stt/.env.example @@ -1,7 +1,8 @@ -# Local Pipecat Whisper — no GapGPT / OpenAI cloud key required for STT. -# Use a local size: tiny | base | small | medium | large-v3 +# Local Faster-Whisper (same engine as Pipecat local STT) — no GapGPT. +# Sizes: tiny | base | small | medium | large-v3 # Do NOT use cloud names like whisper-1 -STT_MODEL=small +# On low-RAM servers prefer: base or tiny +STT_MODEL=base STT_DEVICE=cpu STT_COMPUTE_TYPE=int8 STT_PROMPT=یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس. diff --git a/apps/stt/README.md b/apps/stt/README.md index 82bc9d2..30fdfb8 100644 --- a/apps/stt/README.md +++ b/apps/stt/README.md @@ -1,22 +1,21 @@ -# Montact STT (Pipecat — Whisper محلی) +# Montact STT — Whisper محلی (بدون GapGPT) -تبدیل صوت به متن — فقط STT. بدون TTS، بدون مکالمهٔ صوتی، **بدون GapGPT**. +تبدیل صوت به متن روی همین سرور با **Faster-Whisper** (همان موتوری که [Pipecat](https://www.pipecat.ai/) برای STT محلی استفاده می‌کند). +کلید ابری لازم نیست. -از [Pipecat](https://www.pipecat.ai/) سرویس `WhisperSTTService` استفاده می‌شود؛ مدل Faster-Whisper روی **همین سرور** اجرا می‌شود و کلید ابری لازم نیست. +## نصب -## لوکال (ویندوز) +Python 3.11+ و ترجیحاً `ffmpeg`. -Python 3.11+ و ترجیحاً `ffmpeg` در PATH. - -```powershell +```bash cd apps/stt -py -3.11 -m venv .venv -.\.venv\Scripts\Activate.ps1 +python3.11 -m venv .venv +source .venv/bin/activate # ویندوز: .\.venv\Scripts\Activate.ps1 pip install -r requirements.txt -copy .env.example .env +cp .env.example .env ``` -```powershell +```bash uvicorn main:app --host 127.0.0.1 --port 3040 ``` @@ -25,31 +24,18 @@ uvicorn main:app --host 127.0.0.1 --port 3040 ``` STT_ENABLED=true STT_URL=http://127.0.0.1:3040 +STT_TIMEOUT_MS=180000 STT_INTERNAL_TOKEN= ``` -## تنظیمات `apps/stt/.env` +## تنظیمات -| کلید | پیش‌فرض | توضیح | +| کلید | پیشنهاد | توضیح | |------|---------|--------| -| `STT_MODEL` | `small` | `tiny` / `base` / `small` / `medium` (فارسی: حداقل `small`) | -| `STT_DEVICE` | `cpu` | یا `cuda` اگر GPU دارید | -| `STT_COMPUTE_TYPE` | `int8` | برای CPU؛ روی GPU معمولاً `float16` | -| `STT_PROMPT` | متن فارسی فروش | راهنمای املا/اصطلاحات | -| `STT_INTERNAL_TOKEN` | خالی | اگر پر شود باید با API یکی باشد | +| `STT_MODEL` | `base` | `tiny` سبک‌تر، `small` دقیق‌تر برای فارسی | +| `STT_DEVICE` | `cpu` | یا `cuda` | +| `STT_COMPUTE_TYPE` | `int8` | روی GPU معمولاً `float16` | -**دیگر به `OPENAI_API_KEY` / GapGPT برای دیکته نیاز نیست.** +نام‌های ابری مثل `whisper-1` را نگذارید (اگر بماند خودکار به `base` نگاشت می‌شود). -اولین اجرا مدل را از Hugging Face دانلود می‌کند (چند صد مگابایت تا چند گیگ و بسته به مدل). - -## سرور - -```bash -cd /var/www/crm/apps/stt -source .venv/bin/activate # یا مسیر venv فعلی -pip install -r requirements.txt -# OPENAI_* را از .env بردارید یا نادیده بگیرید -pm2 restart mento-stt -``` - -اگر RAM کم است، `STT_MODEL=base` یا `tiny` بگذارید. +اولین اجرا مدل را دانلود می‌کند. وضعیت: `curl -s http://127.0.0.1:3040/health` — وقتی `"ready": true` شد آماده است. diff --git a/apps/stt/main.py b/apps/stt/main.py index 351e432..5692f89 100644 --- a/apps/stt/main.py +++ b/apps/stt/main.py @@ -1,29 +1,35 @@ -"""Speech-to-text sidecar — Pipecat local Whisper only (no GapGPT / cloud STT).""" +"""Speech-to-text sidecar — local Faster-Whisper (Pipecat stack), no GapGPT.""" from __future__ import annotations import io +import logging import os import shutil import subprocess import tempfile +import threading import wave +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 -from pipecat.services.whisper.stt import WhisperSTTService -from pipecat.transcriptions.language import Language +from faster_whisper import WhisperModel load_dotenv(Path(__file__).resolve().parent / ".env") +logger = logging.getLogger("mento-stt") +logging.basicConfig(level=logging.INFO) + MAX_BYTES = 8 * 1024 * 1024 WAV_MAGIC = (b"RIFF", b"WAVE") -app = FastAPI(title="Mento STT", docs_url=None, redoc_url=None) -_stt: WhisperSTTService | None = None +_model: WhisperModel | None = None +_model_lock = threading.Lock() +_model_error: str | None = None def _env(name: str, default: str = "") -> str: @@ -40,11 +46,10 @@ def _require_token(header_token: str | None) -> None: def _model_name() -> str: """Map env / legacy cloud names to Faster-Whisper local sizes.""" - raw = (_env("STT_MODEL", "small") or "small").lower() - # Old GapGPT/OpenAI cloud id — not a local Whisper size + raw = (_env("STT_MODEL", "base") or "base").lower() legacy = { - "whisper-1": "small", - "whisper": "small", + "whisper-1": "base", + "whisper": "base", "gpt-4o-transcribe": "small", "gpt-4o-mini-transcribe": "base", } @@ -68,70 +73,46 @@ def _model_name() -> str: "distil-large-v3", "large-v3-turbo", } - if raw in allowed: - return raw - # unknown → safe default for Persian - return "small" + return raw if raw in allowed else "base" -def _stt_service() -> WhisperSTTService: - """Local Faster-Whisper via Pipecat — no OPENAI_API_KEY / GapGPT.""" - global _stt - if _stt is not None: - return _stt +def _get_model() -> WhisperModel: + """Load once, thread-safe. Only cache after success.""" + global _model, _model_error + if _model is not None: + return _model - device = _env("STT_DEVICE", "cpu") or "cpu" - compute = _env("STT_COMPUTE_TYPE", "int8") or "int8" - - try: - # Settings fields differ by Pipecat version — keep to model + language only - settings_kwargs: dict = {"model": _model_name()} - try: - settings_kwargs["language"] = Language.FA - settings = WhisperSTTService.Settings(**settings_kwargs) - except TypeError: - settings_kwargs.pop("language", None) - settings = WhisperSTTService.Settings(**settings_kwargs) - - _stt = WhisperSTTService( - device=device, - compute_type=compute, - settings=settings, - ) - except TypeError: - # Older constructor: model=/language= kwargs instead of settings= - try: - _stt = WhisperSTTService( - model=_model_name(), - device=device, - compute_type=compute, - language=Language.FA, - ) - except Exception as exc: + with _model_lock: + if _model is not None: + return _model + if _model_error: raise HTTPException( status_code=503, - detail=f"بارگذاری مدل Whisper محلی ناموفق بود: {exc}", - ) from exc - except Exception as exc: - raise HTTPException( - status_code=503, - detail=f"بارگذاری مدل Whisper محلی ناموفق بود: {exc}", - ) from exc + detail=f"مدل Whisper آماده نیست: {_model_error}", + ) - # force model load at startup path (first request otherwise downloads silently) - if getattr(_stt, "_model", None) is None and hasattr(_stt, "_load"): + 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: - _stt._load() + loaded = WhisperModel(name, device=device, compute_type=compute) except Exception as exc: + _model_error = str(exc) + logger.exception("Whisper model load failed") raise HTTPException( status_code=503, detail=( - "مدل Whisper دانلود/بارگذاری نشد. " - f"اینترنت سرور و فضای دیسک را چک کنید. جزئیات: {exc}" + "بارگذاری مدل Whisper محلی ناموفق بود. " + "اگر اولین بار است، دانلود طول می‌کشد؛ یا STT_MODEL=tiny/base بگذارید. " + f"جزئیات: {exc}" ), ) from exc - return _stt + _model = loaded + _model_error = None + logger.info("Whisper model ready: %s", name) + return _model def _is_wav(data: bytes) -> bool: @@ -196,44 +177,32 @@ def _wav_to_float32(wav_bytes: bytes) -> tuple[np.ndarray, int]: return audio, rate -def _transcribe_local(wav_bytes: bytes) -> str: - service = _stt_service() - model = getattr(service, "_model", None) - if model is None: - raise HTTPException(status_code=503, detail="مدل Whisper محلی آماده نیست") - +def _ensure_16k(wav_bytes: bytes) -> np.ndarray: audio, rate = _wav_to_float32(wav_bytes) - # faster-whisper expects 16k mono float32 ideally - if rate != 16000: - # simple resample via ffmpeg path already targets 16k for non-wav; - # for wav at other rates, re-encode quickly - 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, rate = _wav_to_float32(dst.read_bytes()) + if rate == 16000: + return audio - settings = getattr(service, "_settings", None) - language = getattr(settings, "language", None) or Language.FA - lang_code = getattr(language, "value", language) if language is not None else "fa" - if isinstance(lang_code, str) and "-" in lang_code: - lang_code = lang_code.split("-")[0] - # Language enum may be "fa" or "FA" or full name — faster-whisper wants ISO code - if isinstance(lang_code, str): - lang_code = lang_code.lower() - if lang_code in ("persian", "farsi"): - lang_code = "fa" + 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 + +def _transcribe_local(wav_bytes: bytes) -> str: + model = _get_model() + audio = _ensure_16k(wav_bytes) initial_prompt = _env( "STT_PROMPT", "یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس.", @@ -241,7 +210,7 @@ def _transcribe_local(wav_bytes: bytes) -> str: segments, _info = model.transcribe( audio, - language=lang_code or "fa", + language="fa", initial_prompt=initial_prompt, vad_filter=True, ) @@ -255,23 +224,41 @@ def _friendly_stt_error(exc: BaseException) -> tuple[int, str]: if "timeout" in lowered or "timed out" in lowered: return 504, "زمان تبدیل صوت به پایان رسید. دوباره با صدای کوتاه‌تر تلاش کنید." if "memory" in lowered or "oom" in lowered: - return 503, "حافظهٔ سرور برای مدل Whisper کافی نیست. مدل کوچک‌تر (tiny/base) را امتحان کنید." - if len(text) > 180: + return 503, "حافظهٔ سرور برای مدل Whisper کافی نیست. STT_MODEL=tiny یا base بگذارید." + if len(text) > 220: return 502, "تبدیل صوت به متن محلی ناموفق بود. لاگ mento-stt را بررسی کنید." return 502, f"تبدیل صوت به متن ناموفق بود: {text}" +@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, - "engine": "pipecat", - "service": "WhisperSTTService", + "engine": "faster-whisper", + "service": "local", "mode": "local", - "model": _env("STT_MODEL", "small"), + "model": _model_name(), "device": _env("STT_DEVICE", "cpu") or "cpu", + "ready": _model is not None, "cloud": False, "gapgpt": False, + "lastError": _model_error, } diff --git a/apps/stt/requirements.txt b/apps/stt/requirements.txt index ea739bf..6f95857 100644 --- a/apps/stt/requirements.txt +++ b/apps/stt/requirements.txt @@ -3,5 +3,5 @@ uvicorn[standard]==0.35.0 python-multipart==0.0.20 python-dotenv==1.1.1 numpy>=1.26,<3 -# Local Whisper via Pipecat (Faster-Whisper). No OpenAI/GapGPT cloud STT. -pipecat-ai[whisper]==1.8.1 +# Same engine Pipecat uses for local STT — no GapGPT/OpenAI cloud. +faster-whisper>=1.1.0,<2 diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 03e3438..a766fa5 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -153,9 +153,10 @@ cp .env.example .env nano .env ``` -در `.env` فقط مدل محلی لازم است (`STT_MODEL=small`، `STT_DEVICE=cpu`). +در `.env` فقط مدل محلی لازم است (`STT_MODEL=base`، `STT_DEVICE=cpu`). `OPENAI_API_KEY` برای دیکته لازم نیست. -`STT_INTERNAL_TOKEN` اگر پر شد باید با `apps/api/.env` یکی باشد. +`STT_INTERNAL_TOKEN` اگر پر شد باید با `apps/api/.env` یکی باشد. +در API: `STT_TIMEOUT_MS=180000` پیشنهاد می‌شود (لود اول مدل طول می‌کشد). ### ۷) PM2 — فقط پروسه‌های جدید