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("