Initial commit: ASR v2 with multi-venv isolation

- faster-whisper, Qwen3-ASR, gateway 각 컴포넌트별 Python venv 분리
- 기본언어 한국어(ko)
- 처리내역 탭: 목록/상세/원본파일 재생/삭제
- 백엔드별 동적 모델 드랍다운
- /history, /uploads API 추가
- 기존 인스턴스(port 18100) 보존, 신규 port 18101

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
du5t
2026-05-23 00:24:36 +09:00
commit 56e637a300
17 changed files with 2221 additions and 0 deletions

395
app/gateway.py Normal file
View File

@@ -0,0 +1,395 @@
from __future__ import annotations
import io
import json
import struct
import wave
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import httpx
import uvicorn
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from common import (
DEFAULT_BACKEND,
DEFAULT_LANGUAGE,
DEFAULT_MODEL,
FASTER_WHISPER_URL,
QWEN3_URL,
RESULT_DIR,
UPLOAD_DIR,
ensure_runtime_dirs,
)
app = FastAPI(title="ASR Gateway", docs_url="/docs", redoc_url="/redoc")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
UI_DIR = Path("/app/ui")
BACKENDS = {
"faster-whisper": FASTER_WHISPER_URL,
"qwen3": QWEN3_URL,
}
REALTIME_SAMPLE_RATE = 16000
REALTIME_CHUNK_SECONDS = 3
@app.on_event("startup")
def startup() -> None:
ensure_runtime_dirs()
if UI_DIR.exists():
app.mount("/ui", StaticFiles(directory=str(UI_DIR), html=True), name="ui")
app.mount("/assets", StaticFiles(directory=str(UI_DIR)), name="assets")
# ─── Health / Info ────────────────────────────────────────────────────────────
@app.get("/health")
def health() -> Dict[str, Any]:
return {"status": "ok", "ui_installed": UI_DIR.exists(), "api": "/docs"}
@app.get("/config")
def config() -> Dict[str, Any]:
return {
"default_backend": DEFAULT_BACKEND,
"default_model": DEFAULT_MODEL,
"default_language": DEFAULT_LANGUAGE,
"backends": {
"faster-whisper": {
"models": ["tiny", "base", "small", "medium", "large-v3", "large-v2", "turbo"],
},
"qwen3": {
"models": ["Qwen/Qwen3-ASR-2B", "Qwen/Qwen3-ASR-8B"],
},
},
}
@app.get("/", response_class=HTMLResponse)
def root() -> Any:
index = UI_DIR / "index.html"
if index.exists():
return FileResponse(index)
return JSONResponse({"status": "ok", "message": "UI not installed", "api": "/docs"})
# ─── History ──────────────────────────────────────────────────────────────────
@app.get("/history")
def list_history() -> List[Dict[str, Any]]:
ensure_runtime_dirs()
records = []
for p in sorted(RESULT_DIR.glob("*.json"), reverse=True):
try:
data = json.loads(p.read_text(encoding="utf-8"))
upload_path = data.get("upload_file", "")
upload_name = Path(upload_path).name if upload_path else ""
records.append({
"id": p.stem,
"filename": data.get("_filename", upload_name),
"backend": data.get("gateway_backend", data.get("backend", "")),
"model": data.get("model", ""),
"language": data.get("language", ""),
"duration": data.get("duration"),
"text_preview": (data.get("text", "") or "")[:120],
"upload_file": upload_name,
"diarized": data.get("diarized", False),
})
except Exception:
continue
return records
@app.get("/history/{record_id}")
def get_history(record_id: str) -> Dict[str, Any]:
safe_id = Path(record_id).name
p = RESULT_DIR / f"{safe_id}.json"
if not p.exists():
raise HTTPException(status_code=404, detail="Record not found")
return json.loads(p.read_text(encoding="utf-8"))
@app.delete("/history/{record_id}")
def delete_history(record_id: str) -> Dict[str, str]:
safe_id = Path(record_id).name
p = RESULT_DIR / f"{safe_id}.json"
if not p.exists():
raise HTTPException(status_code=404, detail="Record not found")
# Also remove upload file if referenced
try:
data = json.loads(p.read_text(encoding="utf-8"))
upload_path = data.get("upload_file", "")
if upload_path:
up = Path(upload_path)
if up.exists() and up.is_relative_to(UPLOAD_DIR):
up.unlink(missing_ok=True)
except Exception:
pass
p.unlink(missing_ok=True)
return {"status": "deleted", "id": safe_id}
@app.get("/uploads/{filename}")
def serve_upload(filename: str) -> FileResponse:
safe_name = Path(filename).name
p = UPLOAD_DIR / safe_name
if not p.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(p)
# ─── File Transcription ───────────────────────────────────────────────────────
@app.post("/transcribe")
async def transcribe(
file: UploadFile = File(...),
backend: str = Form(DEFAULT_BACKEND),
model: str = Form(DEFAULT_MODEL),
custom_model_path: Optional[str] = Form(None),
language: Optional[str] = Form(None),
task: str = Form("transcribe"),
# faster-whisper options
beam_size: int = Form(5),
temperature: float = Form(0.0),
word_timestamps: bool = Form(False),
diarize: bool = Form(False),
num_speakers: Optional[int] = Form(None),
min_speakers: Optional[int] = Form(None),
max_speakers: Optional[int] = Form(None),
no_repeat_ngram_size: int = Form(0),
repetition_penalty: float = Form(1.0),
compression_ratio_threshold: float = Form(2.4),
log_prob_threshold: float = Form(-1.0),
no_speech_threshold: float = Form(0.6),
condition_on_previous_text: bool = Form(True),
) -> Dict[str, Any]:
if backend not in BACKENDS:
raise HTTPException(status_code=400, detail=f"Unsupported backend: {backend}")
ensure_runtime_dirs()
original_filename = file.filename or "upload.bin"
suffix = Path(original_filename).suffix or ".bin"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
saved_upload = UPLOAD_DIR / f"{timestamp}{suffix}"
content = await file.read()
saved_upload.write_bytes(content)
try:
worker_url = BACKENDS[backend]
effective_language = language or DEFAULT_LANGUAGE
if backend == "qwen3":
data: Dict[str, str] = {
"model": model,
"language": effective_language,
"task": task,
}
else:
# faster-whisper
data = {
"model": model,
"custom_model_path": custom_model_path or "",
"language": effective_language,
"task": task,
"beam_size": str(beam_size),
"temperature": str(temperature),
"word_timestamps": str(word_timestamps).lower(),
"diarize": str(diarize).lower(),
"no_repeat_ngram_size": str(no_repeat_ngram_size),
"repetition_penalty": str(repetition_penalty),
"compression_ratio_threshold": str(compression_ratio_threshold),
"log_prob_threshold": str(log_prob_threshold),
"no_speech_threshold": str(no_speech_threshold),
"condition_on_previous_text": str(condition_on_previous_text).lower(),
}
if num_speakers is not None:
data["num_speakers"] = str(num_speakers)
if min_speakers is not None:
data["min_speakers"] = str(min_speakers)
if max_speakers is not None:
data["max_speakers"] = str(max_speakers)
files_payload = {
"file": (original_filename, content, file.content_type or "application/octet-stream")
}
async with httpx.AsyncClient(timeout=httpx.Timeout(3600.0, connect=60.0)) as client:
response = await client.post(f"{worker_url}/transcribe", data=data, files=files_payload)
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=f"Backend error: {response.text}")
payload = response.json()
payload["gateway_backend"] = backend
payload["_filename"] = original_filename
payload["_timestamp"] = timestamp
payload["upload_file"] = str(saved_upload)
result_path = RESULT_DIR / f"{timestamp}.json"
result_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
payload["result_file"] = str(result_path)
return payload
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Gateway error: {exc}") from exc
# ─── Real-time WebSocket (faster-whisper only) ────────────────────────────────
def _pcm_float32_to_wav(pcm_bytes: bytes, sample_rate: int = 16000) -> bytes:
n = len(pcm_bytes) // 4
try:
import numpy as np
arr = np.frombuffer(pcm_bytes, dtype=np.float32)
arr_i16 = np.clip(arr * 32767.0, -32768, 32767).astype(np.int16)
raw16 = arr_i16.tobytes()
except ImportError:
raw16 = b"".join(
struct.pack("<h", max(-32768, min(32767, int(struct.unpack("<f", pcm_bytes[i*4:(i+1)*4])[0] * 32767))))
for i in range(n)
)
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(raw16)
return buf.getvalue()
async def _send_chunk(wav_bytes: bytes, *, model: str, language: str, beam_size: int,
no_repeat_ngram_size: int, repetition_penalty: float,
compression_ratio_threshold: float, log_prob_threshold: float,
no_speech_threshold: float, condition_on_previous_text: bool) -> Dict[str, Any]:
data = {
"model": model,
"language": language,
"task": "transcribe",
"beam_size": str(beam_size),
"temperature": "0",
"word_timestamps": "false",
"diarize": "false",
"no_repeat_ngram_size": str(no_repeat_ngram_size),
"repetition_penalty": str(repetition_penalty),
"compression_ratio_threshold": str(compression_ratio_threshold),
"log_prob_threshold": str(log_prob_threshold),
"no_speech_threshold": str(no_speech_threshold),
"condition_on_previous_text": str(condition_on_previous_text).lower(),
}
files_payload = {"file": ("chunk.wav", wav_bytes, "audio/wav")}
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=30.0)) as client:
resp = await client.post(f"{FASTER_WHISPER_URL}/transcribe", data=data, files=files_payload)
resp.raise_for_status()
return resp.json()
@app.websocket("/ws/realtime")
async def realtime_ws(websocket: WebSocket) -> None:
await websocket.accept()
audio_buf = bytearray()
partial_texts: List[str] = []
all_segments: List[Dict[str, Any]] = []
time_offset = 0.0
try:
cfg = json.loads(await websocket.receive_text())
model = cfg.get("model", DEFAULT_MODEL)
language = cfg.get("language") or DEFAULT_LANGUAGE
beam_size = int(cfg.get("beam_size", 5))
no_repeat_ngram_size = int(cfg.get("no_repeat_ngram_size", 0))
repetition_penalty = float(cfg.get("repetition_penalty", 1.0))
compression_ratio_threshold = float(cfg.get("compression_ratio_threshold", 2.4))
log_prob_threshold = float(cfg.get("log_prob_threshold", -1.0))
no_speech_threshold = float(cfg.get("no_speech_threshold", 0.6))
condition_on_previous_text = bool(cfg.get("condition_on_previous_text", True))
chunk_seconds = int(cfg.get("chunk_seconds", REALTIME_CHUNK_SECONDS))
chunk_bytes = chunk_seconds * REALTIME_SAMPLE_RATE * 4
await websocket.send_json({"type": "ready"})
async def process_buffer(buf: bytearray) -> None:
nonlocal time_offset
if len(buf) < REALTIME_SAMPLE_RATE * 4 * 0.3:
return
wav = _pcm_float32_to_wav(bytes(buf))
result = await _send_chunk(
wav, model=model, language=language, beam_size=beam_size,
no_repeat_ngram_size=no_repeat_ngram_size,
repetition_penalty=repetition_penalty,
compression_ratio_threshold=compression_ratio_threshold,
log_prob_threshold=log_prob_threshold,
no_speech_threshold=no_speech_threshold,
condition_on_previous_text=condition_on_previous_text,
)
text = result.get("text", "").strip()
segs = result.get("segments", [])
for seg in segs:
seg["start"] = round(seg.get("start", 0) + time_offset, 3)
seg["end"] = round(seg.get("end", 0) + time_offset, 3)
time_offset += len(buf) / 4 / REALTIME_SAMPLE_RATE
if text:
partial_texts.append(text)
all_segments.extend(segs)
await websocket.send_json({
"type": "partial",
"text": text,
"segments": segs,
"full_text": " ".join(x for x in partial_texts if x),
})
while True:
data = await websocket.receive()
if data.get("type") == "websocket.disconnect":
break
if "bytes" in data:
audio_buf.extend(data["bytes"])
while len(audio_buf) >= chunk_bytes:
await process_buffer(bytearray(audio_buf[:chunk_bytes]))
audio_buf = audio_buf[chunk_bytes:]
elif "text" in data:
msg = json.loads(data["text"])
if msg.get("cmd") == "stop":
if audio_buf:
await process_buffer(audio_buf)
await websocket.send_json({
"type": "final",
"text": " ".join(x for x in partial_texts if x),
"segments": all_segments,
"diarized": False,
})
break
except WebSocketDisconnect:
pass
except Exception as e:
try:
await websocket.send_json({"type": "error", "detail": str(e)})
except Exception:
pass
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()
uvicorn.run(app, host=args.host, port=args.port)