fix(hermes): reuse exact speculative speech snapshot
This commit is contained in:
parent
a23cf838b2
commit
be5566cabd
@ -135,6 +135,59 @@ class InferenceGate:
|
|||||||
|
|
||||||
|
|
||||||
INFERENCE_GATE = InferenceGate()
|
INFERENCE_GATE = InferenceGate()
|
||||||
|
|
||||||
|
|
||||||
|
class StreamTelemetry:
|
||||||
|
"""Retain bounded evidence about the most recently committed voice turn."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._speculative_cache_hits = 0
|
||||||
|
self._direct_final_decodes = 0
|
||||||
|
self._last_commit_cache_hit = False
|
||||||
|
self._last_commit_pending_wait_ms = 0.0
|
||||||
|
self._last_commit_total_ms = 0.0
|
||||||
|
self._last_commit_audio_ms = 0.0
|
||||||
|
|
||||||
|
def record_commit(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cache_hit: bool,
|
||||||
|
pending_wait_ms: float,
|
||||||
|
total_ms: float,
|
||||||
|
audio_bytes: int,
|
||||||
|
) -> None:
|
||||||
|
"""Record whether commit reused its exact speculative EOS snapshot."""
|
||||||
|
with self._lock:
|
||||||
|
if cache_hit:
|
||||||
|
self._speculative_cache_hits += 1
|
||||||
|
else:
|
||||||
|
self._direct_final_decodes += 1
|
||||||
|
self._last_commit_cache_hit = cache_hit
|
||||||
|
self._last_commit_pending_wait_ms = pending_wait_ms
|
||||||
|
self._last_commit_total_ms = total_ms
|
||||||
|
self._last_commit_audio_ms = (
|
||||||
|
audio_bytes
|
||||||
|
* 1000
|
||||||
|
/ (STREAM_SAMPLE_RATE * STREAM_SAMPLE_WIDTH)
|
||||||
|
)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict:
|
||||||
|
"""Return the small aggregate used by health checks and release tests."""
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"speculative_cache_hits": self._speculative_cache_hits,
|
||||||
|
"direct_final_decodes": self._direct_final_decodes,
|
||||||
|
"last_commit_cache_hit": self._last_commit_cache_hit,
|
||||||
|
"last_commit_pending_wait_ms": round(
|
||||||
|
self._last_commit_pending_wait_ms, 1
|
||||||
|
),
|
||||||
|
"last_commit_total_ms": round(self._last_commit_total_ms, 1),
|
||||||
|
"last_commit_audio_ms": round(self._last_commit_audio_ms, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
STREAM_TELEMETRY = StreamTelemetry()
|
||||||
STARTUP_WARMUP_MS: float | None = None
|
STARTUP_WARMUP_MS: float | None = None
|
||||||
STARTUP_ROLLING_WARMUP_MS: float | None = None
|
STARTUP_ROLLING_WARMUP_MS: float | None = None
|
||||||
|
|
||||||
@ -497,6 +550,8 @@ class StreamingTranscription:
|
|||||||
self._pending_key = ""
|
self._pending_key = ""
|
||||||
self._cached_key = ""
|
self._cached_key = ""
|
||||||
self._cached_payload: dict | None = None
|
self._cached_payload: dict | None = None
|
||||||
|
self._eos_pcm = b""
|
||||||
|
self._eos_key = ""
|
||||||
self._rolling_inflight = False
|
self._rolling_inflight = False
|
||||||
self._rolling_last_started_at = 0.0
|
self._rolling_last_started_at = 0.0
|
||||||
self._rolling_speech_bytes = 0
|
self._rolling_speech_bytes = 0
|
||||||
@ -542,6 +597,8 @@ class StreamingTranscription:
|
|||||||
frame is preserved until the next speculative EOS marker, even when a
|
frame is preserved until the next speculative EOS marker, even when a
|
||||||
quiet syllable falls below the server's conservative energy threshold.
|
quiet syllable falls below the server's conservative energy threshold.
|
||||||
"""
|
"""
|
||||||
|
if self._at_eos and self._eos_pcm and self._eos_key:
|
||||||
|
return self._eos_pcm, self._eos_key
|
||||||
cutoff = len(self._pcm)
|
cutoff = len(self._pcm)
|
||||||
if self._heard_speech and self._last_speech_byte:
|
if self._heard_speech and self._last_speech_byte:
|
||||||
tail_bytes = STREAM_SAMPLE_RATE * STREAM_SAMPLE_WIDTH * VAD_TAIL_MS // 1000
|
tail_bytes = STREAM_SAMPLE_RATE * STREAM_SAMPLE_WIDTH * VAD_TAIL_MS // 1000
|
||||||
@ -550,6 +607,11 @@ class StreamingTranscription:
|
|||||||
key = f"{self._epoch}:{hashlib.sha256(pcm).hexdigest()}"
|
key = f"{self._epoch}:{hashlib.sha256(pcm).hexdigest()}"
|
||||||
return pcm, key
|
return pcm, key
|
||||||
|
|
||||||
|
def _clear_eos_snapshot_locked(self) -> None:
|
||||||
|
"""Invalidate frozen EOS audio when authoritative speech resumes."""
|
||||||
|
self._eos_pcm = b""
|
||||||
|
self._eos_key = ""
|
||||||
|
|
||||||
def _rolling_snapshot_locked(self) -> tuple[bytes, int]:
|
def _rolling_snapshot_locked(self) -> tuple[bytes, int]:
|
||||||
"""Return a bounded tail window and its absolute start in milliseconds."""
|
"""Return a bounded tail window and its absolute start in milliseconds."""
|
||||||
window_bytes = _pcm_bytes_for_ms(ROLLING_WINDOW_MS)
|
window_bytes = _pcm_bytes_for_ms(ROLLING_WINDOW_MS)
|
||||||
@ -693,6 +755,7 @@ class StreamingTranscription:
|
|||||||
self._epoch += 1
|
self._epoch += 1
|
||||||
self._cached_key = ""
|
self._cached_key = ""
|
||||||
self._cached_payload = None
|
self._cached_payload = None
|
||||||
|
self._clear_eos_snapshot_locked()
|
||||||
self._reset_rolling_history_locked(rearm=True)
|
self._reset_rolling_history_locked(rearm=True)
|
||||||
self._heard_speech = True
|
self._heard_speech = True
|
||||||
self._at_eos = False
|
self._at_eos = False
|
||||||
@ -728,6 +791,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._clear_eos_snapshot_locked()
|
||||||
self._reset_rolling_history_locked(rearm=True)
|
self._reset_rolling_history_locked(rearm=True)
|
||||||
|
|
||||||
def speculate(self) -> None:
|
def speculate(self) -> None:
|
||||||
@ -738,6 +802,13 @@ class StreamingTranscription:
|
|||||||
self._client_active = False
|
self._client_active = False
|
||||||
self._at_eos = True
|
self._at_eos = True
|
||||||
pcm, key = self._snapshot_locked()
|
pcm, key = self._snapshot_locked()
|
||||||
|
# The browser keeps forwarding PCM until recorder shutdown. Freeze
|
||||||
|
# the exact EOS snapshot so trailing silence cannot invalidate a
|
||||||
|
# completed Whisper-small speculative result. A resume message or
|
||||||
|
# newly detected speech clears this snapshot before more audio is
|
||||||
|
# accepted into the authoritative turn.
|
||||||
|
self._eos_pcm = pcm
|
||||||
|
self._eos_key = key
|
||||||
if not pcm or key in {self._pending_key, self._cached_key}:
|
if not pcm or key in {self._pending_key, self._cached_key}:
|
||||||
return
|
return
|
||||||
self._pending_key = key
|
self._pending_key = key
|
||||||
@ -794,6 +865,9 @@ class StreamingTranscription:
|
|||||||
|
|
||||||
def commit(self) -> None:
|
def commit(self) -> None:
|
||||||
"""Emit one final result from the complete stable utterance snapshot."""
|
"""Emit one final result from the complete stable utterance snapshot."""
|
||||||
|
commit_started = time.monotonic()
|
||||||
|
pending_wait_ms = 0.0
|
||||||
|
cache_hit = False
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if not self._started or self._closed or self._committing:
|
if not self._started or self._closed or self._committing:
|
||||||
raise WebSocketError("turn cannot be committed", 1008)
|
raise WebSocketError("turn cannot be committed", 1008)
|
||||||
@ -802,12 +876,15 @@ class StreamingTranscription:
|
|||||||
self._committing = True
|
self._committing = True
|
||||||
pcm, key = self._snapshot_locked()
|
pcm, key = self._snapshot_locked()
|
||||||
deadline = time.monotonic() + STREAM_IDLE_SECONDS
|
deadline = time.monotonic() + STREAM_IDLE_SECONDS
|
||||||
|
pending_wait_started = time.monotonic()
|
||||||
while self._pending_key == key and not self._cached_payload:
|
while self._pending_key == key and not self._cached_payload:
|
||||||
remaining = deadline - time.monotonic()
|
remaining = deadline - time.monotonic()
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
break
|
break
|
||||||
self._lock.wait(timeout=remaining)
|
self._lock.wait(timeout=remaining)
|
||||||
|
pending_wait_ms = (time.monotonic() - pending_wait_started) * 1000
|
||||||
payload = self._cached_payload if self._cached_key == key else None
|
payload = self._cached_payload if self._cached_key == key else None
|
||||||
|
cache_hit = payload is not None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if payload is None:
|
if payload is None:
|
||||||
@ -820,6 +897,12 @@ class StreamingTranscription:
|
|||||||
model=payload["model"],
|
model=payload["model"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
STREAM_TELEMETRY.record_commit(
|
||||||
|
cache_hit=cache_hit,
|
||||||
|
pending_wait_ms=pending_wait_ms,
|
||||||
|
total_ms=(time.monotonic() - commit_started) * 1000,
|
||||||
|
audio_bytes=len(pcm),
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[stt] streaming transcription failed: {exc}", flush=True)
|
print(f"[stt] streaming transcription failed: {exc}", flush=True)
|
||||||
self.error("transcription failed", fatal=True)
|
self.error("transcription failed", fatal=True)
|
||||||
@ -834,6 +917,7 @@ class StreamingTranscription:
|
|||||||
self._closed = True
|
self._closed = True
|
||||||
self._cached_payload = None
|
self._cached_payload = None
|
||||||
self._cached_key = ""
|
self._cached_key = ""
|
||||||
|
self._clear_eos_snapshot_locked()
|
||||||
self._lock.notify_all()
|
self._lock.notify_all()
|
||||||
|
|
||||||
|
|
||||||
@ -872,6 +956,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
|||||||
"rolling_model": ROLLING_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(),
|
||||||
|
"stream_commits": STREAM_TELEMETRY.snapshot(),
|
||||||
"startup": {
|
"startup": {
|
||||||
"warmed": (
|
"warmed": (
|
||||||
STARTUP_WARMUP_MS is not None
|
STARTUP_WARMUP_MS is not None
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import struct
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import wave
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
@ -80,6 +81,35 @@ class _Model:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _BlockingModel(_Model):
|
||||||
|
"""Hold one authoritative decode to exercise commit/speculation races."""
|
||||||
|
|
||||||
|
def __init__(self, text: str = "hello") -> None:
|
||||||
|
super().__init__(text)
|
||||||
|
self.entered = threading.Event()
|
||||||
|
self.release = threading.Event()
|
||||||
|
|
||||||
|
def transcribe(self, path: str, **options: object) -> dict:
|
||||||
|
self.entered.set()
|
||||||
|
assert self.release.wait(2.0)
|
||||||
|
return super().transcribe(path, **options)
|
||||||
|
|
||||||
|
|
||||||
|
class _SequencedModel(_Model):
|
||||||
|
"""Return evolving text and retain the audio size decoded each time."""
|
||||||
|
|
||||||
|
def __init__(self, texts: list[str]) -> None:
|
||||||
|
super().__init__(texts[0])
|
||||||
|
self.texts = texts
|
||||||
|
self.frame_counts: list[int] = []
|
||||||
|
|
||||||
|
def transcribe(self, path: str, **options: object) -> dict:
|
||||||
|
with wave.open(path, "rb") as wav_file:
|
||||||
|
self.frame_counts.append(wav_file.getnframes())
|
||||||
|
self.text = self.texts[min(len(self.calls), len(self.texts) - 1)]
|
||||||
|
return super().transcribe(path, **options)
|
||||||
|
|
||||||
|
|
||||||
def _start(session) -> None:
|
def _start(session) -> None:
|
||||||
session.start(
|
session.start(
|
||||||
{
|
{
|
||||||
@ -96,6 +126,10 @@ def _speech(samples: int = 1_600) -> bytes:
|
|||||||
return struct.pack(f"<{samples}h", *([12_000] * samples))
|
return struct.pack(f"<{samples}h", *([12_000] * samples))
|
||||||
|
|
||||||
|
|
||||||
|
def _silence(samples: int = 12_000) -> bytes:
|
||||||
|
return bytes(samples * 2)
|
||||||
|
|
||||||
|
|
||||||
def _wait_idle(session) -> None:
|
def _wait_idle(session) -> None:
|
||||||
deadline = time.monotonic() + 1.0
|
deadline = time.monotonic() + 1.0
|
||||||
with session._lock:
|
with session._lock:
|
||||||
@ -129,6 +163,83 @@ def test_rolling_uses_tiny_once_while_final_remains_small(monkeypatch):
|
|||||||
assert len(final_model.calls) == 1
|
assert len(final_model.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_trailing_silence_after_speculation_keeps_exact_cache_key(monkeypatch):
|
||||||
|
"""Recorder shutdown silence must not force a second Whisper-small decode."""
|
||||||
|
module = _load_server(monkeypatch)
|
||||||
|
connection = _Connection()
|
||||||
|
model = _Model("hello")
|
||||||
|
session = module.StreamingTranscription(connection, model)
|
||||||
|
_start(session)
|
||||||
|
|
||||||
|
session.append(_speech(samples=20_800))
|
||||||
|
session.speculate()
|
||||||
|
assert connection.wait_for("partial")["transcript"] == "hello"
|
||||||
|
session.append(_silence(samples=8_000))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert connection.wait_for("final")["transcript"] == "hello"
|
||||||
|
assert len(model.calls) == 1
|
||||||
|
telemetry = module.STREAM_TELEMETRY.snapshot()
|
||||||
|
assert telemetry["speculative_cache_hits"] == 1
|
||||||
|
assert telemetry["direct_final_decodes"] == 0
|
||||||
|
assert telemetry["last_commit_cache_hit"] is True
|
||||||
|
assert telemetry["last_commit_audio_ms"] == 1_300.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_waits_for_one_inflight_exact_speculation_without_redecoding(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Exercise the browser timing race while Whisper-small is still running."""
|
||||||
|
module = _load_server(monkeypatch)
|
||||||
|
connection = _Connection()
|
||||||
|
model = _BlockingModel()
|
||||||
|
session = module.StreamingTranscription(connection, model)
|
||||||
|
_start(session)
|
||||||
|
|
||||||
|
session.append(_speech(samples=20_800))
|
||||||
|
session.speculate()
|
||||||
|
assert model.entered.wait(1.0)
|
||||||
|
session.append(_silence(samples=8_000))
|
||||||
|
committed = threading.Event()
|
||||||
|
worker = threading.Thread(target=lambda: (session.commit(), committed.set()))
|
||||||
|
worker.start()
|
||||||
|
time.sleep(0.02)
|
||||||
|
assert not committed.is_set()
|
||||||
|
model.release.set()
|
||||||
|
worker.join(timeout=1.0)
|
||||||
|
|
||||||
|
assert committed.is_set()
|
||||||
|
assert connection.wait_for("final")["transcript"] == "hello"
|
||||||
|
assert len(model.calls) == 1
|
||||||
|
telemetry = module.STREAM_TELEMETRY.snapshot()
|
||||||
|
assert telemetry["last_commit_cache_hit"] is True
|
||||||
|
assert telemetry["last_commit_pending_wait_ms"] >= 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_resumed_speech_invalidates_frozen_eos_and_decodes_full_turn(monkeypatch):
|
||||||
|
"""Freezing EOS cannot discard an intentional barge-in continuation."""
|
||||||
|
module = _load_server(monkeypatch)
|
||||||
|
connection = _Connection()
|
||||||
|
model = _SequencedModel(["first thought", "complete thought"])
|
||||||
|
session = module.StreamingTranscription(connection, model)
|
||||||
|
_start(session)
|
||||||
|
|
||||||
|
session.append(_speech(samples=8_000))
|
||||||
|
session.speculate()
|
||||||
|
connection.wait_for("partial")
|
||||||
|
session.append(_silence(samples=4_000))
|
||||||
|
session.resume()
|
||||||
|
session.append(_speech(samples=4_000))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert connection.wait_for("final")["transcript"] == "complete thought"
|
||||||
|
assert model.frame_counts == [8_000, 16_000]
|
||||||
|
telemetry = module.STREAM_TELEMETRY.snapshot()
|
||||||
|
assert telemetry["speculative_cache_hits"] == 0
|
||||||
|
assert telemetry["direct_final_decodes"] == 1
|
||||||
|
assert telemetry["last_commit_cache_hit"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_eos_does_not_rearm_but_resumed_speech_does(monkeypatch):
|
def test_eos_does_not_rearm_but_resumed_speech_does(monkeypatch):
|
||||||
module = _load_server(monkeypatch)
|
module = _load_server(monkeypatch)
|
||||||
monkeypatch.setattr(module, "ROLLING_MIN_AUDIO_MS", 100)
|
monkeypatch.setattr(module, "ROLLING_MIN_AUDIO_MS", 100)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user