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:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Secrets — env 파일은 절대 커밋하지 말 것
|
||||||
|
env/
|
||||||
|
*.env
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
uploads/
|
||||||
|
results/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
42
Containerfile
Normal file
42
Containerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
FROM docker.io/pytorch/pytorch:2.7.0-cuda12.6-cudnn9-runtime
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
WHISPER_CACHE_DIR=/srv/asr/models-cache \
|
||||||
|
ASR_BASE_DIR=/srv/asr
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
libsndfile1 \
|
||||||
|
curl \
|
||||||
|
supervisor \
|
||||||
|
python3-venv \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY envs/ /build/envs/
|
||||||
|
|
||||||
|
# gateway venv — 경량, GPU 불필요
|
||||||
|
RUN python -m venv /opt/venvs/gateway && \
|
||||||
|
/opt/venvs/gateway/bin/pip install --upgrade pip && \
|
||||||
|
/opt/venvs/gateway/bin/pip install -r /build/envs/gateway.txt
|
||||||
|
|
||||||
|
# faster-whisper venv — CTranslate2가 CUDA 직접 처리
|
||||||
|
RUN python -m venv /opt/venvs/faster_whisper && \
|
||||||
|
/opt/venvs/faster_whisper/bin/pip install --upgrade pip && \
|
||||||
|
/opt/venvs/faster_whisper/bin/pip install -r /build/envs/faster_whisper.txt
|
||||||
|
|
||||||
|
# qwen3 venv — base image의 torch/CUDA 상속
|
||||||
|
RUN python -m venv --system-site-packages /opt/venvs/qwen3 && \
|
||||||
|
/opt/venvs/qwen3/bin/pip install --upgrade pip && \
|
||||||
|
/opt/venvs/qwen3/bin/pip install -r /build/envs/qwen3.txt
|
||||||
|
|
||||||
|
COPY app/ /app/
|
||||||
|
|
||||||
|
RUN chmod +x /app/start.sh
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["/app/start.sh"]
|
||||||
65
app/common.py
Normal file
65
app/common.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def env_str(name: str, default: str = "") -> str:
|
||||||
|
return os.getenv(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def env_int(name: str, default: int) -> int:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return default
|
||||||
|
return int(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def env_float(name: str, default: float) -> float:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return default
|
||||||
|
return float(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def env_bool(name: str, default: bool = False) -> bool:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
ASR_BASE_DIR = Path(env_str("ASR_BASE_DIR", "/srv/asr"))
|
||||||
|
MODEL_CACHE = Path(env_str("WHISPER_CACHE_DIR", str(ASR_BASE_DIR / "models-cache")))
|
||||||
|
UPLOAD_DIR = ASR_BASE_DIR / "uploads"
|
||||||
|
RESULT_DIR = ASR_BASE_DIR / "results"
|
||||||
|
CUSTOM_MODEL_DIR = ASR_BASE_DIR / "custom-models"
|
||||||
|
|
||||||
|
DEVICE = env_str("ASR_DEVICE", "cuda")
|
||||||
|
COMPUTE_TYPE = env_str("ASR_COMPUTE_TYPE", "float16")
|
||||||
|
DEFAULT_BACKEND = env_str("DEFAULT_BACKEND", "faster-whisper")
|
||||||
|
DEFAULT_MODEL = env_str("DEFAULT_MODEL", "large-v3")
|
||||||
|
DEFAULT_LANGUAGE = env_str("DEFAULT_LANGUAGE", "ko")
|
||||||
|
|
||||||
|
FASTER_WHISPER_URL = env_str("FASTER_WHISPER_URL", "http://127.0.0.1:8001")
|
||||||
|
QWEN3_URL = env_str("QWEN3_URL", "http://127.0.0.1:8004")
|
||||||
|
|
||||||
|
PYANNOTE_HF_TOKEN = env_str("PYANNOTE_HF_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_runtime_dirs() -> None:
|
||||||
|
for p in [ASR_BASE_DIR, MODEL_CACHE, UPLOAD_DIR, RESULT_DIR, CUSTOM_MODEL_DIR]:
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_custom_model_path(custom_model_path: Optional[str]) -> Optional[str]:
|
||||||
|
if not custom_model_path:
|
||||||
|
return None
|
||||||
|
raw = custom_model_path.strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
candidate = Path(raw)
|
||||||
|
if candidate.is_absolute():
|
||||||
|
return str(candidate)
|
||||||
|
return str((CUSTOM_MODEL_DIR / candidate).resolve())
|
||||||
395
app/gateway.py
Normal file
395
app/gateway.py
Normal 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)
|
||||||
4
app/start.sh
Executable file
4
app/start.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p /srv/asr/models-cache /srv/asr/uploads /srv/asr/results /srv/asr/custom-models
|
||||||
|
exec supervisord -c /app/supervisord.conf
|
||||||
37
app/supervisord.conf
Normal file
37
app/supervisord.conf
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
logfile=/dev/null
|
||||||
|
pidfile=/tmp/supervisord.pid
|
||||||
|
|
||||||
|
[program:faster_whisper]
|
||||||
|
command=/opt/venvs/faster_whisper/bin/python /app/workers/faster_whisper_worker.py --host 0.0.0.0 --port 8001
|
||||||
|
directory=/app
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
priority=10
|
||||||
|
|
||||||
|
[program:qwen3]
|
||||||
|
command=/opt/venvs/qwen3/bin/python /app/workers/qwen3_worker.py --host 0.0.0.0 --port 8004
|
||||||
|
directory=/app
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
priority=20
|
||||||
|
|
||||||
|
[program:gateway]
|
||||||
|
command=/opt/venvs/gateway/bin/python -m uvicorn gateway:app --host 0.0.0.0 --port 8000
|
||||||
|
directory=/app
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
priority=30
|
||||||
514
app/ui/app.js
Normal file
514
app/ui/app.js
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
// ─── Config (fetched from /config) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
let SERVER_CONFIG = null;
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/config');
|
||||||
|
SERVER_CONFIG = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
SERVER_CONFIG = {
|
||||||
|
default_backend: 'faster-whisper',
|
||||||
|
default_model: 'large-v3',
|
||||||
|
default_language: 'ko',
|
||||||
|
backends: {
|
||||||
|
'faster-whisper': { models: ['tiny','base','small','medium','large-v3','large-v2','turbo'] },
|
||||||
|
'qwen3': { models: ['Qwen/Qwen3-ASR-2B','Qwen/Qwen3-ASR-8B'] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
initUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utilities ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SPEAKER_COLORS = [
|
||||||
|
'#4fa3e0','#e06b4f','#4fe09c','#e0c44f','#a04fe0',
|
||||||
|
'#e04fa3','#4fd4e0','#e0a44f','#7fe04f','#9b59b6',
|
||||||
|
];
|
||||||
|
const colorMap = {};
|
||||||
|
let colorIdx = 0;
|
||||||
|
|
||||||
|
function speakerColor(speaker) {
|
||||||
|
if (!(speaker in colorMap)) {
|
||||||
|
colorMap[speaker] = speaker === 'UNKNOWN'
|
||||||
|
? '#7a85a8'
|
||||||
|
: SPEAKER_COLORS[colorIdx++ % SPEAKER_COLORS.length];
|
||||||
|
}
|
||||||
|
return colorMap[speaker];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetColors() {
|
||||||
|
Object.keys(colorMap).forEach(k => delete colorMap[k]);
|
||||||
|
colorIdx = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(text) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.appendChild(document.createTextNode(text));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(s) {
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const sec = (s % 60).toFixed(1).padStart(4, '0');
|
||||||
|
return `${String(m).padStart(2,'0')}:${sec}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTimestamp(id) {
|
||||||
|
// id: YYYYMMDD_HHMMSS_ffffff
|
||||||
|
if (!id || id.length < 15) return id;
|
||||||
|
const y = id.slice(0,4), mo = id.slice(4,6), d = id.slice(6,8);
|
||||||
|
const h = id.slice(9,11), mi = id.slice(11,13), s = id.slice(13,15);
|
||||||
|
return `${y}-${mo}-${d} ${h}:${mi}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSpeakerBlocks(segments, container, sectionEl) {
|
||||||
|
if (!segments || !segments.length || !segments.some(s => s.speaker)) {
|
||||||
|
sectionEl.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetColors();
|
||||||
|
const groups = [];
|
||||||
|
let cur = null;
|
||||||
|
for (const seg of segments) {
|
||||||
|
const spk = seg.speaker || 'UNKNOWN';
|
||||||
|
if (!cur || cur.speaker !== spk) { cur = {speaker: spk, segs: [seg]}; groups.push(cur); }
|
||||||
|
else cur.segs.push(seg);
|
||||||
|
}
|
||||||
|
let html = '';
|
||||||
|
for (const g of groups) {
|
||||||
|
const c = speakerColor(g.speaker);
|
||||||
|
const text = g.segs.map(s => (s.text||'').trim()).join(' ');
|
||||||
|
const t0 = g.segs[0].start, t1 = g.segs[g.segs.length-1].end;
|
||||||
|
html += `<div class="speaker-block">
|
||||||
|
<div class="speaker-badge">
|
||||||
|
<span class="speaker-dot" style="background:${c}"></span>
|
||||||
|
<span style="color:${c}">${esc(g.speaker)}</span>
|
||||||
|
<span class="speaker-time">${fmtTime(t0)} – ${fmtTime(t1)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="speaker-text">${esc(text)}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
sectionEl.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(name, text, type) {
|
||||||
|
const blob = new Blob([text], {type});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = name;
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tabs ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.style.display = 'none');
|
||||||
|
btn.classList.add('active');
|
||||||
|
const panel = document.getElementById('tab-' + btn.dataset.tab);
|
||||||
|
panel.style.display = '';
|
||||||
|
if (btn.dataset.tab === 'history') loadHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Collapsible toggles ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeToggle(btnId, panelId, chevronId) {
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
const panel = document.getElementById(panelId);
|
||||||
|
const chevron = document.getElementById(chevronId);
|
||||||
|
if (!btn || !panel) return;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const open = panel.style.display !== 'none';
|
||||||
|
panel.style.display = open ? 'none' : '';
|
||||||
|
if (chevron) chevron.style.transform = open ? '' : 'rotate(90deg)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FILE TAB ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const backendSel = document.getElementById('backend');
|
||||||
|
const modelSel = document.getElementById('model');
|
||||||
|
const fwOptions = document.getElementById('fw-options');
|
||||||
|
const diarizeChk = document.getElementById('diarize');
|
||||||
|
const diarizeOpts = document.getElementById('diarize-options');
|
||||||
|
const fileStatus = document.getElementById('file-status');
|
||||||
|
const fileResultText = document.getElementById('file-result-text');
|
||||||
|
const fileResultJson = document.getElementById('file-result-json');
|
||||||
|
const fileSpeakerSection = document.getElementById('file-speaker-section');
|
||||||
|
const fileSpeakerResult = document.getElementById('file-speaker-result');
|
||||||
|
|
||||||
|
let fileLastPayload = null;
|
||||||
|
|
||||||
|
function populateModels(backend) {
|
||||||
|
if (!SERVER_CONFIG) return;
|
||||||
|
const models = SERVER_CONFIG.backends[backend]?.models || [];
|
||||||
|
const defaultModel = SERVER_CONFIG.default_model;
|
||||||
|
modelSel.innerHTML = '';
|
||||||
|
for (const m of models) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m;
|
||||||
|
opt.textContent = m;
|
||||||
|
if (m === defaultModel) opt.selected = true;
|
||||||
|
modelSel.appendChild(opt);
|
||||||
|
}
|
||||||
|
// If no default matched, select first
|
||||||
|
if (!modelSel.value && models.length) modelSel.value = models[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackendChange() {
|
||||||
|
const isQwen = backendSel.value === 'qwen3';
|
||||||
|
fwOptions.style.display = isQwen ? 'none' : '';
|
||||||
|
populateModels(backendSel.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
backendSel.addEventListener('change', onBackendChange);
|
||||||
|
|
||||||
|
diarizeChk.addEventListener('change', () => {
|
||||||
|
diarizeOpts.style.display = diarizeChk.checked ? '' : 'none';
|
||||||
|
if (!diarizeChk.checked) {
|
||||||
|
['num_speakers','min_speakers','max_speakers'].forEach(id => {
|
||||||
|
document.getElementById(id).value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('file-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fi = document.getElementById('file-input');
|
||||||
|
if (!fi.files.length) { fileStatus.textContent = '파일을 선택하세요.'; return; }
|
||||||
|
|
||||||
|
const fd = new FormData(document.getElementById('file-form'));
|
||||||
|
fd.set('file', fi.files[0]);
|
||||||
|
['num_speakers','min_speakers','max_speakers'].forEach(n => {
|
||||||
|
if (!fd.get(n)) fd.delete(n);
|
||||||
|
});
|
||||||
|
fd.set('word_timestamps', document.getElementById('word_timestamps').checked ? 'true' : 'false');
|
||||||
|
fd.set('diarize', diarizeChk.checked ? 'true' : 'false');
|
||||||
|
fd.set('condition_on_previous_text',
|
||||||
|
document.getElementById('condition_on_previous_text').checked ? 'true' : 'false');
|
||||||
|
|
||||||
|
fileStatus.textContent = '전사 요청 중...';
|
||||||
|
fileResultText.value = '';
|
||||||
|
fileResultJson.textContent = '{}';
|
||||||
|
fileSpeakerSection.style.display = 'none';
|
||||||
|
fileSpeakerResult.innerHTML = '';
|
||||||
|
document.getElementById('file-submit-btn').disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/transcribe', {method:'POST', body: fd});
|
||||||
|
const payload = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(payload.detail || JSON.stringify(payload));
|
||||||
|
fileLastPayload = payload;
|
||||||
|
fileResultText.value = payload.text || '';
|
||||||
|
fileResultJson.textContent = JSON.stringify(payload, null, 2);
|
||||||
|
renderSpeakerBlocks(payload.segments, fileSpeakerResult, fileSpeakerSection);
|
||||||
|
const lang = payload.language ? ` [${payload.language}]` : '';
|
||||||
|
const dur = payload.duration ? ` ${payload.duration.toFixed(1)}s` : '';
|
||||||
|
fileStatus.textContent = `완료${lang}${dur}` + (payload.diarized ? ' — 화자분리 적용' : '');
|
||||||
|
} catch (err) {
|
||||||
|
fileStatus.textContent = `실패: ${err.message}`;
|
||||||
|
} finally {
|
||||||
|
document.getElementById('file-submit-btn').disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('file-copy-btn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(fileResultText.value || '');
|
||||||
|
fileStatus.textContent = '텍스트를 복사했습니다.';
|
||||||
|
} catch (err) { fileStatus.textContent = `복사 실패: ${err.message}`; }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('file-dl-txt-btn').addEventListener('click', () => {
|
||||||
|
downloadBlob('transcript.txt', fileResultText.value || '', 'text/plain;charset=utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('file-dl-json-btn').addEventListener('click', () => {
|
||||||
|
downloadBlob('transcript.json', JSON.stringify(fileLastPayload||{},null,2), 'application/json;charset=utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── REALTIME TAB ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const rtStartBtn = document.getElementById('rt-start-btn');
|
||||||
|
const rtStopBtn = document.getElementById('rt-stop-btn');
|
||||||
|
const rtIndicator = document.getElementById('rt-indicator');
|
||||||
|
const rtLiveBox = document.getElementById('rt-live-box');
|
||||||
|
const rtFinalText = document.getElementById('rt-final-text');
|
||||||
|
|
||||||
|
let ws = null, audioCtx = null, mediaStream = null;
|
||||||
|
let workletNode = null, sourceNode = null;
|
||||||
|
let confirmedText = '', isRecording = false;
|
||||||
|
|
||||||
|
function setIndicator(state, text) {
|
||||||
|
rtIndicator.className = 'rt-indicator ' + state;
|
||||||
|
rtIndicator.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLive(partial) {
|
||||||
|
rtLiveBox.innerHTML =
|
||||||
|
(confirmedText ? `<span class="live-confirmed">${esc(confirmedText)}</span>\n` : '') +
|
||||||
|
(partial ? `<span class="live-partial">${esc(partial)}</span>` : '');
|
||||||
|
rtLiveBox.scrollTop = rtLiveBox.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
if (isRecording) return;
|
||||||
|
confirmedText = '';
|
||||||
|
rtLiveBox.innerHTML = '<span class="muted">연결 중...</span>';
|
||||||
|
rtFinalText.value = '';
|
||||||
|
rtStartBtn.disabled = true;
|
||||||
|
rtStopBtn.disabled = true;
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
model: document.getElementById('rt-model').value,
|
||||||
|
language: document.getElementById('rt-language').value.trim() || 'ko',
|
||||||
|
beam_size: parseInt(document.getElementById('rt-beam_size').value),
|
||||||
|
chunk_seconds: parseInt(document.getElementById('rt-chunk_seconds').value),
|
||||||
|
no_repeat_ngram_size: parseInt(document.getElementById('rt-no_repeat_ngram_size').value),
|
||||||
|
repetition_penalty: parseFloat(document.getElementById('rt-repetition_penalty').value),
|
||||||
|
compression_ratio_threshold: parseFloat(document.getElementById('rt-compression_ratio_threshold').value),
|
||||||
|
no_speech_threshold: parseFloat(document.getElementById('rt-no_speech_threshold').value),
|
||||||
|
condition_on_previous_text: document.getElementById('rt-condition_on_previous_text').checked,
|
||||||
|
};
|
||||||
|
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
ws = new WebSocket(`${proto}://${location.host}/ws/realtime`);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
ws.onopen = () => ws.send(JSON.stringify(cfg));
|
||||||
|
|
||||||
|
ws.onmessage = async (ev) => {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
if (msg.type === 'ready') {
|
||||||
|
try {
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({audio: {channelCount:1, sampleRate:16000}});
|
||||||
|
} catch (err) {
|
||||||
|
setIndicator('idle', `마이크 오류: ${err.message}`);
|
||||||
|
ws.close(); return;
|
||||||
|
}
|
||||||
|
audioCtx = new AudioContext({sampleRate: 16000});
|
||||||
|
await audioCtx.audioWorklet.addModule('/assets/pcm-processor.js');
|
||||||
|
sourceNode = audioCtx.createMediaStreamSource(mediaStream);
|
||||||
|
workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor');
|
||||||
|
workletNode.port.onmessage = (e) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(e.data);
|
||||||
|
};
|
||||||
|
sourceNode.connect(workletNode);
|
||||||
|
workletNode.connect(audioCtx.destination);
|
||||||
|
isRecording = true;
|
||||||
|
rtStartBtn.disabled = true;
|
||||||
|
rtStopBtn.disabled = false;
|
||||||
|
setIndicator('recording', '녹음 중...');
|
||||||
|
rtLiveBox.innerHTML = '<span class="muted">음성을 인식하는 중...</span>';
|
||||||
|
} else if (msg.type === 'partial') {
|
||||||
|
if (msg.text) confirmedText = (confirmedText ? confirmedText + ' ' : '') + msg.text;
|
||||||
|
appendLive('');
|
||||||
|
} else if (msg.type === 'final') {
|
||||||
|
rtFinalText.value = msg.text || '';
|
||||||
|
setIndicator('idle', '완료');
|
||||||
|
cleanupAudio();
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
setIndicator('idle', `오류: ${msg.detail}`);
|
||||||
|
cleanupAudio();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => { setIndicator('idle', 'WebSocket 오류'); cleanupAudio(); };
|
||||||
|
ws.onclose = () => { if (isRecording) { isRecording = false; cleanupAudio(); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupAudio() {
|
||||||
|
isRecording = false;
|
||||||
|
if (workletNode) { try { workletNode.disconnect(); } catch(e){} workletNode = null; }
|
||||||
|
if (sourceNode) { try { sourceNode.disconnect(); } catch(e){} sourceNode = null; }
|
||||||
|
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
|
||||||
|
if (audioCtx) { try { audioCtx.close(); } catch(e){} audioCtx = null; }
|
||||||
|
rtStartBtn.disabled = false;
|
||||||
|
rtStopBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (!isRecording) return;
|
||||||
|
setIndicator('processing', '처리 중...');
|
||||||
|
rtStopBtn.disabled = true;
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({cmd: 'stop'}));
|
||||||
|
if (workletNode) { try { workletNode.disconnect(); } catch(e){} workletNode = null; }
|
||||||
|
if (sourceNode) { try { sourceNode.disconnect(); } catch(e){} sourceNode = null; }
|
||||||
|
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
|
||||||
|
if (audioCtx) { try { audioCtx.close(); } catch(e){} audioCtx = null; }
|
||||||
|
isRecording = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
rtStartBtn.addEventListener('click', startRecording);
|
||||||
|
rtStopBtn.addEventListener('click', stopRecording);
|
||||||
|
|
||||||
|
document.getElementById('rt-copy-btn').addEventListener('click', async () => {
|
||||||
|
try { await navigator.clipboard.writeText(rtFinalText.value || ''); }
|
||||||
|
catch (e) { console.error(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('rt-dl-txt-btn').addEventListener('click', () => {
|
||||||
|
downloadBlob('realtime_transcript.txt', rtFinalText.value || '', 'text/plain;charset=utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── HISTORY TAB ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const historyList = document.getElementById('history-list');
|
||||||
|
const historyDetail = document.getElementById('history-detail');
|
||||||
|
let currentRecord = null;
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
historyList.innerHTML = '<div class="muted" style="padding:16px">불러오는 중...</div>';
|
||||||
|
historyDetail.style.display = 'none';
|
||||||
|
historyList.style.display = '';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/history');
|
||||||
|
const records = await r.json();
|
||||||
|
renderHistoryList(records);
|
||||||
|
} catch (e) {
|
||||||
|
historyList.innerHTML = `<div class="muted" style="padding:16px">오류: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistoryList(records) {
|
||||||
|
if (!records.length) {
|
||||||
|
historyList.innerHTML = '<div class="muted" style="padding:16px">처리 내역이 없습니다.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '<div class="history-table-wrap"><table class="history-table"><thead><tr>'
|
||||||
|
+ '<th>시각</th><th>파일명</th><th>백엔드</th><th>모델</th><th>언어</th><th>길이</th><th>미리보기</th><th></th>'
|
||||||
|
+ '</tr></thead><tbody>';
|
||||||
|
for (const rec of records) {
|
||||||
|
const dur = rec.duration != null ? `${rec.duration.toFixed(1)}s` : '-';
|
||||||
|
const dia = rec.diarized ? ' <span class="badge-green">화자분리</span>' : '';
|
||||||
|
html += `<tr class="history-row" data-id="${esc(rec.id)}">
|
||||||
|
<td class="mono">${esc(fmtTimestamp(rec.id))}</td>
|
||||||
|
<td>${esc(rec.filename || '-')}</td>
|
||||||
|
<td><span class="badge-backend">${esc(rec.backend || '-')}</span></td>
|
||||||
|
<td class="mono small">${esc(rec.model || '-')}</td>
|
||||||
|
<td>${esc(rec.language || '-')}</td>
|
||||||
|
<td>${dur}</td>
|
||||||
|
<td class="preview-cell">${esc(rec.text_preview || '')}${dia}</td>
|
||||||
|
<td><button class="btn-view" data-id="${esc(rec.id)}">보기</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
historyList.innerHTML = html;
|
||||||
|
historyList.querySelectorAll('.btn-view').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => openHistoryDetail(btn.dataset.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openHistoryDetail(id) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/history/${id}`);
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
currentRecord = await r.json();
|
||||||
|
currentRecord._id = id;
|
||||||
|
renderDetail(currentRecord);
|
||||||
|
historyList.style.display = 'none';
|
||||||
|
historyDetail.style.display = '';
|
||||||
|
} catch (e) {
|
||||||
|
alert(`불러오기 실패: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(data) {
|
||||||
|
document.getElementById('detail-title').textContent =
|
||||||
|
data._filename || fmtTimestamp(data._id || '');
|
||||||
|
|
||||||
|
// Meta info
|
||||||
|
const meta = document.getElementById('detail-meta');
|
||||||
|
const fields = [
|
||||||
|
['백엔드', data.gateway_backend || data.backend || '-'],
|
||||||
|
['모델', data.model || '-'],
|
||||||
|
['언어', data.language || '-'],
|
||||||
|
['길이', data.duration != null ? `${data.duration.toFixed(1)}s` : '-'],
|
||||||
|
['화자분리', data.diarized ? '예' : '아니오'],
|
||||||
|
['처리시각', fmtTimestamp(data._timestamp || data._id || '')],
|
||||||
|
];
|
||||||
|
meta.innerHTML = fields.map(([k,v]) =>
|
||||||
|
`<div class="meta-item"><span class="meta-key">${esc(k)}</span><span class="meta-val">${esc(v)}</span></div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Media player
|
||||||
|
const mediaSection = document.getElementById('detail-media-section');
|
||||||
|
const mediaContainer = document.getElementById('detail-media-container');
|
||||||
|
const uploadPath = data.upload_file || '';
|
||||||
|
const uploadName = uploadPath ? uploadPath.split('/').pop() : '';
|
||||||
|
if (uploadName) {
|
||||||
|
const url = `/uploads/${encodeURIComponent(uploadName)}`;
|
||||||
|
const ext = uploadName.split('.').pop().toLowerCase();
|
||||||
|
const videoExts = ['mp4','mkv','webm','avi','mov','m4v'];
|
||||||
|
if (videoExts.includes(ext)) {
|
||||||
|
mediaContainer.innerHTML = `<video controls class="media-player"><source src="${url}"><p>미리보기 불가</p></video>`;
|
||||||
|
} else {
|
||||||
|
mediaContainer.innerHTML = `<audio controls class="media-player"><source src="${url}"><p>미리보기 불가</p></audio>`;
|
||||||
|
}
|
||||||
|
mediaSection.style.display = '';
|
||||||
|
} else {
|
||||||
|
mediaSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text + JSON
|
||||||
|
document.getElementById('detail-text').value = data.text || '';
|
||||||
|
document.getElementById('detail-json').textContent = JSON.stringify(data, null, 2);
|
||||||
|
|
||||||
|
// Speaker blocks
|
||||||
|
renderSpeakerBlocks(
|
||||||
|
data.segments,
|
||||||
|
document.getElementById('detail-speaker-result'),
|
||||||
|
document.getElementById('detail-speaker-section'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('history-back-btn').addEventListener('click', () => {
|
||||||
|
historyDetail.style.display = 'none';
|
||||||
|
historyList.style.display = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('history-refresh-btn').addEventListener('click', loadHistory);
|
||||||
|
|
||||||
|
document.getElementById('detail-delete-btn').addEventListener('click', async () => {
|
||||||
|
if (!currentRecord || !currentRecord._id) return;
|
||||||
|
if (!confirm('이 내역을 삭제하시겠습니까? 원본 파일도 함께 삭제됩니다.')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/history/${currentRecord._id}`, {method: 'DELETE'});
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
historyDetail.style.display = 'none';
|
||||||
|
historyList.style.display = '';
|
||||||
|
await loadHistory();
|
||||||
|
} catch (e) {
|
||||||
|
alert(`삭제 실패: ${e.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('detail-copy-btn').addEventListener('click', async () => {
|
||||||
|
try { await navigator.clipboard.writeText(document.getElementById('detail-text').value || ''); }
|
||||||
|
catch (e) { console.error(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('detail-dl-txt-btn').addEventListener('click', () => {
|
||||||
|
const fname = (currentRecord?._filename || 'transcript').replace(/\.[^.]+$/, '') + '.txt';
|
||||||
|
downloadBlob(fname, document.getElementById('detail-text').value || '', 'text/plain;charset=utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('detail-dl-json-btn').addEventListener('click', () => {
|
||||||
|
const fname = (currentRecord?._filename || 'transcript').replace(/\.[^.]+$/, '') + '.json';
|
||||||
|
downloadBlob(fname, JSON.stringify(currentRecord||{}, null, 2), 'application/json;charset=utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initUI() {
|
||||||
|
populateModels(backendSel.value);
|
||||||
|
makeToggle('antirep-toggle', 'antirep-panel', 'antirep-chevron');
|
||||||
|
makeToggle('rt-antirep-toggle', 'rt-antirep-panel', 'rt-antirep-chevron');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
300
app/ui/index.html
Normal file
300
app/ui/index.html
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ASR 전사 서비스</title>
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wrap">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>ASR 전사 서비스</h1>
|
||||||
|
<p class="muted">파일 전사 · 실시간 전사 · 처리 내역</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" data-tab="file">파일 전사</button>
|
||||||
|
<button class="tab-btn" data-tab="realtime">실시간 전사</button>
|
||||||
|
<button class="tab-btn" data-tab="history">처리 내역</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════ TAB: FILE ═══════════════════ -->
|
||||||
|
<div id="tab-file" class="tab-panel">
|
||||||
|
<section class="card">
|
||||||
|
<form id="file-form">
|
||||||
|
|
||||||
|
<label>오디오 / 동영상 파일
|
||||||
|
<input type="file" id="file-input" name="file" accept="audio/*,video/*" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<label>백엔드
|
||||||
|
<select id="backend" name="backend">
|
||||||
|
<option value="faster-whisper" selected>faster-whisper</option>
|
||||||
|
<option value="qwen3">Qwen3-ASR</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>모델
|
||||||
|
<select id="model" name="model">
|
||||||
|
<!-- populated by JS based on backend -->
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>커스텀 모델 경로 (선택, faster-whisper 전용)
|
||||||
|
<input type="text" id="custom_model_path" name="custom_model_path" placeholder="/srv/asr/custom-models/my-model">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<label>언어
|
||||||
|
<input type="text" id="language" name="language" value="ko" placeholder="ko">
|
||||||
|
</label>
|
||||||
|
<label>작업
|
||||||
|
<select id="task" name="task">
|
||||||
|
<option value="transcribe" selected>transcribe</option>
|
||||||
|
<option value="translate">translate (영어로 번역)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- faster-whisper only -->
|
||||||
|
<div id="fw-options">
|
||||||
|
<div class="grid2">
|
||||||
|
<label>Beam size
|
||||||
|
<input type="number" id="beam_size" name="beam_size" value="5" min="1" max="20">
|
||||||
|
</label>
|
||||||
|
<label>Temperature
|
||||||
|
<input type="number" id="temperature" name="temperature" value="0" min="0" max="1" step="0.1">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="inline">
|
||||||
|
<input type="checkbox" id="word_timestamps" name="word_timestamps">
|
||||||
|
단어별 타임스탬프
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="section-toggle">
|
||||||
|
<button type="button" class="toggle-btn" id="antirep-toggle">
|
||||||
|
반복 억제 옵션 <span class="chevron" id="antirep-chevron">▸</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="antirep-panel" class="collapsible" style="display:none">
|
||||||
|
<p class="muted small">반복 토큰 문제 발생 시 조정하세요.</p>
|
||||||
|
<div class="grid2">
|
||||||
|
<label>no_repeat_ngram_size
|
||||||
|
<input type="number" id="no_repeat_ngram_size" name="no_repeat_ngram_size" value="0" min="0" max="10">
|
||||||
|
<span class="hint">0=비활성, 3~5 권장</span>
|
||||||
|
</label>
|
||||||
|
<label>repetition_penalty
|
||||||
|
<input type="number" id="repetition_penalty" name="repetition_penalty" value="1.0" min="1.0" max="2.0" step="0.05">
|
||||||
|
<span class="hint">1.0=없음, 1.1~1.3 권장</span>
|
||||||
|
</label>
|
||||||
|
<label>compression_ratio_threshold
|
||||||
|
<input type="number" id="compression_ratio_threshold" name="compression_ratio_threshold" value="2.4" min="0.5" max="5.0" step="0.1">
|
||||||
|
</label>
|
||||||
|
<label>log_prob_threshold
|
||||||
|
<input type="number" id="log_prob_threshold" name="log_prob_threshold" value="-1.0" min="-5.0" max="0.0" step="0.1">
|
||||||
|
</label>
|
||||||
|
<label>no_speech_threshold
|
||||||
|
<input type="number" id="no_speech_threshold" name="no_speech_threshold" value="0.6" min="0.0" max="1.0" step="0.05">
|
||||||
|
</label>
|
||||||
|
<label class="inline" style="align-self:end; padding-top:8px">
|
||||||
|
<input type="checkbox" id="condition_on_previous_text" name="condition_on_previous_text" checked>
|
||||||
|
이전 텍스트 조건화
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<label class="inline">
|
||||||
|
<input type="checkbox" id="diarize" name="diarize">
|
||||||
|
화자 분리 (Speaker Diarization)
|
||||||
|
</label>
|
||||||
|
<div id="diarize-options" style="display:none">
|
||||||
|
<div class="grid3">
|
||||||
|
<label>정확한 화자 수
|
||||||
|
<input type="number" id="num_speakers" name="num_speakers" min="1" max="30" placeholder="자동">
|
||||||
|
</label>
|
||||||
|
<label>최소 화자 수
|
||||||
|
<input type="number" id="min_speakers" name="min_speakers" min="1" max="30" placeholder="자동">
|
||||||
|
</label>
|
||||||
|
<label>최대 화자 수
|
||||||
|
<input type="number" id="max_speakers" name="max_speakers" min="1" max="30" placeholder="자동">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" id="file-submit-btn">전사 실행</button>
|
||||||
|
<button type="button" id="file-copy-btn">텍스트 복사</button>
|
||||||
|
<button type="button" id="file-dl-txt-btn">TXT 저장</button>
|
||||||
|
<button type="button" id="file-dl-json-btn">JSON 저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>상태</h2>
|
||||||
|
<pre id="file-status">대기 중</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="file-speaker-section" style="display:none">
|
||||||
|
<h2>화자별 결과</h2>
|
||||||
|
<div id="file-speaker-result"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>텍스트 결과</h2>
|
||||||
|
<textarea id="file-result-text" rows="12" placeholder="전사 결과가 여기에 표시됩니다."></textarea>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>JSON 결과</h2>
|
||||||
|
<pre id="file-result-json" class="result-json">{}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════ TAB: REALTIME ═══════════════════ -->
|
||||||
|
<div id="tab-realtime" class="tab-panel" style="display:none">
|
||||||
|
<section class="card">
|
||||||
|
<h2>실시간 전사 설정</h2>
|
||||||
|
<div class="grid2">
|
||||||
|
<label>모델
|
||||||
|
<select id="rt-model">
|
||||||
|
<option value="tiny">tiny (가장 빠름)</option>
|
||||||
|
<option value="base">base</option>
|
||||||
|
<option value="small">small</option>
|
||||||
|
<option value="medium">medium</option>
|
||||||
|
<option value="large-v3" selected>large-v3</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>언어
|
||||||
|
<input type="text" id="rt-language" value="ko" placeholder="ko">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid2">
|
||||||
|
<label>Beam size
|
||||||
|
<input type="number" id="rt-beam_size" value="3" min="1" max="10">
|
||||||
|
</label>
|
||||||
|
<label>청크 처리 간격
|
||||||
|
<select id="rt-chunk_seconds">
|
||||||
|
<option value="2">2초 (빠른 반응)</option>
|
||||||
|
<option value="3" selected>3초 (균형)</option>
|
||||||
|
<option value="5">5초 (더 정확)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-toggle">
|
||||||
|
<button type="button" class="toggle-btn" id="rt-antirep-toggle">
|
||||||
|
반복 억제 옵션 <span class="chevron" id="rt-antirep-chevron">▸</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="rt-antirep-panel" class="collapsible" style="display:none">
|
||||||
|
<div class="grid2">
|
||||||
|
<label>no_repeat_ngram_size
|
||||||
|
<input type="number" id="rt-no_repeat_ngram_size" value="0" min="0" max="10">
|
||||||
|
<span class="hint">0=비활성, 3~5 권장</span>
|
||||||
|
</label>
|
||||||
|
<label>repetition_penalty
|
||||||
|
<input type="number" id="rt-repetition_penalty" value="1.0" min="1.0" max="2.0" step="0.05">
|
||||||
|
</label>
|
||||||
|
<label>compression_ratio_threshold
|
||||||
|
<input type="number" id="rt-compression_ratio_threshold" value="2.4" min="0.5" max="5.0" step="0.1">
|
||||||
|
</label>
|
||||||
|
<label>no_speech_threshold
|
||||||
|
<input type="number" id="rt-no_speech_threshold" value="0.6" min="0.0" max="1.0" step="0.05">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="inline">
|
||||||
|
<input type="checkbox" id="rt-condition_on_previous_text" checked>
|
||||||
|
이전 텍스트 조건화
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="mic-row">
|
||||||
|
<button id="rt-start-btn" class="mic-btn">● 녹음 시작</button>
|
||||||
|
<button id="rt-stop-btn" class="mic-btn stop-btn" disabled>■ 녹음 중지</button>
|
||||||
|
</div>
|
||||||
|
<div id="rt-indicator" class="rt-indicator idle">대기 중</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>실시간 전사</h2>
|
||||||
|
<div id="rt-live-box" class="live-box">
|
||||||
|
<span class="muted">녹음을 시작하면 텍스트가 여기에 나타납니다.</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>최종 텍스트</h2>
|
||||||
|
<textarea id="rt-final-text" rows="10" placeholder="녹음 종료 후 최종 결과가 표시됩니다."></textarea>
|
||||||
|
<div class="actions" style="margin-top:10px">
|
||||||
|
<button type="button" id="rt-copy-btn">텍스트 복사</button>
|
||||||
|
<button type="button" id="rt-dl-txt-btn">TXT 저장</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════ TAB: HISTORY ═══════════════════ -->
|
||||||
|
<div id="tab-history" class="tab-panel" style="display:none">
|
||||||
|
<section class="card">
|
||||||
|
<div class="history-toolbar">
|
||||||
|
<h2 style="margin:0">처리 내역</h2>
|
||||||
|
<button type="button" id="history-refresh-btn" class="btn-secondary">새로고침</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="history-list">
|
||||||
|
<div class="muted" style="padding:16px">내역을 불러오는 중...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 상세 뷰 -->
|
||||||
|
<div id="history-detail" style="display:none">
|
||||||
|
<section class="card">
|
||||||
|
<div class="detail-header">
|
||||||
|
<button type="button" id="history-back-btn" class="btn-secondary">← 목록으로</button>
|
||||||
|
<span id="detail-title" class="detail-title"></span>
|
||||||
|
<button type="button" id="detail-delete-btn" class="btn-danger">삭제</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="detail-media-section" style="display:none">
|
||||||
|
<h2>원본 파일</h2>
|
||||||
|
<div id="detail-media-container"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>정보</h2>
|
||||||
|
<div id="detail-meta" class="meta-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="detail-speaker-section" style="display:none">
|
||||||
|
<h2>화자별 결과</h2>
|
||||||
|
<div id="detail-speaker-result"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>텍스트 결과</h2>
|
||||||
|
<textarea id="detail-text" rows="12" readonly></textarea>
|
||||||
|
<div class="actions" style="margin-top:10px">
|
||||||
|
<button type="button" id="detail-copy-btn">텍스트 복사</button>
|
||||||
|
<button type="button" id="detail-dl-txt-btn">TXT 저장</button>
|
||||||
|
<button type="button" id="detail-dl-json-btn">JSON 저장</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>JSON 결과</h2>
|
||||||
|
<pre id="detail-json" class="result-json">{}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
app/ui/pcm-processor.js
Normal file
29
app/ui/pcm-processor.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// AudioWorklet processor — accumulates 4096 samples (~256ms at 16kHz) before sending
|
||||||
|
class PCMProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._chunks = [];
|
||||||
|
this._total = 0;
|
||||||
|
this._target = 4096;
|
||||||
|
}
|
||||||
|
|
||||||
|
process(inputs) {
|
||||||
|
const ch = inputs[0] && inputs[0][0];
|
||||||
|
if (ch && ch.length > 0) {
|
||||||
|
this._chunks.push(ch.slice());
|
||||||
|
this._total += ch.length;
|
||||||
|
|
||||||
|
if (this._total >= this._target) {
|
||||||
|
const out = new Float32Array(this._total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const c of this._chunks) { out.set(c, offset); offset += c.length; }
|
||||||
|
this._chunks = [];
|
||||||
|
this._total = 0;
|
||||||
|
this.port.postMessage(out.buffer, [out.buffer]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('pcm-processor', PCMProcessor);
|
||||||
293
app/ui/styles.css
Normal file
293
app/ui/styles.css
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #0b1020;
|
||||||
|
color: #e7ebf3;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||||
|
.page-header { margin-bottom: 8px; }
|
||||||
|
.page-header h1 { margin: 0 0 4px; }
|
||||||
|
.muted { color: #aab3cf; margin: 0; }
|
||||||
|
.small { font-size: 0.87em; }
|
||||||
|
.mono { font-family: monospace; }
|
||||||
|
.hint { display: block; color: #7a85a8; font-size: 0.78em; margin-top: 3px; font-weight: 400; }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #273056;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #7a85a8;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { color: #e7ebf3; }
|
||||||
|
.tab-btn.active { color: #e7ebf3; border-bottom-color: #3d63ff; }
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.card {
|
||||||
|
background: #121933;
|
||||||
|
border: 1px solid #273056;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.card h2 { margin-top: 0; font-size: 1.05em; }
|
||||||
|
|
||||||
|
/* Form elements */
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.93em;
|
||||||
|
}
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 5px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid #3a446d;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #0f1530;
|
||||||
|
color: #e7ebf3;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
textarea { width: 100%; resize: vertical; }
|
||||||
|
.inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.inline input[type="checkbox"] { width: auto; margin-top: 0; }
|
||||||
|
|
||||||
|
/* Grids */
|
||||||
|
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #3d63ff;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.93em;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) { opacity: 0.88; }
|
||||||
|
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.btn-secondary {
|
||||||
|
background: #1a2244;
|
||||||
|
color: #aab3cf;
|
||||||
|
border: 1px solid #3a446d;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) { color: #e7ebf3; opacity: 1; background: #222d55; }
|
||||||
|
.btn-danger { background: #7a1e1e; }
|
||||||
|
.btn-danger:hover:not(:disabled) { background: #9e2626; opacity: 1; }
|
||||||
|
|
||||||
|
/* Section toggle */
|
||||||
|
.section-toggle { margin: 10px 0 0; }
|
||||||
|
.toggle-btn {
|
||||||
|
background: #1a2244;
|
||||||
|
color: #aab3cf;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 0.88em;
|
||||||
|
border: 1px solid #3a446d;
|
||||||
|
}
|
||||||
|
.toggle-btn:hover { color: #e7ebf3; }
|
||||||
|
.chevron { display: inline-block; transition: transform 0.2s; }
|
||||||
|
.collapsible { margin-top: 12px; padding: 14px; background: #0f1530; border: 1px solid #273056; border-radius: 10px; }
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
|
.divider { border-top: 1px solid #273056; margin: 14px 0; }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge-green {
|
||||||
|
background: #1a4a2a;
|
||||||
|
color: #5cd68a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.78em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.badge-backend {
|
||||||
|
background: #1a2a4a;
|
||||||
|
color: #5ab4e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pre / JSON */
|
||||||
|
pre {
|
||||||
|
background: #0f1530;
|
||||||
|
border: 1px solid #3a446d;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
.result-json { max-height: 400px; }
|
||||||
|
|
||||||
|
/* Speaker blocks */
|
||||||
|
.speaker-block {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #0f1530;
|
||||||
|
border: 1px solid #3a446d;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.speaker-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.speaker-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.speaker-time { color: #7a85a8; font-weight: 400; font-size: 0.85em; }
|
||||||
|
.speaker-text { color: #e7ebf3; line-height: 1.7; }
|
||||||
|
|
||||||
|
/* Realtime */
|
||||||
|
.mic-row { display: flex; gap: 12px; margin-bottom: 12px; }
|
||||||
|
.mic-btn { font-size: 1em; padding: 12px 24px; border-radius: 12px; background: #3d63ff; }
|
||||||
|
.stop-btn { background: #c0392b; }
|
||||||
|
.rt-indicator {
|
||||||
|
font-size: 0.88em;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.rt-indicator.idle { background: #1a2244; color: #7a85a8; }
|
||||||
|
.rt-indicator.recording { background: #3a1a1a; color: #e05a5a; animation: blink 1.2s infinite; }
|
||||||
|
.rt-indicator.processing { background: #2a2a1a; color: #e0c44f; }
|
||||||
|
@keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0.5; } }
|
||||||
|
|
||||||
|
.live-box {
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 14px;
|
||||||
|
background: #0f1530;
|
||||||
|
border: 1px solid #3a446d;
|
||||||
|
border-radius: 10px;
|
||||||
|
line-height: 1.75;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 0.97em;
|
||||||
|
}
|
||||||
|
.live-partial { color: #aab3cf; }
|
||||||
|
.live-confirmed { color: #e7ebf3; }
|
||||||
|
|
||||||
|
/* History */
|
||||||
|
.history-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.history-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
}
|
||||||
|
.history-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.history-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #273056;
|
||||||
|
color: #7a85a8;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.history-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #1a2244;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.history-row:hover td { background: #131c38; }
|
||||||
|
.preview-cell {
|
||||||
|
max-width: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #aab3cf;
|
||||||
|
}
|
||||||
|
.btn-view {
|
||||||
|
background: #1a2a4a;
|
||||||
|
color: #5ab4e0;
|
||||||
|
border: 1px solid #3a446d;
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-view:hover { background: #1e3560; opacity: 1; }
|
||||||
|
|
||||||
|
/* Detail view */
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.detail-title {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.meta-item {
|
||||||
|
background: #0f1530;
|
||||||
|
border: 1px solid #273056;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.meta-key { display: block; color: #7a85a8; font-size: 0.78em; font-weight: 600; margin-bottom: 4px; }
|
||||||
|
.meta-val { display: block; font-size: 0.95em; word-break: break-all; }
|
||||||
|
|
||||||
|
/* Media player */
|
||||||
|
.media-player {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #0f1530;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.grid2, .grid3 { grid-template-columns: 1fr; }
|
||||||
|
.mic-row { flex-direction: column; }
|
||||||
|
.detail-header { flex-wrap: wrap; }
|
||||||
|
.history-table th:nth-child(4),
|
||||||
|
.history-table td:nth-child(4) { display: none; }
|
||||||
|
}
|
||||||
273
app/workers/faster_whisper_worker.py
Normal file
273
app/workers/faster_whisper_worker.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
from common import (
|
||||||
|
COMPUTE_TYPE,
|
||||||
|
CUSTOM_MODEL_DIR,
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
DEVICE,
|
||||||
|
MODEL_CACHE,
|
||||||
|
PYANNOTE_HF_TOKEN,
|
||||||
|
ensure_runtime_dirs,
|
||||||
|
resolve_custom_model_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = FastAPI(title="ASR Faster-Whisper Worker")
|
||||||
|
_MODEL_CACHE: Dict[str, WhisperModel] = {}
|
||||||
|
_DIARIZATION_PIPELINE: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup() -> None:
|
||||||
|
ensure_runtime_dirs()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"device": DEVICE,
|
||||||
|
"compute_type": COMPUTE_TYPE,
|
||||||
|
"diarization_available": bool(PYANNOTE_HF_TOKEN),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/transcribe")
|
||||||
|
async def transcribe(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
model: str = Form(DEFAULT_MODEL),
|
||||||
|
custom_model_path: Optional[str] = Form(None),
|
||||||
|
language: Optional[str] = Form(None),
|
||||||
|
task: str = Form("transcribe"),
|
||||||
|
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]:
|
||||||
|
ensure_runtime_dirs()
|
||||||
|
suffix = Path(file.filename or "upload.bin").suffix or ".bin"
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||||
|
tmp.write(await file.read())
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved_model = resolve_custom_model_path(custom_model_path) or model
|
||||||
|
result = _transcribe(
|
||||||
|
audio_path=tmp_path,
|
||||||
|
model_name=resolved_model,
|
||||||
|
language=language or None,
|
||||||
|
task=task,
|
||||||
|
beam_size=beam_size,
|
||||||
|
temperature=temperature,
|
||||||
|
word_timestamps=word_timestamps,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if diarize:
|
||||||
|
result = _apply_diarization(
|
||||||
|
audio_path=tmp_path,
|
||||||
|
result=result,
|
||||||
|
num_speakers=num_speakers,
|
||||||
|
min_speakers=min_speakers,
|
||||||
|
max_speakers=max_speakers,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Worker failure: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_model(model_name: str) -> WhisperModel:
|
||||||
|
if model_name not in _MODEL_CACHE:
|
||||||
|
_MODEL_CACHE[model_name] = WhisperModel(
|
||||||
|
model_name,
|
||||||
|
device=DEVICE,
|
||||||
|
compute_type=COMPUTE_TYPE,
|
||||||
|
download_root=str(MODEL_CACHE),
|
||||||
|
)
|
||||||
|
return _MODEL_CACHE[model_name]
|
||||||
|
|
||||||
|
|
||||||
|
def _transcribe(
|
||||||
|
audio_path: Path,
|
||||||
|
model_name: str,
|
||||||
|
language: Optional[str],
|
||||||
|
task: str,
|
||||||
|
beam_size: int,
|
||||||
|
temperature: float,
|
||||||
|
word_timestamps: bool,
|
||||||
|
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]:
|
||||||
|
model = _load_model(model_name)
|
||||||
|
|
||||||
|
temperatures: Union[float, List[float]] = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] if temperature == 0.0 else temperature
|
||||||
|
|
||||||
|
segments_iter, info = model.transcribe(
|
||||||
|
str(audio_path),
|
||||||
|
language=language,
|
||||||
|
task=task,
|
||||||
|
beam_size=beam_size,
|
||||||
|
temperature=temperatures,
|
||||||
|
word_timestamps=word_timestamps,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
segment_list = []
|
||||||
|
full_text = []
|
||||||
|
for seg in segments_iter:
|
||||||
|
seg_dict: Dict[str, Any] = {
|
||||||
|
"id": seg.id,
|
||||||
|
"start": round(float(seg.start), 3),
|
||||||
|
"end": round(float(seg.end), 3),
|
||||||
|
"text": seg.text,
|
||||||
|
"avg_logprob": round(float(seg.avg_logprob), 4) if seg.avg_logprob is not None else None,
|
||||||
|
"compression_ratio": round(float(seg.compression_ratio), 4) if seg.compression_ratio is not None else None,
|
||||||
|
"no_speech_prob": round(float(seg.no_speech_prob), 4) if seg.no_speech_prob is not None else None,
|
||||||
|
}
|
||||||
|
if word_timestamps and getattr(seg, "words", None):
|
||||||
|
seg_dict["words"] = [
|
||||||
|
{
|
||||||
|
"start": round(float(w.start), 3),
|
||||||
|
"end": round(float(w.end), 3),
|
||||||
|
"word": w.word,
|
||||||
|
"probability": round(float(w.probability), 4),
|
||||||
|
}
|
||||||
|
for w in seg.words
|
||||||
|
]
|
||||||
|
segment_list.append(seg_dict)
|
||||||
|
full_text.append(seg.text.strip())
|
||||||
|
|
||||||
|
duration = None
|
||||||
|
try:
|
||||||
|
duration = round(float(info.duration), 3)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"backend": "faster-whisper",
|
||||||
|
"model": model_name,
|
||||||
|
"language": getattr(info, "language", language),
|
||||||
|
"language_probability": round(float(getattr(info, "language_probability", 0) or 0), 4),
|
||||||
|
"duration": duration,
|
||||||
|
"text": " ".join(x for x in full_text if x),
|
||||||
|
"segments": segment_list,
|
||||||
|
"diarized": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_diarization_pipeline():
|
||||||
|
global _DIARIZATION_PIPELINE
|
||||||
|
if _DIARIZATION_PIPELINE is None:
|
||||||
|
try:
|
||||||
|
from pyannote.audio import Pipeline
|
||||||
|
import torch
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("pyannote.audio가 설치되지 않았습니다.") from exc
|
||||||
|
if not PYANNOTE_HF_TOKEN:
|
||||||
|
raise RuntimeError("화자 분리를 사용하려면 PYANNOTE_HF_TOKEN 환경변수를 설정하세요.")
|
||||||
|
_DIARIZATION_PIPELINE = Pipeline.from_pretrained(
|
||||||
|
"pyannote/speaker-diarization-3.1",
|
||||||
|
use_auth_token=PYANNOTE_HF_TOKEN,
|
||||||
|
)
|
||||||
|
if DEVICE == "cuda":
|
||||||
|
import torch
|
||||||
|
_DIARIZATION_PIPELINE = _DIARIZATION_PIPELINE.to(torch.device("cuda"))
|
||||||
|
return _DIARIZATION_PIPELINE
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_diarization(
|
||||||
|
audio_path: Path,
|
||||||
|
result: Dict[str, Any],
|
||||||
|
num_speakers: Optional[int],
|
||||||
|
min_speakers: Optional[int],
|
||||||
|
max_speakers: Optional[int],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
pipeline = _get_diarization_pipeline()
|
||||||
|
kwargs: Dict[str, Any] = {}
|
||||||
|
if num_speakers is not None:
|
||||||
|
kwargs["num_speakers"] = num_speakers
|
||||||
|
else:
|
||||||
|
if min_speakers is not None:
|
||||||
|
kwargs["min_speakers"] = min_speakers
|
||||||
|
if max_speakers is not None:
|
||||||
|
kwargs["max_speakers"] = max_speakers
|
||||||
|
|
||||||
|
diarization = pipeline(str(audio_path), **kwargs)
|
||||||
|
segments = result.get("segments", [])
|
||||||
|
for seg in segments:
|
||||||
|
seg_start = float(seg.get("start", 0))
|
||||||
|
seg_end = float(seg.get("end", 0))
|
||||||
|
speaker_times: Dict[str, float] = {}
|
||||||
|
for turn, _, speaker in diarization.itertracks(yield_label=True):
|
||||||
|
overlap_start = max(seg_start, turn.start)
|
||||||
|
overlap_end = min(seg_end, turn.end)
|
||||||
|
if overlap_end > overlap_start:
|
||||||
|
speaker_times[speaker] = speaker_times.get(speaker, 0.0) + (overlap_end - overlap_start)
|
||||||
|
seg["speaker"] = max(speaker_times, key=speaker_times.get) if speaker_times else "UNKNOWN"
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
current_speaker: Optional[str] = None
|
||||||
|
current_texts: list = []
|
||||||
|
for seg in segments:
|
||||||
|
speaker = seg.get("speaker", "UNKNOWN")
|
||||||
|
text = seg.get("text", "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if speaker != current_speaker:
|
||||||
|
if current_texts and current_speaker is not None:
|
||||||
|
lines.append(f"[{current_speaker}]: {' '.join(current_texts)}")
|
||||||
|
current_speaker = speaker
|
||||||
|
current_texts = [text]
|
||||||
|
else:
|
||||||
|
current_texts.append(text)
|
||||||
|
if current_texts and current_speaker is not None:
|
||||||
|
lines.append(f"[{current_speaker}]: {' '.join(current_texts)}")
|
||||||
|
|
||||||
|
result["text"] = "\n".join(lines)
|
||||||
|
result["diarized"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--host", default="0.0.0.0")
|
||||||
|
parser.add_argument("--port", type=int, default=8001)
|
||||||
|
args = parser.parse_args()
|
||||||
|
ensure_runtime_dirs()
|
||||||
|
uvicorn.run(app, host=args.host, port=args.port)
|
||||||
178
app/workers/qwen3_worker.py
Normal file
178
app/workers/qwen3_worker.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import gc
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
from common import DEVICE, MODEL_CACHE, ensure_runtime_dirs
|
||||||
|
|
||||||
|
app = FastAPI(title="ASR Qwen3 Worker")
|
||||||
|
|
||||||
|
_PIPE_CACHE: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
LANG_MAP = {
|
||||||
|
"ko": "korean", "en": "english", "ja": "japanese", "zh": "chinese",
|
||||||
|
"fr": "french", "de": "german", "es": "spanish", "ru": "russian",
|
||||||
|
"vi": "vietnamese", "th": "thai", "ar": "arabic", "pt": "portuguese",
|
||||||
|
"it": "italian", "nl": "dutch", "pl": "polish", "tr": "turkish",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _log(msg: str) -> None:
|
||||||
|
print(f"[qwen3] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _free_gpu(*objs: Any) -> None:
|
||||||
|
for obj in objs:
|
||||||
|
try:
|
||||||
|
del obj
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
gc.collect()
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if DEVICE == "cuda" and torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _load_pipe(model_id: str) -> Any:
|
||||||
|
if model_id in _PIPE_CACHE:
|
||||||
|
return _PIPE_CACHE[model_id]
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
|
||||||
|
|
||||||
|
_log(f"loading model {model_id}")
|
||||||
|
dtype = torch.float16 if DEVICE == "cuda" else torch.float32
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = AutoModelForSpeechSeq2Seq.from_pretrained(
|
||||||
|
model_id,
|
||||||
|
torch_dtype=dtype,
|
||||||
|
low_cpu_mem_usage=True,
|
||||||
|
cache_dir=str(MODEL_CACHE),
|
||||||
|
)
|
||||||
|
model.to(DEVICE)
|
||||||
|
processor = AutoProcessor.from_pretrained(model_id, cache_dir=str(MODEL_CACHE))
|
||||||
|
pipe = pipeline(
|
||||||
|
"automatic-speech-recognition",
|
||||||
|
model=model,
|
||||||
|
tokenizer=processor.tokenizer,
|
||||||
|
feature_extractor=processor.feature_extractor,
|
||||||
|
torch_dtype=dtype,
|
||||||
|
device=DEVICE,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Fallback: let pipeline handle loading (uses device_map)
|
||||||
|
_log("direct load failed, trying pipeline auto-load")
|
||||||
|
pipe = pipeline(
|
||||||
|
"automatic-speech-recognition",
|
||||||
|
model=model_id,
|
||||||
|
torch_dtype=torch.float16 if DEVICE == "cuda" else torch.float32,
|
||||||
|
device_map="auto" if DEVICE == "cuda" else None,
|
||||||
|
model_kwargs={"cache_dir": str(MODEL_CACHE)},
|
||||||
|
)
|
||||||
|
|
||||||
|
_PIPE_CACHE[model_id] = pipe
|
||||||
|
_log(f"model {model_id} loaded")
|
||||||
|
return pipe
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup() -> None:
|
||||||
|
ensure_runtime_dirs()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> Dict[str, Any]:
|
||||||
|
return {"status": "ok", "device": DEVICE, "loaded_models": list(_PIPE_CACHE.keys())}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/transcribe")
|
||||||
|
async def transcribe(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
model: str = Form("Qwen/Qwen3-ASR-2B"),
|
||||||
|
language: Optional[str] = Form("ko"),
|
||||||
|
task: str = Form("transcribe"),
|
||||||
|
) -> JSONResponse:
|
||||||
|
ensure_runtime_dirs()
|
||||||
|
suffix = Path(file.filename or "audio.bin").suffix or ".wav"
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||||
|
tmp.write(await file.read())
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pipe = _load_pipe(model)
|
||||||
|
|
||||||
|
lang_code = (language or "ko").strip().lower()
|
||||||
|
lang_name = LANG_MAP.get(lang_code, lang_code)
|
||||||
|
|
||||||
|
generate_kwargs: Dict[str, Any] = {"task": task}
|
||||||
|
if lang_code:
|
||||||
|
generate_kwargs["language"] = lang_name
|
||||||
|
|
||||||
|
_log(f"transcribing language={lang_code} model={model}")
|
||||||
|
raw = pipe(
|
||||||
|
str(tmp_path),
|
||||||
|
return_timestamps=True,
|
||||||
|
generate_kwargs=generate_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# raw may be {"text": "...", "chunks": [...]} or {"text": "..."}
|
||||||
|
full_text: str = raw.get("text", "").strip()
|
||||||
|
chunks: List[Dict[str, Any]] = raw.get("chunks", [])
|
||||||
|
|
||||||
|
segments = []
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
ts = chunk.get("timestamp") or (None, None)
|
||||||
|
start = float(ts[0]) if ts[0] is not None else None
|
||||||
|
end = float(ts[1]) if ts[1] is not None else None
|
||||||
|
segments.append({
|
||||||
|
"id": i,
|
||||||
|
"start": round(start, 3) if start is not None else None,
|
||||||
|
"end": round(end, 3) if end is not None else None,
|
||||||
|
"text": chunk.get("text", "").strip(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Estimate duration from last segment end
|
||||||
|
duration = None
|
||||||
|
if segments and segments[-1]["end"] is not None:
|
||||||
|
duration = segments[-1]["end"]
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"backend": "qwen3",
|
||||||
|
"model": model,
|
||||||
|
"language": lang_code,
|
||||||
|
"duration": duration,
|
||||||
|
"text": full_text,
|
||||||
|
"segments": segments,
|
||||||
|
"diarized": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"error: {type(e).__name__}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"{type(e).__name__}: {e}")
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--host", default="0.0.0.0")
|
||||||
|
parser.add_argument("--port", type=int, default=8004)
|
||||||
|
args = parser.parse_args()
|
||||||
|
_log(f"starting host={args.host} port={args.port} device={DEVICE}")
|
||||||
|
uvicorn.run(app, host=args.host, port=args.port)
|
||||||
19
build.sh
Executable file
19
build.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE="localhost/asr-v2:latest"
|
||||||
|
|
||||||
|
echo "==> Building $IMAGE"
|
||||||
|
podman build -t "$IMAGE" -f Containerfile .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "빌드 완료: $IMAGE"
|
||||||
|
echo ""
|
||||||
|
echo "배포 방법:"
|
||||||
|
echo " 1. quadlet 파일 복사:"
|
||||||
|
echo " cp quadlet/asr-v2.container ~/.config/containers/systemd/"
|
||||||
|
echo " 2. systemd 리로드 및 시작:"
|
||||||
|
echo " systemctl --user daemon-reload"
|
||||||
|
echo " systemctl --user start asr-v2"
|
||||||
|
echo " 3. 로그 확인:"
|
||||||
|
echo " journalctl --user -u asr-v2 -f"
|
||||||
6
envs/faster_whisper.txt
Normal file
6
envs/faster_whisper.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
python-multipart==0.0.9
|
||||||
|
faster-whisper==1.1.1
|
||||||
|
pyannote.audio>=3.1
|
||||||
|
numpy
|
||||||
7
envs/gateway.txt
Normal file
7
envs/gateway.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
python-multipart==0.0.9
|
||||||
|
httpx==0.27.2
|
||||||
|
websockets>=12.0
|
||||||
|
jinja2==3.1.4
|
||||||
|
numpy
|
||||||
9
envs/qwen3.txt
Normal file
9
envs/qwen3.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# torch는 --system-site-packages로 base image에서 상속
|
||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
python-multipart==0.0.9
|
||||||
|
transformers>=4.45.0
|
||||||
|
accelerate>=0.30.0
|
||||||
|
librosa>=0.10.0
|
||||||
|
soundfile>=0.12.0
|
||||||
|
numpy
|
||||||
26
quadlet/asr-v2.container
Normal file
26
quadlet/asr-v2.container
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ASR v2 (faster-whisper + Qwen3-ASR)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
ContainerName=asr-v2
|
||||||
|
Image=localhost/asr-v2:latest
|
||||||
|
Network=proxy
|
||||||
|
User=983:983
|
||||||
|
PublishPort=172.30.1.41:18101:8000
|
||||||
|
Volume=/srv/asr/models-cache:/srv/asr/models-cache:Z
|
||||||
|
Volume=/srv/asr/uploads:/srv/asr/uploads:Z
|
||||||
|
Volume=/srv/asr/results:/srv/asr/results:Z
|
||||||
|
Volume=/srv/asr/custom-models:/srv/asr/custom-models:Z
|
||||||
|
EnvironmentFile=/srv/asr/env/asr.env
|
||||||
|
# GPU 패스스루 (CDI 방식 또는 아래 줄 활성화)
|
||||||
|
# AddDevice=nvidia.com/gpu=all
|
||||||
|
HealthCmd=curl -sf http://127.0.0.1:8000/health
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user