feat: full-platform STT API (v2.3 consensus plan) #1

Open
lukehemmin wants to merge 21 commits from feat/full-platform into main
11 changed files with 1111 additions and 13 deletions
Showing only changes of commit 3dfa660503 - Show all commits
File diff suppressed because one or more lines are too long
+11 -2
View File
@@ -265,6 +265,13 @@ def cells() -> list[dict]:
" print(open('/content/logs/server.log').read()[-1500:])\n" " print(open('/content/logs/server.log').read()[-1500:])\n"
" raise SystemExit('서버 기동 실패')\n" " raise SystemExit('서버 기동 실패')\n"
"\n" "\n"
"# 대시보드 HTML 스모크 (공개 — 키 불필요)\n"
"try:\n"
" d = urllib.request.urlopen('http://localhost:8000/dashboard', timeout=5)\n"
" print('대시보드 HTML:', d.status, len(d.read()), 'bytes')\n"
"except Exception as exc:\n"
" print('대시보드 로드 실패:', exc)\n"
"\n"
"# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\n" "# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\n"
"req = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\n" "req = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\n"
"try:\n" "try:\n"
@@ -290,7 +297,8 @@ def cells() -> list[dict]:
"\n" "\n"
"if TUNNEL_URL:\n" "if TUNNEL_URL:\n"
" print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n" " print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n"
" print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n" " print(' 대시보드:', TUNNEL_URL + '/dashboard')\n"
" print(' API Docs (Swagger):', TUNNEL_URL + '/docs')\n"
" print(' 상태 (health):', TUNNEL_URL + '/health')\n" " print(' 상태 (health):', TUNNEL_URL + '/health')\n"
" # ── 외부 접속 검증 ──\n" " # ── 외부 접속 검증 ──\n"
" # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n" " # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n"
@@ -494,7 +502,8 @@ def cells() -> list[dict]:
"### Cloudflare 터널로 외부에서 접속 (선택)\n\n" "### Cloudflare 터널로 외부에서 접속 (선택)\n\n"
"서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n" "서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n"
"- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n" "- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n"
"- `/docs`(Swagger 대시보드)와 `/health`는 별도 키 없이 열리지만, **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다.\n" "- `/dashboard`(전용 대시보드 UI)와 `/docs`(Swagger), `/health`는 별도 키 없이 열리지만,\n"
" **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다. 대시보드 상단에 키를 저장하면 사용됩니다.\n"
"- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n" "- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n"
"- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n" "- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n"
"- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n" "- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n"
+3 -1
View File
@@ -28,7 +28,7 @@ from ..errors import (
) )
from ..results.store import ResultStore from ..results.store import ResultStore
from .deps import KeyStore from .deps import KeyStore
from .routes import admin, jobs, stream from .routes import admin, dashboard, jobs, keys, stream
logger = logging.getLogger("luke_scribe.api") logger = logging.getLogger("luke_scribe.api")
@@ -130,9 +130,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
lifespan=lifespan, lifespan=lifespan,
) )
app.include_router(dashboard.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(jobs.router) app.include_router(jobs.router)
app.include_router(stream.router) app.include_router(stream.router)
app.include_router(keys.router)
# ── 오류 → HTTP 매핑 (LukeScribeError envelope) ── # ── 오류 → HTTP 매핑 (LukeScribeError envelope) ──
@app.exception_handler(AuthError) @app.exception_handler(AuthError)
+7
View File
@@ -73,6 +73,13 @@ class KeyStore:
return Principal(key_id=key_id, scopes=entry["scopes"]) return Principal(key_id=key_id, scopes=entry["scopes"])
raise AuthError("유효하지 않은 API 키") raise AuthError("유효하지 않은 API 키")
def list_keys(self) -> list[dict]:
"""키 ID/스코프 목록 (raw 키는 절대 노출하지 않음)."""
return [
{"id": key_id, "scopes": sorted(entry["scopes"])}
for key_id, entry in sorted(self._keys.items())
]
def create_key(self, scopes: list[str] | None = None, *, save_path: str | None = None) -> dict: def create_key(self, scopes: list[str] | None = None, *, save_path: str | None = None) -> dict:
"""새 키 생성 — raw 키는 1회만 반환하고 다이제스트만 저장.""" """새 키 생성 — raw 키는 1회만 반환하고 다이제스트만 저장."""
raw = f"luke-{secrets.token_urlsafe(32)}" raw = f"luke-{secrets.token_urlsafe(32)}"
+22
View File
@@ -0,0 +1,22 @@
"""대시보드 정적 페이지 라우트.
HTML 자체는 공개로 제공하고, API 호출은 브라우저가 사용자 입력 키
(X-API-Key)를 헤더로 보낸다 — /docs(Swagger)와 같은 인증 모델.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import FileResponse
router = APIRouter(tags=["dashboard"])
_DASHBOARD = Path(__file__).resolve().parent.parent / "static" / "dashboard.html"
@router.get("/", include_in_schema=False)
@router.get("/dashboard", include_in_schema=False)
async def dashboard() -> FileResponse:
return FileResponse(_DASHBOARD, media_type="text/html; charset=utf-8")
+35
View File
@@ -0,0 +1,35 @@
"""API 키 라우트 — 생성/목록 (admin 스코프).
- ``POST /v1/keys``: 새 키 생성 — raw 키는 **응답에서 1회만** 노출하고
다이제스트만 저장한다 (대시보드/CLI 공통 계약).
- ``GET /v1/keys``: 키 ID/스코프 목록 (raw 키는 노출하지 않음).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from ..deps import Principal, require_scope
from ..schemas import KeyCreateRequest
router = APIRouter(prefix="/v1/keys", tags=["keys"])
@router.post("", status_code=201)
async def create_key(
request: Request,
body: KeyCreateRequest,
principal: Principal = Depends(require_scope("admin")),
) -> dict:
keystore = request.app.state.keystore
created = keystore.create_key(body.scopes)
return created
@router.get("")
async def list_keys(
request: Request,
principal: Principal = Depends(require_scope("admin")),
) -> dict:
keystore = request.app.state.keystore
return {"keys": keystore.list_keys()}
+4
View File
@@ -28,6 +28,10 @@ class TranscribeOptions(BaseModel):
diarize: bool = False diarize: bool = False
class KeyCreateRequest(BaseModel):
scopes: list[str] = Field(default_factory=lambda: ["transcribe"])
class JobCreateResponse(BaseModel): class JobCreateResponse(BaseModel):
job_id: str job_id: str
status: str status: str
+854
View File
@@ -0,0 +1,854 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>luke_scribe 대시보드</title>
<style>
:root {
--bg: #0b0f17;
--panel: #111827;
--panel-2: #16203a;
--border: #24304d;
--text: #e6ecf7;
--muted: #8b98b5;
--accent: #6366f1;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--err: #f87171;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background:
radial-gradient(1200px 500px at 80% -10%, rgba(99,102,241,.14), transparent 60%),
radial-gradient(900px 400px at -10% 110%, rgba(34,211,238,.08), transparent 60%),
var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", "Apple SD Gothic Neo", sans-serif;
font-size: 14px;
line-height: 1.55;
min-height: 100vh;
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 20px 20px 60px; }
/* ── 헤더 ── */
header {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: rgba(17,24,39,.7);
backdrop-filter: blur(8px);
position: sticky; top: 0; z-index: 20;
}
.brand { font-size: 17px; font-weight: 700; letter-spacing: .3px; display: flex; align-items: center; gap: 8px; }
.brand .dot { width: 10px; height: 10px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-2)); box-shadow: 0 0 12px rgba(99,102,241,.8); }
.brand small { color: var(--muted); font-weight: 500; font-size: 12px; }
.spacer { flex: 1; }
#server-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); }
#server-pill .l { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
#server-pill.ok { color: var(--ok); border-color: rgba(52,211,153,.4); }
#server-pill.ok .l { background: var(--ok); box-shadow: 0 0 8px rgba(52,211,153,.7); }
#server-pill.err { color: var(--err); border-color: rgba(248,113,113,.4); }
#server-pill.err .l { background: var(--err); }
.keybox { display: flex; align-items: center; gap: 6px; }
.keybox input {
background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 6px 10px; font-family: var(--mono); font-size: 12px; width: 220px;
}
.keybox input:focus { outline: none; border-color: var(--accent); }
#scope-badge { font-size: 11px; padding: 3px 8px; border-radius: 6px; background: var(--panel-2); color: var(--muted); border: 1px solid var(--border); white-space: nowrap; }
#scope-badge.admin { color: var(--accent-2); border-color: rgba(34,211,238,.4); }
/* ── 탭 ── */
nav.tabs { display: flex; gap: 6px; margin: 20px 0 14px; flex-wrap: wrap; }
nav.tabs button {
background: transparent; color: var(--muted); border: 1px solid transparent;
padding: 8px 16px; border-radius: 10px; cursor: pointer; font-size: 13.5px; font-weight: 600;
transition: all .15s ease;
}
nav.tabs button:hover { color: var(--text); background: rgba(99,102,241,.08); }
nav.tabs button.active { color: #fff; background: linear-gradient(135deg, rgba(99,102,241,.25), rgba(34,211,238,.15)); border-color: rgba(99,102,241,.5); }
section.tab { display: none; animation: fade .25s ease; }
section.tab.active { display: block; }
@keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; } }
/* ── 카드 / 패널 ── */
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
.card h3 { margin: 0 0 14px; font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.card h3 .ico { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 7px; background: linear-gradient(135deg, rgba(99,102,241,.25), rgba(34,211,238,.15)); font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
.stat { background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; }
.stat .k { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; }
.stat .v { font-size: 17px; font-weight: 700; margin-top: 3px; font-family: var(--mono); }
.stat .v small { font-size: 12px; color: var(--muted); font-weight: 500; }
/* ── 폼 ── */
label.f { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 5px; font-weight: 600; }
input[type=text], input[type=password], select, textarea {
width: 100%; background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
border-radius: 9px; padding: 8px 11px; font-size: 13px; font-family: inherit;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
.row { display: flex; gap: 12px; flex-wrap: wrap; }
.row > * { flex: 1 1 180px; }
button.btn {
background: linear-gradient(135deg, var(--accent), #4f46e5); color: #fff; border: none;
padding: 9px 18px; border-radius: 10px; font-size: 13.5px; font-weight: 700; cursor: pointer;
transition: filter .15s ease, transform .1s ease; font-family: inherit;
}
button.btn:hover { filter: brightness(1.15); }
button.btn:active { transform: scale(.98); }
button.btn:disabled { opacity: .5; cursor: not-allowed; }
button.ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
button.ghost:hover { color: var(--text); border-color: var(--accent); }
button.mini { padding: 4px 10px; font-size: 12px; border-radius: 7px; }
.drop {
border: 2px dashed var(--border); border-radius: 12px; padding: 26px; text-align: center;
color: var(--muted); cursor: pointer; transition: all .15s ease; margin-bottom: 8px;
}
.drop:hover, .drop.over { border-color: var(--accent); color: var(--text); background: rgba(99,102,241,.06); }
.drop .fname { color: var(--accent-2); font-family: var(--mono); font-size: 12px; }
.chips { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; }
.chips label { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); cursor: pointer; }
.chips input { accent-color: var(--accent); }
/* ── 진행률 ── */
.progress { height: 8px; background: var(--panel-2); border-radius: 999px; overflow: hidden; margin: 8px 0 4px; }
.progress > i { display: block; height: 100%; width: 0; border-radius: 999px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .4s ease; }
.progress.lg { height: 12px; }
.hint { font-size: 12px; color: var(--muted); margin-top: 6px; }
/* ── 배지 / 테이블 ── */
.badge { display: inline-block; font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; }
.badge.queued { background: rgba(139,152,181,.15); color: var(--muted); }
.badge.processing { background: rgba(34,211,238,.14); color: var(--accent-2); }
.badge.completed { background: rgba(52,211,153,.14); color: var(--ok); }
.badge.failed { background: rgba(248,113,113,.14); color: var(--err); }
.badge.cancelled { background: rgba(248,113,113,.1); color: var(--muted); }
table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
th { text-align: left; color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .4px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
td { padding: 9px 10px; border-bottom: 1px solid rgba(36,48,77,.6); vertical-align: middle; }
tr:hover td { background: rgba(99,102,241,.04); }
.mono { font-family: var(--mono); }
.muted { color: var(--muted); }
.err { color: var(--err); }
/* ── 결과 영역 ── */
.result-text {
background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;
padding: 14px; font-size: 15px; line-height: 1.8; white-space: pre-wrap; word-break: break-word;
min-height: 70px; max-height: 300px; overflow: auto;
}
.seg-table td:nth-child(2), .seg-table td:nth-child(3) { font-family: var(--mono); font-size: 11.5px; color: var(--muted); white-space: nowrap; }
.conf-low { color: var(--warn); }
/* ── 실시간 ── */
#rt-status { font-size: 12.5px; color: var(--muted); margin: 8px 0; min-height: 18px; }
#rt-partial { color: var(--muted); font-style: italic; min-height: 24px; font-size: 14px; }
#rt-final { font-size: 15px; line-height: 1.8; white-space: pre-wrap; word-break: break-word; }
#rt-wave { display: flex; align-items: center; gap: 3px; height: 34px; margin: 10px 0; }
#rt-wave i { width: 3px; background: var(--accent-2); border-radius: 2px; height: 6px; transition: height .12s ease; }
#rt-wave.live i { animation: wave 0.9s ease-in-out infinite; }
@keyframes wave { 0%,100% { height: 30%; } 50% { height: 95%; } }
/* ── 모달 / 토스트 ── */
.modal-backdrop { position: fixed; inset: 0; background: rgba(4,6,12,.7); backdrop-filter: blur(3px); display: none; align-items: flex-start; justify-content: center; padding: 40px 16px; z-index: 50; overflow: auto; }
.modal-backdrop.open { display: flex; }
.modal { background: var(--panel); border: 1px solid var(--border); border-radius: 16px; max-width: 760px; width: 100%; padding: 20px; }
.modal h3 { margin: 0 0 12px; }
.modal .close { float: right; background: none; border: none; color: var(--muted); font-size: 20px; cursor: pointer; }
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 100; }
.toast { background: var(--panel-2); border: 1px solid var(--border); border-left: 3px solid var(--accent); color: var(--text); padding: 10px 14px; border-radius: 10px; font-size: 13px; box-shadow: 0 6px 24px rgba(0,0,0,.4); animation: slidein .2s ease; max-width: 380px; }
.toast.ok { border-left-color: var(--ok); }
.toast.err { border-left-color: var(--err); }
@keyframes slidein { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; } }
.raw-key {
background: #0d1320; border: 1px dashed var(--accent); border-radius: 10px;
padding: 12px; font-family: var(--mono); font-size: 12.5px; word-break: break-all; margin: 10px 0;
color: var(--accent-2);
}
footer { margin-top: 40px; text-align: center; color: var(--muted); font-size: 12px; }
@media (max-width: 640px) { .keybox input { width: 150px; } }
</style>
</head>
<body>
<header>
<div class="brand"><span class="dot"></span>luke_scribe <small>v0.1 · 대시보드</small></div>
<div class="spacer"></div>
<span id="server-pill"><span class="l"></span><span id="server-pill-txt">연결 확인 중…</span></span>
<span id="scope-badge" hidden></span>
<div class="keybox">
<input id="api-key" type="password" placeholder="API 키 (X-API-Key)" autocomplete="off">
<button class="btn mini" id="save-key">저장</button>
</div>
</header>
<div class="wrap">
<nav class="tabs">
<button data-tab="system" class="active">🖥️ 시스템</button>
<button data-tab="upload">🎙️ 전사</button>
<button data-tab="jobs">📋 작업</button>
<button data-tab="realtime">⚡ 실시간</button>
<button data-tab="keys">🔑 API 키</button>
</nav>
<!-- ── 시스템 ── -->
<section id="tab-system" class="tab active">
<div class="card">
<h3><span class="ico">🖥️</span>시스템 상태</h3>
<div class="grid" id="sys-grid">
<div class="stat"><div class="k">연결 상태</div><div class="v" id="s-status"></div></div>
<div class="stat"><div class="k">능력 등급</div><div class="v" id="s-tier"></div></div>
<div class="stat"><div class="k">GPU</div><div class="v" id="s-gpu"><small></small></div></div>
<div class="stat"><div class="k">VRAM</div><div class="v" id="s-vram"></div></div>
<div class="stat"><div class="k">컴퓨트 타입</div><div class="v" id="s-ct"></div></div>
<div class="stat"><div class="k">워커</div><div class="v" id="s-workers"></div></div>
<div class="stat"><div class="k">큐 깊이</div><div class="v" id="s-queue"></div></div>
<div class="stat"><div class="k">모델</div><div class="v" id="s-model"><small></small></div></div>
</div>
<p class="hint" id="sys-hint"></p>
</div>
<div class="card">
<h3><span class="ico">📊</span>장치 상세</h3>
<pre class="mono muted" id="sys-detail" style="font-size:12px; margin:0; white-space:pre-wrap;"></pre>
</div>
</section>
<!-- ── 전사 ── -->
<section id="tab-upload" class="tab">
<div class="card">
<h3><span class="ico">🎙️</span>파일 전사</h3>
<div class="drop" id="drop">
<div>🎧 파일을 끌어다 놓거나 클릭하여 선택</div>
<div class="hint" id="drop-name"></div>
</div>
<input type="file" id="file-input" hidden>
<div class="row">
<div>
<label class="f">언어</label>
<select id="opt-language">
<option value="ko" selected>ko (한국어)</option>
<option value="auto">auto (자동 감지)</option>
<option value="en">en (영어)</option>
<option value="ja">ja (일본어)</option>
</select>
</div>
<div>
<label class="f">모델</label>
<select id="opt-model">
<option value="large-v3-turbo" selected>large-v3-turbo (빠름)</option>
<option value="large-v3">large-v3 (정확)</option>
</select>
</div>
<div>
<label class="f">컴퓨트 타입</label>
<select id="opt-ct">
<option value="auto" selected>auto</option>
<option value="float16">float16</option>
<option value="int8_float16">int8_float16</option>
<option value="int8">int8</option>
</select>
</div>
<div>
<label class="f">장치</label>
<select id="opt-device">
<option value="auto" selected>auto</option>
<option value="cpu">cpu</option>
<option value="cuda">cuda</option>
</select>
</div>
</div>
<div class="row">
<div>
<label class="f">Hotwords (콤마 구분)</label>
<input type="text" id="opt-hotwords" placeholder="vLLM, Kubernetes">
</div>
<div>
<label class="f">Glossary (KEY=VALUE, 콤마 구분)</label>
<input type="text" id="opt-glossary" placeholder="BLM=vLLM">
</div>
</div>
<div class="chips">
<label><input type="checkbox" value="json" checked> JSON</label>
<label><input type="checkbox" value="txt"> TXT</label>
<label><input type="checkbox" value="srt"> SRT</label>
<label><input type="checkbox" value="vtt"> VTT</label>
</div>
<div style="margin-top:14px;">
<button class="btn" id="btn-upload">전사 시작</button>
<span class="hint" id="upload-hint"></span>
</div>
<div id="upload-progress-wrap" hidden>
<div class="progress lg"><i id="upload-progress-bar"></i></div>
<div class="hint" id="upload-progress-txt"></div>
</div>
</div>
<div class="card" id="result-card" hidden>
<h3><span class="ico">📄</span>전사 결과 <span id="result-meta" class="muted" style="font-size:12px; font-weight:500;"></span></h3>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;" id="result-downloads"></div>
<div class="result-text" id="result-text"></div>
<div class="hint" id="result-warnings"></div>
<h3 style="margin-top:18px;"><span class="ico">⏱️</span>세그먼트</h3>
<div style="max-height:300px; overflow:auto;">
<table class="seg-table">
<thead><tr><th>#</th><th>시작</th><th></th><th>텍스트</th><th>신뢰도</th></tr></thead>
<tbody id="result-segments"></tbody>
</table>
</div>
</div>
</section>
<!-- ── 작업 ── -->
<section id="tab-jobs" class="tab">
<div class="card">
<h3><span class="ico">📋</span>작업 히스토리 <span class="muted" style="font-weight:500; font-size:12px;">3초 자동 갱신</span></h3>
<div style="max-height:520px; overflow:auto;">
<table>
<thead><tr><th>상태</th><th>파일</th><th>job id</th><th>진행률</th><th>작업</th></tr></thead>
<tbody id="jobs-tbody"><tr><td colspan="5" class="muted"></td></tr></tbody>
</table>
</div>
</div>
</section>
<!-- ── 실시간 ── -->
<section id="tab-realtime" class="tab">
<div class="card">
<h3><span class="ico"></span>실시간 마이크 전사 (WebSocket)</h3>
<p class="hint" style="margin-top:0;">마이크 오디오를 16kHz PCM으로 보내 서버가 실시간 가설/확정 텍스트를 반환합니다. 첫 가설은 모델 로드로 지연될 수 있습니다.</p>
<button class="btn" id="rt-start">🎤 녹음 시작</button>
<button class="btn ghost" id="rt-stop" disabled>⏹ 정지</button>
<div id="rt-status"></div>
<div id="rt-wave"></div>
<div class="hint" id="rt-partial"></div>
<div id="rt-final"></div>
</div>
</section>
<!-- ── API 키 ── -->
<section id="tab-keys" class="tab">
<div class="card">
<h3><span class="ico">🔑</span>키 생성 (admin 스코프 필요)</h3>
<div class="chips">
<label><input type="checkbox" value="transcribe" checked> transcribe</label>
<label><input type="checkbox" value="admin"> admin</label>
</div>
<div style="margin-top:12px;"><button class="btn" id="btn-create-key">새 키 생성</button></div>
<div id="new-key-area" hidden>
<p class="hint" style="margin:12px 0 2px;">✅ 생성됨 — <b class="err">raw 키는 이번 한 번만 표시됩니다.</b> 복사해서 안전한 곳에 보관하세요.</p>
<div class="raw-key" id="new-key"></div>
<button class="btn mini" id="btn-copy-key">📋 복사</button>
</div>
</div>
<div class="card">
<h3><span class="ico">🗝️</span>키 목록</h3>
<table>
<thead><tr><th>key id</th><th>스코프</th></tr></thead>
<tbody id="keys-tbody"><tr><td colspan="2" class="muted"></td></tr></tbody>
</table>
</div>
</section>
<footer>privacy-first · 모든 처리는 로컬/자체 서버에서 수행됩니다</footer>
</div>
<div class="modal-backdrop" id="result-modal">
<div class="modal">
<button class="close" id="modal-close">×</button>
<h3 id="modal-title">결과</h3>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;" id="modal-downloads"></div>
<div class="result-text" id="modal-text"></div>
<div class="hint" id="modal-warnings"></div>
<h3 style="margin-top:16px;">세그먼트</h3>
<div style="max-height:260px; overflow:auto;">
<table class="seg-table">
<thead><tr><th>#</th><th>시작</th><th></th><th>텍스트</th><th>신뢰도</th></tr></thead>
<tbody id="modal-segments"></tbody>
</table>
</div>
</div>
</div>
<div id="toasts"></div>
<script>
"use strict";
/* ═══ 유틸 ═══ */
const $ = (s) => document.querySelector(s);
const LS_KEY = "luke_dash_key";
let apiKey = localStorage.getItem(LS_KEY) || "";
let scope = null; // null | "none" | "transcribe" | "admin"
let uploadFile = null;
let jobsTimer = null;
let rt = null; // 실시간 세션 객체
$("#api-key").value = apiKey;
function toast(msg, type = "") {
const el = document.createElement("div");
el.className = "toast " + type;
el.textContent = msg;
$("#toasts").appendChild(el);
setTimeout(() => el.remove(), 4200);
}
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function fmtTime(sec) {
if (sec == null || isNaN(sec)) return "—";
const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
function fmtBytes(n) {
if (n == null) return "—";
if (n < 1024) return n + " B";
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
return (n / 1048576).toFixed(1) + " MB";
}
function confidence(seg) {
// avg_logprob → 0~1 신뢰도 추정
if (seg.avg_logprob == null) return null;
return Math.max(0, Math.min(1, 1 + seg.avg_logprob));
}
/* ═══ API 헬퍼 ═══ */
async function api(path, opts = {}) {
const headers = Object.assign({}, opts.headers || {});
if (apiKey) headers["X-API-Key"] = apiKey;
const res = await fetch(path, Object.assign({}, opts, { headers }));
if (res.status === 401) {
scope = "none";
setScopeBadge();
throw new Error("API 키 인증 실패 — 키를 확인하세요");
}
return res;
}
async function apiJson(path, opts = {}) {
const res = await api(path, opts);
const ct = res.headers.get("content-type") || "";
if (!res.ok) {
let msg = res.status + " " + res.statusText;
try { const b = await res.json(); msg = b.detail || b.message || msg; } catch (e) {}
throw new Error(msg);
}
if (ct.includes("json")) return res.json();
return res.text();
}
/* ═══ 키 저장 / 스코프 ═══ */
$("#save-key").addEventListener("click", () => {
apiKey = $("#api-key").value.trim();
localStorage.setItem(LS_KEY, apiKey);
probeScope();
toast(apiKey ? "API 키 저장됨" : "API 키 제거됨", "ok");
});
$("#api-key").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#save-key").click(); });
function setScopeBadge() {
const b = $("#scope-badge");
if (!scope || scope === "none") { b.hidden = true; return; }
b.hidden = false;
b.textContent = scope === "admin" ? "admin" : "transcribe";
b.className = scope === "admin" ? "admin" : "";
}
async function probeScope() {
if (!apiKey) { scope = "none"; setScopeBadge(); return; }
try {
const res = await api("/v1/system");
if (res.status === 200) { scope = "admin"; }
else if (res.status === 403) { scope = "transcribe"; }
else { scope = "none"; }
} catch (e) { scope = "none"; }
setScopeBadge();
}
/* ═══ 서버 상태 ═══ */
async function refreshHealth() {
const pill = $("#server-pill"), txt = $("#server-pill-txt");
try {
const res = await fetch("/health");
if (res.ok) {
const b = await res.json();
pill.className = "ok";
txt.textContent = "서버 정상 · 큐 " + b.queue_depth + (b.model_ready ? " · 모델 준비됨" : "");
} else { pill.className = "err"; txt.textContent = "서버 응답 이상 (" + res.status + ")"; }
} catch (e) { pill.className = "err"; txt.textContent = "서버 오프라인"; }
}
/* ═══ 탭 ═══ */
document.querySelectorAll("nav.tabs button").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll("nav.tabs button").forEach((b) => b.classList.remove("active"));
document.querySelectorAll("section.tab").forEach((s) => s.classList.remove("active"));
btn.classList.add("active");
const tab = btn.dataset.tab;
$("#tab-" + tab).classList.add("active");
if (tab === "system") loadSystem();
if (tab === "jobs") { loadJobs(); startJobsTimer(); }
if (tab === "keys") loadKeys();
});
});
/* ═══ 시스템 ═══ */
async function loadSystem() {
$("#sys-hint").textContent = "";
try {
const res = await api("/v1/system");
if (res.status === 403) { $("#sys-hint").textContent = "⚠ 시스템 정보는 admin 스코프 키가 필요합니다."; return; }
if (!res.ok) { $("#sys-hint").textContent = "시스템 정보 로드 실패: " + res.status; return; }
const b = await res.json();
const d = b.device || {};
$("#s-status").textContent = "정상";
$("#s-tier").textContent = d.capability_tier || b.capability_tier || "—";
$("#s-gpu").innerHTML = d.device_name ? esc(d.device_name) : "<small>—</small>";
$("#s-vram").innerHTML = d.vram_total_mb ? (d.vram_total_mb / 1024).toFixed(0) + " GB" : "—";
$("#s-ct").textContent = b.compute_type_used || "—";
$("#s-workers").textContent = b.workers ?? "—";
$("#s-queue").textContent = b.queue_depth ?? "—";
$("#s-model").innerHTML = (b.models || []).join("<br>");
$("#sys-detail").textContent = JSON.stringify(b, null, 2);
} catch (e) {
$("#sys-hint").textContent = "⚠ " + e.message;
}
}
/* ═══ 업로드 / 전사 ═══ */
const drop = $("#drop"), fileInput = $("#file-input");
drop.addEventListener("click", () => fileInput.click());
drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); });
drop.addEventListener("dragleave", () => drop.classList.remove("over"));
drop.addEventListener("drop", (e) => {
e.preventDefault(); drop.classList.remove("over");
if (e.dataTransfer.files.length) setFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener("change", () => { if (fileInput.files.length) setFile(fileInput.files[0]); });
function setFile(f) {
uploadFile = f;
$("#drop-name").textContent = "선택: " + f.name + " (" + fmtBytes(f.size) + ")";
}
$("#btn-upload").addEventListener("click", async () => {
if (!uploadFile) { toast("파일을 먼저 선택하세요", "err"); return; }
if (!apiKey) { toast("API 키를 먼저 저장하세요 (상단)", "err"); return; }
const formats = Array.from(document.querySelectorAll("#tab-upload .chips input:checked")).map((c) => c.value);
const glossary = {};
($("#opt-glossary").value || "").split(",").map((s) => s.trim()).filter(Boolean).forEach((kv) => {
const i = kv.indexOf("=");
if (i > 0) glossary[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
});
const options = {
language: $("#opt-language").value === "auto" ? null : $("#opt-language").value,
model: $("#opt-model").value,
compute_type: $("#opt-ct").value,
device: $("#opt-device").value,
hotwords: ($("#opt-hotwords").value || "").split(",").map((s) => s.trim()).filter(Boolean),
formats,
};
if (Object.keys(glossary).length) options.glossary = glossary;
const fd = new FormData();
fd.append("file", uploadFile);
fd.append("options", JSON.stringify(options));
const btn = $("#btn-upload");
btn.disabled = true;
$("#upload-hint").textContent = "업로드 중…";
try {
const job = await apiJson("/v1/jobs", { method: "POST", body: fd });
$("#upload-hint").textContent = "job " + job.job_id + " — 처리 대기";
$("#upload-progress-wrap").hidden = false;
await pollJob(job.job_id);
} catch (e) {
toast("업로드 실패: " + e.message, "err");
$("#upload-hint").textContent = "⚠ " + e.message;
} finally {
btn.disabled = false;
}
});
async function pollJob(jobId) {
for (let i = 0; i < 600; i++) {
await new Promise((r) => setTimeout(r, 1500));
let st;
try { st = await apiJson("/v1/jobs/" + jobId); }
catch (e) { continue; }
const pct = st.progress != null ? Math.round(st.progress * 100) : (st.status === "processing" ? 5 : 0);
$("#upload-progress-bar").style.width = pct + "%";
$("#upload-progress-txt").textContent = "상태: " + st.status + (st.progress != null ? " · " + pct + "%" : "");
if (st.status === "completed") {
$("#upload-progress-txt").textContent = "완료 ✅";
await loadResult(jobId);
toast("전사 완료", "ok");
return;
}
if (st.status === "failed" || st.status === "cancelled") {
$("#upload-progress-txt").textContent = "상태: " + st.status + (st.error && st.error.message ? " — " + st.error.message : "");
toast("전사 " + st.status, "err");
return;
}
}
$("#upload-progress-txt").textContent = "폴링 시간 초과 — 작업 탭에서 확인하세요.";
}
async function loadResult(jobId, into = "main") {
const data = await apiJson("/v1/jobs/" + jobId + "/result?format=json");
const parsed = typeof data === "string" ? JSON.parse(data) : data;
const card = into === "modal" ? "#modal-" : "#result-";
renderResult(parsed, card, into, jobId);
}
function renderResult(body, card, into, jobId) {
const textEl = $(card + "text");
const segEl = $(card + "segments");
const warnEl = $(card + "warnings");
const dlEl = into === "modal" ? $("#modal-downloads") : $("#result-downloads");
textEl.textContent = body.text || "(빈 결과)";
warnEl.textContent = (body.warnings || []).join("\n");
segEl.innerHTML = (body.segments || []).map((s) => {
const conf = confidence(s);
const confTxt = conf == null ? "—" : Math.round(conf * 100) + "%";
return "<tr><td>" + s.index + '</td><td>' + fmtTime(s.start) + "</td><td>" + fmtTime(s.end) +
"</td><td>" + esc(s.text) + "</td><td class=\"" + (conf != null && conf < 0.4 ? "conf-low" : "") + "\">" + confTxt + "</td></tr>";
}).join("");
const meta = $(card + "meta");
if (body.execution && meta) {
meta.textContent = (body.execution.model || "") + " · " + (body.execution.device || "") + " · " +
(body.execution.compute_type || "") + " · RTF " + (body.timings && body.timings.rtf != null ? body.timings.rtf : "—");
}
dlEl.innerHTML = "";
["json", "txt", "srt", "vtt"].forEach((fmt) => {
const b = document.createElement("button");
b.className = "btn mini ghost";
b.textContent = "⬇ " + fmt.toUpperCase();
b.onclick = () => downloadResult(jobId, fmt);
dlEl.appendChild(b);
});
if (into === "main") $("#result-card").hidden = false;
}
async function downloadResult(jobId, fmt) {
try {
const res = await api("/v1/jobs/" + jobId + "/result?format=" + fmt);
const text = await res.text();
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = jobId.slice(0, 8) + "." + fmt;
a.click();
URL.revokeObjectURL(a.href);
} catch (e) { toast("다운로드 실패: " + e.message, "err"); }
}
/* ═══ 작업 히스토리 ═══ */
async function loadJobs() {
if (!apiKey) { $("#jobs-tbody").innerHTML = '<tr><td colspan="5" class="muted">API 키를 저장하면 작업이 표시됩니다.</td></tr>'; return; }
let jobs = [];
try { jobs = await apiJson("/v1/jobs"); }
catch (e) {
if (!jobsTimer) startJobsTimer();
$("#jobs-tbody").innerHTML = '<tr><td colspan="5" class="err">' + esc(e.message) + "</td></tr>";
return;
}
if (!jobs.length) { $("#jobs-tbody").innerHTML = '<tr><td colspan="5" class="muted">작업 없음</td></tr>'; return; }
$("#jobs-tbody").innerHTML = jobs.map((j) => {
const pct = j.progress != null ? Math.round(j.progress * 100) : (j.status === "processing" ? 5 : 0);
return "<tr>" +
'<td><span class="badge ' + j.status + '">' + j.status + "</span></td>" +
"<td>" + esc(j.error && j.error.message ? "⚠ " + j.error.message : (j.source_name || j.job_id)) + "</td>" +
'<td class="mono">' + j.job_id.slice(0, 8) + "</td>" +
'<td style="min-width:110px;"><div class="progress"><i style="width:' + pct + '%"></i></div></td>' +
"<td>" +
'<button class="btn mini ghost" data-act="view" data-id="' + j.job_id + '">결과</button> ' +
(j.status === "queued" || j.status === "processing"
? '<button class="btn mini ghost" data-act="cancel" data-id="' + j.job_id + '">취소</button>'
: "") +
"</td></tr>";
}).join("");
$("#jobs-tbody").querySelectorAll("button[data-act]").forEach((b) => {
b.addEventListener("click", async () => {
if (b.dataset.act === "view") {
if (!jobs.find((j) => j.job_id === b.dataset.id && j.result_available)) {
toast("결과가 아직 준비되지 않았습니다");
return;
}
openResultModal(b.dataset.id);
} else if (b.dataset.act === "cancel") {
try { await api("/v1/jobs/" + b.dataset.id, { method: "DELETE" }); toast("취소 요청됨", "ok"); }
catch (e) { toast("취소 실패: " + e.message, "err"); }
}
});
});
}
function startJobsTimer() {
if (jobsTimer) return;
jobsTimer = setInterval(() => { if ($("#tab-jobs").classList.contains("active")) loadJobs(); }, 3000);
}
/* ── 결과 모달 ── */
async function openResultModal(jobId) {
$("#modal-title").textContent = "결과 — " + jobId.slice(0, 8);
$("#result-modal").classList.add("open");
try {
const data = await apiJson("/v1/jobs/" + jobId + "/result?format=json");
const parsed = typeof data === "string" ? JSON.parse(data) : data;
renderResult(parsed, "#modal-", "modal", jobId);
} catch (e) {
$("#modal-text").textContent = "결과 로드 실패: " + e.message;
}
}
$("#modal-close").addEventListener("click", () => $("#result-modal").classList.remove("open"));
$("#result-modal").addEventListener("click", (e) => { if (e.target.id === "result-modal") $("#result-modal").classList.remove("open"); });
/* ═══ 실시간 ═══ */
function initWave() {
const wave = $("#rt-wave");
wave.innerHTML = "";
for (let i = 0; i < 28; i++) { const bar = document.createElement("i"); wave.appendChild(bar); }
}
function waveLevel(v) {
const bars = $("#rt-wave").querySelectorAll("i");
bars.forEach((b, i) => { b.style.height = Math.min(100, v * 140 + (i % 3) * 6) + "%"; });
}
function downsample(buf, from, to) {
if (from === to) return buf;
const ratio = from / to;
const out = new Float32Array(Math.floor(buf.length / ratio));
for (let i = 0; i < out.length; i++) out[i] = buf[Math.floor(i * ratio)];
return out;
}
function toPCM16(f32) {
const out = new Int16Array(f32.length);
for (let i = 0; i < f32.length; i++) out[i] = Math.max(-1, Math.min(1, f32[i])) * 32767;
return out.buffer;
}
$("#rt-start").addEventListener("click", async () => {
if (!apiKey) { toast("API 키를 먼저 저장하세요 (상단)", "err"); return; }
if (rt && rt.ws) { toast("이미 녹음 중"); return; }
initWave();
$("#rt-final").textContent = "";
$("#rt-partial").textContent = "";
$("#rt-start").disabled = true;
$("#rt-stop").disabled = false;
$("#rt-wave").classList.add("live");
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(proto + "//" + location.host + "/v1/stream");
rt = { ws, ctx: null, stream: null, stopped: false };
ws.onopen = () => {
ws.send(JSON.stringify({ type: "init", api_key: apiKey, audio: { sample_rate: 16000 } }));
setRtStatus("연결됨 — 말씀해 주세요 🎙️", "");
};
ws.onmessage = (e) => {
let m; try { m = JSON.parse(e.data); } catch (err) { return; }
if (m.type === "status") {
if (m.status === "ready") setRtStatus("세션 준비됨 — 녹음 중…", "ok");
else if (m.status === "error") setRtStatus("서버 오류: " + (m.message || ""), "err");
else if (m.status === "idle_timeout") setRtStatus("유휴 타임아웃", "");
} else if (m.type === "partial") {
$("#rt-partial").textContent = "⋯ " + (m.text || "");
} else if (m.type === "final") {
const seg = m.segment || {};
$("#rt-partial").textContent = "";
$("#rt-final").textContent += (seg.text || "") + " ";
}
};
ws.onclose = () => {
if (rt && !rt.stopped) setRtStatus("연결 종료", "err");
$("#rt-wave").classList.remove("live");
};
ws.onerror = () => setRtStatus("WebSocket 오류", "err");
// 오디오 캡처 시작 (ws.open 후 init 전송 — 사용자 제스처 컨텍스트 유지)
try {
const ms = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
rt.stream = ms;
const ctx = new AudioContext();
rt.ctx = ctx;
const src = ctx.createMediaStreamSource(ms);
const proc = ctx.createScriptProcessor(4096, 1, 1);
proc.onaudioprocess = (e) => {
if (rt.stopped) return;
const ch = e.inputBuffer.getChannelData(0);
let amp = 0;
for (let i = 0; i < ch.length; i += 32) amp = Math.max(amp, Math.abs(ch[i]));
waveLevel(amp);
const d = downsample(ch, ctx.sampleRate, 16000);
if (ws.readyState === WebSocket.OPEN) ws.send(toPCM16(d));
};
src.connect(proc);
proc.connect(ctx.destination);
} catch (e) {
setRtStatus("오디오 캡처 실패: " + e.message, "err");
stopRt();
}
});
function setRtStatus(txt, cls) {
const el = $("#rt-status");
el.textContent = txt;
el.className = cls === "err" ? "err" : (cls === "ok" ? "ok" : "");
if (cls === "ok") el.style.color = "var(--ok)";
else if (cls === "err") el.style.color = "var(--err)";
else el.style.color = "var(--muted)";
}
function stopRt() {
if (!rt) return;
rt.stopped = true;
if (rt.ctx) rt.ctx.close().catch(() => {});
if (rt.stream) rt.stream.getTracks().forEach((t) => t.stop());
if (rt.ws && rt.ws.readyState <= WebSocket.OPEN) rt.ws.close();
rt = null;
$("#rt-start").disabled = false;
$("#rt-stop").disabled = true;
$("#rt-wave").classList.remove("live");
waveLevel(0);
}
$("#rt-stop").addEventListener("click", () => { stopRt(); setRtStatus("녹음 중지됨", ""); });
/* ═══ API 키 관리 ═══ */
async function loadKeys() {
const tbody = $("#keys-tbody");
try {
const res = await api("/v1/keys");
if (res.status === 403) { tbody.innerHTML = '<tr><td colspan="2" class="err">admin 스코프 키가 필요합니다.</td></tr>'; return; }
if (!res.ok) { tbody.innerHTML = '<tr><td colspan="2" class="err">' + esc(res.status) + "</td></tr>"; return; }
const b = await res.json();
tbody.innerHTML = (b.keys || []).map((k) =>
'<tr><td class="mono">' + esc(k.id) + "</td><td>" + (k.scopes || []).map(esc).join(", ") + "</td></tr>"
).join("") || '<tr><td colspan="2" class="muted">키 없음</td></tr>';
} catch (e) {
tbody.innerHTML = '<tr><td colspan="2" class="err">' + esc(e.message) + "</td></tr>";
}
}
$("#btn-create-key").addEventListener("click", async () => {
const scopes = Array.from(document.querySelectorAll("#tab-keys .chips input:checked")).map((c) => c.value);
if (!scopes.length) { toast("스코프를 하나 이상 선택하세요", "err"); return; }
try {
const res = await api("/v1/keys", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scopes }) });
if (res.status === 403) { toast("admin 스코프 키가 필요합니다", "err"); return; }
if (!res.ok) { toast("생성 실패: " + res.status, "err"); return; }
const b = await res.json();
$("#new-key").textContent = b.key;
$("#new-key-area").hidden = false;
toast("키 생성됨: " + b.key_id, "ok");
loadKeys();
} catch (e) { toast(e.message, "err"); }
});
$("#btn-copy-key").addEventListener("click", () => {
const k = $("#new-key").textContent;
if (navigator.clipboard) navigator.clipboard.writeText(k).then(() => toast("복사됨", "ok"));
else { const ta = document.createElement("textarea"); ta.value = k; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); toast("복사됨", "ok"); }
});
/* ═══ 초기화 ═══ */
initWave();
refreshHealth();
setInterval(refreshHealth, 10000);
if (apiKey) probeScope();
</script>
</body>
</html>
+67 -8
View File
@@ -18,6 +18,9 @@ EngineOwner**가 모든 CT2 모델과 VRAM을 소유하고, API·배치 워커
from __future__ import annotations from __future__ import annotations
import os
import struct
import tempfile
import threading import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -31,6 +34,28 @@ DOWNGRADE_CHAIN_GPU = ["float16", "int8_float16", "int8"]
MAX_DOWNGRADES = 2 MAX_DOWNGRADES = 2
def _pcm16_to_wav(data: bytes, sample_rate: int = 16000) -> bytes:
"""PCM16(mono) 바이트 → WAV(RIFF) 컨테이너. 실시간 청크 decode용."""
n = len(data)
header = struct.pack(
"<4sI4s4sIHHIIHH4sI",
b"RIFF",
36 + n,
b"WAVE",
b"fmt ",
16,
1, # PCM
1, # mono
sample_rate,
sample_rate * 2, # byte rate
2, # block align
16, # bits per sample
b"data",
n,
)
return header + data
@dataclass @dataclass
class InferenceRequest: class InferenceRequest:
audio_path: str audio_path: str
@@ -149,16 +174,50 @@ class EngineOwner:
return profiles return profiles
def emit_hypothesis(self, pcm_chunk: bytes) -> dict: def emit_hypothesis(self, pcm_chunk: bytes) -> dict:
"""실시간 레인 가설 생성 (v0.1 스텁). """실시간 레인 가설 생성 — PCM16(16kHz mono) → WAV → realtime lane decode.
실제 decode는 오디오 청크를 임시 WAV로 이어붙인 뒤 ``transcribe()`` 청크 단위로 ``transcribe(lane="realtime")``를 호출해 세그먼트
호출한다. v0.1 mock 환경(모델 미탑재)에서는 빈 가설을 반환하며, 반환한다 (§3.9a — 단일 GPU 락, 실시간 우선 채널). v0.1 스텁이었던
GPU 환경에서 실전 decode는 이 진입점으로 통일된다 (§3.9a — 단일 GPU 락). 실전 구현으로: 첫 가설은 모델 로드(다운로드)가 필요할 수 있다.
""" """
if self._stats.get("realtime_decode_ready"): from ..results.models import Segment
# 실전 구현: 청크 WAV → transcribe(lane=realtime) → segments
pass wav = _pcm16_to_wav(pcm_chunk)
return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000} fd, path = tempfile.mkstemp(suffix=".wav")
try:
with os.fdopen(fd, "wb") as f:
f.write(wav)
req = InferenceRequest(
audio_path=path,
options=TranscriptionOptions(
model=self.settings.model_rt,
language=self.settings.language,
device=self.settings.device,
compute_type=self.settings.compute_type,
vad=False, # 실시간 레인: 짧은 청크에서 VAD 무음 제거 방지
beam_size=5,
),
lane="realtime",
)
outcome = self.transcribe(req)
segments: list[Segment] = []
for i, seg in enumerate(outcome["segments"]):
segments.append(
Segment(
index=i,
start=float(seg.get("start", 0.0)),
end=float(seg.get("end", 0.0)),
text=seg.get("text", ""),
avg_logprob=seg.get("avg_logprob"),
no_speech_prob=seg.get("no_speech_prob"),
)
)
return {"segments": segments, "audio_sec": len(pcm_chunk) / 2 / 16000}
finally:
try:
os.unlink(path)
except OSError:
pass
def unload_all(self) -> None: def unload_all(self) -> None:
self._engine.unload_all() self._engine.unload_all()
+38
View File
@@ -44,6 +44,44 @@ class TestHealth:
assert body["model_ready"] is False # 모델 캐시 없음 → 프로비저닝 보류 assert body["model_ready"] is False # 모델 캐시 없음 → 프로비저닝 보류
class TestDashboard:
def test_dashboard_html_public(self, client: TestClient):
"""대시보드 HTML은 공개 — 인증은 클라이언트에서 API 키 입력."""
for path in ("/", "/dashboard"):
r = client.get(path)
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "luke_scribe" in r.text
assert "실시간" in r.text # 전체 기능 포함
class TestKeys:
def test_create_and_list_key(self, client: TestClient):
headers = {"X-API-Key": "key-admin"}
r = client.post("/v1/keys", json={"scopes": ["transcribe"]}, headers=headers)
assert r.status_code == 201, r.text
body = r.json()
assert body["key"].startswith("luke-")
assert body["key_id"].startswith("k-")
# raw 키는 1회만 노출 — 목록에는 다이제스트 ID만
r2 = client.get("/v1/keys", headers=headers)
keys = r2.json()["keys"]
assert body["key_id"] in [k["id"] for k in keys]
assert all("key" not in k for k in keys)
def test_admin_scope_required(self, client: TestClient):
headers = {"X-API-Key": "key-transcribe"}
assert (
client.post("/v1/keys", json={"scopes": ["transcribe"]}, headers=headers).status_code
== 403
)
assert client.get("/v1/keys", headers=headers).status_code == 403
def test_no_auth_401(self, client: TestClient):
assert client.get("/v1/keys").status_code == 401
assert client.post("/v1/keys", json={"scopes": []}).status_code == 401
class TestAutoWorker: class TestAutoWorker:
def test_auto_worker_off_by_default(self, client: TestClient): def test_auto_worker_off_by_default(self, client: TestClient):
"""기본(False)에서는 워커 스레드를 띄우지 않는다 (테스트/프로덕션 안전).""" """기본(False)에서는 워커 스레드를 띄우지 않는다 (테스트/프로덕션 안전)."""
+68
View File
@@ -34,6 +34,74 @@ def _opts(**kw) -> TranscriptionOptions:
return TranscriptionOptions(**kw) return TranscriptionOptions(**kw)
class _SegFakeEngine:
"""emit_hypothesis 테스트용 — WAV 경로 기록 + 세그먼트 반환."""
def __init__(self) -> None:
self.path = None
def transcribe(self, audio_path, options, should_cancel=None, download_progress=None):
self.path = audio_path
segs = [
{
"start": 0.0,
"end": 1.2,
"text": "안녕하세요",
"avg_logprob": -0.2,
"no_speech_prob": 0.01,
}
]
return type("O", (), {"segments": iter(segs), "info": {"language": "ko"}})()
def unload_all(self):
pass
def _bare_owner(engine) -> EngineOwner:
import threading
from luke_scribe.config import Settings
owner = EngineOwner.__new__(EngineOwner)
owner.settings = Settings(_env_file=None)
owner._engine = engine
owner._lock = threading.Lock()
owner._realtime_priority = threading.Lock()
owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
return owner
def test_pcm16_to_wav_header():
import struct
from luke_scribe.engine.owner import _pcm16_to_wav
wav = _pcm16_to_wav(b"\x00\x00" * 8000) # 1초 (16kHz mono)
assert wav[:4] == b"RIFF"
assert wav[8:12] == b"WAVE"
assert wav[12:16] == b"fmt "
sr = struct.unpack("<I", wav[24:28])[0]
channels = struct.unpack("<H", wav[22:24])[0]
bits = struct.unpack("<H", wav[34:36])[0]
assert sr == 16000 and channels == 1 and bits == 16
assert struct.unpack("<I", wav[40:44])[0] == 16000 # data 크기
def test_emit_hypothesis_decodes_chunk_and_cleans_temp():
import os
engine = _SegFakeEngine()
owner = _bare_owner(engine)
chunk = b"\x00\x00" * 16000 # 1초 PCM16
out = owner.emit_hypothesis(chunk)
assert out["audio_sec"] == 1.0
assert len(out["segments"]) == 1
assert out["segments"][0].model_dump()["text"] == "안녕하세요"
# 임시 WAV는 실시간 레인 decode 후 삭제됨
assert engine.path is not None
assert not os.path.exists(engine.path)
def test_no_downgrade_on_success(): def test_no_downgrade_on_success():
owner = EngineOwner.__new__(EngineOwner) owner = EngineOwner.__new__(EngineOwner)
owner.settings = None owner.settings = None