- 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>
396 lines
15 KiB
Python
396 lines
15 KiB
Python
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)
|