Compare commits
1 Commits
main
...
hermes/voi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe45d23eae |
@ -113,9 +113,16 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
|
|||||||
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
|
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
|
||||||
&& grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \
|
&& grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \
|
||||||
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
|
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
|
||||||
|
&& grep -Fq '"language": detected_language' /opt/hermes/tools/transcription_tools.py \
|
||||||
|
&& grep -Fq "'language': detected" /opt/hermes-webui/api/upload.py \
|
||||||
|
&& grep -Fq 'def _atlas_tts_language(body):' /opt/hermes-webui/api/routes.py \
|
||||||
|
&& grep -Fq '"language": _atlas_tts_language(data)' /opt/hermes-webui/api/routes.py \
|
||||||
|
&& grep -Fq 'takeSttLanguage(token)' /opt/hermes-webui/static/atlas-voice.js \
|
||||||
&& /opt/hermes/.venv/bin/python -m py_compile \
|
&& /opt/hermes/.venv/bin/python -m py_compile \
|
||||||
/opt/hermes-webui/api/routes.py \
|
/opt/hermes-webui/api/routes.py \
|
||||||
/opt/hermes-webui/api/gateway_chat.py
|
/opt/hermes-webui/api/upload.py \
|
||||||
|
/opt/hermes-webui/api/gateway_chat.py \
|
||||||
|
/opt/hermes/tools/transcription_tools.py
|
||||||
|
|
||||||
# Exercise the real server process in the target architecture before publish.
|
# Exercise the real server process in the target architecture before publish.
|
||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
|
|||||||
@ -62,6 +62,34 @@ def _clean_transcript(result: dict) -> str:
|
|||||||
return " ".join(kept).strip()
|
return " ".join(kept).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _detected_language(result: object) -> str:
|
||||||
|
"""Return the bare ISO-639 code Whisper decoded with, or nothing at all.
|
||||||
|
|
||||||
|
``whisper.transcribe`` reports the language it auto-detected (or the one it
|
||||||
|
was told to use) as a plain lowercase token such as ``en``/``ru``/``yue``.
|
||||||
|
Anything that is not that exact shape is dropped rather than guessed at, so
|
||||||
|
a surprising model result can never become a downstream voice selector.
|
||||||
|
"""
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return ""
|
||||||
|
value = result.get("language")
|
||||||
|
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 _transcription_payload(result: dict) -> dict:
|
||||||
|
"""Build the transcription contract: text plus the model's own language."""
|
||||||
|
return {
|
||||||
|
"text": _clean_transcript(result),
|
||||||
|
"model": MODEL_NAME,
|
||||||
|
"language": _detected_language(result),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
|
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
handler.send_response(status)
|
handler.send_response(status)
|
||||||
@ -148,8 +176,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
|||||||
no_speech_threshold=0.5,
|
no_speech_threshold=0.5,
|
||||||
verbose=False,
|
verbose=False,
|
||||||
)
|
)
|
||||||
transcript = _clean_transcript(result)
|
_json(self, 200, _transcription_payload(result))
|
||||||
_json(self, 200, {"text": transcript, "model": MODEL_NAME})
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[stt] transcription failed: {exc}", flush=True)
|
print(f"[stt] transcription failed: {exc}", flush=True)
|
||||||
_json(self, 500, {"error": "transcription failed"})
|
_json(self, 500, {"error": "transcription failed"})
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI."""
|
"""Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI."""
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path("/opt/hermes-webui")
|
ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
|
||||||
|
# The WebUI imports the pinned agent's STT tooling from the same image, so the
|
||||||
|
# local-command transcription envelope is patched alongside the WebUI itself.
|
||||||
|
AGENT_ROOT = Path(os.environ.get("HERMES_AGENT_PATCH_ROOT", "/opt/hermes"))
|
||||||
|
|
||||||
|
|
||||||
def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
|
def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
|
||||||
@ -45,7 +49,87 @@ replace_exact(
|
|||||||
"if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, null, engine);",
|
"if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, null, engine);",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The private Whisper service reports the language it decoded with. Carry that
|
||||||
|
# through the agent's local-command STT envelope so the WebUI can hand a voice
|
||||||
|
# hint to Piper instead of guessing the reply's language from its text.
|
||||||
|
transcription = AGENT_ROOT / "tools/transcription_tools.py"
|
||||||
|
replace_exact(
|
||||||
|
transcription,
|
||||||
|
''' transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
|
||||||
|
logger.info(
|
||||||
|
"Transcribed %s via local STT command (%s, %d chars)",
|
||||||
|
Path(file_path).name,
|
||||||
|
normalized_model,
|
||||||
|
len(transcript_text),
|
||||||
|
)
|
||||||
|
return {"success": True, "transcript": transcript_text, "provider": "local_command"}
|
||||||
|
''',
|
||||||
|
''' transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
|
||||||
|
logger.info(
|
||||||
|
"Transcribed %s via local STT command (%s, %d chars)",
|
||||||
|
Path(file_path).name,
|
||||||
|
normalized_model,
|
||||||
|
len(transcript_text),
|
||||||
|
)
|
||||||
|
detected_language = ""
|
||||||
|
language_files = sorted(Path(output_dir).glob("*.language"))
|
||||||
|
if language_files:
|
||||||
|
try:
|
||||||
|
candidate = language_files[0].read_text(encoding="utf-8").strip().lower()
|
||||||
|
except OSError:
|
||||||
|
candidate = ""
|
||||||
|
if 2 <= len(candidate) <= 3 and candidate.isascii() and candidate.isalpha():
|
||||||
|
detected_language = candidate
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"transcript": transcript_text,
|
||||||
|
"provider": "local_command",
|
||||||
|
"language": detected_language,
|
||||||
|
}
|
||||||
|
''',
|
||||||
|
)
|
||||||
|
|
||||||
|
upload = ROOT / "api/upload.py"
|
||||||
|
replace_exact(
|
||||||
|
upload,
|
||||||
|
""" transcript = str(result.get('transcript') or '').strip()
|
||||||
|
return j(handler, {'ok': True, 'transcript': transcript})
|
||||||
|
""",
|
||||||
|
""" transcript = str(result.get('transcript') or '').strip()
|
||||||
|
detected = str(result.get('language') or '').strip().lower()
|
||||||
|
if not (2 <= len(detected) <= 3 and detected.isascii() and detected.isalpha()):
|
||||||
|
detected = ''
|
||||||
|
return j(handler, {'ok': True, 'transcript': transcript, 'language': detected})
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
routes = ROOT / "api/routes.py"
|
routes = ROOT / "api/routes.py"
|
||||||
|
replace_exact(
|
||||||
|
routes,
|
||||||
|
"def _tts_open(req, *, timeout=30, opener_factory=None):",
|
||||||
|
'''ATLAS_TTS_LANGUAGES = ("en", "ru", "es")
|
||||||
|
|
||||||
|
|
||||||
|
def _atlas_tts_language(body):
|
||||||
|
"""Resolve the private TTS language from a request body, English by default.
|
||||||
|
|
||||||
|
Only a language the private Piper deployment actually bakes a voice for is
|
||||||
|
honoured. Everything else — a missing field, a hostile string, a wrong type,
|
||||||
|
or a client-supplied "voice" — resolves to English, so nothing a browser
|
||||||
|
sends can steer synthesis outside the fixed policy. The Jetson service
|
||||||
|
applies the same allow-list again as the final authority.
|
||||||
|
"""
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return "en"
|
||||||
|
value = body.get("language")
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return "en"
|
||||||
|
code = value.strip().lower().replace("_", "-").split("-", 1)[0]
|
||||||
|
return code if code in ATLAS_TTS_LANGUAGES else "en"
|
||||||
|
|
||||||
|
|
||||||
|
def _tts_open(req, *, timeout=30, opener_factory=None):''',
|
||||||
|
)
|
||||||
marker = " # ── ElevenLabs TTS ──────────────────────────────────────────────────\n"
|
marker = " # ── ElevenLabs TTS ──────────────────────────────────────────────────\n"
|
||||||
atlas = ''' # ── Atlas private Jetson TTS ─────────────────────────────────────────
|
atlas = ''' # ── Atlas private Jetson TTS ─────────────────────────────────────────
|
||||||
if engine == "atlas":
|
if engine == "atlas":
|
||||||
@ -65,6 +149,7 @@ atlas = ''' # ── Atlas private Jetson TTS ──────────
|
|||||||
"input": text,
|
"input": text,
|
||||||
"voice": "en_US-lessac-high",
|
"voice": "en_US-lessac-high",
|
||||||
"speed": speed,
|
"speed": speed,
|
||||||
|
"language": _atlas_tts_language(data),
|
||||||
}).encode("utf-8")
|
}).encode("utf-8")
|
||||||
request = Request(atlas_url, data=request_body, headers={
|
request = Request(atlas_url, data=request_body, headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@ -19,9 +19,37 @@
|
|||||||
let vadTimer=null;
|
let vadTimer=null;
|
||||||
let currentAudio=null;
|
let currentAudio=null;
|
||||||
let thinkingSession=null;
|
let thinkingSession=null;
|
||||||
|
// The only language signal this file trusts is the one the private Whisper
|
||||||
|
// service returned for the audio of the turn currently being answered. It is
|
||||||
|
// bound to that turn's generation token and consumed exactly once.
|
||||||
|
let sttLanguage='';
|
||||||
|
let sttLanguageToken=-1;
|
||||||
|
const TTS_LANGUAGES=['en','ru','es'];
|
||||||
const originalAutoRead=window.autoReadLastAssistant;
|
const originalAutoRead=window.autoReadLastAssistant;
|
||||||
const originalApplyPreference=window._applyVoiceModePref;
|
const originalApplyPreference=window._applyVoiceModePref;
|
||||||
|
|
||||||
|
function normalizeSttLanguage(value){
|
||||||
|
if(typeof value!=='string') return '';
|
||||||
|
const code=value.trim().toLowerCase();
|
||||||
|
return TTS_LANGUAGES.indexOf(code)>=0?code:'';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSttLanguage(){
|
||||||
|
sttLanguage='';
|
||||||
|
sttLanguageToken=-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberSttLanguage(language, token){
|
||||||
|
sttLanguage=language||'';
|
||||||
|
sttLanguageToken=sttLanguage?token:-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeSttLanguage(token){
|
||||||
|
const language=sttLanguageToken===token?sttLanguage:'';
|
||||||
|
clearSttLanguage();
|
||||||
|
return language;
|
||||||
|
}
|
||||||
|
|
||||||
function toast(message){
|
function toast(message){
|
||||||
if(typeof window.showToast==='function') window.showToast(message,3000);
|
if(typeof window.showToast==='function') window.showToast(message,3000);
|
||||||
}
|
}
|
||||||
@ -54,6 +82,7 @@
|
|||||||
active=false;
|
active=false;
|
||||||
state='idle';
|
state='idle';
|
||||||
thinkingSession=null;
|
thinkingSession=null;
|
||||||
|
clearSttLanguage();
|
||||||
stopCapture();
|
stopCapture();
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
modeBtn.classList.remove('active');
|
modeBtn.classList.remove('active');
|
||||||
@ -67,13 +96,14 @@
|
|||||||
},delay||500);
|
},delay||500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendTranscript(transcript, token){
|
function sendTranscript(transcript, token, language){
|
||||||
if(!active||token!==generation) return;
|
if(!active||token!==generation) return;
|
||||||
const text=String(transcript||'').trim();
|
const text=String(transcript||'').trim();
|
||||||
if(!text){restartSoon(token,350);return;}
|
if(!text){clearSttLanguage();restartSoon(token,350);return;}
|
||||||
composer.value=text;
|
composer.value=text;
|
||||||
if(typeof window.autoResize==='function') window.autoResize();
|
if(typeof window.autoResize==='function') window.autoResize();
|
||||||
thinkingSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
|
thinkingSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
|
||||||
|
rememberSttLanguage(language,token);
|
||||||
setState('thinking');
|
setState('thinking');
|
||||||
if(typeof window.send==='function') window.send();
|
if(typeof window.send==='function') window.send();
|
||||||
}
|
}
|
||||||
@ -88,7 +118,7 @@
|
|||||||
const response=await fetch('/api/transcribe',{method:'POST',body:form});
|
const response=await fetch('/api/transcribe',{method:'POST',body:form});
|
||||||
const payload=await response.json().catch(function(){return {};});
|
const payload=await response.json().catch(function(){return {};});
|
||||||
if(!response.ok) throw new Error(payload.error||('Whisper request failed: '+response.status));
|
if(!response.ok) throw new Error(payload.error||('Whisper request failed: '+response.status));
|
||||||
sendTranscript(payload.transcript,token);
|
sendTranscript(payload.transcript,token,normalizeSttLanguage(payload.language));
|
||||||
}catch(error){
|
}catch(error){
|
||||||
if(!active||token!==generation) return;
|
if(!active||token!==generation) return;
|
||||||
deactivate(false);
|
deactivate(false);
|
||||||
@ -105,6 +135,7 @@
|
|||||||
async function startListening(token){
|
async function startListening(token){
|
||||||
if(!active||token!==generation) return;
|
if(!active||token!==generation) return;
|
||||||
stopCapture();
|
stopCapture();
|
||||||
|
clearSttLanguage();
|
||||||
setState('listening');
|
setState('listening');
|
||||||
try{
|
try{
|
||||||
const capture=await navigator.mediaDevices.getUserMedia({
|
const capture=await navigator.mediaDevices.getUserMedia({
|
||||||
@ -214,11 +245,15 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSpeech(chunk){
|
async function fetchSpeech(chunk, language){
|
||||||
|
// `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 request={text:chunk,engine:'atlas'};
|
||||||
|
if(language) request.language=language;
|
||||||
const response=await fetch('/api/tts',{
|
const response=await fetch('/api/tts',{
|
||||||
method:'POST',
|
method:'POST',
|
||||||
headers:{'Content-Type':'application/json'},
|
headers:{'Content-Type':'application/json'},
|
||||||
body:JSON.stringify({text:chunk,engine:'atlas'}),
|
body:JSON.stringify(request),
|
||||||
});
|
});
|
||||||
if(!response.ok){
|
if(!response.ok){
|
||||||
const payload=await response.json().catch(function(){return {};});
|
const payload=await response.json().catch(function(){return {};});
|
||||||
@ -232,10 +267,12 @@
|
|||||||
const currentSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
|
const currentSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
|
||||||
if(thinkingSession&¤tSession&&thinkingSession!==currentSession){
|
if(thinkingSession&¤tSession&&thinkingSession!==currentSession){
|
||||||
thinkingSession=null;
|
thinkingSession=null;
|
||||||
|
clearSttLanguage();
|
||||||
restartSoon(token,250);
|
restartSoon(token,250);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
thinkingSession=null;
|
thinkingSession=null;
|
||||||
|
const language=takeSttLanguage(token);
|
||||||
const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
|
const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
|
||||||
if(!rows.length){restartSoon(token,250);return;}
|
if(!rows.length){restartSoon(token,250);return;}
|
||||||
const text=cleanForSpeech(rows[rows.length-1].dataset.rawText||'');
|
const text=cleanForSpeech(rows[rows.length-1].dataset.rawText||'');
|
||||||
@ -243,11 +280,11 @@
|
|||||||
setState('speaking');
|
setState('speaking');
|
||||||
const chunks=typeof window._splitForTTS==='function'?window._splitForTTS(text,280):[text];
|
const chunks=typeof window._splitForTTS==='function'?window._splitForTTS(text,280):[text];
|
||||||
try{
|
try{
|
||||||
let pending=fetchSpeech(chunks[0]);
|
let pending=fetchSpeech(chunks[0],language);
|
||||||
for(let index=0;index<chunks.length;index+=1){
|
for(let index=0;index<chunks.length;index+=1){
|
||||||
if(!active||token!==generation) return;
|
if(!active||token!==generation) return;
|
||||||
const blob=await pending;
|
const blob=await pending;
|
||||||
if(index+1<chunks.length) pending=fetchSpeech(chunks[index+1]);
|
if(index+1<chunks.length) pending=fetchSpeech(chunks[index+1],language);
|
||||||
await playBlob(blob,token);
|
await playBlob(blob,token);
|
||||||
}
|
}
|
||||||
}catch(error){
|
}catch(error){
|
||||||
@ -260,6 +297,7 @@
|
|||||||
generation+=1;
|
generation+=1;
|
||||||
const token=generation;
|
const token=generation;
|
||||||
active=true;
|
active=true;
|
||||||
|
clearSttLanguage();
|
||||||
modeBtn.classList.add('active');
|
modeBtn.classList.add('active');
|
||||||
toast('Hands-free private voice mode on');
|
toast('Hands-free private voice mode on');
|
||||||
if(typeof window.stopTTS==='function') window.stopTTS();
|
if(typeof window.stopTTS==='function') window.stopTTS();
|
||||||
|
|||||||
@ -347,6 +347,40 @@ Use this short explanation:
|
|||||||
- `Use $tune-atlas-alerts. Trace one currently firing alert to its generated source and raw PromQL, but do not edit it.`
|
- `Use $tune-atlas-alerts. Trace one currently firing alert to its generated source and raw PromQL, but do not edit it.`
|
||||||
- `Use $master-hermes-on-atlas. Assess me on the request path and permission boundary. One question at a time.`
|
- `Use $master-hermes-on-atlas. Assess me on the request path and permission boundary. One question at a time.`
|
||||||
|
|
||||||
|
## Private voice: choosing the Piper voice from the STT-detected language
|
||||||
|
|
||||||
|
Hands-free voice mode in `chat.hermes.bstein.dev` selects the private Piper
|
||||||
|
voice from the language the private Jetson Whisper service reports for the
|
||||||
|
user's own speech. The signal travels one way only, and every hop narrows it:
|
||||||
|
|
||||||
|
1. `hermes-stt` returns `{text, model, language}`. `language` is whatever
|
||||||
|
Whisper decoded with, accepted only as a bare ISO-639 token (`en`, `ru`,
|
||||||
|
`es`, `yue`, …); anything else is reported as empty.
|
||||||
|
2. `hermes_stt_client.py` writes the usual `<stem>.txt` transcript plus a
|
||||||
|
`<stem>.language` sidecar. The transcript stays the only `.txt` in the
|
||||||
|
output directory, so the stock Hermes local-command contract is unchanged.
|
||||||
|
3. The patched local-command STT envelope reads that sidecar and adds
|
||||||
|
`language` to its result; `/api/transcribe` re-validates it and returns it
|
||||||
|
next to `transcript`.
|
||||||
|
4. `atlas-voice.js` keeps that value only for the turn it belongs to. It is
|
||||||
|
bound to the voice-mode generation token and the chat session id, consumed
|
||||||
|
exactly once by the reply that turn produced, and cleared on cancellation,
|
||||||
|
restart, session change, an empty transcript, or a transcription error.
|
||||||
|
5. `/api/tts` accepts `language` only from the fixed allow-list and otherwise
|
||||||
|
sends English. The Jetson TTS service applies the same allow-list again as
|
||||||
|
the final authority.
|
||||||
|
|
||||||
|
**What this does not claim.** The language is the language the *user spoke*,
|
||||||
|
not the language of the reply. A model asked a Russian question may answer in
|
||||||
|
English and will then be read aloud by the Russian voice, and vice versa; this
|
||||||
|
is a deliberate policy choice for hands-free mode, not a detection failure.
|
||||||
|
Nothing here detects the language of assistant text.
|
||||||
|
|
||||||
|
**Everything else stays English.** Typed messages, the manual read-aloud
|
||||||
|
button, and any assistant reply that was not produced by a hands-free spoken
|
||||||
|
turn carry no trusted STT signal, so they synthesize with `en_US-amy-medium`.
|
||||||
|
A `voice` field from a browser is never honoured at any hop.
|
||||||
|
|
||||||
## Honest limits
|
## Honest limits
|
||||||
|
|
||||||
- Hermes does not currently apply production or cluster changes autonomously.
|
- Hermes does not currently apply production or cluster changes autonomously.
|
||||||
|
|||||||
@ -12,6 +12,34 @@ from pathlib import Path
|
|||||||
from urllib.request import Request, urlopen
|
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]:
|
def _multipart(audio: Path, language: str, model: str) -> tuple[bytes, str]:
|
||||||
boundary = f"atlas-hermes-{secrets.token_hex(12)}"
|
boundary = f"atlas-hermes-{secrets.token_hex(12)}"
|
||||||
mime = mimetypes.guess_type(audio.name)[0] or "application/octet-stream"
|
mime = mimetypes.guess_type(audio.name)[0] or "application/octet-stream"
|
||||||
@ -67,9 +95,12 @@ def main() -> None:
|
|||||||
with urlopen(request, timeout=120) as response:
|
with urlopen(request, timeout=120) as response:
|
||||||
result = json.loads(response.read().decode("utf-8"))
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
transcript = str(result.get("text") or "").strip()
|
transcript = str(result.get("text") or "").strip()
|
||||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
_write_result(
|
||||||
output = args.output_dir / f"{args.input_path.stem}.txt"
|
args.output_dir,
|
||||||
output.write_text(transcript, encoding="utf-8")
|
args.input_path.stem,
|
||||||
|
transcript,
|
||||||
|
_normalize_language(result.get("language")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
452
testing/tests/data/atlas_voice_language_probe.js
Normal file
452
testing/tests/data/atlas_voice_language_probe.js
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
// Deterministic browser stub that drives dockerfiles/hermes-webui-atlas-voice.js
|
||||||
|
// through complete hands-free turns with no microphone, audio device or GPU.
|
||||||
|
//
|
||||||
|
// The script under test is an IIFE with no exported seams, so the only honest
|
||||||
|
// way to assert what reaches POST /api/tts is to run it against a fake DOM and
|
||||||
|
// fake clock and record the requests it actually makes. Usage:
|
||||||
|
//
|
||||||
|
// node atlas_voice_language_probe.js <path-to-atlas-voice.js>
|
||||||
|
//
|
||||||
|
// It prints one JSON object describing every scenario to stdout.
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const SCRIPT_PATH = process.argv[2];
|
||||||
|
if (!SCRIPT_PATH) {
|
||||||
|
throw new Error('usage: atlas_voice_language_probe.js <atlas-voice.js>');
|
||||||
|
}
|
||||||
|
const SOURCE = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
// Four macrotask hops drain the promise chains the script builds around
|
||||||
|
// fetch()/json()/blob()/play() without ever waiting on wall-clock time.
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let hops = 0;
|
||||||
|
(function hop() {
|
||||||
|
hops += 1;
|
||||||
|
if (hops > 12) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImmediate(hop);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeElement(id) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
value: '',
|
||||||
|
textContent: '',
|
||||||
|
className: '',
|
||||||
|
classList: {
|
||||||
|
entries: new Set(),
|
||||||
|
add(name) { this.entries.add(name); },
|
||||||
|
remove(name) { this.entries.delete(name); },
|
||||||
|
contains(name) { return this.entries.has(name); },
|
||||||
|
},
|
||||||
|
listeners: [],
|
||||||
|
addEventListener(type, handler) { this.listeners.push({ type, handler }); },
|
||||||
|
removeEventListener(type, handler) {
|
||||||
|
this.listeners = this.listeners.filter((entry) => entry.handler !== handler);
|
||||||
|
},
|
||||||
|
click() {
|
||||||
|
const event = { preventDefault() {}, stopImmediatePropagation() {} };
|
||||||
|
this.listeners
|
||||||
|
.filter((entry) => entry.type === 'click')
|
||||||
|
.forEach((entry) => entry.handler(event));
|
||||||
|
},
|
||||||
|
querySelector() { return null; },
|
||||||
|
insertBefore() {},
|
||||||
|
appendChild() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHarness() {
|
||||||
|
const clock = { now: 1000000 };
|
||||||
|
const timeouts = [];
|
||||||
|
const intervals = new Map();
|
||||||
|
let timerId = 1;
|
||||||
|
|
||||||
|
const ttsRequests = [];
|
||||||
|
const transcribeCalls = [];
|
||||||
|
const toasts = [];
|
||||||
|
const sends = [];
|
||||||
|
|
||||||
|
let capability = { ok: true, available: true, provider: 'local_command' };
|
||||||
|
let transcribeResponse = { ok: true, transcript: 'hello', language: 'en' };
|
||||||
|
let transcribeStatus = 200;
|
||||||
|
let assistantRows = [];
|
||||||
|
let loud = false;
|
||||||
|
let recorder = null;
|
||||||
|
|
||||||
|
const storage = new Map();
|
||||||
|
const elements = {};
|
||||||
|
['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg']
|
||||||
|
.forEach((id) => { elements[id] = makeElement(id); });
|
||||||
|
|
||||||
|
function MediaRecorder() {
|
||||||
|
this.state = 'recording';
|
||||||
|
this.ondataavailable = null;
|
||||||
|
this.onstop = null;
|
||||||
|
recorder = this;
|
||||||
|
}
|
||||||
|
MediaRecorder.prototype.start = function start() { this.state = 'recording'; };
|
||||||
|
MediaRecorder.prototype.stop = function stop() {
|
||||||
|
if (this.state === 'inactive') return;
|
||||||
|
this.state = 'inactive';
|
||||||
|
if (this.onstop) this.onstop();
|
||||||
|
};
|
||||||
|
MediaRecorder.isTypeSupported = function isTypeSupported() { return true; };
|
||||||
|
|
||||||
|
function AudioContext() {
|
||||||
|
this.createAnalyser = () => ({
|
||||||
|
fftSize: 2048,
|
||||||
|
getByteTimeDomainData(samples) {
|
||||||
|
for (let i = 0; i < samples.length; i += 1) {
|
||||||
|
samples[i] = loud ? (i % 2 ? 200 : 56) : 128;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.createBiquadFilter = () => ({
|
||||||
|
type: '', frequency: { value: 0 }, Q: { value: 0 }, connect() {},
|
||||||
|
});
|
||||||
|
this.createMediaStreamSource = () => ({ connect() {} });
|
||||||
|
this.close = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function AudioElement() {
|
||||||
|
this.currentTime = 0;
|
||||||
|
this.onended = null;
|
||||||
|
this.onerror = null;
|
||||||
|
this.pause = () => {};
|
||||||
|
this.play = () => {
|
||||||
|
setImmediate(() => { if (this.onended) this.onended(); });
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStub(url, init) {
|
||||||
|
if (url === '/api/transcribe/capability') {
|
||||||
|
return { ok: true, status: 200, json: async () => capability };
|
||||||
|
}
|
||||||
|
if (url === '/api/transcribe') {
|
||||||
|
transcribeCalls.push({ body: init && init.body });
|
||||||
|
return {
|
||||||
|
ok: transcribeStatus < 400,
|
||||||
|
status: transcribeStatus,
|
||||||
|
json: async () => transcribeResponse,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (url === '/api/tts') {
|
||||||
|
ttsRequests.push(JSON.parse(init.body));
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
blob: async () => ({ synthetic: true }),
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
console,
|
||||||
|
Uint8Array,
|
||||||
|
Promise,
|
||||||
|
Math,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
Error,
|
||||||
|
parseInt,
|
||||||
|
isNaN,
|
||||||
|
Set,
|
||||||
|
Map,
|
||||||
|
Array,
|
||||||
|
Object,
|
||||||
|
Date: { now: () => clock.now },
|
||||||
|
Blob: function Blob(parts, options) { this.parts = parts; this.type = (options || {}).type || ''; },
|
||||||
|
File: function File(parts, name, options) {
|
||||||
|
this.parts = parts; this.name = name; this.type = (options || {}).type || '';
|
||||||
|
},
|
||||||
|
FormData: function FormData() { this.entries = []; this.append = (k, v) => this.entries.push([k, v]); },
|
||||||
|
URL: { createObjectURL: () => 'blob:atlas-test', revokeObjectURL() {} },
|
||||||
|
Audio: AudioElement,
|
||||||
|
MediaRecorder,
|
||||||
|
AudioContext,
|
||||||
|
fetch: fetchStub,
|
||||||
|
localStorage: {
|
||||||
|
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
|
||||||
|
setItem: (key, value) => { storage.set(key, String(value)); },
|
||||||
|
removeItem: (key) => { storage.delete(key); },
|
||||||
|
},
|
||||||
|
navigator: {
|
||||||
|
mediaDevices: {
|
||||||
|
getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }),
|
||||||
|
getSupportedConstraints: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
getElementById: (id) => elements[id] || null,
|
||||||
|
querySelectorAll: () => assistantRows,
|
||||||
|
createElement: () => ({ value: '', textContent: '' }),
|
||||||
|
},
|
||||||
|
S: { session: { session_id: 'session-1' }, busy: false },
|
||||||
|
setTimeout: (fn, delay) => {
|
||||||
|
const id = timerId; timerId += 1;
|
||||||
|
timeouts.push({ id, fn, at: clock.now + (delay || 0) });
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearTimeout: (id) => {
|
||||||
|
const index = timeouts.findIndex((entry) => entry.id === id);
|
||||||
|
if (index >= 0) timeouts.splice(index, 1);
|
||||||
|
},
|
||||||
|
setInterval: (fn, delay) => {
|
||||||
|
const id = timerId; timerId += 1;
|
||||||
|
intervals.set(id, { fn, delay: delay || 0 });
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearInterval: (id) => { intervals.delete(id); },
|
||||||
|
};
|
||||||
|
context.window = context;
|
||||||
|
context.showToast = (message) => { toasts.push(message); };
|
||||||
|
context.send = () => { sends.push(elements.msg.value); };
|
||||||
|
context.autoResize = () => {};
|
||||||
|
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(SOURCE, context, { filename: 'atlas-voice.js' });
|
||||||
|
|
||||||
|
function runDueTimeouts() {
|
||||||
|
const due = timeouts.filter((entry) => entry.at <= clock.now);
|
||||||
|
due.forEach((entry) => {
|
||||||
|
const index = timeouts.indexOf(entry);
|
||||||
|
if (index >= 0) timeouts.splice(index, 1);
|
||||||
|
entry.fn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(ms) {
|
||||||
|
clock.now += ms;
|
||||||
|
Array.from(intervals.values()).forEach((entry) => entry.fn());
|
||||||
|
runDueTimeouts();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
elements,
|
||||||
|
ttsRequests,
|
||||||
|
transcribeCalls,
|
||||||
|
toasts,
|
||||||
|
sends,
|
||||||
|
clock,
|
||||||
|
recorder: () => recorder,
|
||||||
|
setLoud: (value) => { loud = value; },
|
||||||
|
setCapability: (value) => { capability = value; },
|
||||||
|
setTranscribeResponse: (value, status) => {
|
||||||
|
transcribeResponse = value;
|
||||||
|
transcribeStatus = status === undefined ? 200 : status;
|
||||||
|
},
|
||||||
|
setAssistantReply: (text) => { assistantRows = [{ dataset: { rawText: text } }]; },
|
||||||
|
setSession: (id) => { context.S.session = { session_id: id }; },
|
||||||
|
advance: (ms) => { clock.now += ms; },
|
||||||
|
tick,
|
||||||
|
runDueTimeouts,
|
||||||
|
flush,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk one capture window: pre-roll audio, three loud frames so the VAD latches
|
||||||
|
// speech, then silence past the hangover so MediaRecorder.stop() fires.
|
||||||
|
async function captureSpeech(harness) {
|
||||||
|
const active = harness.recorder();
|
||||||
|
if (!active) throw new Error('voice mode never created a recorder');
|
||||||
|
active.ondataavailable({ data: { size: 512 } });
|
||||||
|
harness.setLoud(true);
|
||||||
|
for (let i = 0; i < 4; i += 1) harness.tick(100);
|
||||||
|
active.ondataavailable({ data: { size: 512 } });
|
||||||
|
harness.setLoud(false);
|
||||||
|
harness.advance(2500);
|
||||||
|
harness.tick(100);
|
||||||
|
await harness.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startVoiceMode(harness) {
|
||||||
|
await harness.flush();
|
||||||
|
harness.elements.btnVoiceMode.click();
|
||||||
|
await harness.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// One complete turn: speak, transcribe, let the app "answer", read it back.
|
||||||
|
async function runTurn(harness, { transcript, language, reply }) {
|
||||||
|
const payload = { ok: true, transcript };
|
||||||
|
if (language !== undefined) payload.language = language;
|
||||||
|
harness.setTranscribeResponse(payload);
|
||||||
|
await captureSpeech(harness);
|
||||||
|
harness.setAssistantReply(reply || 'An answer.');
|
||||||
|
harness.context.autoReadLastAssistant();
|
||||||
|
await harness.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartListening(harness) {
|
||||||
|
harness.advance(1000);
|
||||||
|
harness.runDueTimeouts();
|
||||||
|
await harness.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenarios = {};
|
||||||
|
|
||||||
|
scenarios.english_turn_speaks_english = async () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
await startVoiceMode(harness);
|
||||||
|
await runTurn(harness, { transcript: 'What is the weather?', language: 'en' });
|
||||||
|
return { tts: harness.ttsRequests };
|
||||||
|
};
|
||||||
|
|
||||||
|
scenarios.russian_turn_speaks_russian = async () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
await startVoiceMode(harness);
|
||||||
|
await runTurn(harness, { transcript: 'Как дела?', language: 'ru', reply: 'Всё хорошо.' });
|
||||||
|
return { tts: harness.ttsRequests };
|
||||||
|
};
|
||||||
|
|
||||||
|
scenarios.spanish_turn_speaks_spanish = async () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
await startVoiceMode(harness);
|
||||||
|
await runTurn(harness, { transcript: '¿Qué tal?', language: 'es', reply: 'Muy bien.' });
|
||||||
|
return { tts: harness.ttsRequests };
|
||||||
|
};
|
||||||
|
|
||||||
|
scenarios.missing_language_falls_back = async () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
await startVoiceMode(harness);
|
||||||
|
await runTurn(harness, { transcript: 'Hello there.' });
|
||||||
|
return { tts: harness.ttsRequests };
|
||||||
|
};
|
||||||
|
|
||||||
|
scenarios.unsupported_language_falls_back = async () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
await startVoiceMode(harness);
|
||||||
|
await runTurn(harness, { transcript: 'Bonjour tout le monde.', language: 'fr' });
|
||||||
|
return { tts: harness.ttsRequests };
|
||||||
|
};
|
||||||
|
|
||||||
|
scenarios.hostile_language_values_are_dropped = async () => {
|
||||||
|
const results = [];
|
||||||
|
const hostile = [
|
||||||
|
'ru; rm -rf /',
|
||||||
|
'../../ru_RU-irina-medium',
|
||||||
|
'ru | ||||||