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"
" raise SystemExit('서버 기동 실패')\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"
"req = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\n"
"try:\n"
@@ -290,7 +297,8 @@ def cells() -> list[dict]:
"\n"
"if 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"
" # ── 외부 접속 검증 ──\n"
" # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n"
@@ -494,7 +502,8 @@ def cells() -> list[dict]:
"### Cloudflare 터널로 외부에서 접속 (선택)\n\n"
"서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\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"
"- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n"
"- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n"
+3 -1
View File
@@ -28,7 +28,7 @@ from ..errors import (
)
from ..results.store import ResultStore
from .deps import KeyStore
from .routes import admin, jobs, stream
from .routes import admin, dashboard, jobs, keys, stream
logger = logging.getLogger("luke_scribe.api")
@@ -130,9 +130,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
lifespan=lifespan,
)
app.include_router(dashboard.router)
app.include_router(admin.router)
app.include_router(jobs.router)
app.include_router(stream.router)
app.include_router(keys.router)
# ── 오류 → HTTP 매핑 (LukeScribeError envelope) ──
@app.exception_handler(AuthError)
+7
View File
@@ -73,6 +73,13 @@ class KeyStore:
return Principal(key_id=key_id, scopes=entry["scopes"])
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:
"""새 키 생성 — raw 키는 1회만 반환하고 다이제스트만 저장."""
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
class KeyCreateRequest(BaseModel):
scopes: list[str] = Field(default_factory=lambda: ["transcribe"])
class JobCreateResponse(BaseModel):
job_id: 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>
+66 -7
View File
@@ -18,6 +18,9 @@ EngineOwner**가 모든 CT2 모델과 VRAM을 소유하고, API·배치 워커
from __future__ import annotations
import os
import struct
import tempfile
import threading
from dataclasses import dataclass, field
@@ -31,6 +34,28 @@ DOWNGRADE_CHAIN_GPU = ["float16", "int8_float16", "int8"]
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
class InferenceRequest:
audio_path: str
@@ -149,16 +174,50 @@ class EngineOwner:
return profiles
def emit_hypothesis(self, pcm_chunk: bytes) -> dict:
"""실시간 레인 가설 생성 (v0.1 스텁).
"""실시간 레인 가설 생성 — PCM16(16kHz mono) → WAV → realtime lane decode.
실제 decode는 오디오 청크를 임시 WAV로 이어붙인 뒤 ``transcribe()``
호출한다. v0.1 mock 환경(모델 미탑재)에서는 빈 가설을 반환하며,
GPU 환경에서 실전 decode는 이 진입점으로 통일된다 (§3.9a — 단일 GPU 락).
청크 단위로 ``transcribe(lane="realtime")``를 호출해 세그먼트
반환한다 (§3.9a — 단일 GPU 락, 실시간 우선 채널). v0.1 스텁이었던
실전 구현으로: 첫 가설은 모델 로드(다운로드)가 필요할 수 있다.
"""
if self._stats.get("realtime_decode_ready"):
# 실전 구현: 청크 WAV → transcribe(lane=realtime) → segments
from ..results.models import Segment
wav = _pcm16_to_wav(pcm_chunk)
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
return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000}
def unload_all(self) -> None:
self._engine.unload_all()
+38
View File
@@ -44,6 +44,44 @@ class TestHealth:
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:
def test_auto_worker_off_by_default(self, client: TestClient):
"""기본(False)에서는 워커 스레드를 띄우지 않는다 (테스트/프로덕션 안전)."""
+68
View File
@@ -34,6 +34,74 @@ def _opts(**kw) -> TranscriptionOptions:
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():
owner = EngineOwner.__new__(EngineOwner)
owner.settings = None