feat: full-platform STT API (v2.3 consensus plan) #1
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user