feat: full-platform STT API (v2.3 consensus plan) #1
@@ -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