Initial commit: ASR v2 with multi-venv isolation

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

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

65
app/common.py Normal file
View 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
View File

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

4
app/start.sh Executable file
View 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
View 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
View 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
View 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">&#9679; 녹음 시작</button>
<button id="rt-stop-btn" class="mic-btn stop-btn" disabled>&#9632; 녹음 중지</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
View 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
View 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; }
}

View 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
View 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)