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.
This commit is contained in:
parent
11bd04cce5
commit
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 "'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 '"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-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.
|
||||
RUN set -eu; \
|
||||
|
||||
@ -62,6 +62,34 @@ def _clean_transcript(result: dict) -> str:
|
||||
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:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
handler.send_response(status)
|
||||
@ -148,8 +176,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
||||
no_speech_threshold=0.5,
|
||||
verbose=False,
|
||||
)
|
||||
transcript = _clean_transcript(result)
|
||||
_json(self, 200, {"text": transcript, "model": MODEL_NAME})
|
||||
_json(self, 200, _transcription_payload(result))
|
||||
except Exception as exc:
|
||||
print(f"[stt] transcription failed: {exc}", flush=True)
|
||||
_json(self, 500, {"error": "transcription failed"})
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI."""
|
||||
|
||||
import os
|
||||
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:
|
||||
@ -45,7 +49,87 @@ replace_exact(
|
||||
"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"
|
||||
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"
|
||||
atlas = ''' # ── Atlas private Jetson TTS ─────────────────────────────────────────
|
||||
if engine == "atlas":
|
||||
@ -65,6 +149,7 @@ atlas = ''' # ── Atlas private Jetson TTS ──────────
|
||||
"input": text,
|
||||
"voice": "en_US-lessac-high",
|
||||
"speed": speed,
|
||||
"language": _atlas_tts_language(data),
|
||||
}).encode("utf-8")
|
||||
request = Request(atlas_url, data=request_body, headers={
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@ -19,9 +19,37 @@
|
||||
let vadTimer=null;
|
||||
let currentAudio=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 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){
|
||||
if(typeof window.showToast==='function') window.showToast(message,3000);
|
||||
}
|
||||
@ -54,6 +82,7 @@
|
||||
active=false;
|
||||
state='idle';
|
||||
thinkingSession=null;
|
||||
clearSttLanguage();
|
||||
stopCapture();
|
||||
stopPlayback();
|
||||
modeBtn.classList.remove('active');
|
||||
@ -67,13 +96,14 @@
|
||||
},delay||500);
|
||||
}
|
||||
|
||||
function sendTranscript(transcript, token){
|
||||
function sendTranscript(transcript, token, language){
|
||||
if(!active||token!==generation) return;
|
||||
const text=String(transcript||'').trim();
|
||||
if(!text){restartSoon(token,350);return;}
|
||||
if(!text){clearSttLanguage();restartSoon(token,350);return;}
|
||||
composer.value=text;
|
||||
if(typeof window.autoResize==='function') window.autoResize();
|
||||
thinkingSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
|
||||
rememberSttLanguage(language,token);
|
||||
setState('thinking');
|
||||
if(typeof window.send==='function') window.send();
|
||||
}
|
||||
@ -88,7 +118,7 @@
|
||||
const response=await fetch('/api/transcribe',{method:'POST',body:form});
|
||||
const payload=await response.json().catch(function(){return {};});
|
||||
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){
|
||||
if(!active||token!==generation) return;
|
||||
deactivate(false);
|
||||
@ -105,6 +135,7 @@
|
||||
async function startListening(token){
|
||||
if(!active||token!==generation) return;
|
||||
stopCapture();
|
||||
clearSttLanguage();
|
||||
setState('listening');
|
||||
try{
|
||||
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',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({text:chunk,engine:'atlas'}),
|
||||
body:JSON.stringify(request),
|
||||
});
|
||||
if(!response.ok){
|
||||
const payload=await response.json().catch(function(){return {};});
|
||||
@ -232,10 +267,12 @@
|
||||
const currentSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
|
||||
if(thinkingSession&¤tSession&&thinkingSession!==currentSession){
|
||||
thinkingSession=null;
|
||||
clearSttLanguage();
|
||||
restartSoon(token,250);
|
||||
return;
|
||||
}
|
||||
thinkingSession=null;
|
||||
const language=takeSttLanguage(token);
|
||||
const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
|
||||
if(!rows.length){restartSoon(token,250);return;}
|
||||
const text=cleanForSpeech(rows[rows.length-1].dataset.rawText||'');
|
||||
@ -243,11 +280,11 @@
|
||||
setState('speaking');
|
||||
const chunks=typeof window._splitForTTS==='function'?window._splitForTTS(text,280):[text];
|
||||
try{
|
||||
let pending=fetchSpeech(chunks[0]);
|
||||
let pending=fetchSpeech(chunks[0],language);
|
||||
for(let index=0;index<chunks.length;index+=1){
|
||||
if(!active||token!==generation) return;
|
||||
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);
|
||||
}
|
||||
}catch(error){
|
||||
@ -260,6 +297,7 @@
|
||||
generation+=1;
|
||||
const token=generation;
|
||||
active=true;
|
||||
clearSttLanguage();
|
||||
modeBtn.classList.add('active');
|
||||
toast('Hands-free private voice mode on');
|
||||
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 $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
|
||||
|
||||
- Hermes does not currently apply production or cluster changes autonomously.
|
||||
|
||||
@ -12,6 +12,34 @@ 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"
|
||||
@ -67,9 +95,12 @@ def main() -> None:
|
||||
with urlopen(request, timeout=120) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
transcript = str(result.get("text") or "").strip()
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output = args.output_dir / f"{args.input_path.stem}.txt"
|
||||
output.write_text(transcript, encoding="utf-8")
|
||||
_write_result(
|
||||
args.output_dir,
|
||||
args.input_path.stem,
|
||||
transcript,
|
||||
_normalize_language(result.get("language")),
|
||||
)
|
||||
|
||||
|
||||
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 | ||||