"""Speech-to-text sidecar. Uses Pipecat OpenAISTTService only — no TTS, no voice agent.""" from __future__ import annotations import os import shutil import subprocess import tempfile from pathlib import Path 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.transcriptions.language import Language load_dotenv(Path(__file__).resolve().parent / ".env") 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 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 _stt_service() -> OpenAISTTService: 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") 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, ), ) return _stt 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() @app.get("/health") async def health() -> dict[str, object]: return { "ok": True, "engine": "pipecat", "service": "OpenAISTTService", "configured": bool(_env("OPENAI_API_KEY")), } @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") service = _stt_service() try: result = await service._transcribe(wav) except HTTPException: raise except Exception as exc: raise HTTPException( status_code=502, detail=f"تبدیل صوت به متن ناموفق بود: {exc}", ) from exc text = (getattr(result, "text", None) or "").strip() return JSONResponse({"text": text})