feat: full-platform STT API (v2.3 consensus plan) #1

Open
lukehemmin wants to merge 21 commits from feat/full-platform into main
6 changed files with 61 additions and 6 deletions
Showing only changes of commit 7616b87f4c - Show all commits
+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