Smart STT Pipeline v3: T-one + GigaAM auto-routing + Qwen3.5 punctuation
This commit is contained in:
@@ -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"]
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.32.0
|
||||
python-multipart==0.0.12
|
||||
requests==2.34.2
|
||||
Reference in New Issue
Block a user