diff --git a/src/luke_scribe/api/routes/jobs.py b/src/luke_scribe/api/routes/jobs.py
index 2983b94..78069bb 100644
--- a/src/luke_scribe/api/routes/jobs.py
+++ b/src/luke_scribe/api/routes/jobs.py
@@ -168,6 +168,7 @@ def _to_status(job: Job) -> JobStatusResponse:
return JobStatusResponse(
job_id=job.id,
status=job.status.value,
+ source_name=job.source_name,
queue_position=job.queue_position,
progress=job.progress,
processed_sec=job.processed_sec,
diff --git a/src/luke_scribe/api/schemas.py b/src/luke_scribe/api/schemas.py
index 746d2cb..738f020 100644
--- a/src/luke_scribe/api/schemas.py
+++ b/src/luke_scribe/api/schemas.py
@@ -41,6 +41,7 @@ class JobCreateResponse(BaseModel):
class JobStatusResponse(BaseModel):
job_id: str
status: str
+ source_name: str | None = None
queue_position: int | None = None
jobs_ahead: int | None = None
progress: float | None = None
diff --git a/src/luke_scribe/api/static/dashboard.html b/src/luke_scribe/api/static/dashboard.html
index 0920664..93a1bb0 100644
--- a/src/luke_scribe/api/static/dashboard.html
+++ b/src/luke_scribe/api/static/dashboard.html
@@ -509,7 +509,7 @@ async function loadSystem() {
$("#s-ct").textContent = b.compute_type_used || "—";
$("#s-workers").textContent = b.workers ?? "—";
$("#s-queue").textContent = b.queue_depth ?? "—";
- $("#s-model").innerHTML = (b.models || []).join("
");
+ $("#s-model").innerHTML = (b.models || []).map(esc).join("
");
$("#sys-detail").textContent = JSON.stringify(b, null, 2);
} catch (e) {
$("#sys-hint").textContent = "⚠ " + e.message;
@@ -758,8 +758,15 @@ $("#rt-start").addEventListener("click", async () => {
}
};
ws.onclose = () => {
- if (rt && !rt.stopped) setRtStatus("연결 종료", "err");
$("#rt-wave").classList.remove("live");
+ if (rt && !rt.stopped) {
+ setRtStatus("연결 종료", "err");
+ // 예기치 않은 종료 시 마이크/오디오 컨텍스트 정리
+ if (rt.stream) rt.stream.getTracks().forEach((t) => t.stop());
+ if (rt.ctx) rt.ctx.close().catch(() => {});
+ $("#rt-start").disabled = false;
+ $("#rt-stop").disabled = true;
+ }
};
ws.onerror = () => setRtStatus("WebSocket 오류", "err");
@@ -781,7 +788,8 @@ $("#rt-start").addEventListener("click", async () => {
if (ws.readyState === WebSocket.OPEN) ws.send(toPCM16(d));
};
src.connect(proc);
- proc.connect(ctx.destination);
+ // proc.connect(ctx.destination) 의도적으로 생략 — 마이크 오디오를 스피커로
+ // 재생하면 에코 피드백이 생긴다 (ScriptProcessor는 연결 없이도 동작).
} catch (e) {
setRtStatus("오디오 캡처 실패: " + e.message, "err");
stopRt();
diff --git a/src/luke_scribe/engine/owner.py b/src/luke_scribe/engine/owner.py
index 088819d..ac21f29 100644
--- a/src/luke_scribe/engine/owner.py
+++ b/src/luke_scribe/engine/owner.py
@@ -34,6 +34,27 @@ DOWNGRADE_CHAIN_GPU = ["float16", "int8_float16", "int8"]
MAX_DOWNGRADES = 2
+# 무음 청크 decode 생략 임계 (int16 RMS, ~-36dBFS)
+SILENCE_RMS_THRESHOLD = 500.0
+
+
+def _rms16(data: bytes) -> float:
+ """PCM16 바이트의 RMS 레벨 (int16 단위, 0~32767)."""
+ import array
+
+ if not data:
+ return 0.0
+ usable = data[: len(data) - (len(data) % 2)]
+ samples = array.array("h")
+ samples.frombytes(usable)
+ if not samples:
+ return 0.0
+ s = 0.0
+ for v in samples:
+ s += v * v
+ return (s / len(samples)) ** 0.5
+
+
def _pcm16_to_wav(data: bytes, sample_rate: int = 16000) -> bytes:
"""PCM16(mono) 바이트 → WAV(RIFF) 컨테이너. 실시간 청크 decode용."""
n = len(data)
@@ -179,13 +200,23 @@ class EngineOwner:
청크 단위로 ``transcribe(lane="realtime")``를 호출해 세그먼트를
반환한다 (§3.9a — 단일 GPU 락, 실시간 우선 채널). v0.1 스텁이었던
실전 구현으로: 첫 가설은 모델 로드(다운로드)가 필요할 수 있다.
+ 무음 청크(RMS < 임계)는 decode 없이 빈 가설을 반환해 Whisper의
+ 무음 할루시네이션이 LocalAgreement를 통해 확정되는 것을 막는다.
"""
from ..results.models import Segment
+ if _rms16(pcm_chunk) < SILENCE_RMS_THRESHOLD:
+ return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000}
+
wav = _pcm16_to_wav(pcm_chunk)
fd, path = tempfile.mkstemp(suffix=".wav")
try:
- with os.fdopen(fd, "wb") as f:
+ try:
+ f = os.fdopen(fd, "wb")
+ except Exception:
+ os.close(fd) # fdopen 실패 시 fd 누수 방지
+ raise
+ with f:
f.write(wav)
req = InferenceRequest(
audio_path=path,
diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py
index 39e5412..f71e66c 100644
--- a/tests/integration/test_api.py
+++ b/tests/integration/test_api.py
@@ -147,7 +147,9 @@ class TestJobs:
# 조회
r = client.get(f"/v1/jobs/{job_id}", headers=headers)
assert r.status_code == 200
- assert r.json()["status"] == "queued"
+ body = r.json()
+ assert body["status"] == "queued"
+ assert body["source_name"] == "meeting.mp3" # 대시보드 파일 컬럼용
# 결과는 아직 없음
r = client.get(f"/v1/jobs/{job_id}/result", headers=headers)
diff --git a/tests/unit/test_engine_owner.py b/tests/unit/test_engine_owner.py
index 9e3e9ce..463b917 100644
--- a/tests/unit/test_engine_owner.py
+++ b/tests/unit/test_engine_owner.py
@@ -87,12 +87,24 @@ def test_pcm16_to_wav_header():
assert struct.unpack("