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', 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 headers: Record<string, string> = {};
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim(); const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim();
if (token) headers['X-STT-Token'] = token; 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. # Local Faster-Whisper (same engine as Pipecat local STT) — no GapGPT.
# Use a local size: tiny | base | small | medium | large-v3 # Sizes: tiny | base | small | medium | large-v3
# Do NOT use cloud names like whisper-1 # 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_DEVICE=cpu
STT_COMPUTE_TYPE=int8 STT_COMPUTE_TYPE=int8
STT_PROMPT=یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس. 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. ```bash
```powershell
cd apps/stt cd apps/stt
py -3.11 -m venv .venv python3.11 -m venv .venv
.\.venv\Scripts\Activate.ps1 source .venv/bin/activate # ویندوز: .\.venv\Scripts\Activate.ps1
pip install -r requirements.txt 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 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_ENABLED=true
STT_URL=http://127.0.0.1:3040 STT_URL=http://127.0.0.1:3040
STT_TIMEOUT_MS=180000
STT_INTERNAL_TOKEN= STT_INTERNAL_TOKEN=
``` ```
## تنظیمات `apps/stt/.env` ## تنظیمات
| کلید | پیش‌فرض | توضیح | | کلید | پیشنهاد | توضیح |
|------|---------|--------| |------|---------|--------|
| `STT_MODEL` | `small` | `tiny` / `base` / `small` / `medium` (فارسی: حداقل `small`) | | `STT_MODEL` | `base` | `tiny` سبک‌تر، `small` دقیق‌تر برای فارسی |
| `STT_DEVICE` | `cpu` | یا `cuda` اگر GPU دارید | | `STT_DEVICE` | `cpu` | یا `cuda` |
| `STT_COMPUTE_TYPE` | `int8` | برای CPU؛ روی GPU معمولاً `float16` | | `STT_COMPUTE_TYPE` | `int8` | روی GPU معمولاً `float16` |
| `STT_PROMPT` | متن فارسی فروش | راهنمای املا/اصطلاحات |
| `STT_INTERNAL_TOKEN` | خالی | اگر پر شود باید با API یکی باشد |
**دیگر به `OPENAI_API_KEY` / GapGPT برای دیکته نیاز نیست.** نام‌های ابری مثل `whisper-1` را نگذارید (اگر بماند خودکار به `base` نگاشت می‌شود).
اولین اجرا مدل را از Hugging Face دانلود می‌کند (چند صد مگابایت تا چند گیگ و بسته به مدل). اولین اجرا مدل را دانلود می‌کند. وضعیت: `curl -s http://127.0.0.1:3040/health` — وقتی `"ready": true` شد آماده است.
## سرور
```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` بگذارید.
+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 from __future__ import annotations
import io import io
import logging
import os import os
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
import threading
import wave import wave
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI, File, Header, HTTPException, UploadFile from fastapi import FastAPI, File, Header, HTTPException, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pipecat.services.whisper.stt import WhisperSTTService from faster_whisper import WhisperModel
from pipecat.transcriptions.language import Language
load_dotenv(Path(__file__).resolve().parent / ".env") load_dotenv(Path(__file__).resolve().parent / ".env")
logger = logging.getLogger("mento-stt")
logging.basicConfig(level=logging.INFO)
MAX_BYTES = 8 * 1024 * 1024 MAX_BYTES = 8 * 1024 * 1024
WAV_MAGIC = (b"RIFF", b"WAVE") WAV_MAGIC = (b"RIFF", b"WAVE")
app = FastAPI(title="Mento STT", docs_url=None, redoc_url=None) _model: WhisperModel | None = None
_stt: WhisperSTTService | None = None _model_lock = threading.Lock()
_model_error: str | None = None
def _env(name: str, default: str = "") -> str: def _env(name: str, default: str = "") -> str:
@@ -40,11 +46,10 @@ def _require_token(header_token: str | None) -> None:
def _model_name() -> str: def _model_name() -> str:
"""Map env / legacy cloud names to Faster-Whisper local sizes.""" """Map env / legacy cloud names to Faster-Whisper local sizes."""
raw = (_env("STT_MODEL", "small") or "small").lower() raw = (_env("STT_MODEL", "base") or "base").lower()
# Old GapGPT/OpenAI cloud id — not a local Whisper size
legacy = { legacy = {
"whisper-1": "small", "whisper-1": "base",
"whisper": "small", "whisper": "base",
"gpt-4o-transcribe": "small", "gpt-4o-transcribe": "small",
"gpt-4o-mini-transcribe": "base", "gpt-4o-mini-transcribe": "base",
} }
@@ -68,70 +73,46 @@ def _model_name() -> str:
"distil-large-v3", "distil-large-v3",
"large-v3-turbo", "large-v3-turbo",
} }
if raw in allowed: return raw if raw in allowed else "base"
return raw
# unknown → safe default for Persian
return "small"
def _stt_service() -> WhisperSTTService: def _get_model() -> WhisperModel:
"""Local Faster-Whisper via Pipecat — no OPENAI_API_KEY / GapGPT.""" """Load once, thread-safe. Only cache after success."""
global _stt global _model, _model_error
if _stt is not None: if _model is not None:
return _stt return _model
device = _env("STT_DEVICE", "cpu") or "cpu" with _model_lock:
compute = _env("STT_COMPUTE_TYPE", "int8") or "int8" if _model is not None:
return _model
try: if _model_error:
# 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:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail=f"بارگذاری مدل Whisper محلی ناموفق بود: {exc}", detail=f"مدل Whisper آماده نیست: {_model_error}",
) from exc )
except Exception as exc:
raise HTTPException(
status_code=503,
detail=f"بارگذاری مدل Whisper محلی ناموفق بود: {exc}",
) from exc
# force model load at startup path (first request otherwise downloads silently) name = _model_name()
if getattr(_stt, "_model", None) is None and hasattr(_stt, "_load"): 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: try:
_stt._load() loaded = WhisperModel(name, device=device, compute_type=compute)
except Exception as exc: except Exception as exc:
_model_error = str(exc)
logger.exception("Whisper model load failed")
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail=( detail=(
"مدل Whisper دانلود/بارگذاری نشد. " "بارگذاری مدل Whisper محلی ناموفق بود. "
f"اینترنت سرور و فضای دیسک را چک کنید. جزئیات: {exc}" "اگر اولین بار است، دانلود طول می‌کشد؛ یا STT_MODEL=tiny/base بگذارید. "
f"جزئیات: {exc}"
), ),
) from 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: def _is_wav(data: bytes) -> bool:
@@ -196,44 +177,32 @@ def _wav_to_float32(wav_bytes: bytes) -> tuple[np.ndarray, int]:
return audio, rate return audio, rate
def _transcribe_local(wav_bytes: bytes) -> str: def _ensure_16k(wav_bytes: bytes) -> np.ndarray:
service = _stt_service()
model = getattr(service, "_model", None)
if model is None:
raise HTTPException(status_code=503, detail="مدل Whisper محلی آماده نیست")
audio, rate = _wav_to_float32(wav_bytes) audio, rate = _wav_to_float32(wav_bytes)
# faster-whisper expects 16k mono float32 ideally if rate == 16000:
if rate != 16000: return audio
# 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())
settings = getattr(service, "_settings", None) ffmpeg = shutil.which("ffmpeg")
language = getattr(settings, "language", None) or Language.FA if not ffmpeg:
lang_code = getattr(language, "value", language) if language is not None else "fa" raise HTTPException(status_code=400, detail="نرخ نمونه‌برداری صوت پشتیبانی نشد")
if isinstance(lang_code, str) and "-" in lang_code: with tempfile.TemporaryDirectory() as tmp:
lang_code = lang_code.split("-")[0] src = Path(tmp) / "in.wav"
# Language enum may be "fa" or "FA" or full name — faster-whisper wants ISO code dst = Path(tmp) / "out.wav"
if isinstance(lang_code, str): src.write_bytes(wav_bytes)
lang_code = lang_code.lower() result = subprocess.run(
if lang_code in ("persian", "farsi"): [ffmpeg, "-y", "-i", str(src), "-ac", "1", "-ar", "16000", "-f", "wav", str(dst)],
lang_code = "fa" 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( initial_prompt = _env(
"STT_PROMPT", "STT_PROMPT",
"یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس.", "یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس.",
@@ -241,7 +210,7 @@ def _transcribe_local(wav_bytes: bytes) -> str:
segments, _info = model.transcribe( segments, _info = model.transcribe(
audio, audio,
language=lang_code or "fa", language="fa",
initial_prompt=initial_prompt, initial_prompt=initial_prompt,
vad_filter=True, 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: if "timeout" in lowered or "timed out" in lowered:
return 504, "زمان تبدیل صوت به پایان رسید. دوباره با صدای کوتاه‌تر تلاش کنید." return 504, "زمان تبدیل صوت به پایان رسید. دوباره با صدای کوتاه‌تر تلاش کنید."
if "memory" in lowered or "oom" in lowered: if "memory" in lowered or "oom" in lowered:
return 503, "حافظهٔ سرور برای مدل Whisper کافی نیست. مدل کوچک‌تر (tiny/base) را امتحان کنید." return 503, "حافظهٔ سرور برای مدل Whisper کافی نیست. STT_MODEL=tiny یا base بگذارید."
if len(text) > 180: if len(text) > 220:
return 502, "تبدیل صوت به متن محلی ناموفق بود. لاگ mento-stt را بررسی کنید." return 502, "تبدیل صوت به متن محلی ناموفق بود. لاگ mento-stt را بررسی کنید."
return 502, f"تبدیل صوت به متن ناموفق بود: {text}" 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") @app.get("/health")
async def health() -> dict[str, object]: async def health() -> dict[str, object]:
return { return {
"ok": True, "ok": True,
"engine": "pipecat", "engine": "faster-whisper",
"service": "WhisperSTTService", "service": "local",
"mode": "local", "mode": "local",
"model": _env("STT_MODEL", "small"), "model": _model_name(),
"device": _env("STT_DEVICE", "cpu") or "cpu", "device": _env("STT_DEVICE", "cpu") or "cpu",
"ready": _model is not None,
"cloud": False, "cloud": False,
"gapgpt": 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-multipart==0.0.20
python-dotenv==1.1.1 python-dotenv==1.1.1
numpy>=1.26,<3 numpy>=1.26,<3
# Local Whisper via Pipecat (Faster-Whisper). No OpenAI/GapGPT cloud STT. # Same engine Pipecat uses for local STT — no GapGPT/OpenAI cloud.
pipecat-ai[whisper]==1.8.1 faster-whisper>=1.1.0,<2
+3 -2
View File
@@ -153,9 +153,10 @@ cp .env.example .env
nano .env nano .env
``` ```
در `.env` فقط مدل محلی لازم است (`STT_MODEL=small`، `STT_DEVICE=cpu`). در `.env` فقط مدل محلی لازم است (`STT_MODEL=base`، `STT_DEVICE=cpu`).
`OPENAI_API_KEY` برای دیکته لازم نیست. `OPENAI_API_KEY` برای دیکته لازم نیست.
`STT_INTERNAL_TOKEN` اگر پر شد باید با `apps/api/.env` یکی باشد. `STT_INTERNAL_TOKEN` اگر پر شد باید با `apps/api/.env` یکی باشد.
در API: `STT_TIMEOUT_MS=180000` پیشنهاد می‌شود (لود اول مدل طول می‌کشد).
### ۷) PM2 — فقط پروسه‌های جدید ### ۷) PM2 — فقط پروسه‌های جدید