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.
108 lines
3.4 KiB
Python
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()
|