diff --git a/ci/Jenkinsfile.hermes-agent-image b/ci/Jenkinsfile.hermes-agent-image index 0f459982..76a32000 100644 --- a/ci/Jenkinsfile.hermes-agent-image +++ b/ci/Jenkinsfile.hermes-agent-image @@ -114,7 +114,7 @@ spec: string( name: 'EXPECTED_SOURCE_REVISION', defaultValue: '', - description: 'Full reviewed commit that must be contained by atlas/titan-iac main.' + description: 'Full reviewed commit already contained by atlas/titan-iac main.' ) string( name: 'CONFIRM_PUBLISH', @@ -152,9 +152,11 @@ spec: ;; esac test "${#EXPECTED_SOURCE_REVISION}" -eq 40 + main_revision="$(git rev-parse origin/main)" + git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${main_revision}" + git checkout --detach "${EXPECTED_SOURCE_REVISION}" actual_revision="$(git rev-parse HEAD)" - test "${actual_revision}" = "$(git rev-parse origin/main)" - git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${actual_revision}" + test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}" test -z "$(git status --porcelain)" test -f dockerfiles/Dockerfile.hermes-agent case "${BUILD_NUMBER}" in diff --git a/ci/Jenkinsfile.hermes-voice-image b/ci/Jenkinsfile.hermes-voice-image index 680a33bc..f652c0e8 100644 --- a/ci/Jenkinsfile.hermes-voice-image +++ b/ci/Jenkinsfile.hermes-voice-image @@ -126,9 +126,12 @@ spec: *[!0-9a-f]*|'') echo 'EXPECTED_SOURCE_REVISION must be a lowercase full commit' >&2; exit 2 ;; esac test "${#EXPECTED_SOURCE_REVISION}" -eq 40 + main_revision="$(git rev-parse HEAD)" + test "${main_revision}" = "$(git rev-parse origin/main)" + git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${main_revision}" + git checkout --detach "${EXPECTED_SOURCE_REVISION}" actual_revision="$(git rev-parse HEAD)" - test "${actual_revision}" = "$(git rev-parse origin/main)" - git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${actual_revision}" + test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}" test -z "$(git status --porcelain)" case "${BUILD_NUMBER}" in ''|0*|*[!0-9]*) exit 2 ;; esac image="registry.bstein.dev/bstein/hermes-jetson-${IMAGE_COMPONENT}" @@ -147,7 +150,12 @@ spec: set -eu python3 -m pip install --disable-pip-version-check --no-cache-dir \ --target=/tmp/hermes-voice-test-deps pytest==8.3.4 PyYAML==6.0.2 + python3 -m py_compile \ + dockerfiles/hermes-jetson-stt-server.py \ + dockerfiles/hermes-jetson-tts-server.py \ + dockerfiles/hermes_jetson_tts_cues.py PYTHONPATH=/tmp/hermes-voice-test-deps python3 -m pytest -q \ + testing/tests/test_hermes_stt_streaming.py \ testing/tests/test_hermes_tts_language_routing.py \ testing/tests/test_hermes_voice_language_routing.py \ testing/tests/test_hermes_oci_promote.py \ diff --git a/ci/Jenkinsfile.hermes-webui-image b/ci/Jenkinsfile.hermes-webui-image index 7a9a1a70..a8cf752a 100644 --- a/ci/Jenkinsfile.hermes-webui-image +++ b/ci/Jenkinsfile.hermes-webui-image @@ -143,9 +143,12 @@ spec: ;; esac test "${#EXPECTED_SOURCE_REVISION}" -eq 40 + main_revision="$(git rev-parse HEAD)" + test "${main_revision}" = "$(git rev-parse origin/main)" + git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${main_revision}" + git checkout --detach "${EXPECTED_SOURCE_REVISION}" actual_revision="$(git rev-parse HEAD)" - test "${actual_revision}" = "$(git rev-parse origin/main)" - git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${actual_revision}" + test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}" test -z "$(git status --porcelain)" test -f dockerfiles/Dockerfile.hermes-webui case "${BUILD_NUMBER}" in @@ -172,6 +175,14 @@ spec: pytest==8.3.4 PyYAML==6.0.2 PYTHONPATH=/tmp/hermes-webui-release-test-deps \ python3 -m pytest -q \ + testing/tests/test_hermes_chat_quality.py \ + testing/tests/test_hermes_handsfree_stt.py \ + testing/tests/test_hermes_voice_instrument.py \ + testing/tests/test_hermes_voice_full_duplex.py \ + testing/tests/test_hermes_voice_route_preflight.py \ + testing/tests/test_hermes_voice_preflight_delivery.py \ + testing/tests/test_hermes_thinking_voice_cues.py \ + testing/tests/test_hermes_voice_language_routing.py \ testing/tests/test_hermes_webui_brand.py \ testing/tests/test_hermes_webui_release.py \ testing/tests/test_hermes_oci_promote.py \ diff --git a/dockerfiles/Dockerfile.hermes-jetson-tts b/dockerfiles/Dockerfile.hermes-jetson-tts index 4257934a..dca13cb3 100644 --- a/dockerfiles/Dockerfile.hermes-jetson-tts +++ b/dockerfiles/Dockerfile.hermes-jetson-tts @@ -49,7 +49,8 @@ ADD --checksum=sha256:1afc81f703c0e4cb3b4d7c0dca096b8b54a98806807f0170cf5eb55577 RUN chmod 0555 /opt/models /opt/models/piper COPY dockerfiles/hermes-jetson-tts-server.py /opt/atlas/hermes-jetson-tts-server.py -RUN chmod 0555 /opt/atlas/hermes-jetson-tts-server.py +COPY dockerfiles/hermes_jetson_tts_cues.py /opt/atlas/hermes_jetson_tts_cues.py +RUN chmod 0555 /opt/atlas/hermes-jetson-tts-server.py /opt/atlas/hermes_jetson_tts_cues.py # Load every pinned voice during the ARM64 build, including the three baked # for the multilingual chat policy. This catches package or model-format diff --git a/dockerfiles/Dockerfile.hermes-webui b/dockerfiles/Dockerfile.hermes-webui index 44e13b8b..9b79e085 100644 --- a/dockerfiles/Dockerfile.hermes-webui +++ b/dockerfiles/Dockerfile.hermes-webui @@ -53,6 +53,8 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \ && grep -Fq "'atlas/auto/maximum': 'Automatic · Maximum'" /opt/hermes-webui/static/panels.js \ && grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \ && grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \ + && grep -Fq 'settings["webui_bundle_version"]' /opt/hermes-webui/api/routes.py \ + && grep -Fq 'settings.webui_bundle_version||settings.webui_version' /opt/hermes-webui/static/panels.js \ && grep -Fq 'Audio conversion failed: upload is invalid' /opt/hermes/tools/transcription_tools.py \ && grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \ && grep -Fq 'prefers-reduced-motion: reduce' /opt/hermes-webui/static/atlas-voice.css \ diff --git a/dockerfiles/hermes-jetson-stt-server.py b/dockerfiles/hermes-jetson-stt-server.py index 76722cb2..860a851b 100644 --- a/dockerfiles/hermes-jetson-stt-server.py +++ b/dockerfiles/hermes-jetson-stt-server.py @@ -39,10 +39,96 @@ MAX_WS_MESSAGE_BYTES = 256 * 1024 STREAM_IDLE_SECONDS = int(os.getenv("HERMES_STT_STREAM_IDLE_SECONDS", "120")) VAD_END_SILENCE_MS = int(os.getenv("HERMES_STT_VAD_END_SILENCE_MS", "650")) VAD_TAIL_MS = int(os.getenv("HERMES_STT_VAD_TAIL_MS", "220")) -MODEL_LOCK = threading.Lock() +ROLLING_MIN_AUDIO_MS = max( + 300, + min(2_000, int(os.getenv("HERMES_STT_ROLLING_MIN_AUDIO_MS", "650"))), +) +ROLLING_INTERVAL_MS = max( + 400, + min(5_000, int(os.getenv("HERMES_STT_ROLLING_INTERVAL_MS", "750"))), +) +ROLLING_WINDOW_MS = max( + 3_000, + min(30_000, int(os.getenv("HERMES_STT_ROLLING_WINDOW_MS", "12000"))), +) +ROLLING_MAX_RESULT_LAG_MS = max( + 500, + min(5_000, int(os.getenv("HERMES_STT_ROLLING_MAX_LAG_MS", "2000"))), +) _WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +class InferenceGate: + """Give authoritative transcripts priority over disposable partial work.""" + + def __init__(self) -> None: + self._condition = threading.Condition() + self._busy = False + self._final_waiters = 0 + self._last_final_wait_ms = 0.0 + self._last_final_decode_ms = 0.0 + self._last_background_decode_ms = 0.0 + self._background_skips = 0 + + def run_final(self, callback): + """Wait fairly enough that newly arriving partials cannot steal the GPU.""" + wait_started = time.monotonic() + with self._condition: + self._final_waiters += 1 + try: + while self._busy: + self._condition.wait() + self._busy = True + finally: + self._final_waiters -= 1 + self._last_final_wait_ms = (time.monotonic() - wait_started) * 1000 + decode_started = time.monotonic() + try: + return callback() + finally: + with self._condition: + self._last_final_decode_ms = ( + time.monotonic() - decode_started + ) * 1000 + self._busy = False + self._condition.notify_all() + + def try_background(self, callback): + """Run one useful predecode only when no final is active or waiting.""" + with self._condition: + if self._busy or self._final_waiters: + self._background_skips += 1 + return None + self._busy = True + decode_started = time.monotonic() + try: + return callback() + finally: + with self._condition: + self._last_background_decode_ms = ( + time.monotonic() - decode_started + ) * 1000 + self._busy = False + self._condition.notify_all() + + def snapshot(self) -> dict: + """Expose bounded timing telemetry for live latency verification.""" + with self._condition: + return { + "busy": self._busy, + "final_waiters": self._final_waiters, + "last_final_wait_ms": round(self._last_final_wait_ms, 1), + "last_final_decode_ms": round(self._last_final_decode_ms, 1), + "last_background_decode_ms": round( + self._last_background_decode_ms, 1 + ), + "background_skips": self._background_skips, + } + + +INFERENCE_GATE = InferenceGate() + + def _repetitive_token(token: str) -> bool: """Identify long periodic Whisper hallucinations caused by steady noise.""" letters = "".join(re.findall(r"[a-z]+", token.lower())) @@ -112,37 +198,47 @@ def _transcription_payload(result: dict) -> dict: } +def _decode_path(model: object, path: str, language: str) -> dict: + """Decode one path with the quality settings shared by partial and final work.""" + result = model.transcribe( + path, + language=None if language in {"", "auto"} else language, + task="transcribe", + fp16=torch.cuda.is_available(), + condition_on_previous_text=False, + temperature=0, + compression_ratio_threshold=2.0, + logprob_threshold=-0.8, + no_speech_threshold=0.5, + verbose=False, + ) + return _transcription_payload(result) + + def _transcribe_path(model: object, path: str, language: str) -> dict: """Run the one canonical full-utterance Whisper decode configuration.""" - with MODEL_LOCK: - result = model.transcribe( - path, - language=None if language in {"", "auto"} else language, - task="transcribe", - fp16=torch.cuda.is_available(), - condition_on_previous_text=False, - temperature=0, - compression_ratio_threshold=2.0, - logprob_threshold=-0.8, - no_speech_threshold=0.5, - verbose=False, - ) - return _transcription_payload(result) + return INFERENCE_GATE.run_final(lambda: _decode_path(model, path, language)) + + +def _write_pcm_wav(pcm: bytes) -> str: + """Write an aligned browser PCM snapshot to a temporary Whisper input.""" + with tempfile.NamedTemporaryFile( + prefix="atlas-stt-stream-", suffix=".wav", delete=False + ) as temp: + temp_path = temp.name + with wave.open(temp_path, "wb") as wav_file: + wav_file.setnchannels(STREAM_CHANNELS) + wav_file.setsampwidth(STREAM_SAMPLE_WIDTH) + wav_file.setframerate(STREAM_SAMPLE_RATE) + wav_file.writeframes(pcm) + return temp_path def _transcribe_pcm(model: object, pcm: bytes, language: str) -> dict: """Decode one complete 16-kHz mono PCM snapshot through the canonical path.""" temp_path = "" try: - with tempfile.NamedTemporaryFile( - prefix="atlas-stt-stream-", suffix=".wav", delete=False - ) as temp: - temp_path = temp.name - with wave.open(temp_path, "wb") as wav_file: - wav_file.setnchannels(STREAM_CHANNELS) - wav_file.setsampwidth(STREAM_SAMPLE_WIDTH) - wav_file.setframerate(STREAM_SAMPLE_RATE) - wav_file.writeframes(pcm) + temp_path = _write_pcm_wav(pcm) return _transcribe_path(model, temp_path, language) finally: if temp_path: @@ -152,6 +248,76 @@ def _transcribe_pcm(model: object, pcm: bytes, language: str) -> dict: pass +def _transcribe_pcm_rolling( + model: object, pcm: bytes, language: str +) -> dict | None: + """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 + 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. + """ + temp_path = "" + try: + temp_path = _write_pcm_wav(pcm) + return INFERENCE_GATE.try_background( + lambda: _decode_path(model, temp_path, language) + ) + finally: + if temp_path: + try: + os.unlink(temp_path) + except OSError: + pass + + +def _transcribe_pcm_speculative( + model: object, pcm: bytes, language: str +) -> dict | None: + """Predecode one EOS snapshot only when the model is currently idle.""" + temp_path = "" + try: + temp_path = _write_pcm_wav(pcm) + return INFERENCE_GATE.try_background( + lambda: _decode_path(model, temp_path, language) + ) + finally: + if temp_path: + try: + os.unlink(temp_path) + except OSError: + pass + + +def _pcm_bytes_for_ms(milliseconds: int) -> int: + """Convert milliseconds to an aligned byte count for the stream format.""" + return ( + STREAM_SAMPLE_RATE + * STREAM_SAMPLE_WIDTH + * milliseconds + // 1000 + // STREAM_SAMPLE_WIDTH + * STREAM_SAMPLE_WIDTH + ) + + +def _token_identity(token: str) -> str: + """Normalize punctuation/case drift before comparing consecutive decodes.""" + return re.sub(r"[^\w']+", "", token.casefold(), flags=re.UNICODE) + + +def _stable_token_prefix(previous: str, current: str) -> str: + """Return only tokens repeated in the same order by consecutive decodes.""" + old_tokens = previous.split() + new_tokens = current.split() + agreed = 0 + for old, new in zip(old_tokens, new_tokens): + if not _token_identity(old) or _token_identity(old) != _token_identity(new): + break + agreed += 1 + return " ".join(new_tokens[:agreed]) + + class WebSocketError(Exception): """Protocol error carrying an RFC 6455 close code safe to expose.""" @@ -300,6 +466,12 @@ class StreamingTranscription: self._pending_key = "" self._cached_key = "" self._cached_payload: dict | None = None + self._rolling_inflight = False + self._rolling_last_started_at = 0.0 + self._rolling_last_audio_bytes = 0 + self._rolling_previous = "" + self._rolling_previous_window_start = 0 + self._rolling_revision = 0 def _response(self, message_type: str, **values: object) -> dict: return {"type": message_type, "turn_id": self.turn_id, **values} @@ -345,13 +517,125 @@ class StreamingTranscription: key = f"{self._epoch}:{hashlib.sha256(pcm).hexdigest()}" return pcm, key + def _rolling_snapshot_locked(self) -> tuple[bytes, int]: + """Return a bounded tail window and its absolute start in milliseconds.""" + window_bytes = _pcm_bytes_for_ms(ROLLING_WINDOW_MS) + start = max(0, len(self._pcm) - window_bytes) + start -= start % STREAM_SAMPLE_WIDTH + pcm = bytes(self._pcm[start:]) + start_ms = start * 1000 // (STREAM_SAMPLE_RATE * STREAM_SAMPLE_WIDTH) + return pcm, start_ms + + def _reset_rolling_history_locked(self) -> None: + """Discard provisional agreement after the client's speech epoch changes.""" + self._rolling_previous = "" + self._rolling_previous_window_start = 0 + # Audio before a resume belongs to an invalidated provisional epoch and + # must not immediately satisfy the next rolling interval by itself. + self._rolling_last_audio_bytes = len(self._pcm) + self._rolling_last_started_at = 0.0 + + def _begin_rolling_locked( + self, now: float + ) -> tuple[bytes, int, int, int] | None: + """Reserve one due rolling decode without ever queuing a second one.""" + total_bytes = len(self._pcm) + minimum_bytes = _pcm_bytes_for_ms(ROLLING_MIN_AUDIO_MS) + interval_bytes = _pcm_bytes_for_ms(ROLLING_INTERVAL_MS) + required_new_bytes = ( + interval_bytes if self._rolling_last_audio_bytes else minimum_bytes + ) + if ( + self._rolling_inflight + or total_bytes < minimum_bytes + or total_bytes - self._rolling_last_audio_bytes < required_new_bytes + or ( + self._rolling_last_started_at + and (now - self._rolling_last_started_at) * 1000 + < ROLLING_INTERVAL_MS + ) + ): + return None + pcm, window_start_ms = self._rolling_snapshot_locked() + self._rolling_inflight = True + self._rolling_last_started_at = now + self._rolling_last_audio_bytes = total_bytes + return pcm, window_start_ms, total_bytes, self._epoch + + def _start_rolling( + self, + pcm: bytes, + window_start_ms: int, + audio_bytes: int, + epoch: int, + ) -> None: + """Run one disposable partial decode and publish only a current result.""" + + def worker() -> None: + payload: dict | None = None + try: + payload = _transcribe_pcm_rolling(self.model, pcm, self.language) + except Exception as exc: + # Partial inference is best-effort. The canonical commit path + # remains authoritative and reports its own actionable errors. + print(f"[stt] rolling transcription skipped: {exc}", flush=True) + + message: dict | None = None + with self._lock: + self._rolling_inflight = False + lag_bytes = max(0, len(self._pcm) - audio_bytes) + max_lag_bytes = _pcm_bytes_for_ms(ROLLING_MAX_RESULT_LAG_MS) + current = ( + payload is not None + and not self._closed + and not self._committing + and not self._at_eos + and self._epoch == epoch + and lag_bytes <= max_lag_bytes + ) + if current: + transcript = str(payload.get("text") or "") + stable = "" + if self._rolling_previous_window_start == window_start_ms: + stable = _stable_token_prefix( + self._rolling_previous, + transcript, + ) + self._rolling_previous = transcript + self._rolling_previous_window_start = window_start_ms + self._rolling_revision += 1 + message = self._response( + "partial", + transcript=transcript, + stable_transcript=stable, + language=payload.get("language") or "", + speculative=True, + rolling=True, + revision=self._rolling_revision, + epoch=epoch, + window_start_ms=window_start_ms, + ) + self._lock.notify_all() + if message is not None: + try: + self.connection.send_json(message) + except (BrokenPipeError, ConnectionError, OSError): + self.cancel() + + threading.Thread( + target=worker, + name=f"stt-rolling-{self.turn_id}", + daemon=True, + ).start() + def append(self, pcm: bytes) -> None: - """Append PCM, track server VAD and begin speculation at probable EOS.""" + """Append PCM while overlapping rolling understanding with active speech.""" if not self._started: raise WebSocketError("start must precede audio", 1008) if not pcm or len(pcm) % STREAM_SAMPLE_WIDTH: raise WebSocketError("unaligned PCM audio", 1003) auto_speculate = False + rolling: tuple[bytes, int, int, int] | None = None with self._lock: if self._closed or self._committing: raise WebSocketError("turn is no longer accepting audio", 1008) @@ -368,10 +652,12 @@ class StreamingTranscription: self._epoch += 1 self._cached_key = "" self._cached_payload = None + self._reset_rolling_history_locked() self._heard_speech = True self._at_eos = False self._silence_bytes = 0 self._last_speech_byte = len(self._pcm) + rolling = self._begin_rolling_locked(time.monotonic()) elif self._heard_speech: self._silence_bytes += len(pcm) required = ( @@ -385,6 +671,8 @@ class StreamingTranscription: auto_speculate = True if auto_speculate: self.speculate() + elif rolling is not None: + self._start_rolling(*rolling) def resume(self) -> None: """Invalidate an EOS snapshot when client-side VAD hears resumed speech.""" @@ -397,6 +685,7 @@ class StreamingTranscription: self._client_active = True self._cached_key = "" self._cached_payload = None + self._reset_rolling_history_locked() def speculate(self) -> None: """Decode a stable snapshot once; only an unchanged turn may consume it.""" @@ -412,7 +701,7 @@ class StreamingTranscription: def worker() -> None: try: - payload = _transcribe_pcm(self.model, pcm, self.language) + payload = _transcribe_pcm_speculative(self.model, pcm, self.language) except Exception as exc: print(f"[stt] speculative transcription failed: {exc}", flush=True) with self._lock: @@ -423,6 +712,13 @@ class StreamingTranscription: self.error("speculative transcription failed") return + if payload is None: + with self._lock: + if self._pending_key == key: + self._pending_key = "" + self._lock.notify_all() + return + should_send = False with self._lock: if self._pending_key == key: @@ -531,6 +827,7 @@ class SpeechHandler(BaseHTTPRequestHandler): "ok": True, "model": MODEL_NAME, "device": "cuda" if torch.cuda.is_available() else "cpu", + "inference": INFERENCE_GATE.snapshot(), "streaming": { "enabled": True, "path": "/v1/audio/transcriptions/stream", @@ -539,6 +836,12 @@ class SpeechHandler(BaseHTTPRequestHandler): "max_seconds": MAX_STREAM_SECONDS, "server_vad": True, "speculative": True, + "rolling": { + "enabled": True, + "interval_ms": ROLLING_INTERVAL_MS, + "window_ms": ROLLING_WINDOW_MS, + "stable_prefix": True, + }, }, }, ) diff --git a/dockerfiles/hermes-jetson-tts-server.py b/dockerfiles/hermes-jetson-tts-server.py index aff6afdb..04b1adef 100644 --- a/dockerfiles/hermes-jetson-tts-server.py +++ b/dockerfiles/hermes-jetson-tts-server.py @@ -7,6 +7,7 @@ import io import json import os import re +import struct import threading import wave from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -15,6 +16,8 @@ from pathlib import Path import onnxruntime from piper import PiperConfig, PiperVoice, SynthesisConfig +from hermes_jetson_tts_cues import build_cue_cache, resolve_cached_cue, write_cached_cue + HOST = os.getenv("HERMES_TTS_HOST", "0.0.0.0") PORT = int(os.getenv("HERMES_TTS_PORT", "9001")) @@ -29,6 +32,25 @@ STREAM_WRITE_TIMEOUT_SECONDS = max( VOICE_LOCK = threading.Lock() TURN_ID_PATTERN = re.compile(r"[A-Za-z0-9._:-]{1,128}\Z") +# Piper deliberately leaves a generous tail after sentence punctuation. That +# sounds natural when one synthesis result is played in isolation, but hands- +# free mode queues many independently synthesized clauses and compounds those +# tails with the browser's punctuation cadence. Only silence after the last +# audible 5 ms window is shortened; voiced samples are never faded or removed. +SILENCE_WINDOW_MS = 5 +SILENCE_ABS_THRESHOLD = 32 +SILENCE_TRIM_TOLERANCE_MS = 20 +CLAUSE_PAUSE_MS = 90 +COLON_PAUSE_MS = 110 +QUESTION_PAUSE_MS = 170 +PERIOD_PAUSE_MS = 190 +ELLIPSIS_PAUSE_MS = 220 +PARAGRAPH_PAUSE_MS = 280 +UNPUNCTUATED_PAUSE_MS = 70 +SENTENCE_BOUNDARY_PATTERN = re.compile( + r"(?P\.{1,3}|[!?…]+)(?:[\"'”’)}\]]+)?(?P\s+|$)" +) + # Fixed, allow-listed language -> baked voice mapping. This is the ONLY path # from a client-supplied string to a model name: client input is looked up # here and never used to build a filesystem path directly. Both "-" and "_" @@ -79,6 +101,111 @@ def normalize_turn_id(value: object) -> str | None: return normalized +def trailing_pause_ms(text: str) -> int: + """Return the natural trailing pause budget for one spoken text unit.""" + if re.search(r"\n\s*\n\s*$", text): + return PARAGRAPH_PAUSE_MS + terminal = text.rstrip().rstrip("\"'”’)}]") + if terminal.endswith(("...", "…")): + return ELLIPSIS_PAUSE_MS + if terminal.endswith("."): + return PERIOD_PAUSE_MS + if terminal.endswith(("?", "!")): + return QUESTION_PAUSE_MS + if terminal.endswith((":", ";")): + return COLON_PAUSE_MS + if terminal.endswith(","): + return CLAUSE_PAUSE_MS + return UNPUNCTUATED_PAUSE_MS + + +def sentence_pause_targets(text: str) -> list[int]: + """Map Piper's likely sentence results to punctuation-aware pause budgets.""" + targets: list[int] = [] + cursor = 0 + for match in SENTENCE_BOUNDARY_PATTERN.finditer(text): + unit = text[cursor : match.end()] + targets.append(trailing_pause_ms(unit)) + cursor = match.end() + if text[cursor:].strip() or not targets: + targets.append(trailing_pause_ms(text[cursor:] or text)) + return targets + + +def trim_trailing_pcm_silence( + pcm: bytes | bytearray | memoryview, + sample_rate: int, + pause_ms: int, +) -> memoryview: + """Shorten only a confirmed PCM silence tail to the requested duration. + + The backward scan is restricted to the trailing envelope. Returning a + memoryview avoids copying sentence audio on the latency-sensitive stream + path. All-silent input is retained because it carries no safe phoneme + boundary from which to measure a pause. + """ + raw = memoryview(pcm).cast("B") + if len(raw) < 2 or len(raw) % 2 or sample_rate <= 0: + return raw + + window_samples = max(1, sample_rate * SILENCE_WINDOW_MS // 1000) + window_bytes = window_samples * 2 + active_end: int | None = None + cursor = len(raw) + while cursor > 0: + start = max(0, cursor - window_bytes) + # Piper PCM is explicitly signed 16-bit little endian. A conservative + # threshold preserves quiet word endings while ignoring quantization + # noise in the generated tail. + if any( + abs(struct.unpack_from(" SILENCE_ABS_THRESHOLD + for offset in range(start, cursor, 2) + ): + active_end = cursor + break + cursor = start + + if active_end is None: + return raw + desired_tail_bytes = max(0, sample_rate * pause_ms // 1000) * 2 + cutoff = min(len(raw), active_end + desired_tail_bytes) + tolerance_bytes = max(0, sample_rate * SILENCE_TRIM_TOLERANCE_MS // 1000) * 2 + if len(raw) - cutoff <= tolerance_bytes: + return raw + return raw[:cutoff] + + +def normalize_wav_trailing_pause(audio: bytes, text: str) -> bytes: + """Apply the same safe silence-tail policy to the compatibility WAV path.""" + source = io.BytesIO(audio) + try: + with wave.open(source, "rb") as reader: + params = reader.getparams() + if ( + params.nchannels != 1 + or params.sampwidth != 2 + or params.comptype != "NONE" + ): + return audio + pcm = reader.readframes(params.nframes) + except (EOFError, wave.Error): + return audio + + normalized = trim_trailing_pcm_silence( + pcm, + params.framerate, + trailing_pause_ms(text), + ) + if len(normalized) == len(pcm): + return audio + + output = io.BytesIO() + with wave.open(output, "wb") as writer: + writer.setparams(params) + writer.writeframes(normalized) + return output.getvalue() + + def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None: body = json.dumps(payload).encode("utf-8") handler.send_response(status) @@ -114,6 +241,7 @@ class SpeechHandler(BaseHTTPRequestHandler): "path": "/v1/audio/speech/stream", "format": "pcm_s16le", }, + "thinking_cues": len(self.server.cue_cache), # type: ignore[attr-defined] }, ) @@ -146,6 +274,21 @@ class SpeechHandler(BaseHTTPRequestHandler): return speed = min(2.0, max(0.5, speed)) + try: + cue = resolve_cached_cue( + payload, + getattr(self.server, "cue_cache", {}), + ) + except ValueError as exc: + _json(self, 400, {"error": str(exc)}) + return + if cue is not None: + if self.path != "/v1/audio/speech/stream": + _json(self, 400, {"error": "thinking cues require PCM streaming"}) + return + write_cached_cue(self, cue, normalize_turn_id(payload.get("turn_id"))) + return + # Policy is driven ONLY by "language". A client-supplied "voice" # field is deliberately never read here; it cannot override the # allow-listed mapping. @@ -175,7 +318,7 @@ class SpeechHandler(BaseHTTPRequestHandler): wav_file, SynthesisConfig(length_scale=1.0 / speed), ) - audio = output.getvalue() + audio = normalize_wav_trailing_pause(output.getvalue(), text) self.send_response(200) self.send_header("Content-Type", "audio/wav") self.send_header("Content-Length", str(len(audio))) @@ -241,12 +384,14 @@ class SpeechHandler(BaseHTTPRequestHandler): connected = True try: + pause_targets = sentence_pause_targets(text) audio_chunks = iter( voice.synthesize( text, SynthesisConfig(length_scale=1.0 / speed), ) ) + chunk_index = 0 while connected: # Serialize Piper/ONNX access, but never hold the model lock # while a slow browser applies network back-pressure. @@ -261,7 +406,13 @@ class SpeechHandler(BaseHTTPRequestHandler): or audio_chunk.sample_channels != 1 ): raise RuntimeError("Piper returned an unexpected PCM format") - pcm = memoryview(audio_chunk.audio_int16_bytes) + pause_ms = pause_targets[min(chunk_index, len(pause_targets) - 1)] + pcm = trim_trailing_pcm_silence( + audio_chunk.audio_int16_bytes, + sample_rate, + pause_ms, + ) + chunk_index += 1 for offset in range(0, len(pcm), STREAM_WRITE_BYTES): if not self._write_stream_chunk(pcm[offset : offset + STREAM_WRITE_BYTES]): connected = False @@ -322,6 +473,12 @@ def main() -> None: f"HERMES_TTS_VOICE must name one of the baked policy voices: {sorted(BAKED_VOICE_NAMES)}" ) voices = load_voices(CACHE_DIR, ONNX_THREADS) + cue_cache = build_cue_cache( + voices, + SynthesisConfig, + trim_trailing_pcm_silence, + trailing_pause_ms, + ) print( f"[tts] loaded {len(voices)} Piper voices on CPU with {ONNX_THREADS} ONNX threads each: " + ", ".join(sorted(voices)), @@ -330,6 +487,7 @@ def main() -> None: server = ThreadingHTTPServer((HOST, PORT), SpeechHandler) server.voices = voices # type: ignore[attr-defined] server.default_voice_name = DEFAULT_VOICE_NAME # type: ignore[attr-defined] + server.cue_cache = cue_cache # type: ignore[attr-defined] print(f"[tts] ready on {HOST}:{PORT}", flush=True) server.serve_forever(poll_interval=0.25) diff --git a/dockerfiles/hermes-webui-atlas-patch.py b/dockerfiles/hermes-webui-atlas-patch.py index 186e7cfa..b8fff9fa 100644 --- a/dockerfiles/hermes-webui-atlas-patch.py +++ b/dockerfiles/hermes-webui-atlas-patch.py @@ -313,6 +313,7 @@ replace_exact( routes, "def _tts_open(req, *, timeout=30, opener_factory=None):", '''ATLAS_TTS_LANGUAGES = ("en", "ru", "es") +ATLAS_TTS_CUE_IDS = ("thinking", "let_me_think", "still_working", "one_more_moment") def _atlas_tts_language(body): @@ -343,9 +344,12 @@ replace_exact( "def _tts_open(req, *, timeout=30, opener_factory=None):", '''ATLAS_TTS_STREAM_URL = "http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech/stream" ATLAS_STT_STREAM_URL = "http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions/stream" +ATLAS_VOICE_PREFLIGHT_URL = "http://hermes-switchyard.hermes.svc.cluster.local:9009/voice/route-preflight" ATLAS_VOICE_WS_PROTOCOL = "hermes-voice-v1" ATLAS_VOICE_MAX_BYTES = 8 * 1024 * 1024 ATLAS_VOICE_DEADLINE_SECONDS = 100 +ATLAS_VOICE_PREFLIGHT_TIERS = ("fast", "balanced", "deep", "maximum") +ATLAS_VOICE_PREFLIGHT_MAX_RESPONSE_BYTES = 1024 def _atlas_exact_stream_url(env_name, expected): @@ -358,6 +362,7 @@ def _handle_atlas_streaming_capability(handler): """Advertise only transports whose immutable in-cluster URLs are configured.""" tts = bool(_atlas_exact_stream_url("HERMES_WEBUI_ATLAS_TTS_STREAM_URL", ATLAS_TTS_STREAM_URL)) stt = bool(_atlas_exact_stream_url("HERMES_WEBUI_ATLAS_STT_STREAM_URL", ATLAS_STT_STREAM_URL)) + preflight = True j(handler, { "tts": { "available": tts, @@ -372,10 +377,85 @@ def _handle_atlas_streaming_capability(handler): "format": "pcm_s16le", "sample_rate": 16000, }, + "preflight": { + "available": preflight, + "path": "/api/voice/route-preflight", + "advisory": True, + }, }, extra_headers={"Cache-Control": "no-store"}) return True +def _atlas_voice_preflight_payload(data): + """Validate a provisional transcript without accepting routing authority.""" + if not isinstance(data, dict): + raise ValueError("invalid request body") + turn_id = data.get("turn_id") + revision = data.get("revision") + transcript = data.get("transcript") + if not isinstance(turn_id, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", turn_id): + raise ValueError("invalid turn_id") + if isinstance(revision, bool) or not isinstance(revision, int) or not 1 <= revision <= 1000000: + raise ValueError("invalid revision") + if not isinstance(transcript, str): + raise ValueError("invalid transcript") + transcript = " ".join(transcript.split()) + if not 12 <= len(transcript) <= 512: + raise ValueError("invalid transcript") + return {"turn_id": turn_id, "revision": revision, "transcript": transcript} + + +def _atlas_voice_preflight_open(request, timeout=1.0): + """Open only the immutable in-cluster advisory endpoint without proxies.""" + return build_opener(ProxyHandler({}), _NoRedirectTtsHandler()).open(request, timeout=timeout) + + +def _handle_atlas_voice_preflight(handler): + """Relay one same-origin local advisory and expose no classifier output.""" + if not _check_same_origin_browser_request(handler): + return bad(handler, "Voice route preflight origin validation failed", 403) + target = ATLAS_VOICE_PREFLIGHT_URL + try: + payload = _atlas_voice_preflight_payload(read_body(handler)) + except (TypeError, ValueError) as exc: + return bad(handler, str(exc), 400) + request = Request( + target, + data=json.dumps(payload, separators=(",", ":")).encode("utf-8"), + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + try: + with _atlas_voice_preflight_open(request, timeout=1.0) as upstream: + raw = upstream.read(ATLAS_VOICE_PREFLIGHT_MAX_RESPONSE_BYTES + 1) + if len(raw) > ATLAS_VOICE_PREFLIGHT_MAX_RESPONSE_BYTES: + raise ValueError("oversized advisory response") + result = json.loads(raw) + tier = result.get("tier") if isinstance(result, dict) else None + target_hint = result.get("target") if isinstance(result, dict) else None + if ( + result.get("turn_id") != payload["turn_id"] + or result.get("revision") != payload["revision"] + or tier not in ATLAS_VOICE_PREFLIGHT_TIERS + or target_hint != "atlas/auto/" + tier + or result.get("advisory") is not True + ): + raise ValueError("invalid advisory response") + except Exception: + # Advisory failure never changes or delays the final Switchyard request. + return bad(handler, "Voice route preflight unavailable", 503) + return j( + handler, + { + "turn_id": payload["turn_id"], + "revision": payload["revision"], + "tier": tier, + "target": target_hint, + "advisory": True, + }, + extra_headers={"Cache-Control": "no-store"}, + ) + + def _atlas_tts_stream_payload(data): """Build the narrow Piper payload used by the raw-PCM stream endpoint.""" if not isinstance(data, dict): @@ -390,8 +470,13 @@ def _atlas_tts_stream_payload(data): language = _atlas_tts_language(data) if language: payload["language"] = language + cue_id = data.get("cue_id") + if "cue_id" in data: + if not language or cue_id not in ATLAS_TTS_CUE_IDS: + raise ValueError("invalid thinking cue") + payload["cue_id"] = cue_id turn_id = data.get("turn_id") - if isinstance(turn_id, str) and re.fullmatch(r"[A-Za-z0-9._-]{1,64}", turn_id): + if isinstance(turn_id, str) and re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", turn_id): payload["turn_id"] = turn_id return payload @@ -636,6 +721,9 @@ replace_exact( ''' if parsed.path == "/api/transcribe": return handle_transcribe(handler) + if parsed.path == "/api/voice/route-preflight": + return _handle_atlas_voice_preflight(handler) + if parsed.path == "/api/tts/stream": return _handle_atlas_tts_stream(handler) diff --git a/dockerfiles/hermes-webui-atlas-voice.js b/dockerfiles/hermes-webui-atlas-voice.js index 962d488f..d93a6224 100644 --- a/dockerfiles/hermes-webui-atlas-voice.js +++ b/dockerfiles/hermes-webui-atlas-voice.js @@ -22,6 +22,10 @@ let responsePollTimer=null; let currentAudio=null; let playbackSession=null; + let bargeMonitor=null; + let bargeCancelPromise=null; + let suppressAutoRead=false; + let thinkingCue=null; let streamingStt=null; let captureTurnId=''; let thinkingTurnId=''; @@ -30,13 +34,37 @@ let speechTurn=null; let errorTimer=null; let visualInputLevel=0; - let streamingCapability={tts:null,stt:null}; + let streamingCapability={tts:null,stt:null,preflight:null}; + let voicePreflight=null; + const voiceTabNonce=(function(){ + try{ + const bytes=new Uint8Array(16); + window.crypto.getRandomValues(bytes); + return Array.from(bytes,function(value){return value.toString(16).padStart(2,'0');}).join(''); + }catch(_){return '';} + })(); const reducedMotion=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):{matches:false}; const ERROR_VISIBLE_MS=3200; const STREAMING_CAPABILITY_URL='/api/voice/streaming/capability'; const TTS_STREAM_URL='/api/tts/stream'; const STT_STREAM_PATH='/api/transcribe/stream'; + const VOICE_PREFLIGHT_URL='/api/voice/route-preflight'; + const VOICE_PREFLIGHT_DEBOUNCE_MS=200; + const VOICE_PREFLIGHT_DEADLINE_MS=1100; + const VOICE_PREFLIGHT_TIERS=['fast','balanced','deep','maximum']; const WORKLET_URL='static/atlas-voice-worklet.js'; + const STT_MAX_QUEUED_BYTES=1048576; + const STT_MAX_ARCHIVE_BYTES=2880000; + const BARGE_LOOKBACK_MS=900; + const BARGE_DUCK_FRAMES=2; + const BARGE_TRIGGER_FRAMES=4; + const THINKING_CUE_FIRST_MS=1900; + const THINKING_CUE_INTERVAL_MS=6500; + const THINKING_CUE_POOLS={ + en:[{id:'thinking',text:"I'm thinking."},{id:'let_me_think',text:'Let me think.'},{id:'still_working',text:'Still working on that.'},{id:'one_more_moment',text:'One more moment.'}], + ru:[{id:'thinking',text:'Я думаю.'},{id:'let_me_think',text:'Дайте подумать.'},{id:'still_working',text:'Я всё ещё думаю над этим.'},{id:'one_more_moment',text:'Ещё мгновение.'}], + es:[{id:'thinking',text:'Estoy pensando.'},{id:'let_me_think',text:'Déjame pensar.'},{id:'still_working',text:'Sigo pensando en eso.'},{id:'one_more_moment',text:'Un momento más.'}], + }; const STATE_LABELS={ listening:'Listening', transcribing:'Transcribing…', @@ -112,6 +140,17 @@ return String((error&&error.message)||fallback||'Voice unavailable').trim(); } + function createAbortController(){ + const Controller=window.AbortController||(typeof AbortController!=='undefined'?AbortController:null); + return Controller?new Controller():{signal:undefined,abort:function(){ }}; + } + + function cancelledError(message){ + const error=new Error(message||'Voice turn cancelled'); + error.name='AbortError'; + return error; + } + function cancelSpeechTurn(){ if(!speechTurn) return; speechTurn.cancelled=true; @@ -126,12 +165,148 @@ streamingStt=null; } + function cancelVoicePreflight(turnId){ + const preflight=voicePreflight; + if(!preflight||(turnId&&preflight.turnId!==turnId)) return; + voicePreflight=null; + if(preflight.timer){window.clearTimeout(preflight.timer);preflight.timer=null;} + if(preflight.deadline){window.clearTimeout(preflight.deadline);preflight.deadline=null;} + if(preflight.controller){preflight.controller.abort();preflight.controller=null;} + } + + function scheduleVoicePreflight(turnId,revision,transcript){ + const capability=streamingCapability.preflight; + const stable=String(transcript||'').replace(/\s+/g,' ').trim(); + if(!capability||!voiceTabNonce||!active||captureTurnId!==turnId||state!=='listening') return; + if(!Number.isInteger(revision)||revision<1||stable.length<12||stable.length>512) return; + if(voicePreflight&&voicePreflight.turnId===turnId&&voicePreflight.transcript===stable) return; + cancelVoicePreflight(); + const preflight={turnId:turnId,revision:revision,transcript:stable,timer:null,deadline:null,controller:null}; + voicePreflight=preflight; + preflight.timer=window.setTimeout(async function(){ + preflight.timer=null; + if(voicePreflight!==preflight||!active||captureTurnId!==turnId||state!=='listening') return; + const controller=createAbortController(); + preflight.controller=controller; + preflight.deadline=window.setTimeout(function(){controller.abort();},VOICE_PREFLIGHT_DEADLINE_MS); + try{ + const response=await fetch(VOICE_PREFLIGHT_URL,{ + method:'POST', + credentials:'same-origin', + cache:'no-store', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({turn_id:turnId,revision:revision,transcript:stable}), + signal:controller.signal, + }); + if(!response.ok) return; + const advisory=await response.json(); + const tier=advisory&&advisory.tier; + if( + voicePreflight!==preflight||!active||captureTurnId!==turnId||state!=='listening'|| + advisory.turn_id!==turnId||advisory.revision!==revision||advisory.advisory!==true|| + VOICE_PREFLIGHT_TIERS.indexOf(tier)<0||advisory.target!=='atlas/auto/'+tier + ) return; + // This is intentionally feedback-only. The completed transcript still + // takes the normal Switchyard path and remains the sole routing input. + const preview=stable.length>60?stable.slice(0,57)+'…':stable; + label.textContent='Listening · '+preview+' · '+tier; + }catch(_){ + // A partial can be superseded at any time; advisory failure is silent. + }finally{ + if(preflight.deadline){window.clearTimeout(preflight.deadline);preflight.deadline=null;} + preflight.controller=null; + } + },VOICE_PREFLIGHT_DEBOUNCE_MS); + } + + function cancelThinkingCues(){ + const cue=thinkingCue; + thinkingCue=null; + if(!cue) return; + cue.cancelled=true; + if(cue.timer){window.clearTimeout(cue.timer);cue.timer=null;} + if(cue.controller){cue.controller.abort();cue.controller=null;} + if(cue.audioWake){const wake=cue.audioWake;cue.audioWake=null;wake();} + if(cue.node){try{cue.node.port.postMessage({type:'cancel'});cue.node.disconnect();}catch(_){ }cue.node=null;} + if(cue.context){try{cue.context.close();}catch(_){ }cue.context=null;} + cue.gain=null; + indicator.classList.remove('is-playing'); + indicator.classList.remove('is-ducked'); + } + + function clearBargeCancellation(){ + const cancellation=bargeCancelPromise; + bargeCancelPromise=null; + if(cancellation&&cancellation.controller) cancellation.controller.abort(); + } + + function microphoneConstraints(){ + const constraints={echoCancellation:true,noiseSuppression:true,autoGainControl:true}; + const supported=navigator.mediaDevices.getSupportedConstraints?navigator.mediaDevices.getSupportedConstraints():{}; + if(supported.voiceIsolation) constraints.voiceIsolation=true; + return constraints; + } + + function acquireMicrophone(){ + return navigator.mediaDevices.getUserMedia({audio:microphoneConstraints()}); + } + + function captureAecIsUsable(capture){ + try{ + const tracks=capture&&capture.getAudioTracks?capture.getAudioTracks():[]; + const settings=tracks.length&&tracks[0].getSettings?tracks[0].getSettings():{}; + return settings.echoCancellation!==false; + }catch(_){ + return true; + } + } + + window._atlasCaptureAecIsUsable=captureAecIsUsable; + + function setPlaybackDucked(ducked){ + const session=playbackSession; + if(session&&session.gain&&session.context){ + const gain=ducked?0.16:1; + try{session.gain.gain.setTargetAtTime(gain,session.context.currentTime,0.018);}catch(_){session.gain.gain.value=gain;} + } + if(currentAudio) currentAudio.volume=ducked?0.16:1; + const cue=thinkingCue; + if(cue&&cue.gain&&cue.context){ + const gain=ducked?0.16:1; + try{cue.gain.gain.setTargetAtTime(gain,cue.context.currentTime,0.018);}catch(_){cue.gain.gain.value=gain;} + } + if(ducked) indicator.classList.add('is-ducked'); else indicator.classList.remove('is-ducked'); + } + + function disposeBargeResources(monitor,keepCapture){ + if(!monitor) return; + monitor.cancelled=true; + if(monitor.timer){window.clearInterval(monitor.timer);monitor.timer=null;} + try{if(monitor.node) monitor.node.disconnect();}catch(_){ } + try{if(monitor.source) monitor.source.disconnect();}catch(_){ } + try{if(monitor.silentGain) monitor.silentGain.disconnect();}catch(_){ } + if(!keepCapture){ + if(monitor.stream) monitor.stream.getTracks().forEach(function(track){track.stop();}); + if(monitor.context){try{monitor.context.close();}catch(_){ }} + } + if(!keepCapture) setPlaybackDucked(false); + } + + function stopBargeMonitor(){ + const monitor=bargeMonitor; + bargeMonitor=null; + disposeBargeResources(monitor,false); + } + function showUnavailable(message){ generation+=1; active=false; thinkingSession=null; thinkingTurnId=''; + suppressAutoRead=false; + clearBargeCancellation(); stopResponseObserver(); + cancelThinkingCues(); cancelSpeechTurn(); stopCapture(); stopPlayback(); @@ -145,6 +320,8 @@ } function stopCapture(){ + cancelVoicePreflight(captureTurnId); + stopBargeMonitor(); if(vadTimer){clearInterval(vadTimer);vadTimer=null;} if(recorder&&recorder.state!=='inactive'){ try{recorder.stop();}catch(_){ } @@ -177,6 +354,7 @@ playbackSession=null; } indicator.classList.remove('is-playing'); + indicator.classList.remove('is-ducked'); } function stopResponseObserver(){ @@ -190,7 +368,10 @@ thinkingTurnId=''; clearErrorTimer(); clearSttLanguage(); + suppressAutoRead=false; + clearBargeCancellation(); stopResponseObserver(); + cancelThinkingCues(); cancelSpeechTurn(); stopCapture(); stopPlayback(); @@ -221,23 +402,61 @@ assistantBaseline={row:row,text:readAssistantRow(row),count:rows.length}; } - function sendTranscript(transcript,token,language){ + async function settleBargeCancellation(token){ + const cancellation=bargeCancelPromise; + if(!cancellation) return true; + try{await cancellation.promise;}catch(_){ } + const deadline=Date.now()+10000; + while(active&&token===generation&&Date.now()=0) return 'wav'; if(normalized.indexOf('ogg')>=0) return 'ogg'; if(normalized.indexOf('mp4')>=0) return 'mp4'; return 'webm'; @@ -283,6 +502,33 @@ return bytes; } + function pcm16WavBlob(buffers,sampleRate){ + const chunks=Array.isArray(buffers)?buffers:[]; + const pcmBytes=chunks.reduce(function(total,chunk){return total+(chunk?chunk.byteLength:0);},0); + const wav=new ArrayBuffer(44+pcmBytes); + const view=new DataView(wav); + const write=function(offset,value){for(let index=0;index=STT_MAX_ARCHIVE_BYTES) return; + const retained=Math.min(bytes.byteLength,STT_MAX_ARCHIVE_BYTES-archiveBytes); + const even=retained-(retained%2); + if(!even) return; + archive.push(bytes.slice(0,even)); + archiveBytes+=even; + } function reject(error){ if(settled) return; settled=true; + cancelVoicePreflight(turnId); + clearQueue(); rejectFinal(error instanceof Error?error:new Error(String(error||'Streaming transcription failed'))); } function sendJson(payload){ if(socket&&socket.readyState===1){socket.send(JSON.stringify(payload));return true;} return false; } + function scheduleFlush(){ + if(flushTimer||settled||!queue.length||!socket||socket.readyState!==1) return; + flushTimer=window.setTimeout(function(){flushTimer=null;flush();},20); + } function flush(){ + clearFlushTimer(); + if(settled){clearQueue();return;} if(!socket||socket.readyState!==1) return; - while(queue.length&&socket.bufferedAmount<524288) socket.send(queue.shift()); - if(queue.length) window.setTimeout(flush,20); + while(queue.length&&socket.bufferedAmount<524288){ + const bytes=queue.shift(); + queuedBytes=Math.max(0,queuedBytes-bytes.byteLength); + socket.send(bytes); + } + if(queue.length) scheduleFlush(); } try{ const csrf=String((window.__HERMES_CONFIG__&&window.__HERMES_CONFIG__.csrfToken)||''); @@ -355,8 +633,25 @@ let payload; try{payload=JSON.parse(event.data);}catch(_){return;} if(payload.turn_id!==turnId) return; - if(payload.type==='final'&&!settled){ + if(payload.type==='partial'&&payload.rolling===true){ + const revision=Number(payload.revision); + if(Number.isFinite(revision)&&revision>partialRevision){ + partialRevision=revision; + const stable=String(payload.stable_transcript||'').trim(); + if(stable&&active&&captureTurnId===turnId&&state==='listening'){ + const preview=stable.length>72?stable.slice(0,69)+'…':stable; + label.textContent='Listening · '+preview; + if(stable!==lastPreflightText){ + lastPreflightText=stable; + scheduleVoicePreflight(turnId,revision,stable); + } + } + } + }else if(payload.type==='final'&&!settled){ settled=true; + cancelVoicePreflight(turnId); + clearQueue(); + clearArchive(); resolveFinal({transcript:payload.transcript||'',language:normalizeSttLanguage(payload.language)}); }else if(payload.type==='error'){ reject(new Error(payload.error||'Streaming transcription failed')); @@ -369,12 +664,27 @@ finalPromise:finalPromise, setWorkletReady:function(value){workletReady=value;}, push:function(samples){ - if(settled||committed||!workletReady) return; + if(cancelled||committed||!workletReady) return; const pcm=resample(samples); if(!pcm.length) return; - queue.push(encodePcm16(pcm)); + const bytes=encodePcm16(pcm); + archivePcm(bytes); + if(settled) return; + if(queuedBytes+bytes.byteLength>STT_MAX_QUEUED_BYTES){ + reject(new Error('Streaming transcription backpressure limit exceeded')); + if(socket&&socket.readyState<2) socket.close(1008,'client backpressure'); + return; + } + queue.push(bytes); + queuedBytes+=bytes.byteLength; flush(); }, + takeFallbackBlob:function(){ + if(!archiveBytes) return null; + const blob=pcm16WavBlob(archive,16000); + clearArchive(); + return blob; + }, speculate:function(){ if(settled||committed||speculative) return; speculative=true; @@ -403,6 +713,10 @@ return finalPromise; }, cancel:function(){ + cancelled=true; + cancelVoicePreflight(turnId); + clearQueue(); + clearArchive(); if(!settled){sendJson({type:'cancel',turn_id:turnId});reject(new Error('Streaming transcription cancelled'));} if(socket&&socket.readyState<2) socket.close(1000,'cancelled'); }, @@ -445,9 +759,162 @@ } } - async function transcribeStreamingOrFallback(blob,token,session){ + function cancelActiveModelTurn(){ + const sessionId=(typeof S!=='undefined'&&S.session)?String(S.session.session_id||''):''; + const streamId=typeof S!=='undefined'?String(S.activeStreamId||''):''; + if(!streamId) return {sessionId:sessionId,streamId:'',controller:null,promise:Promise.resolve(false)}; + const controller=createAbortController(); + const timer=window.setTimeout(function(){controller.abort();},1800); + const promise=(async function(){ + try{ + const url=new URL('api/chat/cancel?stream_id='+encodeURIComponent(streamId),document.baseURI||location.href).href; + const response=await fetch(url,{credentials:'include',signal:controller.signal}); + let payload=null; + try{payload=await response.json();}catch(_){ } + const currentSession=(typeof S!=='undefined'&&S.session)?String(S.session.session_id||''):''; + if(response.ok&&payload&&payload.cancelled===false&¤tSession===sessionId&&String(S.activeStreamId||'')===streamId){ + S.activeStreamId=null; + if(S.session) S.session.active_stream_id=null; + if(typeof setBusy==='function') setBusy(false); else S.busy=false; + } + return !!response.ok; + }catch(_){ + return false; + }finally{ + window.clearTimeout(timer); + } + })(); + return {sessionId:sessionId,streamId:streamId,controller:controller,promise:promise}; + } + + function trimBargeLookback(monitor){ + const maximum=Math.max(1,Math.round(monitor.context.sampleRate*BARGE_LOOKBACK_MS/1000)); + while(monitor.lookbackFrames>maximum&&monitor.lookback.length){ + const overflow=monitor.lookbackFrames-maximum; + const first=monitor.lookback[0]; + if(first.length<=overflow){monitor.lookback.shift();monitor.lookbackFrames-=first.length;continue;} + monitor.lookback[0]=first.slice(overflow); + monitor.lookbackFrames-=overflow; + } + } + + async function triggerBargeIn(monitor){ + if(!bargeMonitor||bargeMonitor!==monitor||monitor.cancelled||!active||monitor.token!==generation) return; + bargeMonitor=null; + if(monitor.timer){window.clearInterval(monitor.timer);monitor.timer=null;} + // Keep the old capture worklet alive until the new STT worklet is attached. + // Its lookback becomes an untrimmed handoff buffer so no syllables disappear + // during AudioWorklet/session setup. + monitor.handoff=true; + const oldToken=monitor.token; + generation+=1; + const token=generation; + thinkingSession=null; + thinkingTurnId=''; + suppressAutoRead=true; + clearSttLanguage(); + stopResponseObserver(); + cancelThinkingCues(); + cancelSpeechTurn(); + stopPlayback(); + if(typeof window.stopTTS==='function') window.stopTTS(); + clearBargeCancellation(); + bargeCancelPromise=cancelActiveModelTurn(); + setState('listening','Listening — interrupted'); + if(oldToken===token) return; + startListening(token,{ + stream:monitor.stream, + context:monitor.context, + lookback:monitor.lookback, + handoffMonitor:monitor, + heardSpeech:true, + requireStreamingLookback:true, + }); + } + + async function startBargeMonitor(token){ + if(!active||token!==generation||(state!=='thinking'&&state!=='speaking')||bargeMonitor) return; + const monitor={token:token,cancelled:false,handoff:false,aecUsable:true,timer:null,stream:null,context:null,source:null,node:null,silentGain:null,lookback:[],lookbackFrames:0}; + bargeMonitor=monitor; + try{ + monitor.stream=await acquireMicrophone(); + if(!active||token!==generation||bargeMonitor!==monitor){disposeBargeResources(monitor,false);return;} + monitor.aecUsable=captureAecIsUsable(monitor.stream); + const Context=window.AudioContext||window.webkitAudioContext; + try{monitor.context=new Context({latencyHint:'interactive'});}catch(_){monitor.context=new Context();} + const analyser=monitor.context.createAnalyser(); + analyser.fftSize=1024; + monitor.source=monitor.context.createMediaStreamSource(monitor.stream); + monitor.source.connect(analyser); + if(monitor.context.audioWorklet&&window.AudioWorkletNode){ + await monitor.context.audioWorklet.addModule(WORKLET_URL); + if(bargeMonitor!==monitor||monitor.cancelled){disposeBargeResources(monitor,false);return;} + monitor.node=new AudioWorkletNode(monitor.context,'atlas-pcm-capture'); + monitor.silentGain=monitor.context.createGain(); + monitor.silentGain.gain.value=0; + monitor.node.port.onmessage=function(event){ + if(monitor.cancelled||(!monitor.handoff&&bargeMonitor!==monitor)||!event.data||event.data.type!=='pcm') return; + const samples=new Float32Array(event.data.samples); + monitor.lookback.push(samples); + monitor.lookbackFrames+=samples.length; + if(!monitor.handoff) trimBargeLookback(monitor); + }; + monitor.source.connect(monitor.node); + monitor.node.connect(monitor.silentGain); + monitor.silentGain.connect(monitor.context.destination); + } + await monitor.context.resume(); + const samples=new Uint8Array(analyser.fftSize); + let noiseFloor=0.008; + let voiceFrames=0; + let ducked=false; + let speechArmAt=Date.now()+100; + let playbackWasActive=false; + monitor.timer=window.setInterval(function(){ + if(!active||token!==generation||bargeMonitor!==monitor||(state!=='thinking'&&state!=='speaking')){stopBargeMonitor();return;} + analyser.getByteTimeDomainData(samples); + let energy=0; + for(let index=0;index=speechArmAt&&rms>threshold; + if(!voiceNow) noiseFloor=(noiseFloor*0.975)+(Math.min(rms,threshold)*0.025); + voiceFrames=voiceNow?Math.min(voiceFrames+1,BARGE_TRIGGER_FRAMES):Math.max(voiceFrames-1,0); + if(voiceFrames>=BARGE_DUCK_FRAMES&&!ducked){ducked=true;setPlaybackDucked(true);} + if(!voiceFrames&&ducked){ducked=false;setPlaybackDucked(false);} + if(voiceFrames>=BARGE_TRIGGER_FRAMES) triggerBargeIn(monitor); + },50); + }catch(_){ + if(bargeMonitor===monitor) bargeMonitor=null; + disposeBargeResources(monitor,false); + // Barge-in is an optional full-duplex enhancement. The normal finalized + // microphone turn remains available if the browser rejects concurrent + // capture or AudioWorklet initialization. + } + } + + async function transcribeStreamingOrFallback(blob,token,session,allowContainerFallback){ if(!active||token!==generation) return; setState('transcribing'); + let pcmFallback=null; if(session){ try{ const result=await session.commit(); @@ -460,37 +927,48 @@ }catch(_){ // The finalized browser container below is the quality-preserving path // whenever rolling PCM transport or speculative inference fails. + if(typeof session.takeFallbackBlob==='function') pcmFallback=session.takeFallbackBlob(); } if(streamingStt===session) streamingStt=null; } + if(pcmFallback){ + transcribe(pcmFallback,token); + return; + } + if(allowContainerFallback===false){ + toast('I missed the start of that interruption. Please repeat it.'); + restartSoon(token,250); + return; + } transcribe(blob,token); } - async function startListening(token){ + async function startListening(token,reusedCapture){ if(!active||token!==generation) return; stopCapture(); stopPlayback(); cancelSpeechTurn(); clearSttLanguage(); - captureTurnId=String(token)+'-'+String(++turnSequence); + captureTurnId=(voiceTabNonce?voiceTabNonce+'-':'')+String(token)+'-'+String(++turnSequence); setState('listening'); try{ - const capture=await navigator.mediaDevices.getUserMedia({ - audio:(function(){ - const constraints={echoCancellation:true,noiseSuppression:true,autoGainControl:true}; - const supported=navigator.mediaDevices.getSupportedConstraints?navigator.mediaDevices.getSupportedConstraints():{}; - if(supported.voiceIsolation) constraints.voiceIsolation=true; - return constraints; - })(), - }); - if(!active||token!==generation){capture.getTracks().forEach(function(track){track.stop();});return;} + const capture=reusedCapture&&reusedCapture.stream?reusedCapture.stream:await acquireMicrophone(); + if(!active||token!==generation){ + if(reusedCapture&&reusedCapture.handoffMonitor) disposeBargeResources(reusedCapture.handoffMonitor,false); + else capture.getTracks().forEach(function(track){track.stop();}); + return; + } stream=capture; const Context=window.AudioContext||window.webkitAudioContext; - try{ - // Let the browser's native resampler produce Whisper's 16 kHz input. - audioContext=new Context({sampleRate:16000,latencyHint:'interactive'}); - }catch(_){ - audioContext=new Context(); + if(reusedCapture&&reusedCapture.context){ + audioContext=reusedCapture.context; + }else{ + try{ + // Let the browser's native resampler produce Whisper's 16 kHz input. + audioContext=new Context({sampleRate:16000,latencyHint:'interactive'}); + }catch(_){ + audioContext=new Context(); + } } const analyser=audioContext.createAnalyser(); analyser.fftSize=2048; @@ -506,12 +984,25 @@ // the 140 Hz high-pass remains a VAD-only aid so low voices are not // needlessly altered before final recognition. if(streamingStt) await installCaptureWorklet(audioContext,mediaSource,streamingStt); + if(streamingStt&&reusedCapture&&Array.isArray(reusedCapture.lookback)){ + reusedCapture.lookback.forEach(function(samples){streamingStt.push(samples);}); + } + if(reusedCapture&&reusedCapture.handoffMonitor){ + disposeBargeResources(reusedCapture.handoffMonitor,true); + reusedCapture.handoffMonitor=null; + } + if(reusedCapture&&reusedCapture.requireStreamingLookback&&!streamingStt){ + toast('Streaming transcription is unavailable. Please repeat your interruption.'); + stopCapture(); + restartSoon(token,250); + return; + } const samples=new Uint8Array(analyser.fftSize); const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/webm']; const mime=mimeTypes.find(function(value){return MediaRecorder.isTypeSupported(value);})||''; const chunks=[]; - let heardSpeech=false; - let voiceFrames=0; + let heardSpeech=!!(reusedCapture&&reusedCapture.heardSpeech); + let voiceFrames=heardSpeech?3:0; let noiseFloor=0.008; let lastSpeech=Date.now(); let speculative=false; @@ -543,7 +1034,12 @@ if(!active||token!==generation){cancelStreamingStt();return;} if(!heardSpeech||!chunks.length){cancelStreamingStt();restartSoon(token,300);return;} const session=streamingStt; - transcribeStreamingOrFallback(new Blob(chunks,{type:recordedMime||'audio/webm'}),token,session); + transcribeStreamingOrFallback( + new Blob(chunks,{type:recordedMime||'audio/webm'}), + token, + session, + !(reusedCapture&&reusedCapture.requireStreamingLookback) + ); }; // Ask the browser for one finalized container at stop. Android Chromium // can emit timeslice fragments without a reusable EBML initialization @@ -588,6 +1084,10 @@ } },100); }catch(error){ + if(reusedCapture&&reusedCapture.handoffMonitor){ + disposeBargeResources(reusedCapture.handoffMonitor,true); + reusedCapture.handoffMonitor=null; + } if(!active||token!==generation) return; const message=errorMessage(error,'Microphone permission is required'); showUnavailable(message); @@ -595,9 +1095,36 @@ } } + function stripHttpUrlsForSpeech(text){ + return String(text||'').replace(/(^|\s+[([{]|\s+|[([{"'])https?:\/\/[^\s<>"']+/gi,function(match,prefix){ + let address=match.slice(prefix.length); + let suffix=''; + while(address){ + const last=address.slice(-1); + let trailing=/[.,!?;:…,。!?;:]/.test(last); + if(last===')') trailing=(address.match(/\)/g)||[]).length>(address.match(/\(/g)||[]).length; + if(last===']') trailing=(address.match(/\]/g)||[]).length>(address.match(/\[/g)||[]).length; + if(last==='}') trailing=(address.match(/\}/g)||[]).length>(address.match(/\{/g)||[]).length; + if(!trailing) break; + suffix=last+suffix; + address=address.slice(0,-1); + } + const pairs={'(':')','[':']','{':'}'}; + const opening=prefix.slice(-1); + if(pairs[opening]&&suffix.startsWith(pairs[opening])){ + prefix=prefix.slice(0,-1); + suffix=suffix.slice(1); + } + if(/^\s+$/.test(prefix)&&/^[.,!?;:…,。!?;:]/.test(suffix)) prefix=''; + return prefix+suffix; + }); + } + + window._atlasStripHttpUrlsForSpeech=stripHttpUrlsForSpeech; + function cleanForSpeech(text){ - if(typeof window._stripForTTS==='function') return window._stripForTTS(text); - return String(text||'').replace(/```[\s\S]*?```/g,' code block ').replace(/\s+/g,' ').trim(); + const cleaned=typeof window._stripForTTS==='function'?window._stripForTTS(text):String(text||'').replace(/```[\s\S]*?```/g,' code block '); + return stripHttpUrlsForSpeech(cleaned).replace(/\s+/g,' ').trim(); } function sentenceEnd(text){ @@ -707,24 +1234,154 @@ return request; } - async function fetchSpeech(chunk,language,turnId){ + async function fetchSpeech(chunk,language,turnId,token){ // `language` is only ever the private STT result for this turn. When it is // absent the field is omitted entirely and the server picks English. - const response=await fetch('/api/tts',{ - method:'POST', - headers:{'Content-Type':'application/json'}, - body:JSON.stringify(ttsRequest(chunk,language,turnId)), - }); - if(!response.ok){ - const payload=await response.json().catch(function(){return {};}); - throw new Error(payload.error||('Local speech request failed: '+response.status)); + if(!active||token!==generation) throw cancelledError('Speech turn cancelled'); + const session=playbackSession; + if(!session||session.cancelled||session.turnId!==turnId) throw cancelledError('Speech turn cancelled'); + const controller=createAbortController(); + session.controllers.add(controller); + try{ + const response=await fetch('/api/tts',{ + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify(ttsRequest(chunk,language,turnId)), + signal:controller.signal, + }); + if(!response.ok){ + const payload=await response.json().catch(function(){return {};}); + throw new Error(payload.error||('Local speech request failed: '+response.status)); + } + const blob=await response.blob(); + if(!active||token!==generation||session.cancelled||playbackSession!==session) throw cancelledError('Speech turn cancelled'); + return blob; + }finally{ + session.controllers.delete(controller); } - return response.blob(); + } + + function cuePoolOffset(turnId,length){ + let hash=0; + const source=String(turnId||''); + for(let index=0;index>>0; + return length?hash%length:0; + } + + function thinkingCueStillOwned(cue){ + return !!(cue&&!cue.cancelled&&thinkingCue===cue&&active&&cue.token===generation&&state==='thinking'); + } + + function scheduleNextThinkingCue(cue,delay){ + if(!thinkingCueStillOwned(cue)||cue.issued>=cue.pool.length) return; + cue.timer=window.setTimeout(function(){ + cue.timer=null; + issueThinkingCue(cue); + },delay); + } + + async function issueThinkingCue(cue){ + if(!thinkingCueStillOwned(cue)||cue.issued>=cue.pool.length) return; + const entry=cue.pool[(cue.offset+cue.issued)%cue.pool.length]; + const cueNumber=cue.issued+1; + cue.issued+=1; + const controller=createAbortController(); + cue.controller=controller; + try{ + const request=ttsRequest(entry.text,cue.language,cue.turnId+':thinking-cue:'+cueNumber); + request.cue_id=entry.id; + const response=await fetch(TTS_STREAM_URL,{ + method:'POST', + headers:{'Content-Type':'application/json','Accept':'audio/pcm'}, + body:JSON.stringify(request), + signal:controller.signal, + }); + if(!response.ok||!response.body) throw new Error('Cached thinking cue unavailable'); + const sampleRate=parseInt(response.headers.get('X-Audio-Sample-Rate')||'22050',10); + if(!Number.isFinite(sampleRate)||sampleRate<8000||sampleRate>96000) throw new Error('Cached thinking cue metadata invalid'); + if(!thinkingCueStillOwned(cue)) return; + const Context=window.AudioContext||window.webkitAudioContext; + try{cue.context=new Context({sampleRate:sampleRate,latencyHint:'interactive'});}catch(_){cue.context=new Context();} + await cue.context.audioWorklet.addModule(WORKLET_URL); + if(!thinkingCueStillOwned(cue)) return; + cue.node=new AudioWorkletNode(cue.context,'atlas-pcm-playback'); + cue.gain=cue.context.createGain(); + cue.node.connect(cue.gain); + cue.gain.connect(cue.context.destination); + const drained=new Promise(function(resolve){ + cue.audioWake=resolve; + cue.node.port.onmessage=function(event){ + if((event.data||{}).type==='drained'){ + const wake=cue.audioWake; + cue.audioWake=null; + if(wake) wake(); + } + }; + }); + await cue.context.resume(); + indicator.classList.add('is-playing'); + const reader=response.body.getReader(); + let carry=null; + while(thinkingCueStillOwned(cue)){ + const result=await reader.read(); + if(result.done) break; + let bytes=result.value; + if(carry!==null){const joined=new Uint8Array(bytes.length+1);joined[0]=carry;joined.set(bytes,1);bytes=joined;carry=null;} + if(bytes.length%2){carry=bytes[bytes.length-1];bytes=bytes.slice(0,-1);} + if(!bytes.length) continue; + const copy=bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.byteLength); + cue.node.port.postMessage({type:'push',samples:copy,sampleRate:sampleRate},[copy]); + } + if(thinkingCueStillOwned(cue)){ + cue.node.port.postMessage({type:'end'}); + await drained; + }else{ + try{await reader.cancel();}catch(_){ } + } + cue.controller=null; + if(cue.node){try{cue.node.disconnect();}catch(_){ }cue.node=null;} + if(cue.context){try{cue.context.close();}catch(_){ }cue.context=null;} + cue.gain=null; + indicator.classList.remove('is-playing'); + if(thinkingCueStillOwned(cue)) scheduleNextThinkingCue(cue,THINKING_CUE_INTERVAL_MS); + }catch(error){ + cue.controller=null; + if(cue.cancelled||(error&&error.name==='AbortError')) return; + // Cues are optional. A playback or synthesis failure must not disturb the + // answer, retry noisily, or disable the primary hands-free conversation. + if(thinkingCue===cue) cancelThinkingCues(); + } + } + + function scheduleThinkingCues(token,language,turnId){ + cancelThinkingCues(); + if(!active||token!==generation||state!=='thinking') return; + // Missing/unknown is the same fail-safe route as answer TTS: English/Amy. + // Known STT languages remain bound exactly to their turn voice. + const localized=normalizeSttLanguage(language)||'en'; + const pool=THINKING_CUE_POOLS[localized]; + const cue={ + token:token, + turnId:String(turnId||token), + language:localized, + pool:pool, + offset:cuePoolOffset(turnId,pool.length), + issued:0, + timer:null, + controller:null, + audioWake:null, + context:null, + node:null, + gain:null, + cancelled:false, + }; + thinkingCue=cue; + scheduleNextThinkingCue(cue,THINKING_CUE_FIRST_MS); } async function prepareSpeech(chunk,language,turnId,token){ if(streamingCapability.tts&&window.ReadableStream&&window.AudioWorkletNode){ - const controller=new AbortController(); + const controller=createAbortController(); if(playbackSession) playbackSession.controllers.add(controller); try{ const response=await fetch(TTS_STREAM_URL,{ @@ -746,11 +1403,17 @@ streamingCapability.tts=null; } } - return {kind:'blob',blob:await fetchSpeech(chunk,language,turnId)}; + return {kind:'blob',blob:await fetchSpeech(chunk,language,turnId,token)}; } - async function playPcm(asset,token){ - if(!active||token!==generation) return; + async function ensurePcmPlayback(asset,token){ + if(!active||token!==generation) return null; + const session=playbackSession; + if(!session||session.cancelled) return null; + if(session.node){ + if(session.sampleRate!==asset.sampleRate) throw new Error('Streaming speech sample rate changed mid-turn'); + return session; + } const Context=window.AudioContext||window.webkitAudioContext; let context; try{ @@ -763,39 +1426,56 @@ await context.audioWorklet.addModule(WORKLET_URL); if(!active||token!==generation){context.close();return;} const node=new AudioWorkletNode(context,'atlas-pcm-playback'); - const session=playbackSession; if(!session||session.cancelled){context.close();return;} + const gain=context.createGain(); session.context=context; session.node=node; - node.connect(context.destination); + session.gain=gain; + session.sampleRate=asset.sampleRate; + session.bufferedFrames=0; + session.playbackEnded=false; + node.connect(gain); + gain.connect(context.destination); await context.resume(); - let bufferedFrames=0; - let lowWaterResolve=null; - let ended=false; - const drained=new Promise(function(resolve,reject){ + session.drained=new Promise(function(resolve,reject){ session.drainWake=resolve; - node.port.onmessage=function(event){ - const data=event.data||{}; - if(data.type==='buffer'){ - bufferedFrames=data.frames||0; - if(bufferedFramesasset.sampleRate*2){ + if(session.bufferedFrames>asset.sampleRate*2){ await new Promise(function(resolve){ let completed=false; - const wake=function(){if(completed)return;completed=true;lowWaterResolve=null;session.lowWaterWake=null;resolve();}; - lowWaterResolve=wake; + const wake=function(){if(completed)return;completed=true;session.lowWaterWake=null;resolve();}; session.lowWaterWake=wake; window.setTimeout(wake,3000); }); @@ -810,31 +1490,45 @@ if(bytes.length%2){carry=bytes[bytes.length-1];bytes=bytes.slice(0,-1);} if(!bytes.length) continue; const copy=bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.byteLength); - bufferedFrames+=bytes.byteLength/2; + session.bufferedFrames+=bytes.byteLength/2; asset.started=true; - node.port.postMessage({type:'push',samples:copy,sampleRate:asset.sampleRate},[copy]); + session.node.port.postMessage({type:'push',samples:copy,sampleRate:asset.sampleRate},[copy]); } - node.port.postMessage({type:'end'}); - if(active&&token===generation&&!session.cancelled) await drained; }finally{ - if(!ended){try{await reader.cancel();}catch(_){ }} - indicator.classList.remove('is-playing'); - try{node.disconnect();context.close();}catch(_){ } - session.node=null; - session.context=null; - session.drainWake=null; + if((!active||token!==generation||session.cancelled)){try{await reader.cancel();}catch(_){ }} session.controllers.delete(asset.controller); } } + async function drainPcmPlayback(session,token){ + if(!session||!session.node||session.cancelled||!active||token!==generation) return; + if(session.playbackEnded) return; + session.node.port.postMessage({type:'end'}); + await session.drained; + } + + async function closePcmBeforeBlob(session,token){ + if(!session||!session.node) return; + await drainPcmPlayback(session,token); + if(session.node){try{session.node.disconnect();}catch(_){ }session.node=null;} + if(session.context){try{session.context.close();}catch(_){ }session.context=null;} + session.gain=null; + session.drained=null; + indicator.classList.remove('is-playing'); + } + async function playPrepared(asset,token){ - if(asset.kind!=='pcm') return playBlob(asset.blob,token); + if(asset.kind!=='pcm'){ + if(playbackSession&&playbackSession.node) await closePcmBeforeBlob(playbackSession,token); + return playBlob(asset.blob,token); + } try{ return await playPcm(asset,token); }catch(error){ if(asset.started) throw error; streamingCapability.tts=null; - return playBlob(await fetchSpeech(asset.chunk,asset.language,asset.turnId),token); + if(playbackSession&&playbackSession.node) await closePcmBeforeBlob(playbackSession,token); + return playBlob(await fetchSpeech(asset.chunk,asset.language,asset.turnId,token),token); } } @@ -860,7 +1554,7 @@ async function runSpeechQueue(turn){ if(turn.running) return; turn.running=true; - playbackSession={turnId:turn.turnId,controllers:new Set(),context:null,node:null,cancelled:false,lowWaterWake:null,drainWake:null,blobWake:null}; + playbackSession={turnId:turn.turnId,controllers:new Set(),context:null,node:null,gain:null,sampleRate:0,bufferedFrames:0,drained:null,drainReject:null,playbackEnded:false,cancelled:false,lowWaterWake:null,drainWake:null,blobWake:null}; const session=playbackSession; try{ let chunk=await nextSpeechChunk(turn); @@ -874,10 +1568,14 @@ const nextPrepared=next.then(function(value){ return value?prepareSpeech(value,turn.language,turn.turnId,turn.token):null; }); + // Barge-in can abort both the playing request and its one-ahead request. + // Observe the latter even when the cancelled current turn returns first. + nextPrepared.catch(function(){ }); await playPrepared(asset,turn.token); if(!active||turn.token!==generation||turn.cancelled||session.cancelled) return; current=await nextPrepared; } + await drainPcmPlayback(session,turn.token); if(turn.final&&active&&turn.token===generation&&!turn.cancelled) restartSoon(turn.token,300); }catch(error){ if(active&&turn.token===generation&&!turn.cancelled){ @@ -928,6 +1626,7 @@ thinkingTurnId=''; clearSttLanguage(); stopResponseObserver(); + cancelThinkingCues(); cancelSpeechTurn(); stopPlayback(); restartSoon(token,250); @@ -935,9 +1634,13 @@ } const text=currentAssistantText(); if(!text){ - if(isFinal){thinkingSession=null;thinkingTurnId='';clearSttLanguage();stopResponseObserver();restartSoon(token,250);} + if(isFinal){thinkingSession=null;thinkingTurnId='';clearSttLanguage();stopResponseObserver();cancelThinkingCues();restartSoon(token,250);} return; } + // Any answer text, even a not-yet-speakable partial clause, owns the audio + // timeline from this point forward. A cue must never overlap or become part + // of the answer queue. + cancelThinkingCues(); const turn=ensureSpeechTurn(token); if(text.length int: "Hermes WebUI smoke passed: " f"manifest={result['manifest']} " f"icon_requests={result['icon_requests']} " + f"release_identity={result['release_identity']} " "root=branded direct_icon=png" ) return 0 diff --git a/dockerfiles/hermes_jetson_tts_cues.py b/dockerfiles/hermes_jetson_tts_cues.py new file mode 100644 index 00000000..f06888cb --- /dev/null +++ b/dockerfiles/hermes_jetson_tts_cues.py @@ -0,0 +1,119 @@ +"""Precompute the small, localized Hermes thinking-cue audio set.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + + +CUE_TEXT = { + "en": { + "thinking": "I'm thinking.", + "let_me_think": "Let me think.", + "still_working": "Still working on that.", + "one_more_moment": "One more moment.", + }, + "ru": { + "thinking": "Я думаю.", + "let_me_think": "Дайте подумать.", + "still_working": "Я всё ещё думаю над этим.", + "one_more_moment": "Ещё мгновение.", + }, + "es": { + "thinking": "Estoy pensando.", + "let_me_think": "Déjame pensar.", + "still_working": "Sigo pensando en eso.", + "one_more_moment": "Un momento más.", + }, +} +LANGUAGE_VOICE = { + "en": "en_US-amy-medium", + "ru": "ru_RU-irina-medium", + "es": "es_MX-claude-high", +} + + +@dataclass(frozen=True) +class CachedCue: + """One immutable PCM response synthesized before the server becomes ready.""" + + cue_id: str + language: str + voice_name: str + sample_rate: int + pcm: bytes + + +def build_cue_cache( + voices: dict, + synthesis_config_factory: Callable[..., object], + normalize_pcm: Callable[[object, int, int], object], + pause_for_text: Callable[[str], int], +) -> dict[tuple[str, str], CachedCue]: + """Synthesize every allow-listed cue once, failing startup on voice drift.""" + cache: dict[tuple[str, str], CachedCue] = {} + for language, entries in CUE_TEXT.items(): + voice_name = LANGUAGE_VOICE[language] + voice = voices[voice_name] + sample_rate = int(voice.config.sample_rate) + for cue_id, text in entries.items(): + chunks: list[bytes] = [] + for result in voice.synthesize(text, synthesis_config_factory(length_scale=1.0)): + if ( + result.sample_rate != sample_rate + or result.sample_width != 2 + or result.sample_channels != 1 + ): + raise RuntimeError("Piper returned an unexpected cue PCM format") + chunks.append( + bytes( + normalize_pcm( + result.audio_int16_bytes, + sample_rate, + pause_for_text(text), + ) + ) + ) + if not chunks: + raise RuntimeError(f"Piper returned no audio for cue {language}/{cue_id}") + cache[(language, cue_id)] = CachedCue( + cue_id, language, voice_name, sample_rate, b"".join(chunks) + ) + return cache + + +def resolve_cached_cue(payload: object, cache: dict) -> CachedCue | None: + """Resolve only an exact language/cue ID pair; client text is never trusted.""" + if not isinstance(payload, dict) or "cue_id" not in payload: + return None + language = payload.get("language") + cue_id = payload.get("cue_id") + if not isinstance(language, str) or not isinstance(cue_id, str): + raise ValueError("invalid thinking cue") + cue = cache.get((language, cue_id)) + if cue is None: + raise ValueError("unknown thinking cue") + return cue + + +def write_cached_cue(handler, cue: CachedCue, turn_id: str | None) -> None: + """Return immutable PCM without acquiring the live Piper model lock.""" + handler.send_response(200) + handler.send_header("Content-Type", "audio/pcm") + handler.send_header("Content-Length", str(len(cue.pcm))) + handler.send_header("Cache-Control", "private, max-age=86400, immutable") + handler.send_header("X-Audio-Format", "pcm_s16le") + handler.send_header("X-Audio-Sample-Rate", str(cue.sample_rate)) + handler.send_header("X-Audio-Channels", "1") + handler.send_header("X-Audio-Sample-Width", "2") + handler.send_header("X-TTS-Voice", cue.voice_name) + handler.send_header("X-TTS-Cue-ID", cue.cue_id) + handler.send_header("X-TTS-Cache", "HIT") + if turn_id is not None: + handler.send_header("X-TTS-Turn-ID", turn_id) + handler.end_headers() + try: + handler.wfile.write(cue.pcm) + handler.wfile.flush() + except (BrokenPipeError, ConnectionResetError, TimeoutError, OSError): + handler.close_connection = True diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index 9b15579d..b4f36491 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -134,6 +134,7 @@ configMapGenerator: - configure_agent_clients.py=scripts/configure_agent_clients.py - codex_broker.py=scripts/codex_broker.py - classifier_broker.py=scripts/classifier_broker.py + - voice_route_preflight.py=scripts/voice_route_preflight.py - claude_oauth_broker.py=scripts/claude_oauth_broker.py - worker_route_broker.py=scripts/worker_route_broker.py - hermes_coordinator.py=scripts/hermes_coordinator.py diff --git a/services/hermes/networkpolicy.yaml b/services/hermes/networkpolicy.yaml index 461a7973..fa6311f9 100644 --- a/services/hermes/networkpolicy.yaml +++ b/services/hermes/networkpolicy.yaml @@ -405,6 +405,7 @@ spec: app: hermes-switchyard ports: - {protocol: TCP, port: 9005} + - {protocol: TCP, port: 9009} - to: - podSelector: matchLabels: @@ -457,6 +458,12 @@ spec: values: [hermes, hermes-agent, hermes-chat-tenant] ports: - {protocol: TCP, port: 9005} + - from: + - podSelector: + matchLabels: + app: hermes-chat-tenant + ports: + - {protocol: TCP, port: 9009} - from: - namespaceSelector: matchLabels: @@ -466,6 +473,7 @@ spec: app: server ports: - {protocol: TCP, port: 9005} + - {protocol: TCP, port: 9009} egress: - to: - namespaceSelector: diff --git a/services/hermes/scripts/classifier_broker.py b/services/hermes/scripts/classifier_broker.py index ba0d64c1..13e6c364 100644 --- a/services/hermes/scripts/classifier_broker.py +++ b/services/hermes/scripts/classifier_broker.py @@ -6,14 +6,29 @@ from __future__ import annotations import copy import json import os +import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, Final import httpx +try: + from voice_route_preflight import ( + COORDINATOR as VOICE_PREFLIGHT, + MAX_BODY_BYTES as VOICE_PREFLIGHT_MAX_BODY_BYTES, + validate_request as validate_voice_preflight_request, + ) +except ModuleNotFoundError: # Test imports use the repository package path. + from services.hermes.scripts.voice_route_preflight import ( + COORDINATOR as VOICE_PREFLIGHT, + MAX_BODY_BYTES as VOICE_PREFLIGHT_MAX_BODY_BYTES, + validate_request as validate_voice_preflight_request, + ) + HOST: Final = os.environ.get("HERMES_CLASSIFIER_BROKER_HOST", "0.0.0.0") PORT: Final = int(os.environ.get("HERMES_CLASSIFIER_BROKER_PORT", "9008")) +VOICE_PORT: Final = int(os.environ.get("HERMES_VOICE_PREFLIGHT_PORT", "9009")) UPSTREAM: Final = os.environ.get( "HERMES_CLASSIFIER_BROKER_UPSTREAM", "http://ollama.ai.svc.cluster.local:11434", @@ -31,6 +46,7 @@ READ_TIMEOUT_SECONDS: Final = float( os.environ.get("HERMES_CLASSIFIER_BROKER_READ_TIMEOUT", "60") ) ALLOWED_PATHS: Final = {"/v1/chat/completions", "/v1/models"} +VOICE_PREFLIGHT_PATH: Final = "/voice/route-preflight" def _bounded_text(value: str, limit: int) -> str: @@ -177,10 +193,28 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def _empty(self, status: int) -> None: + self.send_response(status) + self.send_header("Content-Length", "0") + self.send_header("Cache-Control", "no-store") + self.end_headers() + + def _text(self, status: int, body: str, content_type: str) -> None: + encoded = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(encoded))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(encoded) + def do_GET(self) -> None: # noqa: N802 if self.path == "/health": self._json(200, {"ok": True, "upstream": "ollama"}) return + if self.path == "/metrics": + self._text(200, VOICE_PREFLIGHT.metrics(), "text/plain; version=0.0.4") + return if self.path not in ALLOWED_PATHS: self._json(404, {"error": "not found"}) return @@ -190,28 +224,81 @@ class Handler(BaseHTTPRequestHandler): if self.path not in ALLOWED_PATHS: self._json(404, {"error": "not found"}) return + authoritative = self.path == "/v1/chat/completions" + if authoritative: + # Mark priority as soon as the authoritative path arrives, before + # even validating or reading its local body, so an + # in-flight disposable decode is closed at authoritative arrival. + VOICE_PREFLIGHT.begin_authoritative() + try: + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + length = -1 + if length <= 0 or length > MAX_BODY_BYTES: + self._json(413, {"error": "request too large"}) + return + try: + payload = json.loads(self.rfile.read(length)) + if not isinstance(payload, dict): + raise ValueError("request must be a JSON object") + body = json.dumps( + compact_payload(payload), separators=(",", ":") + ).encode("utf-8") + except (ValueError, TypeError, json.JSONDecodeError) as exc: + self._json(400, {"error": str(exc)}) + return + print( + f"classifier-broker request_bytes={length} compacted_bytes={len(body)}", + flush=True, + ) + if authoritative: + self._proxy(body) + return + self._proxy(body) + finally: + if authoritative: + VOICE_PREFLIGHT.end_authoritative() + + def _voice_preflight(self) -> None: + """Return one disposable local tier without starting a hosted turn.""" try: length = int(self.headers.get("Content-Length", "0")) except ValueError: length = -1 - if length <= 0 or length > MAX_BODY_BYTES: + if length <= 0 or length > VOICE_PREFLIGHT_MAX_BODY_BYTES: self._json(413, {"error": "request too large"}) return try: payload = json.loads(self.rfile.read(length)) if not isinstance(payload, dict): raise ValueError("request must be a JSON object") - body = json.dumps( - compact_payload(payload), separators=(",", ":") - ).encode("utf-8") + turn_id, revision, transcript = validate_voice_preflight_request(payload) except (ValueError, TypeError, json.JSONDecodeError) as exc: self._json(400, {"error": str(exc)}) return - print( - f"classifier-broker request_bytes={length} compacted_bytes={len(body)}", - flush=True, + + cancel = VOICE_PREFLIGHT.begin(turn_id) + if cancel is None: + self._empty(204) + return + try: + tier = VOICE_PREFLIGHT.stream(transcript, cancel) + finally: + VOICE_PREFLIGHT.end() + if not tier: + self._empty(204) + return + self._json( + 200, + { + "turn_id": turn_id, + "revision": revision, + "tier": tier, + "target": f"atlas/auto/{tier}", + "advisory": True, + }, ) - self._proxy(body) def _proxy(self, body: bytes | None) -> None: try: @@ -235,9 +322,37 @@ class Handler(BaseHTTPRequestHandler): self._json(503, {"error": f"classifier unavailable: {exc}"}) +class VoiceHandler(Handler): + """Expose only advisory, health and telemetry routes to chat tenants.""" + + server_version = "HermesVoicePreflight/1" + + def do_GET(self) -> None: # noqa: N802 + if self.path == "/health": + self._json(200, {"ok": True, "upstream": "ollama"}) + return + if self.path == "/metrics": + self._text(200, VOICE_PREFLIGHT.metrics(), "text/plain; version=0.0.4") + return + self._json(404, {"error": "not found"}) + + def do_POST(self) -> None: # noqa: N802 + if self.path == VOICE_PREFLIGHT_PATH: + self._voice_preflight() + return + self._json(404, {"error": "not found"}) + + def main() -> None: + voice_server = ThreadingHTTPServer((HOST, VOICE_PORT), VoiceHandler) + voice_thread = threading.Thread(target=voice_server.serve_forever, daemon=True) + voice_thread.start() server = ThreadingHTTPServer((HOST, PORT), Handler) - server.serve_forever() + try: + server.serve_forever() + finally: + voice_server.shutdown() + voice_server.server_close() if __name__ == "__main__": diff --git a/services/hermes/scripts/voice_route_preflight.py b/services/hermes/scripts/voice_route_preflight.py new file mode 100644 index 00000000..159b4e6e --- /dev/null +++ b/services/hermes/scripts/voice_route_preflight.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""Bounded local route advice for provisional Hermes voice transcripts.""" + +from __future__ import annotations + +import json +import os +import re +import threading +import time +from typing import Any, Final + +import httpx + + +UPSTREAM: Final = os.environ.get( + "HERMES_CLASSIFIER_BROKER_UPSTREAM", + "http://ollama.ai.svc.cluster.local:11434", +).rstrip("/") +MODEL: Final = os.environ.get( + "HERMES_VOICE_PREFLIGHT_MODEL", "qwen2.5:14b-instruct-q4_0" +) +MAX_BODY_BYTES: Final = 2 * 1024 +MAX_TEXT_CHARS: Final = 512 +TIMEOUT_SECONDS: Final = max( + 0.1, min(0.75, float(os.environ.get("HERMES_VOICE_PREFLIGHT_TIMEOUT", "0.7"))) +) +TIERS: Final = {"fast", "balanced", "deep", "maximum"} +TURN_PATTERN: Final = re.compile(r"[A-Za-z0-9_.:-]{1,128}\Z") +SEEN_TTL_SECONDS: Final = 120.0 +MAX_SEEN_TURNS: Final = 256 + + +def validate_request(payload: dict[str, Any]) -> tuple[str, int, str]: + """Validate one bounded provisional transcript without identity coercion.""" + turn_id = payload.get("turn_id") + revision = payload.get("revision") + transcript = payload.get("transcript") + if not isinstance(turn_id, str) or not TURN_PATTERN.fullmatch(turn_id): + raise ValueError("invalid turn_id") + if isinstance(revision, bool) or not isinstance(revision, int): + raise ValueError("invalid revision") + if revision < 1 or revision > 1_000_000: + raise ValueError("invalid revision") + if not isinstance(transcript, str): + raise ValueError("invalid transcript") + transcript = " ".join(transcript.split()) + if len(transcript) < 12 or len(transcript) > MAX_TEXT_CHARS: + raise ValueError("invalid transcript") + return turn_id, revision, transcript + + +def inference_payload(transcript: str) -> dict[str, Any]: + """Build the tiny local-only classification request used during speech.""" + return { + "model": MODEL, + "messages": [ + { + "role": "system", + "content": ( + "Classify this incomplete spoken request into one advisory effort " + "tier. Return JSON only as {\"tier\":\"fast|balanced|deep|maximum\"}. " + "Use fast for simple conversation or facts, balanced for ordinary " + "assistance, deep for multi-step work, and maximum only for explicit " + "high-stakes or unusually complex work. This is not authoritative." + ), + }, + {"role": "user", "content": transcript}, + ], + "temperature": 0, + "max_tokens": 16, + "stream": True, + "response_format": {"type": "json_object"}, + } + + +class Coordinator: + """Serialize disposable inference and give final classification priority.""" + + def __init__(self) -> None: + self.condition = threading.Condition() + self.authoritative = 0 + self.active = False + self.cancel: threading.Event | None = None + self.client: Any = None + self.response: Any = None + self.seen: dict[str, float] = {} + self.stats = { + "admitted": 0, + "rejected": 0, + "success": 0, + "timeout": 0, + "cancelled": 0, + "failure": 0, + "preempted": 0, + } + self.duration_count = 0 + self.duration_sum = 0.0 + + def _prune(self, now: float) -> None: + self.seen = { + turn: created + for turn, created in self.seen.items() + if now - created <= SEEN_TTL_SECONDS + } + if len(self.seen) > MAX_SEEN_TURNS: + self.seen = dict(sorted(self.seen.items(), key=lambda item: item[1])[-MAX_SEEN_TURNS:]) + + def begin_authoritative(self) -> None: + """Cancel advisory I/O as soon as a real classifier request arrives.""" + with self.condition: + self.authoritative += 1 + if self.cancel is not None: + self.cancel.set() + self.stats["preempted"] += 1 + resources = (self.response, self.client) + for resource in resources: + if resource is not None: + try: + resource.close() + except Exception: + pass + + def end_authoritative(self) -> None: + """Release the real-classifier priority marker.""" + with self.condition: + self.authoritative = max(0, self.authoritative - 1) + self.condition.notify_all() + + def begin(self, turn_id: str) -> threading.Event | None: + """Admit at most one advisory globally and once per browser turn.""" + now = time.monotonic() + with self.condition: + self._prune(now) + if self.active or self.authoritative or turn_id in self.seen: + self.stats["rejected"] += 1 + return None + self.seen[turn_id] = now + self.active = True + self.cancel = threading.Event() + self.stats["admitted"] += 1 + return self.cancel + + def register( + self, cancel: threading.Event, *, client: Any = None, response: Any = None + ) -> bool: + """Publish cancellable resources unless authority already preempted them.""" + with self.condition: + if ( + not self.active + or self.cancel is not cancel + or cancel.is_set() + or self.authoritative + ): + cancel.set() + return False + if client is not None: + self.client = client + if response is not None: + self.response = response + return True + + def end(self) -> None: + """Release the disposable local lane after its bounded request.""" + with self.condition: + self.active = False + self.cancel = None + self.client = None + self.response = None + self.condition.notify_all() + + def _record(self, outcome: str, started: float) -> None: + """Record bounded low-cardinality timing without transcript or turn labels.""" + elapsed = max(0.0, time.monotonic() - started) + with self.condition: + self.stats[outcome] += 1 + self.duration_count += 1 + self.duration_sum += elapsed + print( + f"voice-route-preflight outcome={outcome} duration_ms={elapsed * 1000:.1f}", + flush=True, + ) + + def metrics(self) -> str: + """Render low-cardinality Prometheus telemetry with no user content.""" + with self.condition: + stats = dict(self.stats) + duration_count = self.duration_count + duration_sum = self.duration_sum + authority = self.authoritative + active = int(self.active) + lines = [ + "# HELP hermes_voice_route_preflight_total Local voice route preflight outcomes.", + "# TYPE hermes_voice_route_preflight_total counter", + ] + lines.extend( + f'hermes_voice_route_preflight_total{{outcome="{name}"}} {value}' + for name, value in sorted(stats.items()) + ) + lines.extend( + [ + "# TYPE hermes_voice_route_preflight_duration_seconds summary", + f"hermes_voice_route_preflight_duration_seconds_count {duration_count}", + f"hermes_voice_route_preflight_duration_seconds_sum {duration_sum:.6f}", + "# TYPE hermes_voice_route_preflight_active gauge", + f"hermes_voice_route_preflight_active {active}", + "# TYPE hermes_classifier_authoritative_active gauge", + f"hermes_classifier_authoritative_active {authority}", + ] + ) + return "\n".join(lines) + "\n" + + def stream(self, transcript: str, cancel: threading.Event) -> str: + """Run one hard-bounded cancellable local Qwen advisory decode.""" + started = time.monotonic() + outcome = "failure" + deadline = time.monotonic() + TIMEOUT_SECONDS + timeout = httpx.Timeout( + min(0.35, TIMEOUT_SECONDS), + connect=min(0.25, TIMEOUT_SECONDS), + read=min(0.35, TIMEOUT_SECONDS), + write=min(0.25, TIMEOUT_SECONDS), + pool=min(0.1, TIMEOUT_SECONDS), + ) + client = httpx.Client(timeout=timeout) + if not self.register(cancel, client=client): + client.close() + self._record("cancelled", started) + return "" + + response = None + + def expire() -> None: + cancel.set() + for resource in (response, client): + if resource is not None: + try: + resource.close() + except Exception: + pass + + timer = threading.Timer(max(0.01, deadline - time.monotonic()), expire) + timer.daemon = True + timer.start() + content = "" + try: + request = client.build_request( + "POST", + f"{UPSTREAM}/v1/chat/completions", + headers={"Content-Type": "application/json"}, + json=inference_payload(transcript), + ) + response = client.send(request, stream=True) + if not self.register(cancel, response=response): + outcome = "cancelled" + response.close() + return "" + response.raise_for_status() + for line in response.iter_lines(): + if cancel.is_set() or time.monotonic() >= deadline: + break + if not line.startswith("data:"): + continue + event = line[5:].strip() + if event == "[DONE]": + break + try: + delta = (json.loads(event)["choices"][0].get("delta") or {}).get( + "content", "" + ) + except (KeyError, IndexError, TypeError, json.JSONDecodeError): + continue + if isinstance(delta, str): + content += delta + if len(content) > 128: + cancel.set() + break + if cancel.is_set() or time.monotonic() >= deadline: + outcome = "timeout" if time.monotonic() >= deadline else "cancelled" + return "" + decoded = json.loads(content) + tier = decoded.get("tier") if isinstance(decoded, dict) else None + if tier in TIERS: + outcome = "success" + return tier + return "" + except Exception: + outcome = "cancelled" if cancel.is_set() else "failure" + return "" + finally: + cancel.set() + timer.cancel() + if response is not None: + try: + response.close() + except Exception: + pass + try: + client.close() + except Exception: + pass + self._record(outcome, started) + + +COORDINATOR = Coordinator() diff --git a/services/hermes/switchyard-deployment.yaml b/services/hermes/switchyard-deployment.yaml index 8839ffed..4b259aa2 100644 --- a/services/hermes/switchyard-deployment.yaml +++ b/services/hermes/switchyard-deployment.yaml @@ -22,7 +22,7 @@ spec: labels: app: hermes-switchyard annotations: - ai.bstein.dev/config-rev: "20260823-provider-neutral-pools" + ai.bstein.dev/config-rev: "20260823-voice-route-preflight-v1" prometheus.io/scrape: "true" prometheus.io/port: "9005" prometheus.io/path: /metrics @@ -182,11 +182,16 @@ spec: - name: classifier containerPort: 9008 protocol: TCP + - name: voice-preflight + containerPort: 9009 + protocol: TCP env: - name: HERMES_CLASSIFIER_BROKER_UPSTREAM value: http://ollama.ai.svc.cluster.local:11434 - name: HERMES_CLASSIFIER_BROKER_READ_TIMEOUT value: "60" + - name: HERMES_VOICE_PREFLIGHT_MODEL + value: qwen2.5:14b-instruct-q4_0 readinessProbe: httpGet: path: /health diff --git a/services/hermes/switchyard-service.yaml b/services/hermes/switchyard-service.yaml index 12b2d519..fb01dbc9 100644 --- a/services/hermes/switchyard-service.yaml +++ b/services/hermes/switchyard-service.yaml @@ -4,6 +4,10 @@ kind: Service metadata: name: hermes-switchyard namespace: hermes + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9009" + prometheus.io/path: /metrics labels: app: hermes-switchyard spec: @@ -15,3 +19,7 @@ spec: port: 9005 targetPort: http protocol: TCP + - name: voice-preflight + port: 9009 + targetPort: voice-preflight + protocol: TCP diff --git a/services/jenkins/configmap-jcasc.yaml b/services/jenkins/configmap-jcasc.yaml index daf40a2d..af7e139e 100644 --- a/services/jenkins/configmap-jcasc.yaml +++ b/services/jenkins/configmap-jcasc.yaml @@ -654,11 +654,11 @@ data: } pipelineJob('hermes-agent-image') { disabled(false) - description('Bounded daemonless Kaniko release for the latest atlas/titan-iac main containing a reviewed commit. Archives exact evidence, then publishes an immutable tag for Flux deployment.') + description('Bounded daemonless Kaniko release for one exact reviewed atlas/titan-iac revision already contained by main. Archives exact evidence, then publishes an immutable tag for Flux deployment.') authenticationToken(System.getenv('HERMES_AGENT_IMAGE_BUILD_TOKEN')) parameters { booleanParam('PUBLISH_IMAGE', false, 'Publish the reviewed Hermes agent image.') - stringParam('EXPECTED_SOURCE_REVISION', '', 'Full reviewed commit that must be contained by atlas/titan-iac main.') + stringParam('EXPECTED_SOURCE_REVISION', '', 'Full reviewed commit already contained by atlas/titan-iac main.') stringParam('CONFIRM_PUBLISH', '', 'Exact confirmation: PUBLISH HERMES AGENT') } definition { @@ -678,7 +678,7 @@ data: } pipelineJob('hermes-webui-image') { disabled(false) - description('Bounded daemonless Kaniko release for the latest atlas/titan-iac main containing a reviewed commit. Archives exact evidence, then publishes an immutable tag for Flux deployment.') + description('Bounded daemonless Kaniko release for one exact reviewed atlas/titan-iac revision already contained by main. Archives exact evidence, then publishes an immutable tag for Flux deployment.') authenticationToken(System.getenv('HERMES_AGENT_IMAGE_BUILD_TOKEN')) parameters { booleanParam('PUBLISH_IMAGE', false, 'Publish the reviewed Hermes WebUI image.') diff --git a/testing/fixtures/hermes-webui-0.52.181/SOURCE.md b/testing/fixtures/hermes-webui-0.52.181/SOURCE.md index cb9c2e43..fdf80260 100644 --- a/testing/fixtures/hermes-webui-0.52.181/SOURCE.md +++ b/testing/fixtures/hermes-webui-0.52.181/SOURCE.md @@ -7,6 +7,8 @@ Hermes WebUI image pinned by `dockerfiles/Dockerfile.hermes-webui`: - Version: `0.52.181` - OCI source revision: `7a94e34a6d639576576baa9131acf6765f6d2b98` - Full upstream `static/index.html` SHA-256: `6e218d42f6e047168a774c59aa9fc98a55b608cdc070cf1049ad414a597a722c` +- Full upstream `api/routes.py` SHA-256: `a09c3a236a323b0d1ad22409f6576fa21a0f3ca413ad8eeec06abb87067dc2ab` +- Full upstream `static/panels.js` SHA-256: `276e5dfc2cac6eee4a1820e4fab9c4da52743efed4a1b1e9d58d6dd15f6fd5c9` The fixture stays intentionally narrow, but tests execute the shipped patchers against it; large inline artwork bodies are reduced while their exact unique diff --git a/testing/fixtures/hermes-webui-0.52.181/api/routes.py b/testing/fixtures/hermes-webui-0.52.181/api/routes.py index e7f3b377..c499859c 100644 --- a/testing/fixtures/hermes-webui-0.52.181/api/routes.py +++ b/testing/fixtures/hermes-webui-0.52.181/api/routes.py @@ -44,6 +44,17 @@ def _handle_tts(handler, data, text, rate_str, engine): def handle_get(handler, parsed) -> bool: """Handle all GET routes. Returns True if handled, False for 404.""" + if parsed.path == "/api/settings": + settings = {} + # Inject the running version so the UI badge stays in sync with git tags + # without any manual release step. + try: + from api.updates import AGENT_VERSION, WEBUI_VERSION + settings["webui_version"] = WEBUI_VERSION + settings["agent_version"] = AGENT_VERSION + except Exception: + pass + return j(handler, settings) return False diff --git a/testing/fixtures/hermes-webui-0.52.181/static/panels.js b/testing/fixtures/hermes-webui-0.52.181/static/panels.js index 856dd8b1..871488e1 100644 --- a/testing/fixtures/hermes-webui-0.52.181/static/panels.js +++ b/testing/fixtures/hermes-webui-0.52.181/static/panels.js @@ -5,6 +5,67 @@ const _SETTINGS_SPEECH_STORAGE_KEYS={ }; let _settingsSpeechChangedKeys=new Set(); +function _normalizeWebUIVersion(value){ + if(!value) return ''; + const s=String(value).trim(); + if(!s) return ''; + const lower=s.toLowerCase(); + if(lower==='__webui_version__'||lower==='not detected'||lower==='unknown') return ''; + return s; +} + +function _currentWebUIBundleVersion(){ + try{ + const raw=window.__HERMES_WEBUI_BUNDLE_VERSION__; + if(!raw) return ''; + let s=String(raw); + try{ s=decodeURIComponent(s.replace(/\+/g,' ')); }catch(_){} + return _normalizeWebUIVersion(s); + }catch(_){ return ''; } +} + +function _showStaleWebUIClientBanner(clientVersion,serverVersion){ + const banner=document.getElementById('staleClientBanner'); + if(!banner) return; + const msg=document.getElementById('staleClientMessage'); + const versions=document.getElementById('staleClientVersions'); + if(msg) msg.textContent='This tab is running a different WebUI version. Hard refresh to restore full functionality.'; + if(versions) versions.textContent='Running: '+clientVersion+' → Server: '+serverVersion; + banner.style.display='flex'; +} + +function checkWebUIVersionSkew(settings){ + try{ + if(!settings) return; + const client=_currentWebUIBundleVersion(); + const server=_normalizeWebUIVersion(settings.webui_version); + if(!client||!server) return; + if(client===server) return; + _showStaleWebUIClientBanner(client,server); + }catch(_){} +} +window.checkWebUIVersionSkew=checkWebUIVersionSkew; + +function _startWebUIVersionSkewMonitor(){ + let _pollTimer=null; + function _isBannerVisible(){ + const banner=document.getElementById('staleClientBanner'); + return !!(banner&&banner.style.display==='flex'); + } + function _check(){ + if(_isBannerVisible()) return; + Promise.resolve().then(function(){ return api('/api/settings'); }).then(function(s){ checkWebUIVersionSkew(s); }).catch(function(){}); + } + function _startPoll(){ + if(_pollTimer||document.hidden) return; + _pollTimer=setInterval(function(){ + if(document.hidden){ clearInterval(_pollTimer); _pollTimer=null; return; } + if(_isBannerVisible()){ clearInterval(_pollTimer); _pollTimer=null; return; } + _check(); + },60000); + } +} + function _speechPreferencesPayloadFromUi(){ const payload={}; const ttsVoiceSel=$('settingsTtsVoice'); diff --git a/testing/quality_contract.json b/testing/quality_contract.json index 871b957f..cbcdefd3 100644 --- a/testing/quality_contract.json +++ b/testing/quality_contract.json @@ -18,6 +18,7 @@ "dockerfiles/hermes-kaniko-heredoc-runner.py", "services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py", "services/hermes/scripts/hermes_image_release_status.py", + "services/hermes/scripts/voice_route_preflight.py", "services/hermes/scripts/jenkins_image_build_trigger.py", "ci/scripts/publish_test_metrics.py", "ci/scripts/publish_test_metrics_quality.py", @@ -109,6 +110,7 @@ "dockerfiles/hermes-kaniko-heredoc-runner.py", "services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py", "services/hermes/scripts/hermes_image_release_status.py", + "services/hermes/scripts/voice_route_preflight.py", "services/hermes/scripts/jenkins_image_build_trigger.py", "ci/scripts/publish_test_metrics.py", "ci/scripts/publish_test_metrics_quality.py", @@ -319,6 +321,7 @@ "minimum_percent": 95.0, "minimum_branch_percent": 95.0, "branch_tracked_files": [ + "services/hermes/scripts/voice_route_preflight.py", "scripts/ops/hermes_handoff_acceptance.py", "scripts/ops/hermes_handoff_arming.py", "scripts/ops/hermes_handoff_catalog.py", @@ -340,6 +343,7 @@ "testing/quality_handoff_mutation.py" ], "tracked_files": [ + "services/hermes/scripts/voice_route_preflight.py", "ci/scripts/hermes_image_release.py", "dockerfiles/hermes-kaniko-heredoc-runner.py", "services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py", diff --git a/testing/tests/data/atlas_voice_language_probe.js b/testing/tests/data/atlas_voice_language_probe.js index 05e071d5..05721c4b 100644 --- a/testing/tests/data/atlas_voice_language_probe.js +++ b/testing/tests/data/atlas_voice_language_probe.js @@ -492,6 +492,71 @@ scenarios.adaptive_chunks_are_sentence_gated = async () => { return { partial, complete }; }; +scenarios.spoken_urls_are_skipped_without_damaging_text = async () => { + const harness = makeHarness(); + await harness.flush(); + const clean = harness.context._atlasStripHttpUrlsForSpeech; + return { + sentence: clean('Read this https://example.com/docs. Then continue.'), + wrapped: clean('Open (https://example.com/a_(b)). Next.'), + punctuated: clean('Try https://example.com/a?q=1, or https://example.org/x!'), + domains: clean('Keep example.com and sub.example.org exactly as written.'), + prose: clean('No links here; keep this sentence exactly as written.'), + }; +}; + +scenarios.spoken_urls_are_elided_before_all_voice_routes = async () => { + const results = []; + for (const language of ['en', 'ru', 'es']) { + const harness = makeHarness(); + await startVoiceMode(harness); + await runTurn(harness, { + transcript: 'Read the answer.', + language, + reply: 'Keep example.com, but skip https://private.example/path. Done.', + }); + results.push({ language, tts: harness.ttsRequests }); + } + return { results }; +}; + +scenarios.canonical_pcm_fallback_builds_a_valid_wav = async () => { + const harness = makeHarness(); + await harness.flush(); + const pcm = new Uint8Array([0x00, 0x00, 0xff, 0x7f, 0x00, 0x80]); + const blob = harness.context._atlasPcm16WavBlob([pcm.buffer], 16000); + const bytes = new Uint8Array(blob.parts[0]); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const text = (start, length) => String.fromCharCode(...bytes.slice(start, start + length)); + return { + type: blob.type, + size: bytes.length, + riff: text(0, 4), + wave: text(8, 4), + format: view.getUint16(20, true), + channels: view.getUint16(22, true), + rate: view.getUint32(24, true), + bits: view.getUint16(34, true), + data: view.getUint32(40, true), + pcm: Array.from(bytes.slice(44)), + }; +}; + +scenarios.applied_aec_settings_are_fail_safe = async () => { + const harness = makeHarness(); + await harness.flush(); + const check = harness.context._atlasCaptureAecIsUsable; + const stream = (value) => ({ + getAudioTracks: () => [{ getSettings: () => ({ echoCancellation: value }) }], + }); + return { + applied: check(stream(true)), + rejected: check(stream(false)), + unknown: check({ getAudioTracks: () => [{ getSettings: () => ({}) }] }), + unsupported: check({}), + }; +}; + scenarios.first_sentence_speaks_before_stream_completion = async () => { const harness = makeHarness(); await startVoiceMode(harness); diff --git a/testing/tests/test_hermes_image_builder.py b/testing/tests/test_hermes_image_builder.py index fe8615a5..9c6fdd92 100644 --- a/testing/tests/test_hermes_image_builder.py +++ b/testing/tests/test_hermes_image_builder.py @@ -101,6 +101,8 @@ def test_pipeline_requires_reviewed_main_and_runtime_credentials() -> None: assert "git rev-parse origin/main" in source assert "git fetch --no-tags origin main" not in source assert 'git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}"' in source + assert 'git checkout --detach "${EXPECTED_SOURCE_REVISION}"' in source + assert 'test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"' in source assert "credentialsId: 'harbor-robot'" in source assert "set +x" in source assert "umask 077" in source diff --git a/testing/tests/test_hermes_stt_streaming.py b/testing/tests/test_hermes_stt_streaming.py index 6ccba386..f2da03c0 100644 --- a/testing/tests/test_hermes_stt_streaming.py +++ b/testing/tests/test_hermes_stt_streaming.py @@ -8,6 +8,7 @@ import struct import sys import threading import time +import wave from pathlib import Path from types import SimpleNamespace @@ -63,6 +64,25 @@ class _Connection: self.condition.wait(deadline - time.monotonic()) raise AssertionError(f"no {message_type} response: {self.messages}") + def wait_for_revision(self, revision: int, timeout: float = 2.0) -> dict: + """Wait for one particular rolling partial rather than an older one.""" + deadline = time.monotonic() + timeout + with self.condition: + while time.monotonic() < deadline: + match = next( + ( + message + for message in self.messages + if message.get("type") == "partial" + and message.get("revision") == revision + ), + None, + ) + if match: + return match + self.condition.wait(deadline - time.monotonic()) + raise AssertionError(f"no rolling revision {revision}: {self.messages}") + class _Model: """Return a deterministic Whisper-shaped result and retain decode options.""" @@ -85,6 +105,46 @@ class _Model: } +class _SequencedModel(_Model): + """Return evolving text and record how much PCM each decode consumed.""" + + def __init__(self, texts: list[str]) -> None: + super().__init__() + 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()) + text = self.texts[min(len(self.calls), len(self.texts) - 1)] + self.calls.append((path, options)) + return { + "text": f" {text}", + "language": "en", + "segments": [ + { + "text": f" {text}", + "no_speech_prob": 0.01, + "avg_logprob": -0.1, + } + ], + } + + +class _BlockingModel(_Model): + """Hold inference so epoch/cancel behavior can be tested deterministically.""" + + def __init__(self) -> None: + super().__init__() + 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) + + def _start(session) -> None: session.start( { @@ -105,6 +165,22 @@ def _silence(samples: int = 12_000) -> bytes: return bytes(samples * 2) +def _configure_fast_rolling(module, monkeypatch) -> None: + """Make rolling thresholds deterministic without adding test sleeps.""" + monkeypatch.setattr(module, "ROLLING_MIN_AUDIO_MS", 100) + monkeypatch.setattr(module, "ROLLING_INTERVAL_MS", 100) + monkeypatch.setattr(module, "ROLLING_MAX_RESULT_LAG_MS", 5_000) + + +def _wait_rolling_idle(session, timeout: float = 1.0) -> None: + """Wait until an inference worker has applied or discarded its result.""" + deadline = time.monotonic() + timeout + with session._lock: + while session._rolling_inflight and time.monotonic() < deadline: + session._lock.wait(deadline - time.monotonic()) + assert not session._rolling_inflight + + def test_websocket_handshake_matches_rfc_example(monkeypatch): module = _load_server(monkeypatch) @@ -158,6 +234,161 @@ def test_stream_speculation_is_reused_for_unchanged_commit(monkeypatch): assert model.calls[0][1]["condition_on_previous_text"] is False +def test_active_speech_emits_rate_limited_stable_rolling_partials(monkeypatch): + module = _load_server(monkeypatch) + _configure_fast_rolling(module, monkeypatch) + connection = _Connection() + model = _SequencedModel(["please open", "please open my calendar"]) + session = module.StreamingTranscription(connection, model) + _start(session) + + session.append(_speech()) + first = connection.wait_for_revision(1) + assert first == { + "type": "partial", + "turn_id": "turn-10", + "transcript": "please open", + "stable_transcript": "", + "language": "en", + "speculative": True, + "rolling": True, + "revision": 1, + "epoch": 0, + "window_start_ms": 0, + } + + # Audio progress is required in addition to wall-clock rate limiting. + session._rolling_last_started_at = 0.0 + session.append(_speech()) + second = connection.wait_for_revision(2) + assert second["transcript"] == "please open my calendar" + assert second["stable_transcript"] == "please open" + assert len(model.calls) == 2 + + +def test_rolling_window_is_bounded_but_final_decode_uses_full_utterance(monkeypatch): + module = _load_server(monkeypatch) + _configure_fast_rolling(module, monkeypatch) + monkeypatch.setattr(module, "ROLLING_WINDOW_MS", 300) + connection = _Connection() + model = _SequencedModel(["partial", "complete request"]) + session = module.StreamingTranscription(connection, model) + _start(session) + + utterance = _speech(samples=16_000) + session.append(utterance) + partial = connection.wait_for_revision(1) + assert partial["window_start_ms"] == 700 + assert model.frame_counts == [4_800] + + session.commit() + assert connection.wait_for("final")["transcript"] == "complete request" + assert model.frame_counts == [4_800, 16_000] + + +def test_cancel_drops_inflight_rolling_result(monkeypatch): + module = _load_server(monkeypatch) + _configure_fast_rolling(module, monkeypatch) + connection = _Connection() + model = _BlockingModel() + session = module.StreamingTranscription(connection, model) + _start(session) + + session.append(_speech()) + assert model.entered.wait(1.0) + session.cancel() + model.release.set() + _wait_rolling_idle(session) + + assert connection.messages == [] + + +def test_resume_epoch_drops_old_rolling_result(monkeypatch): + module = _load_server(monkeypatch) + _configure_fast_rolling(module, monkeypatch) + connection = _Connection() + model = _BlockingModel() + session = module.StreamingTranscription(connection, model) + _start(session) + + session.append(_speech()) + assert model.entered.wait(1.0) + session.resume() + model.release.set() + _wait_rolling_idle(session) + + assert connection.messages == [] + assert session._epoch == 1 + + +def test_busy_gpu_skips_partial_instead_of_queuing_work(monkeypatch): + module = _load_server(monkeypatch) + _configure_fast_rolling(module, monkeypatch) + connection = _Connection() + model = _Model() + session = module.StreamingTranscription(connection, model) + _start(session) + + release = threading.Event() + entered = threading.Event() + + def occupy_gpu(): + module.INFERENCE_GATE.try_background( + lambda: (entered.set(), release.wait(2.0)) + ) + + worker = threading.Thread(target=occupy_gpu) + worker.start() + assert entered.wait(1.0) + try: + session.append(_speech()) + _wait_rolling_idle(session) + assert model.calls == [] + assert connection.messages == [] + finally: + release.set() + worker.join(timeout=1.0) + + # A later audio interval gets another opportunity once the final-priority + # GPU lock is free; the skipped snapshot did not leave a queued worker. + session._rolling_last_started_at = 0.0 + session.append(_speech()) + assert connection.wait_for_revision(1)["transcript"] == "hello" + assert len(model.calls) == 1 + + +def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch): + module = _load_server(monkeypatch) + first_entered = threading.Event() + release_first = threading.Event() + final_ran = threading.Event() + + first = threading.Thread( + target=lambda: module.INFERENCE_GATE.try_background( + lambda: (first_entered.set(), release_first.wait(2.0)) + ) + ) + first.start() + assert first_entered.wait(1.0) + final = threading.Thread( + target=lambda: module.INFERENCE_GATE.run_final(final_ran.set) + ) + final.start() + deadline = time.monotonic() + 1.0 + while module.INFERENCE_GATE._final_waiters < 1 and time.monotonic() < deadline: + time.sleep(0.005) + assert module.INFERENCE_GATE._final_waiters == 1 + assert module.INFERENCE_GATE.try_background(lambda: "ran") is None + release_first.set() + first.join(timeout=1.0) + final.join(timeout=1.0) + assert final_ran.is_set() + telemetry = module.INFERENCE_GATE.snapshot() + assert telemetry["background_skips"] == 1 + assert telemetry["last_final_wait_ms"] >= 0 + assert telemetry["last_final_decode_ms"] >= 0 + + def test_resume_invalidates_speculative_snapshot(monkeypatch): module = _load_server(monkeypatch) connection = _Connection() @@ -238,4 +469,5 @@ def test_stream_health_contract_is_declared_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 "MAX_STREAM_AUDIO_BYTES" in source diff --git a/testing/tests/test_hermes_thinking_voice_cues.py b/testing/tests/test_hermes_thinking_voice_cues.py new file mode 100644 index 00000000..4671ee6b --- /dev/null +++ b/testing/tests/test_hermes_thinking_voice_cues.py @@ -0,0 +1,65 @@ +"""Release contracts for localized, interruptible thinking voice cues.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +VOICE = ROOT / "dockerfiles/hermes-webui-atlas-voice.js" + + +def _source() -> str: + return VOICE.read_text(encoding="utf-8") + + +def test_fast_answers_suppress_cues_and_long_waits_are_bounded(): + source = _source() + + assert "const THINKING_CUE_FIRST_MS=1900" in source + assert "const THINKING_CUE_INTERVAL_MS=6500" in source + assert "cue.issued>=cue.pool.length" in source + assert "scheduleNextThinkingCue(cue,THINKING_CUE_FIRST_MS)" in source + assert "scheduleNextThinkingCue(cue,THINKING_CUE_INTERVAL_MS)" in source + assert "cancelThinkingCues();\n const turn=ensureSpeechTurn(token)" in source + + +def test_cues_are_localized_deterministic_and_non_repeating(): + source = _source() + + assert "{id:'thinking',text:\"I'm thinking.\"}" in source + assert "{id:'let_me_think',text:'Дайте подумать.'}" in source + assert "{id:'still_working',text:'Sigo pensando en eso.'}" in source + assert "offset:cuePoolOffset(turnId,pool.length)" in source + assert "cue.pool[(cue.offset+cue.issued)%cue.pool.length]" in source + assert "cue.issued+=1" in source + assert "const localized=normalizeSttLanguage(language)||'en'" in source + + +def test_cues_are_turn_owned_audio_only_and_abortable(): + source = _source() + cue_region = source.split("async function issueThinkingCue(cue){", 1)[1].split( + "function scheduleThinkingCues", 1 + )[0] + + assert "cue.token===generation" in source + assert "state==='thinking'" in source + assert "cue.turnId+':thinking-cue:'" in cue_region + assert "request.cue_id=entry.id" in cue_region + assert "fetch(TTS_STREAM_URL" in cue_region + assert "fetch('/api/tts'" not in cue_region + assert "atlas-pcm-playback" in cue_region + assert "signal:controller.signal" in cue_region + assert "cue.controller.abort()" in source + assert "cancelThinkingCues();\n cancelSpeechTurn();" in source + assert "composer.value" not in cue_region + assert "window.send" not in cue_region + + +def test_thinking_barge_in_arms_quickly_but_guards_playback_echo(): + source = _source() + + assert "let speechArmAt=Date.now()+100" in source + assert "speechArmAt=now+(state==='thinking'?150:450)" in source + assert "playbackActive&&!playbackWasActive" in source + assert "const BARGE_TRIGGER_FRAMES=4" in source + assert "},50);" in source + assert "lookback:monitor.lookback" in source diff --git a/testing/tests/test_hermes_tts_language_routing.py b/testing/tests/test_hermes_tts_language_routing.py index 10c04d4d..e881468f 100644 --- a/testing/tests/test_hermes_tts_language_routing.py +++ b/testing/tests/test_hermes_tts_language_routing.py @@ -5,7 +5,9 @@ from __future__ import annotations import importlib.util import io import json +import struct import sys +import wave from types import SimpleNamespace import pytest @@ -19,6 +21,7 @@ CLAUDE = "es_MX-claude-high" def _load_tts_server(monkeypatch): server_path = ROOT / "dockerfiles" / "hermes-jetson-tts-server.py" + monkeypatch.syspath_prepend(str(server_path.parent)) spec = importlib.util.spec_from_file_location("hermes_jetson_tts_server", server_path) assert spec and spec.loader module = importlib.util.module_from_spec(spec) @@ -239,6 +242,72 @@ def test_turn_ids_are_safe_to_echo_in_response_headers(tts, value, expected): assert tts.normalize_turn_id(value) == expected +@pytest.mark.parametrize( + "text,expected", + [ + ("A short clause,", 90), + ("A continued thought:", 110), + ("Is this natural?", 170), + ("This is natural.", 190), + ("Thinking...", 220), + ("First paragraph.\n\n", 280), + ("A provisional phrase", 70), + ('She said, "done."', 190), + ], +) +def test_trailing_pause_budget_tracks_punctuation(tts, text, expected): + assert tts.trailing_pause_ms(text) == expected + + +def test_sentence_pause_targets_keep_paragraph_and_final_clause_cadence(tts): + assert tts.sentence_pause_targets("First.\n\nSecond? A tail,") == [280, 170, 90] + + +def _pcm(*runs: tuple[int, int]) -> bytes: + return b"".join(struct.pack(f"<{count}h", *([value] * count)) for value, count in runs) + + +def test_pcm_tail_trim_preserves_voice_and_keeps_period_pause(tts): + # A deliberately low-amplitude final phoneme remains above the very + # conservative silence threshold and must remain intact. + pcm = _pcm((1200, 50), (40, 50), (0, 600)) + normalized = tts.trim_trailing_pcm_silence(pcm, sample_rate=1000, pause_ms=190) + + assert bytes(normalized[:200]) == pcm[:200] + assert len(normalized) == (100 + 190) * 2 + assert bytes(normalized[-190 * 2 :]) == b"\x00" * (190 * 2) + + +def test_pcm_tail_trim_leaves_short_and_all_silent_audio_unchanged(tts): + near_target = _pcm((900, 100), (0, 205)) + all_silent = _pcm((0, 800)) + + assert bytes(tts.trim_trailing_pcm_silence(near_target, 1000, 190)) == near_target + assert bytes(tts.trim_trailing_pcm_silence(all_silent, 1000, 190)) == all_silent + + +def test_pcm_tail_trim_rejects_malformed_or_unknown_format_without_mutation(tts): + malformed = b"\x01\x02\x03" + assert bytes(tts.trim_trailing_pcm_silence(malformed, 22_050, 190)) == malformed + assert bytes(tts.trim_trailing_pcm_silence(b"\x01\x00", 0, 190)) == b"\x01\x00" + + +def test_compatibility_wav_uses_the_same_period_pause_budget(tts): + source = io.BytesIO() + with wave.open(source, "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(1000) + wav_file.writeframes(_pcm((1000, 100), (0, 600))) + + normalized = tts.normalize_wav_trailing_pause(source.getvalue(), "Finished.") + with wave.open(io.BytesIO(normalized), "rb") as wav_file: + assert wav_file.getparams().nchannels == 1 + assert wav_file.getparams().sampwidth == 2 + assert wav_file.getparams().framerate == 1000 + assert wav_file.getnframes() == 100 + 190 + + def _decode_chunked_response(data: bytes) -> bytes: decoded = bytearray() cursor = 0 @@ -328,6 +397,154 @@ def test_stream_endpoint_returns_progressive_pcm_and_preserves_voice_policy(tts) assert _decode_chunked_response(handler.wfile.getvalue()) == first_pcm + second_pcm +def test_cached_thinking_cue_is_lock_free_and_ignores_client_text(tts): + cached = SimpleNamespace( + cue_id="thinking", + language="ru", + voice_name=IRINA, + sample_rate=22_050, + pcm=b"\x01\x00\x02\x00", + ) + + class _CachedHandler(tts.SpeechHandler): + def __init__(self): + payload = json.dumps( + { + "text": "attacker-controlled text is ignored", + "language": "ru", + "cue_id": "thinking", + "turn_id": "turn-9:thinking-cue:1", + } + ).encode() + self.path = "/v1/audio/speech/stream" + self.headers = {"Content-Length": str(len(payload))} + self.rfile = io.BytesIO(payload) + self.wfile = io.BytesIO() + self.status = None + self.response_headers = {} + self.close_connection = False + self.server = SimpleNamespace( + voices={}, default_voice_name=AMY, cue_cache={("ru", "thinking"): cached} + ) + + def send_response(self, status, message=None): + self.status = status + + def send_header(self, name, value): + self.response_headers[name] = value + + def end_headers(self): + return None + + handler = _CachedHandler() + handler.do_POST() + + assert handler.status == 200 + assert handler.wfile.getvalue() == cached.pcm + assert handler.response_headers["X-TTS-Voice"] == IRINA + assert handler.response_headers["X-TTS-Cue-ID"] == "thinking" + assert handler.response_headers["X-TTS-Cache"] == "HIT" + assert handler.response_headers["X-TTS-Turn-ID"] == "turn-9:thinking-cue:1" + + +def test_unknown_cached_thinking_cue_fails_closed(tts): + with pytest.raises(ValueError, match="unknown thinking cue"): + tts.resolve_cached_cue( + {"language": "en", "cue_id": "invented"}, + {}, + ) + + +def test_thinking_cue_cache_precomputes_all_localized_voice_pairs(tts): + class _CueVoice: + config = SimpleNamespace(sample_rate=22_050) + + def synthesize(self, text, syn_config): + assert text + assert syn_config.length_scale == 1.0 + yield SimpleNamespace( + sample_rate=22_050, + sample_width=2, + sample_channels=1, + audio_int16_bytes=b"\x01\x00\x02\x00", + ) + + voices = {name: _CueVoice() for name in (AMY, IRINA, CLAUDE)} + cache = tts.build_cue_cache( + voices, + tts.SynthesisConfig, + lambda pcm, _rate, _pause: memoryview(pcm), + lambda _text: 190, + ) + + assert len(cache) == 12 + assert {key[0] for key in cache} == {"en", "ru", "es"} + assert {cue.voice_name for (language, _), cue in cache.items() if language == "en"} == {AMY} + assert {cue.voice_name for (language, _), cue in cache.items() if language == "ru"} == {IRINA} + assert {cue.voice_name for (language, _), cue in cache.items() if language == "es"} == {CLAUDE} + assert all(cue.pcm and len(cue.pcm) % 2 == 0 for cue in cache.values()) + + +def test_thinking_cue_cache_fails_startup_on_missing_or_malformed_audio(tts): + class _EmptyVoice: + config = SimpleNamespace(sample_rate=22_050) + + def synthesize(self, _text, _syn_config): + return iter(()) + + voices = {name: _EmptyVoice() for name in (AMY, IRINA, CLAUDE)} + with pytest.raises(RuntimeError, match="returned no audio"): + tts.build_cue_cache( + voices, + tts.SynthesisConfig, + lambda pcm, _rate, _pause: memoryview(pcm), + lambda _text: 190, + ) + + +def test_stream_endpoint_trims_each_sentence_with_its_own_pause_budget(tts): + sentence_pcm = _pcm((1000, 100), (0, 600)) + + class _StreamingVoice: + config = SimpleNamespace(sample_rate=1000) + + def synthesize(self, text, syn_config): + assert text == "First. Final clause," + for _ in range(2): + yield SimpleNamespace( + sample_rate=1000, + sample_width=2, + sample_channels=1, + audio_int16_bytes=sentence_pcm, + ) + + class _StreamingHandler(tts.SpeechHandler): + def __init__(self): + self.wfile = io.BytesIO() + self.response_headers = {} + self.close_connection = False + + def send_response(self, status, message=None): + return None + + def send_header(self, name, value): + self.response_headers[name] = value + + def end_headers(self): + return None + + handler = _StreamingHandler() + handler._stream_pcm("First. Final clause,", 1.0, AMY, _StreamingVoice(), "turn-2") + decoded = _decode_chunked_response(handler.wfile.getvalue()) + + # 100 ms of voiced audio plus 190 ms after the period and 90 ms after the + # final clause. The audio itself remains byte-for-byte identical. + assert len(decoded) == ((100 + 190) + (100 + 90)) * 2 + assert decoded[: 100 * 2] == sentence_pcm[: 100 * 2] + second_start = (100 + 190) * 2 + assert decoded[second_start : second_start + 100 * 2] == sentence_pcm[: 100 * 2] + + def test_stream_disconnect_stops_before_synthesizing_another_sentence(tts): generated: list[int] = [] diff --git a/testing/tests/test_hermes_voice_full_duplex.py b/testing/tests/test_hermes_voice_full_duplex.py new file mode 100644 index 00000000..2f70124e --- /dev/null +++ b/testing/tests/test_hermes_voice_full_duplex.py @@ -0,0 +1,120 @@ +"""Contracts for low-latency, interruptible Hermes hands-free playback.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +VOICE = ROOT / "dockerfiles/hermes-webui-atlas-voice.js" +WORKLET = ROOT / "dockerfiles/hermes-webui-atlas-voice-worklet.js" + + +def test_barge_in_keeps_aec_capture_and_requires_sustained_speech(): + source = VOICE.read_text(encoding="utf-8") + + assert "echoCancellation:true" in source + assert "noiseSuppression:true" in source + assert "autoGainControl:true" in source + assert "const BARGE_DUCK_FRAMES=2" in source + assert "const BARGE_TRIGGER_FRAMES=4" in source + assert "let speechArmAt=Date.now()+100" in source + assert "speechArmAt=now+(state==='thinking'?150:450)" in source + assert "speechArmAt=now+100" in source + assert "playbackActive&&!playbackWasActive" in source + assert "const threshold=Math.max(0.05,(noiseFloor*3)+0.008)" in source + assert "setPlaybackDucked(true)" in source + assert "setPlaybackDucked(false)" in source + assert "settings.echoCancellation!==false" in source + assert "const automaticBargeAllowed=!playbackActive||monitor.aecUsable" in source + + +def test_barge_in_cancels_owned_turn_and_reuses_pcm_lookback(): + source = VOICE.read_text(encoding="utf-8") + + assert "api/chat/cancel?stream_id=" in source + assert "controller.abort();},1800" in source + assert "const deadline=Date.now()+10000" in source + assert "stopResponseObserver();" in source + assert "cancelSpeechTurn();" in source + assert "stopPlayback();" in source + assert "bargeCancelPromise=cancelActiveModelTurn()" in source + assert "lookback:monitor.lookback" in source + assert "heardSpeech:true" in source + assert "reusedCapture.lookback.forEach" in source + assert "const bytes=new Uint8Array(16)" in source + assert "window.crypto.getRandomValues(bytes)" in source + assert "captureTurnId=(voiceTabNonce?voiceTabNonce+'-':'')+String(token)+'-'+String(++turnSequence)" in source + + +def test_streamed_chunks_share_one_audio_timeline_until_final_drain(): + source = VOICE.read_text(encoding="utf-8") + worklet = WORKLET.read_text(encoding="utf-8") + + assert "if(session.node)" in source + assert "return session;" in source + assert "session.node.port.postMessage({type:'push'" in source + assert "session.node.port.postMessage({type:'end'})" in source + assert "await drainPcmPlayback(session,turn.token)" in source + assert "node.connect(gain)" in source + assert "gain.connect(context.destination)" in source + assert "this.ended&&!this.queue.length" in worklet + assert "this.port.postMessage({type:'drained'})" in worklet + + +def test_barge_cancel_settles_before_new_turn_baseline_and_observer(): + source = VOICE.read_text(encoding="utf-8") + region = source.split("async function sendTranscript", 1)[1].split( + "function audioExtension", 1 + )[0] + + assert region.index("await settleBargeCancellation(token)") < region.index( + "rememberAssistantBaseline()" + ) + assert region.index("rememberAssistantBaseline()") < region.index("window.send()") + assert region.index("window.send()") < region.index("startResponseObserver(token)") + assert "suppressAutoRead||bargeCancelPromise||state==='listening'||state==='transcribing'" in source + + +def test_barge_handoff_never_uses_a_truncated_container_fallback(): + source = VOICE.read_text(encoding="utf-8") + + assert "monitor.handoff=true" in source + assert "if(!monitor.handoff) trimBargeLookback(monitor)" in source + assert "handoffMonitor:monitor" in source + assert "reusedCapture.lookback.forEach" in source + assert "requireStreamingLookback:true" in source + assert "allowContainerFallback===false" in source + assert "Please repeat your interruption" in source + + +def test_rolling_partials_are_feedback_only_and_wav_fallback_is_owned(): + source = VOICE.read_text(encoding="utf-8") + partial = source.split("payload.type==='partial'", 1)[1].split( + "payload.type==='final'", 1 + )[0] + fallback = source.split("async function fetchSpeech", 1)[1].split( + "function cuePoolOffset", 1 + )[0] + + assert "payload.stable_transcript" in partial + assert "label.textContent='Listening · '+preview" in partial + assert "composer.value" not in partial + assert "window.send" not in partial + assert "signal:controller.signal" in fallback + assert "token!==generation" in fallback + assert "session.turnId!==turnId" in fallback + assert "closePcmBeforeBlob" in source + + +def test_stt_client_queue_and_canonical_archive_are_strictly_bounded(): + source = VOICE.read_text(encoding="utf-8") + + assert "const STT_MAX_QUEUED_BYTES=1048576" in source + assert "const STT_MAX_ARCHIVE_BYTES=2880000" in source + assert "queuedBytes+bytes.byteLength>STT_MAX_QUEUED_BYTES" in source + assert "Streaming transcription backpressure limit exceeded" in source + assert "if(flushTimer||settled||!queue.length" in source + assert "flushTimer=window.setTimeout(function(){flushTimer=null;flush();},20)" in source + assert "function clearQueue(){queue=[];queuedBytes=0;clearFlushTimer();}" in source + assert "takeFallbackBlob:function()" in source + assert "pcm16WavBlob(archive,16000)" in source + assert "if(pcmFallback)" in source diff --git a/testing/tests/test_hermes_voice_instrument.py b/testing/tests/test_hermes_voice_instrument.py index 12d6e20e..4c9b9988 100644 --- a/testing/tests/test_hermes_voice_instrument.py +++ b/testing/tests/test_hermes_voice_instrument.py @@ -175,7 +175,8 @@ def test_release_candidate_streaming_keeps_one_ahead_and_safe_fallbacks(): assert "At most one later synthesis request exists" in script assert "const nextPrepared=next.then" in script assert "await playPrepared(asset,turn.token)" in script - assert "playBlob(await fetchSpeech(asset.chunk,asset.language,asset.turnId),token)" in script + assert "playBlob(await fetchSpeech(asset.chunk,asset.language,asset.turnId,token),token)" in script + assert "await closePcmBeforeBlob(playbackSession,token)" in script assert "if(asset.started) throw error" in script assert "playbackSession.controllers.forEach(function(controller){controller.abort();})" in script assert "sendJson({type:'cancel',turn_id:turnId})" in script diff --git a/testing/tests/test_hermes_voice_language_routing.py b/testing/tests/test_hermes_voice_language_routing.py index c07e4f1c..0d9ee47b 100644 --- a/testing/tests/test_hermes_voice_language_routing.py +++ b/testing/tests/test_hermes_voice_language_routing.py @@ -674,6 +674,53 @@ def test_adaptive_chunks_wait_for_sentence_then_change_size(voice_probe): assert "".join(chunks).replace(" ", "") +def test_spoken_http_urls_are_skipped_without_mangling_prose(voice_probe): + result = voice_probe["spoken_urls_are_skipped_without_damaging_text"] + assert result["sentence"] == "Read this. Then continue." + assert result["wrapped"] == "Open. Next." + assert result["punctuated"] == "Try, or!" + assert result["domains"] == "Keep example.com and sub.example.org exactly as written." + assert result["prose"] == "No links here; keep this sentence exactly as written." + + +def test_url_elision_happens_before_every_localized_voice_route(voice_probe): + cases = voice_probe["spoken_urls_are_elided_before_all_voice_routes"]["results"] + assert [case["language"] for case in cases] == ["en", "ru", "es"] + for case in cases: + assert case["tts"] + spoken = " ".join(request["text"] for request in case["tts"]) + assert "http://" not in spoken + assert "https://" not in spoken + assert "private.example" not in spoken + assert "example.com" in spoken + assert all(request["language"] == case["language"] for request in case["tts"]) + + +def test_canonical_pcm_fallback_wav_is_runtime_valid(voice_probe): + result = voice_probe["canonical_pcm_fallback_builds_a_valid_wav"] + assert result == { + "type": "audio/wav", + "size": 50, + "riff": "RIFF", + "wave": "WAVE", + "format": 1, + "channels": 1, + "rate": 16000, + "bits": 16, + "data": 6, + "pcm": [0, 0, 255, 127, 0, 128], + } + + +def test_applied_aec_settings_are_runtime_fail_safe(voice_probe): + assert voice_probe["applied_aec_settings_are_fail_safe"] == { + "applied": True, + "rejected": False, + "unknown": True, + "unsupported": True, + } + + def test_first_complete_sentence_reaches_tts_before_completion_callback(voice_probe): result = voice_probe["first_sentence_speaks_before_stream_completion"] assert result["beforeBoundary"] == 0 @@ -756,6 +803,34 @@ def test_streaming_tts_payload_is_narrow_and_turn_bound(patched_webui): } +def test_streaming_tts_payload_forwards_only_allowlisted_localized_cues(patched_webui): + routes = patched_webui.routes + assert routes._atlas_tts_stream_payload( + { + "text": "client text is ignored by the cue cache", + "language": "es", + "cue_id": "still_working", + "turn_id": "voice-turn-7:thinking-cue:2", + } + ) == { + "model": "piper", + "input": "client text is ignored by the cue cache", + "speed": 1.0, + "language": "es", + "cue_id": "still_working", + "turn_id": "voice-turn-7:thinking-cue:2", + } + for hostile in ("invented", "../thinking", 7, None): + with pytest.raises(ValueError, match="invalid thinking cue"): + routes._atlas_tts_stream_payload( + {"text": "ignored", "language": "en", "cue_id": hostile} + ) + with pytest.raises(ValueError, match="invalid thinking cue"): + routes._atlas_tts_stream_payload( + {"text": "ignored", "language": "fr", "cue_id": "thinking"} + ) + + # --------------------------------------------------------------------------- # 6. Build-time enforcement and documented semantics # --------------------------------------------------------------------------- diff --git a/testing/tests/test_hermes_voice_preflight_delivery.py b/testing/tests/test_hermes_voice_preflight_delivery.py new file mode 100644 index 00000000..93e422d5 --- /dev/null +++ b/testing/tests/test_hermes_voice_preflight_delivery.py @@ -0,0 +1,171 @@ +"""Browser, proxy, and Flux delivery contracts for voice route preflight.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import yaml + +from testing.tests.test_hermes_chat_support import ROOT, _documents +from testing.tests.test_hermes_voice_language_routing import patched_webui # noqa: F401 + + +HERMES = ROOT / "services" / "hermes" +VOICE = ROOT / "dockerfiles" / "hermes-webui-atlas-voice.js" + + +def test_webui_proxy_enforces_origin_exact_echo_and_no_store( + patched_webui, # noqa: F811 + monkeypatch, +): + routes = patched_webui.routes + request_payload = { + "turn_id": "ef" * 16 + "-4-1", + "revision": 2, + "transcript": "Explain the safest non disruptive option", + } + monkeypatch.setattr(routes, "read_body", lambda _handler: request_payload, raising=False) + monkeypatch.setattr( + routes, + "j", + lambda _handler, payload, status=200, extra_headers=None: { + "status": status, + "payload": payload, + "headers": extra_headers, + }, + raising=False, + ) + trusted = {"value": True} + monkeypatch.setattr( + routes, + "_check_same_origin_browser_request", + lambda _handler: trusted["value"], + raising=False, + ) + monkeypatch.setattr( + routes, + "bad", + lambda _handler, message, status=400: {"status": status, "error": message}, + raising=False, + ) + observed = {} + + class Upstream: + def read(self, size): + observed["size"] = size + return json.dumps( + { + "turn_id": request_payload["turn_id"], + "revision": 2, + "tier": "deep", + "target": "atlas/auto/deep", + "advisory": True, + "private": "must be removed", + } + ).encode() + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def open_preflight(request, timeout): + observed["url"] = request.full_url + observed["body"] = json.loads(request.data) + observed["timeout"] = timeout + return Upstream() + + monkeypatch.setattr(routes, "_atlas_voice_preflight_open", open_preflight) + result = routes._handle_atlas_voice_preflight(SimpleNamespace()) + assert observed["url"] == routes.ATLAS_VOICE_PREFLIGHT_URL + assert observed["body"] == request_payload + assert observed["timeout"] == 1.0 + assert result["headers"] == {"Cache-Control": "no-store"} + assert result["payload"] == { + "turn_id": request_payload["turn_id"], + "revision": 2, + "tier": "deep", + "target": "atlas/auto/deep", + "advisory": True, + } + trusted["value"] = False + assert routes._handle_atlas_voice_preflight(SimpleNamespace())["status"] == 403 + + +def test_browser_debounces_stable_partials_and_never_forces_final_route(): + source = VOICE.read_text(encoding="utf-8") + partial = source.split("payload.type==='partial'", 1)[1].split( + "payload.type==='final'", 1 + )[0] + scheduler = source.split("function scheduleVoicePreflight", 1)[1].split( + "function cancelThinkingCues", 1 + )[0] + send = source.split("async function sendTranscript", 1)[1].split( + "function audioExtension", 1 + )[0] + + assert "VOICE_PREFLIGHT_DEBOUNCE_MS=200" in source + assert "window.crypto.getRandomValues(bytes)" in source + assert "scheduleVoicePreflight(turnId,revision,stable)" in partial + assert "cancelVoicePreflight(turnId)" in source + assert "controller.abort()" in scheduler + assert "advisory.turn_id!==turnId" in scheduler + assert "advisory.revision!==revision" in scheduler + assert "label.textContent='Listening · '+preview+' · '+tier" in scheduler + assert "composer.value" not in scheduler + assert "window.send" not in scheduler + assert "advisory" not in send + assert "atlas/auto/" not in send + + +def test_flux_wires_the_sibling_runtime_rollout_service_and_narrow_policy(): + kustomization = yaml.safe_load((HERMES / "kustomization.yaml").read_text()) + generator = next( + item + for item in kustomization["configMapGenerator"] + if item["name"] == "hermes-coordinator" + ) + assert "voice_route_preflight.py=scripts/voice_route_preflight.py" in generator["files"] + + deployment = _documents(HERMES / "switchyard-deployment.yaml")[0] + template = deployment["spec"]["template"] + assert template["metadata"]["annotations"]["ai.bstein.dev/config-rev"].endswith( + "voice-route-preflight-v1" + ) + classifier = next( + item for item in template["spec"]["containers"] if item["name"] == "classifier-broker" + ) + env = {item["name"]: item["value"] for item in classifier["env"]} + assert env["HERMES_VOICE_PREFLIGHT_MODEL"] == "qwen2.5:14b-instruct-q4_0" + + service = _documents(HERMES / "switchyard-service.yaml")[0] + ports = {item["name"]: item["port"] for item in service["spec"]["ports"]} + assert ports["voice-preflight"] == 9009 + voice_port = next( + item for item in service["spec"]["ports"] if item["name"] == "voice-preflight" + ) + assert voice_port["targetPort"] == "voice-preflight" + assert service["metadata"]["annotations"]["prometheus.io/port"] == "9009" + + policies = { + item["metadata"]["name"]: item + for item in _documents(HERMES / "networkpolicy.yaml") + } + ingress = policies["hermes-switchyard-isolation"]["spec"]["ingress"] + preflight_rules = [ + rule + for rule in ingress + if any(port["port"] == 9009 for port in rule.get("ports", [])) + ] + assert len(preflight_rules) == 2 + assert preflight_rules[0]["from"][0]["podSelector"]["matchLabels"] == { + "app": "hermes-chat-tenant" + } + assert preflight_rules[1]["from"][0]["namespaceSelector"]["matchLabels"] == { + "kubernetes.io/metadata.name": "monitoring" + } + + chat = (HERMES / "chat-statefulset.yaml").read_text() + assert "HERMES_WEBUI_VOICE_PREFLIGHT_URL" not in chat diff --git a/testing/tests/test_hermes_voice_release.py b/testing/tests/test_hermes_voice_release.py index 6071fa4e..746b570b 100644 --- a/testing/tests/test_hermes_voice_release.py +++ b/testing/tests/test_hermes_voice_release.py @@ -13,6 +13,8 @@ def test_voice_pipeline_binds_component_source_digest_and_release() -> None: pipeline = PIPELINE.read_text(encoding="utf-8") assert "IMAGE_COMPONENT must be stt or tts" in pipeline assert "git merge-base --is-ancestor" in pipeline + assert 'git checkout --detach "${EXPECTED_SOURCE_REVISION}"' in pipeline + assert 'test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"' in pipeline assert "git status --porcelain" in pipeline assert "Dockerfile.hermes-jetson-${component}" in pipeline assert "--digest-file=" in pipeline @@ -33,6 +35,8 @@ def test_voice_pipeline_binds_component_source_digest_and_release() -> None: assert "--compressed-caching=false" in pipeline assert "--snapshot-mode=redo" in pipeline assert "privileged: true" not in pipeline + assert "testing/tests/test_hermes_stt_streaming.py" in pipeline + assert "testing/tests/test_hermes_tts_language_routing.py" in pipeline def test_jenkins_declares_one_token_guarded_voice_job() -> None: diff --git a/testing/tests/test_hermes_voice_route_preflight.py b/testing/tests/test_hermes_voice_route_preflight.py new file mode 100644 index 00000000..dc06468e --- /dev/null +++ b/testing/tests/test_hermes_voice_route_preflight.py @@ -0,0 +1,415 @@ +"""Release gates for bounded, advisory-only voice route preflight.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import threading +import time +from http.server import ThreadingHTTPServer +from types import SimpleNamespace +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +import pytest +from testing.tests.test_hermes_chat_support import ROOT + + +HERMES = ROOT / "services" / "hermes" + + +def _load_runtime(monkeypatch): + """Load the sibling runtime modules without requiring httpx in image CI.""" + monkeypatch.setitem(sys.modules, "httpx", SimpleNamespace()) + preflight_path = HERMES / "scripts" / "voice_route_preflight.py" + spec = importlib.util.spec_from_file_location("voice_route_preflight", preflight_path) + assert spec and spec.loader + preflight = importlib.util.module_from_spec(spec) + spec.loader.exec_module(preflight) + monkeypatch.setitem(sys.modules, "voice_route_preflight", preflight) + broker_path = HERMES / "scripts" / "classifier_broker.py" + spec = importlib.util.spec_from_file_location("classifier_broker", broker_path) + assert spec and spec.loader + broker = importlib.util.module_from_spec(spec) + spec.loader.exec_module(broker) + return preflight, broker + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"turn_id": "bad/id", "revision": 1, "transcript": "long enough text"}, + {"turn_id": "nonce-1", "revision": True, "transcript": "long enough text"}, + {"turn_id": "nonce-1", "revision": 0, "transcript": "long enough text"}, + {"turn_id": "nonce-1", "revision": 1, "transcript": None}, + {"turn_id": "nonce-1", "revision": 1, "transcript": "short"}, + {"turn_id": "nonce-1", "revision": 1, "transcript": "x" * 513}, + ], +) +def test_preflight_input_bounds_fail_closed(monkeypatch, payload): + preflight, _ = _load_runtime(monkeypatch) + with pytest.raises(ValueError): + preflight.validate_request(payload) + + +def test_preflight_is_same_14b_model_and_never_hosts_a_foreground_turn(monkeypatch): + preflight, _ = _load_runtime(monkeypatch) + payload = preflight.inference_payload("Please compare these two safe approaches") + + assert preflight.MODEL == "qwen2.5:14b-instruct-q4_0" + assert payload["model"] == preflight.MODEL + assert payload["stream"] is True + assert payload["max_tokens"] <= 16 + assert payload["temperature"] == 0 + assert "tools" not in payload + assert "provider" not in payload + assert preflight.TIMEOUT_SECONDS <= 0.75 + + +def test_preflight_is_once_per_random_browser_turn_and_authority_preempts(monkeypatch): + preflight, _ = _load_runtime(monkeypatch) + coordinator = preflight.Coordinator() + cancel = coordinator.begin("8f" * 16 + "-1-1") + assert cancel is not None + + class Resource: + closed = False + + def close(self): + self.closed = True + + client = Resource() + response = Resource() + assert coordinator.register(cancel, client=client, response=response) + started = time.monotonic() + coordinator.begin_authoritative() + assert time.monotonic() - started < 0.1 + assert cancel.is_set() + assert client.closed and response.closed + assert coordinator.begin("9e" * 16 + "-1-1") is None + coordinator.end_authoritative() + coordinator.end() + # A completed browser turn cannot spend another 750 ms on a later partial. + assert coordinator.begin("8f" * 16 + "-1-1") is None + + +def test_coordinator_prunes_seen_turns_and_tolerates_close_failures(monkeypatch): + preflight, _ = _load_runtime(monkeypatch) + coordinator = preflight.Coordinator() + now = time.monotonic() + coordinator.seen = {f"turn-{index}": now for index in range(300)} + coordinator._prune(now) + assert len(coordinator.seen) == preflight.MAX_SEEN_TURNS + + class BrokenResource: + def close(self): + raise RuntimeError("close failed") + + cancel = coordinator.begin("fresh-turn") + assert cancel is not None + assert coordinator.register(cancel, client=BrokenResource(), response=BrokenResource()) + coordinator.begin_authoritative() + assert cancel.is_set() + coordinator.end_authoritative() + coordinator.end_authoritative() + coordinator.end() + + unrelated = threading.Event() + assert coordinator.register(unrelated) is False + assert unrelated.is_set() + + idle = preflight.Coordinator() + idle.begin_authoritative() + idle.end_authoritative() + + +def _stream_with_fake_httpx( + monkeypatch, + preflight, + coordinator, + lines, + *, + fail_build=False, + close_raises=False, + preempt_on_send=False, +): + """Drive the streamed parser and cleanup branches without network I/O.""" + + class Response: + def raise_for_status(self): + return None + + def iter_lines(self): + yield from lines + + def close(self): + if close_raises: + raise RuntimeError("response close failed") + + response = Response() + + class Client: + def __init__(self, timeout): + self.timeout = timeout + + def build_request(self, *args, **kwargs): + if fail_build: + raise RuntimeError("build failed") + return (args, kwargs) + + def send(self, request, stream=False): + assert stream is True + if preempt_on_send: + coordinator.begin_authoritative() + return response + + def close(self): + if close_raises: + raise RuntimeError("client close failed") + + monkeypatch.setattr( + preflight, + "httpx", + SimpleNamespace(Timeout=lambda *args, **kwargs: (args, kwargs), Client=Client), + ) + cancel = coordinator.begin("stream-" + str(time.monotonic_ns())) + assert cancel is not None + result = coordinator.stream("This provisional request is stable", cancel) + if preempt_on_send: + coordinator.end_authoritative() + coordinator.end() + return result + + +def test_stream_parser_accepts_only_valid_bounded_json(monkeypatch): + preflight, _ = _load_runtime(monkeypatch) + coordinator = preflight.Coordinator() + content = json.dumps({"tier": "deep"}) + lines = [ + "ignored", + "data: not-json", + 'data: {"choices":[]}', + 'data: {"choices":[{"delta":{"content":7}}]}', + "data: " + + json.dumps({"choices": [{"delta": {"content": content}}]}), + "data: [DONE]", + ] + assert _stream_with_fake_httpx(monkeypatch, preflight, coordinator, lines) == "deep" + assert 'outcome="success"} 1' in coordinator.metrics() + + invalid = preflight.Coordinator() + lines = [ + "data: " + + json.dumps( + {"choices": [{"delta": {"content": json.dumps({"tier": "hosted"})}}]} + ) + ] + assert _stream_with_fake_httpx(monkeypatch, preflight, invalid, lines) == "" + assert 'outcome="failure"} 1' in invalid.metrics() + + +def test_stream_fails_closed_on_oversize_preemption_and_cleanup_errors(monkeypatch): + preflight, _ = _load_runtime(monkeypatch) + oversized = preflight.Coordinator() + line = "data: " + json.dumps({"choices": [{"delta": {"content": "x" * 129}}]}) + assert _stream_with_fake_httpx(monkeypatch, preflight, oversized, [line]) == "" + assert 'outcome="cancelled"} 1' in oversized.metrics() + + preempted = preflight.Coordinator() + assert ( + _stream_with_fake_httpx( + monkeypatch, preflight, preempted, [], preempt_on_send=True + ) + == "" + ) + assert 'outcome="cancelled"} 1' in preempted.metrics() + + failed = preflight.Coordinator() + assert ( + _stream_with_fake_httpx( + monkeypatch, + preflight, + failed, + [], + fail_build=True, + close_raises=True, + ) + == "" + ) + assert 'outcome="failure"} 1' in failed.metrics() + + +def test_stream_rejects_a_cancelled_reservation_before_network(monkeypatch): + preflight, _ = _load_runtime(monkeypatch) + coordinator = preflight.Coordinator() + cancel = coordinator.begin("cancel-before-network") + assert cancel is not None + cancel.set() + closed = {"value": False} + + class Client: + def __init__(self, timeout): + self.timeout = timeout + + def close(self): + closed["value"] = True + + monkeypatch.setattr( + preflight, + "httpx", + SimpleNamespace(Timeout=lambda *args, **kwargs: None, Client=Client), + ) + assert coordinator.stream("This request will be cancelled", cancel) == "" + assert closed["value"] is True + assert 'outcome="cancelled"} 1' in coordinator.metrics() + coordinator.end() + + +def test_absolute_deadline_closes_a_stalled_stream(monkeypatch): + preflight, _ = _load_runtime(monkeypatch) + monkeypatch.setattr(preflight, "TIMEOUT_SECONDS", 0.05) + closed = threading.Event() + + class Response: + def raise_for_status(self): + return None + + def iter_lines(self): + closed.wait(1) + if False: + yield "" + + def close(self): + closed.set() + + response = Response() + + class Client: + def __init__(self, timeout): + self.timeout = timeout + + def build_request(self, *args, **kwargs): + return (args, kwargs) + + def send(self, request, stream=False): + assert stream is True + return response + + def close(self): + response.close() + + monkeypatch.setattr( + preflight, + "httpx", + SimpleNamespace(Timeout=lambda *args, **kwargs: (args, kwargs), Client=Client), + ) + coordinator = preflight.Coordinator() + cancel = coordinator.begin("ab" * 16 + "-2-1") + assert cancel is not None + started = time.monotonic() + assert coordinator.stream("This provisional request is stable", cancel) == "" + elapsed = time.monotonic() - started + coordinator.end() + + assert elapsed < 0.25 + assert 'outcome="timeout"} 1' in coordinator.metrics() + + +def test_preflight_is_warm_only_and_final_payload_semantics_never_change(monkeypatch): + preflight, broker = _load_runtime(monkeypatch) + coordinator = preflight.Coordinator() + authoritative = { + "model": "qwen2.5:14b-instruct-q4_0", + "messages": [ + {"role": "system", "content": "normal routing contract"}, + {"role": "user", "content": "Please compare the two deployments"}, + ], + "response_format": {"type": "json_object"}, + } + before = broker.compact_payload(authoritative) + cancel = coordinator.begin("aa" * 16 + "-2-1") + assert cancel is not None + coordinator.end() + after = broker.compact_payload(authoritative) + + assert before == after + assert not hasattr(coordinator, "cache") + assert not hasattr(coordinator, "remember") + assert not hasattr(coordinator, "take_hint") + assert not hasattr(preflight, "add_advisory_hint") + + +def _post(server, payload): + request = Request( + f"http://127.0.0.1:{server.server_port}/voice/route-preflight", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urlopen(request, timeout=2) as response: + return response.status, response.headers, response.read() + + +def test_handler_returns_exact_sanitized_binding_and_204_when_busy(monkeypatch): + preflight, broker = _load_runtime(monkeypatch) + coordinator = preflight.Coordinator() + monkeypatch.setattr(broker, "VOICE_PREFLIGHT", coordinator) + monkeypatch.setattr(coordinator, "stream", lambda transcript, cancel: "balanced") + server = ThreadingHTTPServer(("127.0.0.1", 0), broker.VoiceHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + payload = { + "turn_id": "cd" * 16 + "-3-1", + "revision": 4, + "transcript": "Private words that must never leave this boundary", + } + try: + status, headers, raw = _post(server, payload) + result = json.loads(raw) + assert status == 200 + assert headers["Cache-Control"] == "no-store" + assert result == { + "turn_id": payload["turn_id"], + "revision": 4, + "tier": "balanced", + "target": "atlas/auto/balanced", + "advisory": True, + } + assert b"Private words" not in raw + status, headers, raw = _post(server, payload) + assert status == 204 + assert headers["Cache-Control"] == "no-store" + assert raw == b"" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def test_public_and_authoritative_listeners_have_disjoint_route_surfaces(monkeypatch): + _, broker = _load_runtime(monkeypatch) + + def post_status(handler, path): + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + request = Request( + f"http://127.0.0.1:{server.server_port}{path}", + data=b"{}", + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urlopen(request, timeout=2) as response: + return response.status + except HTTPError as error: + return error.code + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + assert post_status(broker.Handler, "/voice/route-preflight") == 404 + assert post_status(broker.VoiceHandler, "/v1/chat/completions") == 404 diff --git a/testing/tests/test_hermes_webui_brand.py b/testing/tests/test_hermes_webui_brand.py index ecbd52fb..2467ea0e 100644 --- a/testing/tests/test_hermes_webui_brand.py +++ b/testing/tests/test_hermes_webui_brand.py @@ -239,11 +239,16 @@ def test_server_smoke_resolves_served_manifest_icons_and_checks_root() -> None: """The image gate validates HTTP responses, not its own source files.""" module = _load(SMOKE, "hermes_webui_smoke_contract") manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + release_identity = "exp-v0.52.181-git-0123456789abcdef-build-13" root = ( b'' b'' b'' b'' + + ( + "" + ).encode() ) calls = [] @@ -259,6 +264,18 @@ def test_server_smoke_resolves_served_manifest_icons_and_checks_root() -> None: ) if path == "/": return _SmokeResponse(root, url) + if path == "/api/settings": + return _SmokeResponse( + json.dumps( + {"webui_bundle_version": release_identity} + ).encode(), + url, + ) + if path == "/sw.js": + return _SmokeResponse( + f"const CACHE_NAME='hermes-shell-{release_identity}';".encode(), + url, + ) if path in ( "/pwa/static/hermes-agent-192.png", "/pwa/static/hermes-agent-512.png", @@ -270,11 +287,51 @@ def test_server_smoke_resolves_served_manifest_icons_and_checks_root() -> None: result = module.smoke("http://hermes.test/", opener=open_fixture) assert result["manifest"] == "http://assets.hermes.test/pwa/manifest.json" assert result["icon_requests"] == 3 + assert result["release_identity"] == release_identity assert calls.count("http://assets.hermes.test/pwa/static/hermes-agent-192.png") == 2 assert "http://assets.hermes.test/pwa/static/hermes-agent-512.png" in calls assert calls.count("http://hermes.test/static/hermes-agent-192.png") == 1 +def test_server_smoke_rejects_a_client_server_release_mismatch() -> None: + """A build cannot publish the permanently stale banner regression.""" + module = _load(SMOKE, "hermes_webui_smoke_release_mismatch") + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + client = "exp-v0.52.181-git-0123456789abcdef-build-13" + root = ( + b'' + b'' + b'' + b'' + + ( + "" + ).encode() + ) + + def open_mismatch(request, *, timeout): + assert timeout == 5 + url = request.full_url + path = urlsplit(url).path + if path == "/manifest.json": + return _SmokeResponse(json.dumps(manifest).encode(), url) + if path == "/": + return _SmokeResponse(root, url) + if path == "/api/settings": + return _SmokeResponse( + json.dumps( + {"webui_bundle_version": client + "-different"} + ).encode(), + url, + ) + if path.endswith(".png"): + return _SmokeResponse(module.PNG_MAGIC + b"fixture", url) + raise AssertionError(f"unexpected smoke URL: {url}") + + with pytest.raises(RuntimeError, match="identities do not match"): + module.smoke("http://hermes.test/", opener=open_mismatch) + + def test_server_smoke_rejects_unbranded_served_manifest() -> None: """A successful health check cannot mask the upstream PWA identity.""" module = _load(SMOKE, "hermes_webui_smoke_unbranded") @@ -330,7 +387,7 @@ def test_empty_state_uses_canonical_character_without_inline_staff( def test_release_patch_gives_every_shell_url_an_immutable_build_token( tmp_path: Path, ) -> None: - """Atlas image releases never share a browser cache key.""" + """Atlas releases share one server/client identity and browser cache key.""" target = _patched_fixture(tmp_path) env = os.environ.copy() env["HERMES_WEBUI_PATCH_ROOT"] = str(target) @@ -345,11 +402,27 @@ def test_release_patch_gives_every_shell_url_an_immutable_build_token( ) index = (target / "static/index.html").read_text(encoding="utf-8") worker = (target / "static/sw.js").read_text(encoding="utf-8") + routes = (target / "api/routes.py").read_text(encoding="utf-8") + panels = (target / "static/panels.js").read_text(encoding="utf-8") token = "__WEBUI_VERSION__-git-0123456789abcdef-build-11" assert token in index assert token in worker assert not re.search(r"__WEBUI_VERSION__(?!-git-0123456789abcdef-build-11)", index) assert not re.search(r"__WEBUI_VERSION__(?!-git-0123456789abcdef-build-11)", worker) + assert ( + 'settings["webui_bundle_version"] = ' + 'f"{WEBUI_VERSION}-git-0123456789abcdef-build-11"' + ) in routes + assert ( + "settings.webui_bundle_version||settings.webui_version" + ) in panels + assert "if(client===server) return;" not in panels + assert "if(banner) banner.style.display='none';" in panels + assert "if(_isBannerVisible()) return;" not in panels + assert ( + "if(_isBannerVisible()){ clearInterval(_pollTimer);" + not in panels + ) @pytest.mark.parametrize("release_id", ["", "../escape", "UPPERCASE", "a" * 129]) @@ -406,6 +479,11 @@ def test_dockerfile_copies_and_verifies_every_tracked_brand_asset() -> None: assert "python /tmp/hermes-webui-manifest-patch.py" in dockerfile assert "COPY dockerfiles/hermes-webui-release-patch.py" in dockerfile assert "python /tmp/hermes-webui-release-patch.py" in dockerfile + assert 'settings["webui_bundle_version"]' in dockerfile + assert ( + "settings.webui_bundle_version||settings.webui_version" + in dockerfile + ) assert ( "COPY dockerfiles/hermes-webui-manifest.json /tmp/hermes-webui-manifest.json" in dockerfile diff --git a/testing/tests/test_hermes_webui_release.py b/testing/tests/test_hermes_webui_release.py index 3cfe7a71..d4f0b425 100644 --- a/testing/tests/test_hermes_webui_release.py +++ b/testing/tests/test_hermes_webui_release.py @@ -91,13 +91,15 @@ def test_webui_job_is_independent_bounded_and_main_only() -> None: assert "stringParam('CONFIRM_PUBLISH', ''" in block -def test_pipeline_builds_latest_main_containing_reviewed_anchor() -> None: - """Publish is explicit, evidence-bound, and handed only to Flux.""" +def test_pipeline_builds_exact_reviewed_anchor_from_main() -> None: + """Publish uses one reviewed source revision even when main later advances.""" source = PIPELINE.read_text(encoding="utf-8") assert 'test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES WEBUI"' in source - assert 'test "${actual_revision}" = "$(git rev-parse origin/main)"' in source + assert 'test "${main_revision}" = "$(git rev-parse origin/main)"' in source assert "git fetch --no-tags origin main" not in source assert 'git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}"' in source + assert 'git checkout --detach "${EXPECTED_SOURCE_REVISION}"' in source + assert 'test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"' in source assert "dockerfiles/Dockerfile.hermes-webui" in source assert "ci/scripts/hermes_webui_release.py" in source assert "registry.bstein.dev/bstein/hermes-webui" in source @@ -111,6 +113,15 @@ def test_pipeline_builds_latest_main_containing_reviewed_anchor() -> None: assert "ci/scripts/hermes_oci_promote.py" in source assert "test_hermes_webui_brand.py" in source assert "test_hermes_webui_release.py" in source + for voice_suite in ( + "test_hermes_chat_quality.py", + "test_hermes_handsfree_stt.py", + "test_hermes_voice_full_duplex.py", + "test_hermes_thinking_voice_cues.py", + "test_hermes_voice_instrument.py", + "test_hermes_voice_language_routing.py", + ): + assert voice_suite in source for forbidden in ("kubectl ", "flux reconcile", "git push", "git commit"): assert forbidden not in source