refine: dashboard review fixes - echo removal, model escape, RMS gate, fd leak, mic cleanup

Reviewer feedback: remove AudioContext destination echo, escape model names, add RMS gate to skip silence, close mkstemp fd, clean up mic on WS close. Tests updated for the RMS gate.
This commit is contained in:
2026-08-12 21:44:26 +09:00
parent 3dfa660503
commit 7616b87f4c
6 changed files with 61 additions and 6 deletions
+1
View File
@@ -168,6 +168,7 @@ def _to_status(job: Job) -> JobStatusResponse:
return JobStatusResponse( return JobStatusResponse(
job_id=job.id, job_id=job.id,
status=job.status.value, status=job.status.value,
source_name=job.source_name,
queue_position=job.queue_position, queue_position=job.queue_position,
progress=job.progress, progress=job.progress,
processed_sec=job.processed_sec, processed_sec=job.processed_sec,
+1
View File
@@ -41,6 +41,7 @@ class JobCreateResponse(BaseModel):
class JobStatusResponse(BaseModel): class JobStatusResponse(BaseModel):
job_id: str job_id: str
status: str status: str
source_name: str | None = None
queue_position: int | None = None queue_position: int | None = None
jobs_ahead: int | None = None jobs_ahead: int | None = None
progress: float | None = None progress: float | None = None
+11 -3
View File
@@ -509,7 +509,7 @@ async function loadSystem() {
$("#s-ct").textContent = b.compute_type_used || "—"; $("#s-ct").textContent = b.compute_type_used || "—";
$("#s-workers").textContent = b.workers ?? "—"; $("#s-workers").textContent = b.workers ?? "—";
$("#s-queue").textContent = b.queue_depth ?? "—"; $("#s-queue").textContent = b.queue_depth ?? "—";
$("#s-model").innerHTML = (b.models || []).join("<br>"); $("#s-model").innerHTML = (b.models || []).map(esc).join("<br>");
$("#sys-detail").textContent = JSON.stringify(b, null, 2); $("#sys-detail").textContent = JSON.stringify(b, null, 2);
} catch (e) { } catch (e) {
$("#sys-hint").textContent = "⚠ " + e.message; $("#sys-hint").textContent = "⚠ " + e.message;
@@ -758,8 +758,15 @@ $("#rt-start").addEventListener("click", async () => {
} }
}; };
ws.onclose = () => { ws.onclose = () => {
if (rt && !rt.stopped) setRtStatus("연결 종료", "err");
$("#rt-wave").classList.remove("live"); $("#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"); ws.onerror = () => setRtStatus("WebSocket 오류", "err");
@@ -781,7 +788,8 @@ $("#rt-start").addEventListener("click", async () => {
if (ws.readyState === WebSocket.OPEN) ws.send(toPCM16(d)); if (ws.readyState === WebSocket.OPEN) ws.send(toPCM16(d));
}; };
src.connect(proc); src.connect(proc);
proc.connect(ctx.destination); // proc.connect(ctx.destination) 의도적으로 생략 — 마이크 오디오를 스피커로
// 재생하면 에코 피드백이 생긴다 (ScriptProcessor는 연결 없이도 동작).
} catch (e) { } catch (e) {
setRtStatus("오디오 캡처 실패: " + e.message, "err"); setRtStatus("오디오 캡처 실패: " + e.message, "err");
stopRt(); stopRt();
+32 -1
View File
@@ -34,6 +34,27 @@ DOWNGRADE_CHAIN_GPU = ["float16", "int8_float16", "int8"]
MAX_DOWNGRADES = 2 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: def _pcm16_to_wav(data: bytes, sample_rate: int = 16000) -> bytes:
"""PCM16(mono) 바이트 → WAV(RIFF) 컨테이너. 실시간 청크 decode용.""" """PCM16(mono) 바이트 → WAV(RIFF) 컨테이너. 실시간 청크 decode용."""
n = len(data) n = len(data)
@@ -179,13 +200,23 @@ class EngineOwner:
청크 단위로 ``transcribe(lane="realtime")``를 호출해 세그먼트를 청크 단위로 ``transcribe(lane="realtime")``를 호출해 세그먼트를
반환한다 (§3.9a — 단일 GPU 락, 실시간 우선 채널). v0.1 스텁이었던 반환한다 (§3.9a — 단일 GPU 락, 실시간 우선 채널). v0.1 스텁이었던
실전 구현으로: 첫 가설은 모델 로드(다운로드)가 필요할 수 있다. 실전 구현으로: 첫 가설은 모델 로드(다운로드)가 필요할 수 있다.
무음 청크(RMS < 임계)는 decode 없이 빈 가설을 반환해 Whisper의
무음 할루시네이션이 LocalAgreement를 통해 확정되는 것을 막는다.
""" """
from ..results.models import Segment 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) wav = _pcm16_to_wav(pcm_chunk)
fd, path = tempfile.mkstemp(suffix=".wav") fd, path = tempfile.mkstemp(suffix=".wav")
try: 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) f.write(wav)
req = InferenceRequest( req = InferenceRequest(
audio_path=path, audio_path=path,
+3 -1
View File
@@ -147,7 +147,9 @@ class TestJobs:
# 조회 # 조회
r = client.get(f"/v1/jobs/{job_id}", headers=headers) r = client.get(f"/v1/jobs/{job_id}", headers=headers)
assert r.status_code == 200 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) r = client.get(f"/v1/jobs/{job_id}/result", headers=headers)
+13 -1
View File
@@ -87,12 +87,24 @@ def test_pcm16_to_wav_header():
assert struct.unpack("<I", wav[40:44])[0] == 16000 # data 크기 assert struct.unpack("<I", wav[40:44])[0] == 16000 # data 크기
def test_emit_hypothesis_skips_silence():
"""무음 청크는 decode 없이 빈 가설 반환 (할루시네이션 방지)."""
engine = _SegFakeEngine()
owner = _bare_owner(engine)
out = owner.emit_hypothesis(b"\x00\x00" * 16000) # 완전 무음
assert out["segments"] == []
assert engine.path is None # decode 미실행 → 임시 파일도 안 만듦
def test_emit_hypothesis_decodes_chunk_and_cleans_temp(): def test_emit_hypothesis_decodes_chunk_and_cleans_temp():
import os import os
import random
import struct
engine = _SegFakeEngine() engine = _SegFakeEngine()
owner = _bare_owner(engine) owner = _bare_owner(engine)
chunk = b"\x00\x00" * 16000 # 1초 PCM16 # 1초 PCM16 — RMS 게이트를 통과하는 유성 신호 (무음이면 decode가 생략됨)
chunk = b"".join(struct.pack("<h", random.randint(-6000, 6000)) for _ in range(16000))
out = owner.emit_hypothesis(chunk) out = owner.emit_hypothesis(chunk)
assert out["audio_sec"] == 1.0 assert out["audio_sec"] == 1.0
assert len(out["segments"]) == 1 assert len(out["segments"]) == 1