Modification of the STT structure timeout

This commit is contained in:
2026-09-22 11:37:59 +03:30
parent d53136d91c
commit 42db5919e1
6 changed files with 115 additions and 140 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ export class AiSttService {
file.originalname || 'audio.wav',
);
const timeoutMs = Number(this.config.get<string>('STT_TIMEOUT_MS') || 60000);
const timeoutMs = Number(this.config.get<string>('STT_TIMEOUT_MS') || 180000);
const headers: Record<string, string> = {};
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim();
if (token) headers['X-STT-Token'] = token;
+4 -3
View File
@@ -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=یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس.
+18 -32
View File
@@ -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` شد آماده است.
+87 -100
View File
@@ -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,
}
+2 -2
View File
@@ -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
+3 -2
View File
@@ -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 — فقط پروسه‌های جدید