atlas-iac/services/hermes/scripts/hermes_stt_client.py
Hermes Agent 876b3dbee3 feat(hermes-voice): pick the Piper voice from the private Whisper language
Hands-free voice mode had no language signal at all, so every spoken reply was
synthesized with the English voice no matter what the user actually said. The
multilingual Piper work (PR #26) added server-side routing for a "language"
field but nothing ever sent one.

Carry the language the private Jetson Whisper service already detects through
to the TTS request for the reply that speech produced, and only for that reply.

  hermes-stt returns {text, model, language}, accepted only as a bare ISO-639
  token; hermes_stt_client.py writes a <stem>.language sidecar next to the .txt
  transcript Hermes reads, leaving the local-command contract intact; the
  patched local-command envelope and /api/transcribe re-validate it and surface
  it; atlas-voice.js binds it to the voice-mode generation token and chat
  session, consumes it exactly once, and clears it on cancellation, restart,
  session change, empty transcript or transcription error; /api/tts honours it
  only from the fixed en/ru/es allow-list and otherwise sends English.

A client "voice" field is never read at any hop, and typed messages, the manual
read-aloud button, and any reply not produced by a spoken turn carry no trusted
signal and stay on the English voice.

The two WebUI-side and one agent-side edits are fail-closed replace_exact
patches; both patch roots are now env-overridable so the contract can be
verified offline without a GPU or an image build.
2026-08-20 20:08:17 +00:00

108 lines
3.4 KiB
Python

#!/usr/bin/env python3
"""Send one Hermes local-command STT request to the private Whisper service."""
from __future__ import annotations
import argparse
import json
import mimetypes
import os
import secrets
from pathlib import Path
from urllib.request import Request, urlopen
LANGUAGE_SUFFIX = ".language"
def _normalize_language(value: object) -> str:
"""Accept only a bare ISO-639 code from the private Whisper response."""
if not isinstance(value, str):
return ""
code = value.strip().lower()
if not 2 <= len(code) <= 3 or not code.isascii() or not code.isalpha():
return ""
return code
def _write_result(output_dir: Path, stem: str, transcript: str, language: str) -> Path:
"""Write the .txt Hermes reads, plus the language sidecar when we have one.
Hermes' local-command contract is "leave a .txt in --output-dir"; it globs
``*.txt`` and reads the first match. The sidecar deliberately uses another
suffix so the transcript stays the only ``.txt`` in the directory.
"""
output_dir.mkdir(parents=True, exist_ok=True)
transcript_path = output_dir / f"{stem}.txt"
transcript_path.write_text(transcript, encoding="utf-8")
if language:
(output_dir / f"{stem}{LANGUAGE_SUFFIX}").write_text(language, encoding="utf-8")
return transcript_path
def _multipart(audio: Path, language: str, model: str) -> tuple[bytes, str]:
boundary = f"atlas-hermes-{secrets.token_hex(12)}"
mime = mimetypes.guess_type(audio.name)[0] or "application/octet-stream"
chunks: list[bytes] = []
def field(name: str, value: str) -> None:
chunks.extend(
[
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
value.encode(),
b"\r\n",
]
)
field("language", language)
field("model", model)
chunks.extend(
[
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="file"; filename="{audio.name}"\r\n'.encode(),
f"Content-Type: {mime}\r\n\r\n".encode(),
audio.read_bytes(),
b"\r\n",
f"--{boundary}--\r\n".encode(),
]
)
return b"".join(chunks), boundary
def main() -> None:
"""Transcribe one file and emit the .txt contract Hermes expects."""
parser = argparse.ArgumentParser()
parser.add_argument("input_path", type=Path)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--language", default="auto")
parser.add_argument("--model", default="small")
args = parser.parse_args()
body, boundary = _multipart(args.input_path, args.language, args.model)
request = Request(
os.getenv(
"HERMES_STT_URL",
"http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions",
),
data=body,
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Accept": "application/json",
},
method="POST",
)
with urlopen(request, timeout=120) as response:
result = json.loads(response.read().decode("utf-8"))
transcript = str(result.get("text") or "").strip()
_write_result(
args.output_dir,
args.input_path.stem,
transcript,
_normalize_language(result.get("language")),
)
if __name__ == "__main__":
main()