feat: dashboard UI + key API + realtime decode

Full HTML dashboard (dark theme, vanilla JS, no external deps) served at / and /dashboard with five tabs: system status (admin), file upload-to-transcribe with progress/result/downloads, job history with cancel/result modal, realtime mic demo over the existing WebSocket, and API key create/list.

Backend: POST/GET /v1/keys (admin; raw key returned once, digest-only storage); KeyStore.list_keys(); EngineOwner.emit_hypothesis is now a real implementation (PCM16 chunk -> WAV -> realtime-lane decode with the single GPU lock) instead of a stub.

Notebook: tunnel cell links /dashboard and smoke-checks the HTML.

+ 6 tests (dashboard public HTML, key create/list/scope, WAV header, emit_hypothesis cleanup). 153 tests pass, ruff clean, JS syntax verified with node --check.
This commit is contained in:
2026-08-12 21:41:42 +09:00
parent 440e947d47
commit 3dfa660503
11 changed files with 1111 additions and 13 deletions
+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