perf(hermes): isolate rolling speech inference

This commit is contained in:
jenkins 2026-08-24 00:36:28 -03:00
parent c7aa7f462b
commit 91eb4f92b7
7 changed files with 325 additions and 86 deletions

View File

@ -156,6 +156,7 @@ spec:
dockerfiles/hermes_jetson_tts_cues.py dockerfiles/hermes_jetson_tts_cues.py
PYTHONPATH=/tmp/hermes-voice-test-deps python3 -m pytest -q \ PYTHONPATH=/tmp/hermes-voice-test-deps python3 -m pytest -q \
testing/tests/test_hermes_stt_streaming.py \ testing/tests/test_hermes_stt_streaming.py \
testing/tests/test_hermes_stt_rolling_model.py \
testing/tests/test_hermes_tts_language_routing.py \ testing/tests/test_hermes_tts_language_routing.py \
testing/tests/test_hermes_voice_language_routing.py \ testing/tests/test_hermes_voice_language_routing.py \
testing/tests/test_hermes_oci_promote.py \ testing/tests/test_hermes_oci_promote.py \

View File

@ -17,6 +17,9 @@ ADD --checksum=sha256:aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c98392
ADD --checksum=sha256:9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794 --chmod=0444 \ ADD --checksum=sha256:9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794 --chmod=0444 \
https://openaipublic.azureedge.net/main/whisper/models/9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/small.pt \ https://openaipublic.azureedge.net/main/whisper/models/9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/small.pt \
/opt/models/whisper/small.pt /opt/models/whisper/small.pt
ADD --checksum=sha256:65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9 --chmod=0444 \
https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt \
/opt/models/whisper/tiny.pt
RUN chmod 0555 /opt/models /opt/models/whisper RUN chmod 0555 /opt/models /opt/models/whisper
COPY dockerfiles/hermes-jetson-stt-server.py /opt/atlas/hermes-jetson-stt-server.py COPY dockerfiles/hermes-jetson-stt-server.py /opt/atlas/hermes-jetson-stt-server.py
@ -28,11 +31,12 @@ WORKDIR /opt/atlas
# Import the Xavier CUDA stack and confirm Whisper resolves the baked artifact. # Import the Xavier CUDA stack and confirm Whisper resolves the baked artifact.
# Full GPU warm-up is covered by the Kubernetes startup probe on titan-21. # Full GPU warm-up is covered by the Kubernetes startup probe on titan-21.
RUN python3 -c "import stat; from pathlib import Path; import torch, whisper; p=Path('/opt/models/whisper'); print(whisper.__file__, whisper.__version__, whisper.available_models()); assert {'large-v3-turbo','small'} <= set(whisper.available_models()); assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE((p/name).stat().st_mode)==0o444 for name in ('large-v3-turbo.pt','small.pt')); print(torch.__version__)" RUN python3 -c "import stat; from pathlib import Path; import torch, whisper; p=Path('/opt/models/whisper'); print(whisper.__file__, whisper.__version__, whisper.available_models()); assert {'large-v3-turbo','small','tiny'} <= set(whisper.available_models()); assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE((p/name).stat().st_mode)==0o444 for name in ('large-v3-turbo.pt','small.pt','tiny.pt')); print(torch.__version__)"
ENV HERMES_STT_HOST=0.0.0.0 \ ENV HERMES_STT_HOST=0.0.0.0 \
HERMES_STT_PORT=9000 \ HERMES_STT_PORT=9000 \
HERMES_STT_MODEL=small \ HERMES_STT_MODEL=small \
HERMES_STT_ROLLING_MODEL=tiny \
HERMES_STT_CACHE=/opt/models/whisper \ HERMES_STT_CACHE=/opt/models/whisper \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1

View File

