From 56e637a30080837bf092a39ceb93eb8c91a077cf Mon Sep 17 00:00:00 2001 From: du5t Date: Sat, 23 May 2026 00:24:36 +0900 Subject: [PATCH] Initial commit: ASR v2 with multi-venv isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - faster-whisper, Qwen3-ASR, gateway 각 컴포넌트별 Python venv 분리 - 기본언어 한국어(ko) - 처리내역 탭: 목록/상세/원본파일 재생/삭제 - 백엔드별 동적 모델 드랍다운 - /history, /uploads API 추가 - 기존 인스턴스(port 18100) 보존, 신규 port 18101 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 24 ++ Containerfile | 42 +++ app/common.py | 65 ++++ app/gateway.py | 395 ++++++++++++++++++++ app/start.sh | 4 + app/supervisord.conf | 37 ++ app/ui/app.js | 514 +++++++++++++++++++++++++++ app/ui/index.html | 300 ++++++++++++++++ app/ui/pcm-processor.js | 29 ++ app/ui/styles.css | 293 +++++++++++++++ app/workers/faster_whisper_worker.py | 273 ++++++++++++++ app/workers/qwen3_worker.py | 178 ++++++++++ build.sh | 19 + envs/faster_whisper.txt | 6 + envs/gateway.txt | 7 + envs/qwen3.txt | 9 + quadlet/asr-v2.container | 26 ++ 17 files changed, 2221 insertions(+) create mode 100644 .gitignore create mode 100644 Containerfile create mode 100644 app/common.py create mode 100644 app/gateway.py create mode 100755 app/start.sh create mode 100644 app/supervisord.conf create mode 100644 app/ui/app.js create mode 100644 app/ui/index.html create mode 100644 app/ui/pcm-processor.js create mode 100644 app/ui/styles.css create mode 100644 app/workers/faster_whisper_worker.py create mode 100644 app/workers/qwen3_worker.py create mode 100755 build.sh create mode 100644 envs/faster_whisper.txt create mode 100644 envs/gateway.txt create mode 100644 envs/qwen3.txt create mode 100644 quadlet/asr-v2.container diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b885c97 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..107edc6 --- /dev/null +++ b/Containerfile @@ -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"] diff --git a/app/common.py b/app/common.py new file mode 100644 index 0000000..28d43ea --- /dev/null +++ b/app/common.py @@ -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()) diff --git a/app/gateway.py b/app/gateway.py new file mode 100644 index 0000000..5c9c912 --- /dev/null +++ b/app/gateway.py @@ -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(" 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) diff --git a/app/start.sh b/app/start.sh new file mode 100755 index 0000000..97a85a6 --- /dev/null +++ b/app/start.sh @@ -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 diff --git a/app/supervisord.conf b/app/supervisord.conf new file mode 100644 index 0000000..0c1f905 --- /dev/null +++ b/app/supervisord.conf @@ -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 diff --git a/app/ui/app.js b/app/ui/app.js new file mode 100644 index 0000000..ab67feb --- /dev/null +++ b/app/ui/app.js @@ -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 += `
+
+ + ${esc(g.speaker)} + ${fmtTime(t0)} – ${fmtTime(t1)} +
+
${esc(text)}
+
`; + } + 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 ? `${esc(confirmedText)}\n` : '') + + (partial ? `${esc(partial)}` : ''); + rtLiveBox.scrollTop = rtLiveBox.scrollHeight; +} + +async function startRecording() { + if (isRecording) return; + confirmedText = ''; + rtLiveBox.innerHTML = '연결 중...'; + 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 = '음성을 인식하는 중...'; + } 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 = '
불러오는 중...
'; + historyDetail.style.display = 'none'; + historyList.style.display = ''; + try { + const r = await fetch('/history'); + const records = await r.json(); + renderHistoryList(records); + } catch (e) { + historyList.innerHTML = `
오류: ${esc(e.message)}
`; + } +} + +function renderHistoryList(records) { + if (!records.length) { + historyList.innerHTML = '
처리 내역이 없습니다.
'; + return; + } + let html = '
' + + '' + + ''; + for (const rec of records) { + const dur = rec.duration != null ? `${rec.duration.toFixed(1)}s` : '-'; + const dia = rec.diarized ? ' 화자분리' : ''; + html += ` + + + + + + + + + `; + } + html += '
시각파일명백엔드모델언어길이미리보기
${esc(fmtTimestamp(rec.id))}${esc(rec.filename || '-')}${esc(rec.backend || '-')}${esc(rec.model || '-')}${esc(rec.language || '-')}${dur}${esc(rec.text_preview || '')}${dia}
'; + 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]) => + `
${esc(k)}${esc(v)}
` + ).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 = ``; + } else { + mediaContainer.innerHTML = ``; + } + 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(); diff --git a/app/ui/index.html b/app/ui/index.html new file mode 100644 index 0000000..5009022 --- /dev/null +++ b/app/ui/index.html @@ -0,0 +1,300 @@ + + + + + + ASR 전사 서비스 + + + +
+ + +
+ + + +
+ + +
+
+
+ + + +
+ + +
+ + + +
+ + +
+ + +
+
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ + + + +
+
+
+ +
+

상태

+
대기 중
+
+ + + +
+

텍스트 결과

+ +
+ +
+

JSON 결과

+
{}
+
+
+ + + + + + + +
+ + + diff --git a/app/ui/pcm-processor.js b/app/ui/pcm-processor.js new file mode 100644 index 0000000..9da3fa2 --- /dev/null +++ b/app/ui/pcm-processor.js @@ -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); diff --git a/app/ui/styles.css b/app/ui/styles.css new file mode 100644 index 0000000..d5c6dab --- /dev/null +++ b/app/ui/styles.css @@ -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; } +} diff --git a/app/workers/faster_whisper_worker.py b/app/workers/faster_whisper_worker.py new file mode 100644 index 0000000..3f3f54d --- /dev/null +++ b/app/workers/faster_whisper_worker.py @@ -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) diff --git a/app/workers/qwen3_worker.py b/app/workers/qwen3_worker.py new file mode 100644 index 0000000..3373b65 --- /dev/null +++ b/app/workers/qwen3_worker.py @@ -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) diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..8d4afe4 --- /dev/null +++ b/build.sh @@ -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" diff --git a/envs/faster_whisper.txt b/envs/faster_whisper.txt new file mode 100644 index 0000000..4e7c858 --- /dev/null +++ b/envs/faster_whisper.txt @@ -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 diff --git a/envs/gateway.txt b/envs/gateway.txt new file mode 100644 index 0000000..d63113d --- /dev/null +++ b/envs/gateway.txt @@ -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 diff --git a/envs/qwen3.txt b/envs/qwen3.txt new file mode 100644 index 0000000..317caf0 --- /dev/null +++ b/envs/qwen3.txt @@ -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 diff --git a/quadlet/asr-v2.container b/quadlet/asr-v2.container new file mode 100644 index 0000000..87237f3 --- /dev/null +++ b/quadlet/asr-v2.container @@ -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