242 lines
7.2 KiB
Python
242 lines
7.2 KiB
Python
|
|
"""Contract tests for Hermes' bounded, speculative PCM transcription stream."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import importlib.util
|
||
|
|
import io
|
||
|
|
import struct
|
||
|
|
import sys
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
from pathlib import Path
|
||
|
|
from types import SimpleNamespace
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parents[2]
|
||
|
|
SERVER = ROOT / "dockerfiles" / "hermes-jetson-stt-server.py"
|
||
|
|
|
||
|
|
|
||
|
|
def _load_server(monkeypatch):
|
||
|
|
"""Import the server without requiring the image's CUDA dependencies."""
|
||
|
|
spec = importlib.util.spec_from_file_location("hermes_stt_stream_server", 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)),
|
||
|
|
)
|
||
|
|
monkeypatch.setitem(sys.modules, "whisper", SimpleNamespace())
|
||
|
|
spec.loader.exec_module(module)
|
||
|
|
return module
|
||
|
|
|
||
|
|
|
||
|
|
class _Connection:
|
||
|
|
"""Capture server messages without constructing an HTTP handler."""
|
||
|
|
|
||
|
|
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 = 2.0) -> dict:
|
||
|
|
deadline = time.monotonic() + timeout
|
||
|
|
with self.condition:
|
||
|
|
while time.monotonic() < deadline:
|
||
|
|
match = next(
|
||
|
|
(
|
||
|
|
message
|
||
|
|
for message in self.messages
|
||
|
|
if message.get("type") == message_type
|
||
|
|
),
|
||
|
|
None,
|
||
|
|
)
|
||
|
|
if match:
|
||
|
|
return match
|
||
|
|
self.condition.wait(deadline - time.monotonic())
|
||
|
|
raise AssertionError(f"no {message_type} response: {self.messages}")
|
||
|
|
|
||
|
|
|
||
|
|
class _Model:
|
||
|
|
"""Return a deterministic Whisper-shaped result and retain decode options."""
|
||
|
|
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self.calls: list[tuple[str, dict]] = []
|
||
|
|
|
||
|
|
def transcribe(self, path: str, **options: object) -> dict:
|
||
|
|
self.calls.append((path, options))
|
||
|
|
return {
|
||
|
|
"text": " hello",
|
||
|
|
"language": "en",
|
||
|
|
"segments": [
|
||
|
|
{
|
||
|
|
"text": " hello",
|
||
|
|
"no_speech_prob": 0.01,
|
||
|
|
"avg_logprob": -0.1,
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _start(session) -> None:
|
||
|
|
session.start(
|
||
|
|
{
|
||
|
|
"type": "start",
|
||
|
|
"turn_id": "turn-10",
|
||
|
|
"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 _silence(samples: int = 12_000) -> bytes:
|
||
|
|
return bytes(samples * 2)
|
||
|
|
|
||
|
|
|
||
|
|
def test_websocket_handshake_matches_rfc_example(monkeypatch):
|
||
|
|
module = _load_server(monkeypatch)
|
||
|
|
|
||
|
|
assert (
|
||
|
|
module.WebSocketConnection.accept_value("dGhlIHNhbXBsZSBub25jZQ==")
|
||
|
|
== "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_websocket_read_exact_accumulates_partial_socket_reads(monkeypatch):
|
||
|
|
module = _load_server(monkeypatch)
|
||
|
|
payload = b'{"type":"resume"}'
|
||
|
|
mask = b"mask"
|
||
|
|
masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
|
||
|
|
frame = bytes([0x81, 0x80 | len(payload)]) + mask + masked
|
||
|
|
|
||
|
|
class ShortReader(io.BytesIO):
|
||
|
|
def read(self, size: int = -1) -> bytes:
|
||
|
|
return super().read(1 if size > 0 else size)
|
||
|
|
|
||
|
|
handler = SimpleNamespace(rfile=ShortReader(frame), wfile=io.BytesIO())
|
||
|
|
connection = module.WebSocketConnection(handler)
|
||
|
|
|
||
|
|
assert connection.receive() == (0x1, payload)
|
||
|
|
|
||
|
|
|
||
|
|
def test_stream_speculation_is_reused_for_unchanged_commit(monkeypatch):
|
||
|
|
module = _load_server(monkeypatch)
|
||
|
|
connection = _Connection()
|
||
|
|
model = _Model()
|
||
|
|
session = module.StreamingTranscription(connection, model)
|
||
|
|
_start(session)
|
||
|
|
|
||
|
|
session.append(_speech())
|
||
|
|
session.append(_silence())
|
||
|
|
partial = connection.wait_for("partial")
|
||
|
|
assert partial == {
|
||
|
|
"type": "partial",
|
||
|
|
"turn_id": "turn-10",
|
||
|
|
"transcript": "hello",
|
||
|
|
"language": "en",
|
||
|
|
"speculative": True,
|
||
|
|
}
|
||
|
|
|
||
|
|
session.commit()
|
||
|
|
final = connection.wait_for("final")
|
||
|
|
assert final["transcript"] == "hello"
|
||
|
|
assert final["language"] == "en"
|
||
|
|
assert len(model.calls) == 1
|
||
|
|
assert model.calls[0][1]["language"] is None
|
||
|
|
assert model.calls[0][1]["condition_on_previous_text"] is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_resume_invalidates_speculative_snapshot(monkeypatch):
|
||
|
|
module = _load_server(monkeypatch)
|
||
|
|
connection = _Connection()
|
||
|
|
model = _Model()
|
||
|
|
session = module.StreamingTranscription(connection, model)
|
||
|
|
_start(session)
|
||
|
|
|
||
|
|
session.append(_speech())
|
||
|
|
session.append(_silence())
|
||
|
|
connection.wait_for("partial")
|
||
|
|
session.resume()
|
||
|
|
session.append(_speech(800))
|
||
|
|
session.commit()
|
||
|
|
|
||
|
|
assert len(model.calls) == 2
|
||
|
|
assert connection.wait_for("final")["turn_id"] == "turn-10"
|
||
|
|
|
||
|
|
|
||
|
|
def test_final_snapshot_preserves_resumed_audio_and_only_trims_tail(monkeypatch):
|
||
|
|
module = _load_server(monkeypatch)
|
||
|
|
session = module.StreamingTranscription(_Connection(), _Model())
|
||
|
|
_start(session)
|
||
|
|
voiced = _speech()
|
||
|
|
session.append(voiced)
|
||
|
|
session.append(_silence())
|
||
|
|
|
||
|
|
with session._lock:
|
||
|
|
trimmed, _ = session._snapshot_locked()
|
||
|
|
tail_bytes = (
|
||
|
|
module.STREAM_SAMPLE_RATE
|
||
|
|
* module.STREAM_SAMPLE_WIDTH
|
||
|
|
* module.VAD_TAIL_MS
|
||
|
|
// 1000
|
||
|
|
)
|
||
|
|
assert len(trimmed) == len(voiced) + tail_bytes
|
||
|
|
|
||
|
|
session.resume()
|
||
|
|
quiet_resumed_speech = struct.pack("<800h", *([100] * 800))
|
||
|
|
session.append(quiet_resumed_speech)
|
||
|
|
with session._lock:
|
||
|
|
resumed, _ = session._snapshot_locked()
|
||
|
|
assert resumed.endswith(quiet_resumed_speech)
|
||
|
|
|
||
|
|
|
||
|
|
def test_stream_contract_rejects_wrong_format_and_oversize(monkeypatch):
|
||
|
|
module = _load_server(monkeypatch)
|
||
|
|
session = module.StreamingTranscription(_Connection(), _Model())
|
||
|
|
with pytest.raises(module.WebSocketError, match="pcm_s16le"):
|
||
|
|
session.start(
|
||
|
|
{
|
||
|
|
"turn_id": "turn-10",
|
||
|
|
"format": "webm",
|
||
|
|
"sample_rate": 16_000,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
malformed_rate = module.StreamingTranscription(_Connection(), _Model())
|
||
|
|
with pytest.raises(module.WebSocketError, match="invalid sample rate"):
|
||
|
|
malformed_rate.start(
|
||
|
|
{
|
||
|
|
"turn_id": "turn-10",
|
||
|
|
"format": "pcm_s16le",
|
||
|
|
"sample_rate": "sixteen kilohertz",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
session = module.StreamingTranscription(_Connection(), _Model())
|
||
|
|
_start(session)
|
||
|
|
monkeypatch.setattr(module, "MAX_STREAM_AUDIO_BYTES", 4)
|
||
|
|
with pytest.raises(module.WebSocketError, match="too long"):
|
||
|
|
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 "MAX_STREAM_AUDIO_BYTES" in source
|