@ -25,6 +25,7 @@ import whisper
HOST = os.getenv("HERMES_STT_HOST", "0.0.0.0") HOST = os.getenv("HERMES_STT_HOST", "0.0.0.0")
PORT = int(os.getenv("HERMES_STT_PORT", "9000")) PORT = int(os.getenv("HERMES_STT_PORT", "9000"))
MODEL_NAME = os.getenv("HERMES_STT_MODEL", "small") MODEL_NAME = os.getenv("HERMES_STT_MODEL", "small")
ROLLING_MODEL_NAME = os.getenv("HERMES_STT_ROLLING_MODEL", "tiny")
CACHE_DIR = Path(os.getenv("HERMES_STT_CACHE", "/cache/whisper")) CACHE_DIR = Path(os.getenv("HERMES_STT_CACHE", "/cache/whisper"))
MAX_AUDIO_BYTES = 30 * 1024 * 1024 MAX_AUDIO_BYTES = 30 * 1024 * 1024
STREAM_SAMPLE_RATE = 16_000 STREAM_SAMPLE_RATE = 16_000
@ -68,6 +69,8 @@ class InferenceGate:
self._last_final_wait_ms = 0.0 self._last_final_wait_ms = 0.0
self._last_final_decode_ms = 0.0 self._last_final_decode_ms = 0.0
self._last_background_decode_ms = 0.0 self._last_background_decode_ms = 0.0
self._last_rolling_decode_ms = 0.0
self._last_speculative_decode_ms = 0.0
self._background_skips = 0 self._background_skips = 0
def run_final(self, callback): def run_final(self, callback):
@ -87,14 +90,14 @@ class InferenceGate:
return callback() return callback()
finally: finally:
with self._condition: with self._condition:
self._last_final_decode_ms = ( self._last_final_decode_ms = (time.monotonic() - decode_started) * 1000
time.monotonic() - decode_started
) * 1000
self._busy = False self._busy = False
self._condition.notify_all() self._condition.notify_all()
def try_background(self, callback): def try_background(self, callback, *, kind: str):
"""Run one useful predecode only when no final is active or waiting.""" """Run one useful predecode only when no final is active or waiting."""
if kind not in {"rolling", "speculative"}:
raise ValueError("unknown background inference kind")
with self._condition: with self._condition:
if self._busy or self._final_waiters: if self._busy or self._final_waiters:
self._background_skips += 1 self._background_skips += 1
@ -105,9 +108,12 @@ class InferenceGate:
return callback() return callback()
finally: finally:
with self._condition: with self._condition:
self._last_background_decode_ms = ( elapsed_ms = (time.monotonic() - decode_started) * 1000
time.monotonic() - decode_started self._last_background_decode_ms = elapsed_ms
) * 1000 if kind == "rolling":
self._last_rolling_decode_ms = elapsed_ms
else:
self._last_speculative_decode_ms = elapsed_ms
self._busy = False self._busy = False
self._condition.notify_all() self._condition.notify_all()
@ -119,8 +125,10 @@ class InferenceGate:
"final_waiters": self._final_waiters, "final_waiters": self._final_waiters,
"last_final_wait_ms": round(self._last_final_wait_ms, 1), "last_final_wait_ms": round(self._last_final_wait_ms, 1),
"last_final_decode_ms": round(self._last_final_decode_ms, 1), "last_final_decode_ms": round(self._last_final_decode_ms, 1),
"last_background_decode_ms": round( "last_background_decode_ms": round(self._last_background_decode_ms, 1),
self._last_background_decode_ms, 1 "last_rolling_decode_ms": round(self._last_rolling_decode_ms, 1),
"last_speculative_decode_ms": round(
self._last_speculative_decode_ms, 1
), ),
"background_skips": self._background_skips, "background_skips": self._background_skips,
} }
@ -128,6 +136,7 @@ class InferenceGate:
INFERENCE_GATE = InferenceGate() INFERENCE_GATE = InferenceGate()
STARTUP_WARMUP_MS: float | None = None STARTUP_WARMUP_MS: float | None = None
STARTUP_ROLLING_WARMUP_MS: float | None = None
def _repetitive_token(token: str) -> bool: def _repetitive_token(token: str) -> bool:
@ -264,20 +273,19 @@ def _warm_model(model: object) -> float:
return (time.monotonic() - started) * 1000 return (time.monotonic() - started) * 1000
def _transcribe_pcm_rolling( def _transcribe_pcm_rolling(model: object, pcm: bytes, language: str) -> dict | None:
model: object, pcm: bytes, language: str
) -> dict | None:
"""Decode a rolling window only when the single GPU worker is immediately free. """Decode a rolling window only when the single GPU worker is immediately free.
Partial work is disposable. It must never form an inference backlog ahead of Partial work is disposable. It must never form an inference backlog ahead of
a final utterance or another user's request, so a busy model means this a final utterance or another user's request, so a busy model means this
snapshot is skipped and a later audio frame may try again. speech epoch is skipped rather than retried.
""" """
temp_path = "" temp_path = ""
try: try:
temp_path = _write_pcm_wav(pcm) temp_path = _write_pcm_wav(pcm)
return INFERENCE_GATE.try_background( return INFERENCE_GATE.try_background(
lambda: _decode_path(model, temp_path, language) lambda: _decode_path(model, temp_path, language),
kind="rolling",
) )
finally: finally:
if temp_path: if temp_path:
@ -295,7 +303,8 @@ def _transcribe_pcm_speculative(
try: try:
temp_path = _write_pcm_wav(pcm) temp_path = _write_pcm_wav(pcm)
return INFERENCE_GATE.try_background( return INFERENCE_GATE.try_background(
lambda: _decode_path(model, temp_path, language) lambda: _decode_path(model, temp_path, language),
kind="speculative",
) )
finally: finally:
if temp_path: if temp_path:
@ -462,9 +471,15 @@ def _pcm_rms(pcm: bytes) -> float:
class StreamingTranscription: class StreamingTranscription:
"""Own one turn's bounded PCM, VAD state and speculative Whisper work.""" """Own one turn's bounded PCM, VAD state and speculative Whisper work."""
def __init__(self, connection: WebSocketConnection, model: object) -> None: def __init__(
self,
connection: WebSocketConnection,
model: object,
rolling_model: object | None = None,
) -> None:
self.connection = connection self.connection = connection
self.model = model self.model = model
self.rolling_model = rolling_model if rolling_model is not None else model
self.turn_id = "" self.turn_id = ""
self.language = "auto" self.language = "auto"
self._pcm = bytearray() self._pcm = bytearray()
@ -489,6 +504,7 @@ class StreamingTranscription:
self._rolling_previous = "" self._rolling_previous = ""
self._rolling_previous_window_start = 0 self._rolling_previous_window_start = 0
self._rolling_revision = 0 self._rolling_revision = 0
self._rolling_attempted = False
def _response(self, message_type: str, **values: object) -> dict: def _response(self, message_type: str, **values: object) -> dict:
return {"type": message_type, "turn_id": self.turn_id, **values} return {"type": message_type, "turn_id": self.turn_id, **values}
@ -543,7 +559,7 @@ class StreamingTranscription:
start_ms = start * 1000 // (STREAM_SAMPLE_RATE * STREAM_SAMPLE_WIDTH) start_ms = start * 1000 // (STREAM_SAMPLE_RATE * STREAM_SAMPLE_WIDTH)
return pcm, start_ms return pcm, start_ms
def _reset_rolling_history_locked(self) -> None: def _reset_rolling_history_locked(self, *, rearm: bool) -> None:
"""Discard provisional agreement after the client's speech epoch changes.""" """Discard provisional agreement after the client's speech epoch changes."""
self._rolling_previous = "" self._rolling_previous = ""
self._rolling_previous_window_start = 0 self._rolling_previous_window_start = 0
@ -551,10 +567,10 @@ class StreamingTranscription:
self._rolling_speech_bytes = 0 self._rolling_speech_bytes = 0
self._rolling_last_speech_bytes = 0 self._rolling_last_speech_bytes = 0
self._rolling_last_started_at = 0.0 self._rolling_last_started_at = 0.0
if rearm:
self._rolling_attempted = False
def _begin_rolling_locked( def _begin_rolling_locked(self, now: float) -> tuple[bytes, int, int, int] | None:
self, now: float
) -> tuple[bytes, int, int, int] | None:
"""Reserve one due rolling decode without ever queuing a second one.""" """Reserve one due rolling decode without ever queuing a second one."""
total_bytes = len(self._pcm) total_bytes = len(self._pcm)
minimum_bytes = _pcm_bytes_for_ms(ROLLING_MIN_AUDIO_MS) minimum_bytes = _pcm_bytes_for_ms(ROLLING_MIN_AUDIO_MS)
@ -564,17 +580,20 @@ class StreamingTranscription:
) )
if ( if (
self._rolling_inflight self._rolling_inflight
or self._rolling_attempted
or self._rolling_speech_bytes < minimum_bytes or self._rolling_speech_bytes < minimum_bytes
or self._rolling_speech_bytes - self._rolling_last_speech_bytes or self._rolling_speech_bytes - self._rolling_last_speech_bytes
< required_new_bytes < required_new_bytes
or ( or (
self._rolling_last_started_at self._rolling_last_started_at
and (now - self._rolling_last_started_at) * 1000 and (now - self._rolling_last_started_at) * 1000 < ROLLING_INTERVAL_MS
< ROLLING_INTERVAL_MS
) )
): ):
return None return None
pcm, window_start_ms = self._rolling_snapshot_locked() pcm, window_start_ms = self._rolling_snapshot_locked()
# Reserve the epoch before spawning. A busy gate, decode error or stale
# result must not create a retry loop that can collide with the final.
self._rolling_attempted = True
self._rolling_inflight = True self._rolling_inflight = True
self._rolling_last_started_at = now self._rolling_last_started_at = now
self._rolling_last_speech_bytes = self._rolling_speech_bytes self._rolling_last_speech_bytes = self._rolling_speech_bytes
@ -592,7 +611,11 @@ class StreamingTranscription:
def worker() -> None: def worker() -> None:
payload: dict | None = None payload: dict | None = None
try: try:
payload = _transcribe_pcm_rolling(self.model, pcm, self.language) payload = _transcribe_pcm_rolling(
self.rolling_model,
pcm,
self.language,
)
except Exception as exc: except Exception as exc:
# Partial inference is best-effort. The canonical commit path # Partial inference is best-effort. The canonical commit path
# remains authoritative and reports its own actionable errors. # remains authoritative and reports its own actionable errors.
@ -670,7 +693,7 @@ class StreamingTranscription:
self._epoch += 1 self._epoch += 1
self._cached_key = "" self._cached_key = ""
self._cached_payload = None self._cached_payload = None
self._reset_rolling_history_locked() self._reset_rolling_history_locked(rearm=True)
self._heard_speech = True self._heard_speech = True
self._at_eos = False self._at_eos = False
self._silence_bytes = 0 self._silence_bytes = 0
@ -687,7 +710,7 @@ class StreamingTranscription:
) )
if self._silence_bytes >= required and not self._at_eos: if self._silence_bytes >= required and not self._at_eos:
self._at_eos = True self._at_eos = True
self._reset_rolling_history_locked() self._reset_rolling_history_locked(rearm=False)
auto_speculate = True auto_speculate = True
if auto_speculate: if auto_speculate:
self.speculate() self.speculate()
@ -705,7 +728,7 @@ class StreamingTranscription:
self._client_active = True self._client_active = True
self._cached_key = "" self._cached_key = ""
self._cached_payload = None self._cached_payload = None
self._reset_rolling_history_locked() self._reset_rolling_history_locked(rearm=True)
def speculate(self) -> None: def speculate(self) -> None:
"""Decode a stable snapshot once; only an unchanged turn may consume it.""" """Decode a stable snapshot once; only an unchanged turn may consume it."""
@ -846,11 +869,19 @@ class SpeechHandler(BaseHTTPRequestHandler):
{ {
"ok": True, "ok": True,
"model": MODEL_NAME, "model": MODEL_NAME,
"rolling_model": ROLLING_MODEL_NAME,
"device": "cuda" if torch.cuda.is_available() else "cpu", "device": "cuda" if torch.cuda.is_available() else "cpu",
"inference": INFERENCE_GATE.snapshot(), "inference": INFERENCE_GATE.snapshot(),
"startup": { "startup": {
"warmed": STARTUP_WARMUP_MS is not None, "warmed": (
STARTUP_WARMUP_MS is not None
and STARTUP_ROLLING_WARMUP_MS is not None
),
"warmup_ms": round(STARTUP_WARMUP_MS or 0.0, 1), "warmup_ms": round(STARTUP_WARMUP_MS or 0.0, 1),
"rolling_warmup_ms": round(
STARTUP_ROLLING_WARMUP_MS or 0.0,
1,
),
}, },
"streaming": { "streaming": {
"enabled": True, "enabled": True,
@ -909,6 +940,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
session = StreamingTranscription( session = StreamingTranscription(
websocket, websocket,
self.server.model, # type: ignore[attr-defined] self.server.model, # type: ignore[attr-defined]
self.server.rolling_model, # type: ignore[attr-defined]
) )
close_code = 1000 close_code = 1000
close_reason = "" close_reason = ""
@ -1018,16 +1050,32 @@ class SpeechHandler(BaseHTTPRequestHandler):
def main() -> None: def main() -> None:
"""Warm Whisper once, then serve concurrent clients through one GPU lock.""" """Warm Whisper once, then serve concurrent clients through one GPU lock."""
global STARTUP_WARMUP_MS global STARTUP_ROLLING_WARMUP_MS, STARTUP_WARMUP_MS
CACHE_DIR.mkdir(parents=True, exist_ok=True) CACHE_DIR.mkdir(parents=True, exist_ok=True)
if not torch.cuda.is_available(): if not torch.cuda.is_available():
raise RuntimeError("CUDA is required for the Atlas Whisper service") raise RuntimeError("CUDA is required for the Atlas Whisper service")
print(f"[stt] loading Whisper {MODEL_NAME} into CUDA", flush=True) print(f"[stt] loading Whisper {MODEL_NAME} for final CUDA", flush=True)
model = whisper.load_model(MODEL_NAME, device="cuda", download_root=str(CACHE_DIR)) model = whisper.load_model(MODEL_NAME, device="cuda", download_root=str(CACHE_DIR))
print(f"[stt] loading Whisper {ROLLING_MODEL_NAME} for rolling CUDA", flush=True)
rolling_model = whisper.load_model(
ROLLING_MODEL_NAME,
device="cuda",
download_root=str(CACHE_DIR),
)
STARTUP_ROLLING_WARMUP_MS = _warm_model(rolling_model)
print(
"[stt] rolling CUDA warm-up completed in " f"{STARTUP_ROLLING_WARMUP_MS:.1f}ms",
flush=True,
)
# Warm the authoritative model last so the first real final never inherits
# lazy CUDA work from either model's startup path.
STARTUP_WARMUP_MS = _warm_model(model) STARTUP_WARMUP_MS = _warm_model(model)
print(f"[stt] CUDA warm-up completed in {STARTUP_WARMUP_MS:.1f}ms", flush=True) print(
f"[stt] final CUDA warm-up completed in {STARTUP_WARMUP_MS:.1f}ms", flush=True
)
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler) server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
server.model = model # type: ignore[attr-defined] server.model = model # type: ignore[attr-defined]
server.rolling_model = rolling_model # type: ignore[attr-defined]
print(f"[stt] ready on {HOST}:{PORT}", flush=True) print(f"[stt] ready on {HOST}:{PORT}", flush=True)
server.serve_forever(poll_interval=0.25) server.serve_forever(poll_interval=0.25)

View File

@ -638,12 +638,16 @@
if(Number.isFinite(revision)&&revision>partialRevision){ if(Number.isFinite(revision)&&revision>partialRevision){
partialRevision=revision; partialRevision=revision;
const stable=String(payload.stable_transcript||'').trim(); const stable=String(payload.stable_transcript||'').trim();
if(stable&&active&&captureTurnId===turnId&&state==='listening'){ const provisional=String(payload.transcript||'').trim();
const preview=stable.length>72?stable.slice(0,69)+'…':stable; const visible=stable||provisional;
label.textContent='Listening · '+preview; if(visible&&active&&captureTurnId===turnId&&state==='listening'){
const preview=visible.length>72?visible.slice(0,69)+'…':visible;
label.textContent='Listening · '+preview+(stable?'':' · provisional');
if(stable!==lastPreflightText){ if(stable!==lastPreflightText){
lastPreflightText=stable; if(stable){
scheduleVoicePreflight(turnId,revision,stable); lastPreflightText=stable;
scheduleVoicePreflight(turnId,revision,stable);
}
} }
} }
} }

View File

@ -0,0 +1,198 @@
"""Release gates for Hermes' disposable tiny rolling transcription model."""
from __future__ import annotations
import importlib.util
import struct
import sys
import threading
import time
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[2]
SERVER = ROOT / "dockerfiles" / "hermes-jetson-stt-server.py"
DOCKERFILE = ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-stt"
PIPELINE = ROOT / "ci" / "Jenkinsfile.hermes-voice-image"
def _load_server(monkeypatch):
"""Import the server without requiring image-only CUDA dependencies."""
spec = importlib.util.spec_from_file_location("hermes_stt_dual_model", SERVER)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, "cgi", SimpleNamespace())
monkeypatch.setitem(
sys.modules,
"torch",
SimpleNamespace(
cuda=SimpleNamespace(is_available=lambda: False, synchronize=lambda: None)
),
)
monkeypatch.setitem(sys.modules, "whisper", SimpleNamespace())
spec.loader.exec_module(module)
return module
class _Connection:
"""Capture asynchronous stream responses."""
def __init__(self) -> None:
self.messages: list[dict] = []
self.condition = threading.Condition()
def send_json(self, payload: dict) -> None:
with self.condition:
self.messages.append(payload)
self.condition.notify_all()
def wait_for(self, message_type: str, timeout: float = 1.0) -> dict:
deadline = time.monotonic() + timeout
with self.condition:
while time.monotonic() < deadline:
for message in self.messages:
if message.get("type") == message_type:
return message
self.condition.wait(deadline - time.monotonic())
raise AssertionError(f"missing {message_type}: {self.messages}")
class _Model:
"""Return deterministic Whisper output while recording model ownership."""
def __init__(self, text: str) -> None:
self.text = text
self.calls: list[dict] = []
def transcribe(self, path: str, **options: object) -> dict:
self.calls.append({"path": path, "options": options})
return {
"text": self.text,
"language": "en",
"segments": [
{
"text": self.text,
"no_speech_prob": 0.01,
"avg_logprob": -0.1,
}
],
}
def _start(session) -> None:
session.start(
{
"type": "start",
"turn_id": "dual-model-turn",
"format": "pcm_s16le",
"sample_rate": 16_000,
"language": "auto",
}
)
def _speech(samples: int = 1_600) -> bytes:
return struct.pack(f"<{samples}h", *([12_000] * samples))
def _wait_idle(session) -> None:
deadline = time.monotonic() + 1.0
with session._lock:
while session._rolling_inflight and time.monotonic() < deadline:
session._lock.wait(deadline - time.monotonic())
assert not session._rolling_inflight
def test_rolling_uses_tiny_once_while_final_remains_small(monkeypatch):
module = _load_server(monkeypatch)
monkeypatch.setattr(module, "ROLLING_MIN_AUDIO_MS", 100)
monkeypatch.setattr(module, "ROLLING_MAX_RESULT_LAG_MS", 5_000)
final_model = _Model("authoritative small")
rolling_model = _Model("provisional tiny")
connection = _Connection()
session = module.StreamingTranscription(connection, final_model, rolling_model)
_start(session)
session.append(_speech())
partial = connection.wait_for("partial")
assert partial["transcript"] == "provisional tiny"
assert partial["stable_transcript"] == ""
assert len(rolling_model.calls) == 1
assert final_model.calls == []
session.append(_speech())
_wait_idle(session)
assert len(rolling_model.calls) == 1
session.commit()
assert connection.wait_for("final")["transcript"] == "authoritative small"
assert len(final_model.calls) == 1
def test_eos_does_not_rearm_but_resumed_speech_does(monkeypatch):
module = _load_server(monkeypatch)
monkeypatch.setattr(module, "ROLLING_MIN_AUDIO_MS", 100)
monkeypatch.setattr(module, "ROLLING_MAX_RESULT_LAG_MS", 5_000)
final_model = _Model("final")
rolling_model = _Model("rolling")
session = module.StreamingTranscription(_Connection(), final_model, rolling_model)
_start(session)
session.append(_speech())
_wait_idle(session)
assert session._rolling_attempted is True
session.append(bytes(24_000))
assert session._at_eos is True
assert session._rolling_attempted is True
session.resume()
assert session._rolling_attempted is False
session.append(_speech())
_wait_idle(session)
assert len(rolling_model.calls) == 2
def test_dual_warmup_and_separate_timing_are_declared(monkeypatch):
module = _load_server(monkeypatch)
model = _Model("warm")
synchronized: list[bool] = []
monkeypatch.setattr(
module.torch.cuda, "synchronize", lambda: synchronized.append(True)
)
elapsed = module._warm_model(model)
assert elapsed >= 0
assert module.ROLLING_MIN_AUDIO_MS == 1_800
assert len(model.calls) == 1
assert model.calls[0]["options"]["language"] is None
assert synchronized == [True]
assert not Path(model.calls[0]["path"]).exists()
source = SERVER.read_text()
rolling_warm = "STARTUP_ROLLING_WARMUP_MS = _warm_model(rolling_model)"
final_warm = "STARTUP_WARMUP_MS = _warm_model(model)"
assert source.index(rolling_warm) < source.index(final_warm)
assert source.index(final_warm) < source.index("server = ThreadingHTTPServer")
assert 'kind="rolling"' in source
assert 'kind="speculative"' in source
assert '"last_rolling_decode_ms"' in source
assert '"last_speculative_decode_ms"' in source
def test_tiny_model_is_checksum_pinned_read_only_in_the_runtime_image():
dockerfile = DOCKERFILE.read_text()
digest = "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9"
assert f"--checksum=sha256:{digest} --chmod=0444" in dockerfile
assert f"/models/{digest}/tiny.pt" in dockerfile
assert "HERMES_STT_ROLLING_MODEL=tiny" in dockerfile
assert "('large-v3-turbo.pt','small.pt','tiny.pt')" in dockerfile
def test_dual_model_contract_is_an_exact_voice_release_gate():
pipeline = PIPELINE.read_text()
assert "testing/tests/test_hermes_stt_rolling_model.py" in pipeline
source = SERVER.read_text()
assert '"path": "/v1/audio/transcriptions/stream"' in source
assert '"inference": INFERENCE_GATE.snapshot()' in source
assert '"rolling_model": ROLLING_MODEL_NAME' in source
assert "STARTUP_ROLLING_WARMUP_MS is not None" in source

View File

@ -239,7 +239,7 @@ def test_stream_speculation_is_reused_for_unchanged_commit(monkeypatch):
assert model.calls[0][1]["condition_on_previous_text"] is False assert model.calls[0][1]["condition_on_previous_text"] is False
def test_active_speech_emits_rate_limited_stable_rolling_partials(monkeypatch): def test_active_speech_emits_only_one_disposable_rolling_partial(monkeypatch):
module = _load_server(monkeypatch) module = _load_server(monkeypatch)
connection = _Connection() connection = _Connection()
model = _SequencedModel(["please open", "please open my calendar"]) model = _SequencedModel(["please open", "please open my calendar"])
@ -261,13 +261,12 @@ def test_active_speech_emits_rate_limited_stable_rolling_partials(monkeypatch):
"window_start_ms": 0, "window_start_ms": 0,
} }
# Audio progress is required in addition to wall-clock rate limiting. # More PCM in the same epoch must not launch disposable GPU work again.
session._rolling_last_started_at = 0.0 session._rolling_last_started_at = 0.0
session.append(_speech(samples=12_000)) session.append(_speech(samples=12_000))
second = connection.wait_for_revision(2) time.sleep(0.02)
assert second["transcript"] == "please open my calendar" assert len(model.calls) == 1
assert second["stable_transcript"] == "please open" assert not any(message.get("revision") == 2 for message in connection.messages)
assert len(model.calls) == 2
def test_rolling_window_is_bounded_but_final_decode_uses_full_utterance(monkeypatch): def test_rolling_window_is_bounded_but_final_decode_uses_full_utterance(monkeypatch):
@ -338,7 +337,8 @@ def test_busy_gpu_skips_partial_instead_of_queuing_work(monkeypatch):
def occupy_gpu(): def occupy_gpu():
module.INFERENCE_GATE.try_background( module.INFERENCE_GATE.try_background(
lambda: (entered.set(), release.wait(2.0)) lambda: (entered.set(), release.wait(2.0)),
kind="rolling",
) )
worker = threading.Thread(target=occupy_gpu) worker = threading.Thread(target=occupy_gpu)
@ -353,12 +353,12 @@ def test_busy_gpu_skips_partial_instead_of_queuing_work(monkeypatch):
release.set() release.set()
worker.join(timeout=1.0) worker.join(timeout=1.0)
# A later audio interval gets another opportunity once the final-priority # A skipped epoch is never retried, so it cannot race the final later.
# GPU lock is free; the skipped snapshot did not leave a queued worker.
session._rolling_last_started_at = 0.0 session._rolling_last_started_at = 0.0
session.append(_speech()) session.append(_speech())
assert connection.wait_for_revision(1)["transcript"] == "hello" time.sleep(0.02)
assert len(model.calls) == 1 assert connection.messages == []
assert model.calls == []
def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch): def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
@ -369,7 +369,8 @@ def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
first = threading.Thread( first = threading.Thread(
target=lambda: module.INFERENCE_GATE.try_background( target=lambda: module.INFERENCE_GATE.try_background(
lambda: (first_entered.set(), release_first.wait(2.0)) lambda: (first_entered.set(), release_first.wait(2.0)),
kind="rolling",
) )
) )
first.start() first.start()
@ -382,7 +383,7 @@ def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
while module.INFERENCE_GATE._final_waiters < 1 and time.monotonic() < deadline: while module.INFERENCE_GATE._final_waiters < 1 and time.monotonic() < deadline:
time.sleep(0.005) time.sleep(0.005)
assert module.INFERENCE_GATE._final_waiters == 1 assert module.INFERENCE_GATE._final_waiters == 1
assert module.INFERENCE_GATE.try_background(lambda: "ran") is None assert module.INFERENCE_GATE.try_background(lambda: "ran", kind="rolling") is None
release_first.set() release_first.set()
first.join(timeout=1.0) first.join(timeout=1.0)
final.join(timeout=1.0) final.join(timeout=1.0)
@ -464,36 +465,3 @@ def test_stream_contract_rejects_wrong_format_and_oversize(monkeypatch):
monkeypatch.setattr(module, "MAX_STREAM_AUDIO_BYTES", 4) monkeypatch.setattr(module, "MAX_STREAM_AUDIO_BYTES", 4)
with pytest.raises(module.WebSocketError, match="too long"): with pytest.raises(module.WebSocketError, match="too long"):
session.append(bytes(6)) session.append(bytes(6))
def test_stream_health_contract_is_declared_in_source():
source = SERVER.read_text()
assert '"path": "/v1/audio/transcriptions/stream"' in source
assert '"format": "pcm_s16le"' in source
assert '"server_vad": True' in source
assert '"speculative": True' in source
assert '"inference": INFERENCE_GATE.snapshot()' in source
assert '"minimum_audio_ms": ROLLING_MIN_AUDIO_MS' in source
assert '"warmed": STARTUP_WARMUP_MS is not None' in source
assert "MAX_STREAM_AUDIO_BYTES" in source
def test_startup_warms_canonical_decode_before_serving(monkeypatch):
module = _load_server(monkeypatch)
model = _Model()
synchronized: list[bool] = []
monkeypatch.setattr(module.torch.cuda, "synchronize", lambda: synchronized.append(True))
elapsed = module._warm_model(model)
assert elapsed >= 0
assert module.ROLLING_MIN_AUDIO_MS == 1_800
assert len(model.calls) == 1
assert model.calls[0][1]["language"] is None
assert synchronized == [True]
assert not Path(model.calls[0][0]).exists()
source = SERVER.read_text()
assert source.index("STARTUP_WARMUP_MS = _warm_model(model)") < source.index(
"server = ThreadingHTTPServer"
)

