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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<br>");
|
||||
$("#s-model").innerHTML = (b.models || []).map(esc).join("<br>");
|
||||
$("#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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -87,12 +87,24 @@ def test_pcm16_to_wav_header():
|
||||
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():
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
|
||||
engine = _SegFakeEngine()
|
||||
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)
|
||||
assert out["audio_sec"] == 1.0
|
||||
assert len(out["segments"]) == 1
|
||||
|
||||
Reference in New Issue
Block a user