Port the original #27 detected-language pipeline onto the verified PR #39 prerequisite while preserving the current-main conversation instrument and host continuity changes. Keep voice selection server-side with no user selector or client voice field. Reuse 207c16ab only for its stricter exact-code trust boundary, omitting malformed or absent language so Piper defaults to Amy.
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()
|