View File

@ -42,7 +42,10 @@ def test_barge_in_cancels_owned_turn_and_reuses_pcm_lookback():
assert "reusedCapture.lookback.forEach" in source assert "reusedCapture.lookback.forEach" in source
assert "const bytes=new Uint8Array(16)" in source assert "const bytes=new Uint8Array(16)" in source
assert "window.crypto.getRandomValues(bytes)" in source assert "window.crypto.getRandomValues(bytes)" in source
assert "captureTurnId=(voiceTabNonce?voiceTabNonce+'-':'')+String(token)+'-'+String(++turnSequence)" in source assert (
"captureTurnId=(voiceTabNonce?voiceTabNonce+'-':'')+String(token)+'-'+String(++turnSequence)"
in source
)
def test_streamed_chunks_share_one_audio_timeline_until_final_drain(): def test_streamed_chunks_share_one_audio_timeline_until_final_drain():
@ -71,7 +74,10 @@ def test_barge_cancel_settles_before_new_turn_baseline_and_observer():
) )
assert region.index("rememberAssistantBaseline()") < region.index("window.send()") assert region.index("rememberAssistantBaseline()") < region.index("window.send()")
assert region.index("window.send()") < region.index("startResponseObserver(token)") assert region.index("window.send()") < region.index("startResponseObserver(token)")
assert "suppressAutoRead||bargeCancelPromise||state==='listening'||state==='transcribing'" in source assert (
"suppressAutoRead||bargeCancelPromise||state==='listening'||state==='transcribing'"
in source
)
def test_barge_handoff_never_uses_a_truncated_container_fallback(): def test_barge_handoff_never_uses_a_truncated_container_fallback():
@ -96,7 +102,14 @@ def test_rolling_partials_are_feedback_only_and_wav_fallback_is_owned():
)[0] )[0]
assert "payload.stable_transcript" in partial assert "payload.stable_transcript" in partial
assert "label.textContent='Listening · '+preview" in partial assert "const provisional=String(payload.transcript||'').trim()" in partial
assert "const visible=stable||provisional" in partial
assert (
"label.textContent='Listening · '+preview+(stable?'':' · provisional')"
in partial
)
assert "if(stable){" in partial
assert "scheduleVoicePreflight(turnId,revision,stable)" in partial
assert "composer.value" not in partial assert "composer.value" not in partial
assert "window.send" not in partial assert "window.send" not in partial
assert "signal:controller.signal" in fallback assert "signal:controller.signal" in fallback
@ -113,7 +126,10 @@ def test_stt_client_queue_and_canonical_archive_are_strictly_bounded():
assert "queuedBytes+bytes.byteLength>STT_MAX_QUEUED_BYTES" in source assert "queuedBytes+bytes.byteLength>STT_MAX_QUEUED_BYTES" in source
assert "Streaming transcription backpressure limit exceeded" in source assert "Streaming transcription backpressure limit exceeded" in source
assert "if(flushTimer||settled||!queue.length" in source assert "if(flushTimer||settled||!queue.length" in source
assert "flushTimer=window.setTimeout(function(){flushTimer=null;flush();},20)" in source assert (
"flushTimer=window.setTimeout(function(){flushTimer=null;flush();},20)"
in source
)
assert "function clearQueue(){queue=[];queuedBytes=0;clearFlushTimer();}" in source assert "function clearQueue(){queue=[];queuedBytes=0;clearFlushTimer();}" in source
assert "takeFallbackBlob:function()" in source assert "takeFallbackBlob:function()" in source
assert "pcm16WavBlob(archive,16000)" in source assert "pcm16WavBlob(archive,16000)" in source