Refining the speech-to-text conversion structure
This commit is contained in:
+129
-28
@@ -1,17 +1,20 @@
|
||||
"""Speech-to-text sidecar. Uses Pipecat OpenAISTTService only — no TTS, no voice agent."""
|
||||
"""Speech-to-text sidecar — Pipecat local Whisper only (no GapGPT / cloud STT)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import wave
|
||||
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.openai.stt import OpenAISTTService
|
||||
from pipecat.services.whisper.stt import WhisperSTTService
|
||||
from pipecat.transcriptions.language import Language
|
||||
|
||||
load_dotenv(Path(__file__).resolve().parent / ".env")
|
||||
@@ -20,7 +23,7 @@ MAX_BYTES = 8 * 1024 * 1024
|
||||
WAV_MAGIC = (b"RIFF", b"WAVE")
|
||||
|
||||
app = FastAPI(title="Mento STT", docs_url=None, redoc_url=None)
|
||||
_stt: OpenAISTTService | None = None
|
||||
_stt: WhisperSTTService | None = None
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
@@ -35,31 +38,53 @@ def _require_token(header_token: str | None) -> None:
|
||||
raise HTTPException(status_code=401, detail="توکن سرویس تبدیل صوت نامعتبر است")
|
||||
|
||||
|
||||
def _stt_service() -> OpenAISTTService:
|
||||
def _model_name() -> str:
|
||||
# faster-whisper / Pipecat: tiny | base | small | medium | large-v3 | ...
|
||||
return _env("STT_MODEL", "small") or "small"
|
||||
|
||||
|
||||
def _stt_service() -> WhisperSTTService:
|
||||
"""Local Faster-Whisper via Pipecat — no OPENAI_API_KEY / GapGPT."""
|
||||
global _stt
|
||||
if _stt is not None:
|
||||
return _stt
|
||||
|
||||
api_key = _env("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=503, detail="کلید تبدیل صوت تنظیم نشده است")
|
||||
|
||||
model = _env("STT_MODEL", "whisper-1")
|
||||
device = _env("STT_DEVICE", "cpu") or "cpu"
|
||||
compute = _env("STT_COMPUTE_TYPE", "int8") or "int8"
|
||||
prompt = _env(
|
||||
"STT_PROMPT",
|
||||
"یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسبوکار را دقیق بنویس.",
|
||||
)
|
||||
base_url = _env("OPENAI_BASE_URL") or None
|
||||
|
||||
_stt = OpenAISTTService(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
settings=OpenAISTTService.Settings(
|
||||
model=model,
|
||||
language=Language.FA,
|
||||
prompt=prompt,
|
||||
),
|
||||
)
|
||||
try:
|
||||
_stt = WhisperSTTService(
|
||||
device=device,
|
||||
compute_type=compute,
|
||||
settings=WhisperSTTService.Settings(
|
||||
model=_model_name(),
|
||||
language=Language.FA,
|
||||
initial_prompt=prompt or None,
|
||||
),
|
||||
)
|
||||
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)
|
||||
if getattr(_stt, "_model", None) is None and hasattr(_stt, "_load"):
|
||||
try:
|
||||
_stt._load()
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"مدل Whisper دانلود/بارگذاری نشد. "
|
||||
f"اینترنت سرور و فضای دیسک را چک کنید. جزئیات: {exc}"
|
||||
),
|
||||
) from exc
|
||||
|
||||
return _stt
|
||||
|
||||
|
||||
@@ -105,13 +130,93 @@ def _to_wav(data: bytes, filename: str) -> bytes:
|
||||
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
|
||||
|
||||
|
||||
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 محلی آماده نیست")
|
||||
|
||||
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())
|
||||
|
||||
settings = getattr(service, "_settings", None)
|
||||
language = getattr(settings, "language", 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]
|
||||
initial_prompt = getattr(settings, "initial_prompt", None)
|
||||
|
||||
segments, _info = model.transcribe(
|
||||
audio,
|
||||
language=lang_code or "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:
|
||||
return 503, "حافظهٔ سرور برای مدل Whisper کافی نیست. مدل کوچکتر (tiny/base) را امتحان کنید."
|
||||
if len(text) > 180:
|
||||
return 502, "تبدیل صوت به متن محلی ناموفق بود. لاگ mento-stt را بررسی کنید."
|
||||
return 502, f"تبدیل صوت به متن ناموفق بود: {text}"
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, object]:
|
||||
return {
|
||||
"ok": True,
|
||||
"engine": "pipecat",
|
||||
"service": "OpenAISTTService",
|
||||
"configured": bool(_env("OPENAI_API_KEY")),
|
||||
"service": "WhisperSTTService",
|
||||
"mode": "local",
|
||||
"model": _env("STT_MODEL", "small"),
|
||||
"device": _env("STT_DEVICE", "cpu") or "cpu",
|
||||
"cloud": False,
|
||||
"gapgpt": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -129,17 +234,13 @@ async def transcribe(
|
||||
raise HTTPException(status_code=400, detail="حجم فایل صوتی بیش از حد مجاز است")
|
||||
|
||||
wav = _to_wav(data, audio.filename or "audio.wav")
|
||||
service = _stt_service()
|
||||
|
||||
try:
|
||||
result = await service._transcribe(wav)
|
||||
text = _transcribe_local(wav)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"تبدیل صوت به متن ناموفق بود: {exc}",
|
||||
) from exc
|
||||
status, detail = _friendly_stt_error(exc)
|
||||
raise HTTPException(status_code=status, detail=detail) from exc
|
||||
|
||||
text = (getattr(result, "text", None) or "").strip()
|
||||
return JSONResponse({"text": text})
|
||||
|
||||
Reference in New Issue
Block a user