diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7585277 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . +EXPOSE 8005 +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8005"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..985887e --- /dev/null +++ b/app.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Smart STT Pipeline v3 +T-one (fast) + GigaAM (accurate) + Qwen3.5-4B (punctuation) +Auto-selects best STT based on confidence/silence detection. +""" + +import os +import tempfile +import time +import subprocess +import re +import json + +from fastapi import FastAPI, File, UploadFile, HTTPException +from pydantic import BaseModel +import requests + +# === Configuration === +TONE_URL = "http://localhost:8003/transcribe" +GIGAAM_URL = "http://localhost:8001/transcribe" +QWEN_URL = "http://178.17.143.46:8080/v1/chat/completions" +QWEN_URL_FALLBACK = "http://192.168.10.101:8888/v1/chat/completions" +QWEN_API_KEY = "f9LHodD0cOIaku1zu7i7vh9KZsb5EelGK4K85k1tJWgzablcQrFw-QNIJaEHHwMb3LZ5k1nd-CKYiYY0F5Yw" +QWEN_MODEL = "Qwen3.5-4B-Q5_K_M.gguf" +QWEN_MODEL_FALLBACK = "Qwen3.5-4B-MTP-GGUF" +MIN_TEXT_LENGTH = 5 # Minimum chars to consider T-one result valid +SHORT_AUDIO_THRESHOLD = 10 # Seconds: if audio < this, always use GigaAM + +app = FastAPI(title="Smart STT Pipeline v3", version="3.0.0") + + +class STTResponse(BaseModel): + original: str + punctuated: str + duration_sec: float + stt_time_sec: float + punct_time_sec: float + server_used: str + stt_engine: str # "t-one", "gigaam", or "both" + + +def convert_to_wav(audio_bytes: bytes) -> str: + """Convert any audio to 16kHz mono WAV""" + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp_path = tmp.name + cmd = [ + "ffmpeg", "-i", "pipe:0", + "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", + "-y", tmp_path + ] + subprocess.run(cmd, input=audio_bytes, capture_output=True) + return tmp_path + + +def transcribe_tone(wav_path: str) -> dict: + """Transcribe using T-one, returns dict with text and validity""" + with open(wav_path, "rb") as f: + files = {"file": ("audio.wav", f, "audio/wav")} + resp = requests.post(TONE_URL, files=files, timeout=30) + data = resp.json() + if "detail" in data: + return {"text": "", "valid": False, "error": data["detail"]} + + text = data.get("text", "[]") + phrases = re.findall(r"TextPhrase\(text='([^']*)'", text) + result_text = " ".join(phrases) + + # Check if result is meaningful + valid = len(result_text.strip()) >= MIN_TEXT_LENGTH + + return { + "text": result_text, + "valid": valid, + "stt_time": data.get("stt_time_sec", 0) + } + + +def transcribe_gigaam(ogg_path: str) -> dict: + """Transcribe using GigaAM, returns dict with full response""" + with open(ogg_path, "rb") as f: + files = {"file": ("audio.ogg", f, "audio/ogg")} + resp = requests.post(GIGAAM_URL, files=files, timeout=60) + return resp.json() + + +def add_punctuation(text: str, server_url: str, api_key: str, model: str) -> str: + """Add punctuation using Qwen3.5-4B""" + payload = { + "model": model, + "messages": [ + { + "role": "system", + "content": "You are a punctuation assistant. Add punctuation to Russian text. Only output the punctuated text, no explanations." + }, + {"role": "user", "content": text} + ], + "max_tokens": 500, + "temperature": 0.1 + } + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + } + resp = requests.post(server_url, json=payload, headers=headers, timeout=60) + resp.raise_for_status() + return resp.json()["choices"][0]["message"]["content"] + + +@app.post("/transcribe") +async def transcribe(file: UploadFile = File(...)): + """Smart transcription: T-one first, fallback to GigaAM if needed""" + audio_bytes = await file.read() + + # Get audio duration from file + ogg_path = tempfile.mktemp(suffix=".ogg") + with open(ogg_path, "wb") as f: + f.write(audio_bytes) + + cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", ogg_path] + proc = subprocess.run(cmd, capture_output=True, text=True) + duration = float(proc.stdout.strip()) + + # Convert to WAV for T-one + wav_path = convert_to_wav(audio_bytes) + + try: + # Step 1: Choose STT engine based on duration + if duration < SHORT_AUDIO_THRESHOLD: + # Short audio: use GigaAM (more accurate) + giga_result = transcribe_gigaam(ogg_path) + + text = giga_result.get("original", "") + stt_engine = "gigaam" + stt_time = giga_result.get("stt_time_sec", 0) + else: + # Long audio: try T-one first (fast) + stt_start = time.time() + tone_result = transcribe_tone(wav_path) + stt_time = time.time() - stt_start + + if tone_result["valid"]: + text = tone_result["text"] + stt_engine = "t-one" + else: + # T-one failed, fallback to GigaAM + giga_result = transcribe_gigaam(ogg_path) + text = giga_result.get("original", "") + stt_engine = "gigaam" + stt_time = giga_result.get("stt_time_sec", 0) + + # Step 2: Punctuation + punct_start = time.time() + server_used = "178.17.143.46:8080" + try: + punctuated = add_punctuation(text, QWEN_URL, QWEN_API_KEY, QWEN_MODEL) + except Exception: + server_used = "192.168.10.101:8888" + punctuated = add_punctuation(text, QWEN_URL_FALLBACK, QWEN_API_KEY, QWEN_MODEL_FALLBACK) + punct_time = time.time() - punct_start + + return { + "original": text, + "punctuated": punctuated, + "duration_sec": round(duration, 1), + "stt_time_sec": round(stt_time, 2), + "punct_time_sec": round(punct_time, 2), + "server_used": server_used, + "stt_engine": stt_engine + } + finally: + os.unlink(wav_path) + + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "stt-pipeline-v3"} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5525705 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn==0.32.0 +python-multipart==0.0.12 +requests==2.34.2