diff --git a/dockerfiles/Dockerfile.hermes-webui b/dockerfiles/Dockerfile.hermes-webui index 52803505..29e69ad3 100644 --- a/dockerfiles/Dockerfile.hermes-webui +++ b/dockerfiles/Dockerfile.hermes-webui @@ -92,6 +92,7 @@ PY COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voice.js +COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py @@ -109,6 +110,8 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \ && grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \ && grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \ && grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \ + && grep -Fq 'prefers-reduced-motion: reduce' /opt/hermes-webui/static/atlas-voice.css \ + && grep -Fq 'id="voiceInstrumentStyles"' /opt/hermes-webui/static/index.html \ && grep -Fq 'data-priority="maximum"' /opt/hermes-webui/static/index.html \ && 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 \ diff --git a/dockerfiles/hermes-webui-atlas-patch.py b/dockerfiles/hermes-webui-atlas-patch.py index 20d04399..d7bccf63 100644 --- a/dockerfiles/hermes-webui-atlas-patch.py +++ b/dockerfiles/hermes-webui-atlas-patch.py @@ -1,10 +1,11 @@ #!/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")) def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None: @@ -16,6 +17,13 @@ def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None: index = ROOT / "static/index.html" +replace_exact( + index, + '', + '\n' + '', +) replace_exact( index, '', @@ -26,6 +34,32 @@ replace_exact( '', '\n', ) +replace_exact( + index, + ''' ''', + ''' ''', +) ui = ROOT / "static/ui.js" replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){") diff --git a/dockerfiles/hermes-webui-atlas-voice.css b/dockerfiles/hermes-webui-atlas-voice.css new file mode 100644 index 00000000..910fbfad --- /dev/null +++ b/dockerfiles/hermes-webui-atlas-voice.css @@ -0,0 +1,336 @@ +/* Private hands-free conversation instrument for the Atlas voice bridge. */ +.voice-mode-bar { + --voice-accent: 72, 207, 204; + --voice-accent-secondary: 76, 164, 205; + position: relative; + isolation: isolate; + box-sizing: border-box; + min-height: 106px; + padding: 9px 12px 10px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + overflow: hidden; + border-bottom: 1px solid rgba(174, 192, 218, 0.1); + background: + radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.075), transparent 46%), + linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(4, 8, 18, 0.035)); +} + +.voice-mode-bar::before { + content: ""; + position: absolute; + z-index: -1; + top: 0; + left: 18%; + right: 18%; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(233, 244, 255, 0.16), transparent); +} + +:root:not(.dark) .voice-mode-bar { + border-bottom-color: rgba(38, 55, 78, 0.1); + background: + radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.09), transparent 48%), + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(226, 232, 240, 0.12)); +} + +.voice-mode-indicator { + --voice-ripple-scale: 1.035; + --voice-ripple-opacity: 0.3; + position: relative; + width: 70px; + height: 70px; + flex: 0 0 70px; + border-radius: 50%; + transform: translateZ(0); +} + +.voice-mode-indicator.listening { + --voice-accent: 69, 218, 207; + --voice-accent-secondary: 73, 169, 209; +} + +.voice-mode-indicator.transcribing { + --voice-accent: 73, 193, 218; + --voice-accent-secondary: 93, 129, 224; +} + +.voice-mode-indicator.thinking { + --voice-accent: 135, 111, 244; + --voice-accent-secondary: 81, 62, 190; +} + +.voice-mode-indicator.speaking { + --voice-accent: 249, 169, 105; + --voice-accent-secondary: 236, 112, 137; +} + +.voice-mode-indicator.error { + --voice-accent: 235, 135, 88; + --voice-accent-secondary: 198, 74, 78; +} + +.voice-instrument-halo, +.voice-instrument-ripple, +.voice-instrument-orbit, +.voice-instrument-core { + position: absolute; + display: block; + box-sizing: border-box; + border-radius: 50%; + pointer-events: none; +} + +.voice-instrument-halo { + inset: 2px; + opacity: 0; + background: radial-gradient(circle, rgba(var(--voice-accent), 0.2), rgba(var(--voice-accent), 0.05) 43%, transparent 70%); + filter: blur(4px); +} + +.voice-instrument-ripple { + inset: 3px; + opacity: 0; + border: 1px solid rgba(var(--voice-accent), 0.56); + box-shadow: 0 0 12px rgba(var(--voice-accent), 0.13); +} + +.voice-instrument-orbit { + inset: 2px; + opacity: 0; + border: 1px solid rgba(var(--voice-accent), 0.18); + box-shadow: + inset 0 0 8px rgba(var(--voice-accent), 0.06), + 0 0 11px rgba(var(--voice-accent), 0.07); +} + +.voice-instrument-orbit::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + border-top: 1.5px solid rgba(var(--voice-accent), 0.88); + border-right: 1px solid rgba(var(--voice-accent-secondary), 0.35); + border-bottom: 1px solid transparent; + border-left: 1px solid transparent; +} + +.voice-instrument-orbit::after { + content: ""; + position: absolute; + top: -2px; + left: 50%; + width: 5px; + height: 5px; + margin-left: -2.5px; + border-radius: 50%; + background: rgb(var(--voice-accent)); + box-shadow: + 0 0 5px rgba(var(--voice-accent), 0.92), + 0 0 12px rgba(var(--voice-accent), 0.44); +} + +.voice-instrument-core { + inset: 12px; + display: grid; + place-items: center; + color: rgba(242, 250, 255, 0.94); + border: 1px solid rgba(238, 247, 255, 0.2); + background: + radial-gradient(circle at 38% 29%, rgba(255, 255, 255, 0.26), transparent 24%), + radial-gradient(circle at 50% 66%, rgba(var(--voice-accent), 0.36), transparent 64%), + linear-gradient(145deg, rgba(36, 45, 61, 0.96), rgba(10, 15, 27, 0.98)); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.22), + inset 0 -9px 18px rgba(0, 0, 0, 0.24), + 0 4px 13px rgba(0, 0, 0, 0.25), + 0 0 17px rgba(var(--voice-accent), 0.17); +} + +:root:not(.dark) .voice-instrument-core { + color: rgba(31, 48, 66, 0.9); + border-color: rgba(255, 255, 255, 0.82); + background: + radial-gradient(circle at 38% 29%, rgba(255, 255, 255, 0.96), transparent 27%), + radial-gradient(circle at 50% 66%, rgba(var(--voice-accent), 0.2), transparent 65%), + linear-gradient(145deg, rgba(248, 250, 251, 0.98), rgba(216, 225, 231, 0.96)); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.94), + inset 0 -8px 17px rgba(64, 84, 100, 0.1), + 0 4px 12px rgba(35, 53, 72, 0.15), + 0 0 15px rgba(var(--voice-accent), 0.13); +} + +.voice-instrument-symbol { + display: block; + width: 22px; + height: 22px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.32)); +} + +.voice-instrument-symbol svg { + display: block; + width: 100%; + height: 100%; +} + +.voice-symbol { + display: none; +} + +.voice-mode-indicator.listening .voice-symbol-listening, +.voice-mode-indicator.transcribing .voice-symbol-transcribing, +.voice-mode-indicator.thinking .voice-symbol-thinking, +.voice-mode-indicator.speaking .voice-symbol-speaking, +.voice-mode-indicator.error .voice-symbol-error { + display: inline; +} + +.voice-mode-indicator.listening .voice-instrument-halo { + opacity: 0.62; + animation: voice-instrument-breathe 3.8s ease-in-out infinite; +} + +.voice-mode-indicator.listening .voice-instrument-ripple { + opacity: var(--voice-ripple-opacity); + transform: scale(var(--voice-ripple-scale)); + transition: transform 140ms ease-out, opacity 160ms ease-out; +} + +.voice-mode-indicator.transcribing .voice-instrument-orbit { + opacity: 0.92; + animation: voice-instrument-orbit 3.4s cubic-bezier(0.58, 0.12, 0.42, 0.88) infinite; +} + +.voice-mode-indicator.thinking .voice-instrument-orbit { + opacity: 0.96; + animation: voice-instrument-orbit 4.6s cubic-bezier(0.58, 0.12, 0.42, 0.88) infinite; +} + +.voice-mode-indicator.thinking .voice-instrument-core { + animation: voice-instrument-core-breathe 5.7s ease-in-out infinite; +} + +.voice-mode-indicator.speaking .voice-instrument-orbit { + opacity: 0.86; + transform: rotate(38deg); +} + +.voice-mode-indicator.speaking.is-playing .voice-instrument-halo { + opacity: 0.66; + animation: voice-instrument-speaking-pulse 2.15s ease-out infinite; +} + +.voice-mode-indicator.error .voice-instrument-orbit { + opacity: 0.9; + transform: rotate(-32deg); + border-color: rgba(var(--voice-accent), 0.44); +} + +.voice-mode-indicator.error .voice-instrument-core { + color: rgba(255, 232, 219, 0.95); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.2), + inset 0 -9px 18px rgba(0, 0, 0, 0.24), + 0 4px 13px rgba(0, 0, 0, 0.25), + 0 0 12px rgba(var(--voice-accent), 0.17); +} + +.voice-mode-label { + position: relative; + z-index: 1; + min-height: 16px; + max-width: min(100%, 34rem); + overflow: hidden; + color: var(--text, #f4f6fb); + font-size: 12px; + font-weight: 650; + line-height: 1.35; + letter-spacing: 0.018em; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.28); +} + +:root:not(.dark) .voice-mode-label { + color: #263648; + text-shadow: 0 1px 1px rgba(255, 255, 255, 0.8); +} + +@keyframes voice-instrument-breathe { + 0%, 100% { opacity: 0.38; transform: scale(0.94); } + 50% { opacity: 0.72; transform: scale(1.06); } +} + +@keyframes voice-instrument-orbit { + 0% { transform: rotate(0deg); } + 50% { transform: rotate(177deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes voice-instrument-core-breathe { + 0%, 100% { filter: saturate(0.96) brightness(0.98); } + 50% { filter: saturate(1.08) brightness(1.06); } +} + +@keyframes voice-instrument-speaking-pulse { + 0% { opacity: 0.58; transform: scale(0.88); } + 58% { opacity: 0.22; transform: scale(1.12); } + 100% { opacity: 0; transform: scale(1.2); } +} + +@media (max-width: 640px) { + .voice-mode-bar { + min-height: 94px; + padding: 7px 10px 8px; + gap: 4px; + } + + .voice-mode-indicator { + width: 62px; + height: 62px; + flex-basis: 62px; + } + + .voice-instrument-core { + inset: 11px; + } + + .voice-instrument-symbol { + width: 20px; + height: 20px; + } +} + +@media (prefers-reduced-motion: reduce) { + .voice-mode-bar *, + .voice-mode-bar *::before, + .voice-mode-bar *::after { + animation: none !important; + transition: none !important; + } + + .voice-mode-indicator.listening .voice-instrument-halo { + opacity: 0.45; + transform: none; + } + + .voice-mode-indicator.listening .voice-instrument-ripple { + opacity: 0.34; + transform: scale(1.035); + } + + .voice-mode-indicator.transcribing .voice-instrument-orbit, + .voice-mode-indicator.thinking .voice-instrument-orbit { + transform: rotate(24deg); + } + + .voice-mode-indicator.speaking.is-playing .voice-instrument-halo { + opacity: 0.42; + transform: scale(1.03); + } +} diff --git a/dockerfiles/hermes-webui-atlas-voice.js b/dockerfiles/hermes-webui-atlas-voice.js index e83c35b7..707e3457 100644 --- a/dockerfiles/hermes-webui-atlas-voice.js +++ b/dockerfiles/hermes-webui-atlas-voice.js @@ -19,6 +19,18 @@ let vadTimer=null; let currentAudio=null; let thinkingSession=null; + let errorTimer=null; + let visualInputLevel=0; + const reducedMotion=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):{matches:false}; + const ERROR_VISIBLE_MS=3200; + const STATE_LABELS={ + listening:'Listening', + transcribing:'Transcribing…', + thinking:'Thinking…', + speaking:'Speaking', + error:'Voice unavailable', + idle:'', + }; const originalAutoRead=window.autoReadLastAssistant; const originalApplyPreference=window._applyVoiceModePref; @@ -29,8 +41,48 @@ function setState(next, customLabel){ state=next; indicator.className='voice-mode-indicator '+next; - label.textContent=customLabel||(next==='listening'?'Listening…':next==='speaking'?'Speaking…':next==='thinking'?'Thinking…':''); - bar.style.display=active&&next!=='idle'?'':'none'; + bar.dataset.voiceState=next; + bar.setAttribute('aria-busy',next==='transcribing'||next==='thinking'?'true':'false'); + label.textContent=customLabel||STATE_LABELS[next]||''; + bar.style.display=(active&&next!=='idle')||next==='error'?'':'none'; + resetInputLevel(); + } + + function resetInputLevel(){ + visualInputLevel=0; + indicator.style.setProperty('--voice-ripple-scale','1.035'); + indicator.style.setProperty('--voice-ripple-opacity','0.3'); + } + + function updateInputLevel(rms){ + if(reducedMotion.matches||state!=='listening') return; + const target=Math.max(0,Math.min(1,(rms-0.01)/0.18)); + visualInputLevel=(visualInputLevel*0.72)+(target*0.28); + indicator.style.setProperty('--voice-ripple-scale',(1.035+(visualInputLevel*0.16)).toFixed(3)); + indicator.style.setProperty('--voice-ripple-opacity',(0.26+(visualInputLevel*0.48)).toFixed(3)); + } + + function clearErrorTimer(){ + if(errorTimer){window.clearTimeout(errorTimer);errorTimer=null;} + } + + function errorMessage(error, fallback){ + return String((error&&error.message)||fallback||'Voice unavailable').trim(); + } + + function showUnavailable(message){ + generation+=1; + active=false; + thinkingSession=null; + stopCapture(); + stopPlayback(); + modeBtn.classList.remove('active'); + setState('error',message); + clearErrorTimer(); + errorTimer=window.setTimeout(function(){ + errorTimer=null; + if(!active&&state==='error'&&indicator.classList.contains('error')) setState('idle'); + },ERROR_VISIBLE_MS); } function stopCapture(){ @@ -47,17 +99,18 @@ if(!currentAudio) return; try{currentAudio.pause();currentAudio.currentTime=0;}catch(_){ } currentAudio=null; + indicator.classList.remove('is-playing'); } function deactivate(showMessage){ generation+=1; active=false; - state='idle'; thinkingSession=null; + clearErrorTimer(); stopCapture(); stopPlayback(); modeBtn.classList.remove('active'); - bar.style.display='none'; + setState('idle'); if(showMessage) toast('Hands-free voice mode off'); } @@ -80,7 +133,7 @@ async function transcribe(blob, token){ if(!active||token!==generation) return; - setState('thinking','Transcribing…'); + setState('transcribing'); const ext=(blob.type||'').indexOf('ogg')>=0?'ogg':'webm'; const form=new FormData(); form.append('file',new File([blob],'voice-input.'+ext,{type:blob.type||'audio/'+ext})); @@ -91,8 +144,9 @@ sendTranscript(payload.transcript,token); }catch(error){ if(!active||token!==generation) return; - deactivate(false); - toast((error&&error.message)||'Private Whisper is unavailable'); + const message=errorMessage(error,'Private Whisper is unavailable'); + showUnavailable(message); + toast(message); // If the browser supplies its own recognizer, hand control back to the // upstream voice implementation until the Jetson becomes healthy again. if(window.SpeechRecognition||window.webkitSpeechRecognition){ @@ -166,6 +220,7 @@ energy+=normalized*normalized; } const rms=Math.sqrt(energy/samples.length); + updateInputLevel(rms); const now=Date.now(); const speechThreshold=Math.max(0.04,noiseFloor*2.4+0.006); const voiceNow=rms>speechThreshold; @@ -188,8 +243,9 @@ },100); }catch(error){ if(!active||token!==generation) return; - deactivate(false); - toast((error&&error.message)||'Microphone permission is required'); + const message=errorMessage(error,'Microphone permission is required'); + showUnavailable(message); + toast(message); } } @@ -206,11 +262,14 @@ currentAudio=audio; function cleanup(){ if(currentAudio===audio) currentAudio=null; + indicator.classList.remove('is-playing'); URL.revokeObjectURL(url); } audio.onended=function(){cleanup();resolve();}; audio.onerror=function(){cleanup();reject(new Error('Local speech playback failed'));}; - audio.play().catch(function(error){cleanup();reject(error);}); + audio.play().then(function(){ + if(active&&token===generation&¤tAudio===audio) indicator.classList.add('is-playing'); + }).catch(function(error){cleanup();reject(error);}); }); } @@ -251,7 +310,11 @@ await playBlob(blob,token); } }catch(error){ - if(active&&token===generation) toast((error&&error.message)||'Local speech is unavailable'); + if(active&&token===generation){ + const message=errorMessage(error,'Local speech is unavailable'); + setState('error',message); + toast(message); + } } restartSoon(token,450); } @@ -260,6 +323,7 @@ generation+=1; const token=generation; active=true; + clearErrorTimer(); modeBtn.classList.add('active'); toast('Hands-free private voice mode on'); if(typeof window.stopTTS==='function') window.stopTTS(); diff --git a/testing/fixtures/hermes-webui-0.52.181/SOURCE.md b/testing/fixtures/hermes-webui-0.52.181/SOURCE.md new file mode 100644 index 00000000..12e0e76b --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/SOURCE.md @@ -0,0 +1,12 @@ +# Hermes WebUI fixture provenance + +These minimal files contain the exact patch-context fragments extracted from the +Hermes WebUI image pinned by `dockerfiles/Dockerfile.hermes-webui`: + +- Image: `ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2` +- Version: `0.52.181` +- OCI source revision: `7a94e34a6d639576576baa9131acf6765f6d2b98` +- Full upstream `static/index.html` SHA-256: `6e218d42f6e047168a774c59aa9fc98a55b608cdc070cf1049ad414a597a722c` + +The fixture stays intentionally narrow, but tests execute the shipped +`hermes-webui-atlas-patch.py` against it; they do not duplicate its patch logic. diff --git a/testing/fixtures/hermes-webui-0.52.181/api/routes.py b/testing/fixtures/hermes-webui-0.52.181/api/routes.py new file mode 100644 index 00000000..de5cf520 --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/api/routes.py @@ -0,0 +1,3 @@ +def tts(handler, engine): + # ── ElevenLabs TTS ────────────────────────────────────────────────── + return False diff --git a/testing/fixtures/hermes-webui-0.52.181/static/index.html b/testing/fixtures/hermes-webui-0.52.181/static/index.html new file mode 100644 index 00000000..6eb7f63d --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/static/index.html @@ -0,0 +1,18 @@ + + + + + + + +
+ + + +
+ + + diff --git a/testing/fixtures/hermes-webui-0.52.181/static/ui.js b/testing/fixtures/hermes-webui-0.52.181/static/ui.js new file mode 100644 index 00000000..e6546193 --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/static/ui.js @@ -0,0 +1,13 @@ +function _playEdgeTtsChunked(text, btn){ + return fetch('/api/tts',{body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})}); +} +function speakSelected(clean, btn, engine){ + if(engine==='edge'){ + _playEdgeTtsChunked(clean, btn); + } +} +function speakAutomatically(clean, engine){ + if(engine==='edge'){ + _playEdgeTtsChunked(clean, null); + } +} diff --git a/testing/probes/hermes_voice_instrument_probe.js b/testing/probes/hermes_voice_instrument_probe.js new file mode 100644 index 00000000..06b705ff --- /dev/null +++ b/testing/probes/hermes_voice_instrument_probe.js @@ -0,0 +1,356 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +class StyleDeclaration { + constructor() { + this.values = new Map(); + this.display = ''; + } + setProperty(name, value) { this.values.set(name, String(value)); } + removeProperty(name) { this.values.delete(name); } + getPropertyValue(name) { return this.values.get(name) || ''; } +} + +class Element { + constructor(id) { + this.id = id; + this.className = ''; + this.textContent = ''; + this.value = ''; + this.dataset = {}; + this.style = new StyleDeclaration(); + this.attributes = new Map(); + this.listeners = new Map(); + this.children = []; + this.firstChild = null; + } + get classList() { + const element = this; + return { + add(name) { + const names = new Set(element.className.split(/\s+/).filter(Boolean)); + names.add(name); + element.className = [...names].join(' '); + }, + remove(name) { + element.className = element.className.split(/\s+/).filter(value => value && value !== name).join(' '); + }, + contains(name) { return element.className.split(/\s+/).includes(name); }, + }; + } + addEventListener(type, callback) { this.listeners.set(type, callback); } + removeEventListener(type, callback) { + if (this.listeners.get(type) === callback) this.listeners.delete(type); + } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + getAttribute(name) { return this.attributes.get(name) || null; } + insertBefore(child) { + this.children.unshift(child); + this.firstChild = this.children[0]; + } + querySelector(selector) { + if (selector === 'option[value="atlas"]') { + return this.children.find(child => child.value === 'atlas') || null; + } + return null; + } + click() { + const callback = this.listeners.get('click'); + if (callback) callback({preventDefault() {}, stopImmediatePropagation() {}}); + } +} + +function flush() { + return new Promise(resolve => setImmediate(resolve)); +} + +async function boot(scriptPath, reduced) { + const elements = new Map(); + for (const id of ['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg', 'settingsTtsEngine', 'voiceInstrumentStyles']) { + elements.set(id, new Element(id)); + } + const modeButton = elements.get('btnVoiceMode'); + const bar = elements.get('voiceModeBar'); + const indicator = elements.get('voiceModeIndicator'); + const label = elements.get('voiceModeLabel'); + const styleLink = elements.get('voiceInstrumentStyles'); + styleLink.setAttribute('href', 'static/atlas-voice.css?v=test'); + bar.style.display = 'none'; + + let now = 1000; + let nextTimer = 1; + const intervals = new Map(); + const timeouts = new Map(); + const captures = []; + const recorders = []; + const analysers = []; + const assistantRows = []; + const toasts = []; + const reducedMotion = {matches: reduced, addEventListener() {}, removeEventListener() {}}; + let rejectNextCapture = false; + let transcriptResolve; + let sent = 0; + let lastAudio = null; + + class FakeDate extends Date { + static now() { return now; } + } + class FakeAnalyser { + constructor() { this.fftSize = 0; this.level = 0; } + getByteTimeDomainData(samples) { + const sample = 128 + Math.round(this.level * 128); + samples.fill(sample); + } + } + class FakeAudioContext { + createAnalyser() { + const analyser = new FakeAnalyser(); + analysers.push(analyser); + return analyser; + } + createBiquadFilter() { return {type: '', frequency: {value: 0}, Q: {value: 0}, connect() {}}; } + createMediaStreamSource() { return {connect() {}}; } + close() { return Promise.resolve(); } + } + class FakeMediaRecorder { + static isTypeSupported() { return true; } + constructor(stream) { + this.stream = stream; + this.state = 'inactive'; + this.ondataavailable = null; + this.onstop = null; + recorders.push(this); + } + start() { this.state = 'recording'; } + stop() { + if (this.state === 'inactive') return; + this.state = 'inactive'; + if (this.onstop) queueMicrotask(() => this.onstop()); + } + } + class FakeAudio { + constructor() { + this.currentTime = 0; + this.onended = null; + this.onerror = null; + this.paused = false; + lastAudio = this; + } + play() { this.paused = false; return Promise.resolve(); } + pause() { this.paused = true; } + finish() { if (this.onended) this.onended(); } + } + + const document = { + getElementById(id) { return elements.get(id) || null; }, + createElement() { return new Element('created'); }, + querySelectorAll(selector) { + assert.equal(selector, '.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]'); + return assistantRows; + }, + }; + const localValues = new Map(); + const localStorage = { + getItem(key) { return localValues.has(key) ? localValues.get(key) : null; }, + setItem(key, value) { localValues.set(key, String(value)); }, + }; + const stream = {getTracks() { return [{stop() {}}]; }}; + const navigator = { + mediaDevices: { + getSupportedConstraints() { return {}; }, + async getUserMedia() { + if (rejectNextCapture) { + rejectNextCapture = false; + throw new Error('Microphone unavailable'); + } + captures.push(stream); + return stream; + }, + }, + }; + async function fetch(url) { + if (url === '/api/transcribe/capability') { + return {ok: true, json: async () => ({available: true, provider: 'local_command'})}; + } + if (url === '/api/transcribe') { + return new Promise(resolve => { transcriptResolve = resolve; }); + } + if (url === '/api/tts') { + return {ok: true, blob: async () => new Blob(['wave'], {type: 'audio/wav'})}; + } + throw new Error(`unexpected fetch: ${url}`); + } + const window = { + MediaRecorder: FakeMediaRecorder, + AudioContext: FakeAudioContext, + setInterval(callback) { + const id = nextTimer++; + intervals.set(id, callback); + return id; + }, + clearInterval(id) { intervals.delete(id); }, + setTimeout(callback, delay) { + const id = nextTimer++; + timeouts.set(id, {callback, delay}); + return id; + }, + clearTimeout(id) { timeouts.delete(id); }, + matchMedia(query) { + assert.equal(query, '(prefers-reduced-motion: reduce)'); + return reducedMotion; + }, + showToast(message) { toasts.push(message); }, + autoResize() {}, + send() { sent += 1; }, + stopTTS() {}, + _splitForTTS(text) { return [text]; }, + _stripForTTS(text) { return text; }, + URL: {createObjectURL() { return 'blob:voice'; }, revokeObjectURL() {}}, + }; + const context = { + Audio: FakeAudio, + Blob, + Date: FakeDate, + File, + FormData, + MediaRecorder: FakeMediaRecorder, + URL: window.URL, + Uint8Array, + clearInterval: window.clearInterval, + console, + document, + fetch, + localStorage, + navigator, + queueMicrotask, + S: {busy: false, session: {session_id: 'session-1'}}, + window, + }; + window.window = window; + window.document = document; + window.fetch = fetch; + window.localStorage = localStorage; + window.navigator = navigator; + Object.assign(window, {Blob, Date: FakeDate, File, FormData, URL: window.URL, Uint8Array}); + + vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), context, {filename: scriptPath}); + await flush(); + await flush(); + + return { + elements, modeButton, bar, indicator, label, styleLink, intervals, timeouts, window, + captures, recorders, analysers, assistantRows, toasts, reducedMotion, + get sent() { return sent; }, + get lastAudio() { return lastAudio; }, + set now(value) { now = value; }, + rejectCapture() { rejectNextCapture = true; }, + resolveTranscript(payload) { + assert.ok(transcriptResolve, 'transcription request was not started'); + transcriptResolve({ok: true, json: async () => payload}); + }, + runIntervals() { for (const callback of [...intervals.values()]) callback(); }, + runTimeout(delay) { + const match = [...timeouts].find(([, timer]) => timer.delay === delay); + assert.ok(match, `timer ${delay}ms was not scheduled`); + timeouts.delete(match[0]); + match[1].callback(); + }, + }; +} + +async function normalMotionContract(scriptPath) { + const probe = await boot(scriptPath, false); + const originalStyleLink = probe.styleLink; + + probe.modeButton.click(); + assert.match(probe.indicator.className, /\blistening\b/); + assert.equal(probe.label.textContent, 'Listening'); + assert.equal(probe.bar.style.display, ''); + await flush(); + assert.equal(probe.captures.length, 1); + + probe.analysers[0].level = 0.3; + probe.runIntervals(); + probe.runIntervals(); + probe.runIntervals(); + probe.recorders[0].ondataavailable({data: new Blob(['speech'], {type: 'audio/webm'})}); + probe.now = 4000; + probe.analysers[0].level = 0; + probe.runIntervals(); + await flush(); + assert.match(probe.indicator.className, /\btranscribing\b/); + assert.equal(probe.label.textContent, 'Transcribing…'); + + probe.resolveTranscript({transcript: 'Hello Hermes'}); + await flush(); + await flush(); + assert.match(probe.indicator.className, /\bthinking\b/); + assert.equal(probe.label.textContent, 'Thinking…'); + assert.equal(probe.sent, 1); + + probe.assistantRows.push({dataset: {rawText: 'A calm answer.'}}); + const capturesBeforeSpeech = probe.captures.length; + probe.window.autoReadLastAssistant(); + assert.match(probe.indicator.className, /\bspeaking\b/); + assert.equal(probe.label.textContent, 'Speaking'); + assert.equal(probe.captures.length, capturesBeforeSpeech); + await flush(); + await flush(); + assert.match(probe.indicator.className, /\bis-playing\b/); + assert.equal(probe.captures.length, capturesBeforeSpeech); + probe.lastAudio.finish(); + await flush(); + assert.doesNotMatch(probe.indicator.className, /\bis-playing\b/); + + return {probe, originalStyleLink, capturesBeforeSpeech}; +} + +async function main() { + const scriptPath = process.argv[2]; + assert.ok(scriptPath, 'usage: node hermes_voice_instrument_probe.js '); + const first = await normalMotionContract(scriptPath); + const {probe, originalStyleLink, capturesBeforeSpeech} = first; + + assert.equal(probe.captures.length, capturesBeforeSpeech); + assert.strictEqual(probe.elements.get('voiceInstrumentStyles'), originalStyleLink); + + probe.modeButton.click(); + assert.equal(probe.bar.style.display, 'none'); + assert.match(probe.indicator.className, /\bidle\b/); + probe.modeButton.click(); + await flush(); + probe.modeButton.click(); + assert.equal(probe.bar.style.display, 'none'); + assert.strictEqual(probe.elements.get('voiceInstrumentStyles'), originalStyleLink); + + const reduced = await boot(scriptPath, true); + reduced.modeButton.click(); + await flush(); + const initialScale = reduced.indicator.style.getPropertyValue('--voice-ripple-scale'); + reduced.analysers[0].level = 0.5; + reduced.runIntervals(); + assert.equal(reduced.indicator.style.getPropertyValue('--voice-ripple-scale'), initialScale); + reduced.modeButton.click(); + + const unavailable = await boot(scriptPath, false); + unavailable.rejectCapture(); + unavailable.modeButton.click(); + await flush(); + await flush(); + assert.match(unavailable.indicator.className, /\berror\b/); + assert.equal(unavailable.label.textContent, 'Microphone unavailable'); + assert.equal(unavailable.bar.style.display, ''); + unavailable.runTimeout(3200); + assert.equal(unavailable.bar.style.display, 'none'); + assert.match(unavailable.indicator.className, /\bidle\b/); + + process.stdout.write('voice instrument DOM contract passed\n'); +} + +main().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +}); diff --git a/testing/tests/test_hermes_voice_instrument.py b/testing/tests/test_hermes_voice_instrument.py new file mode 100644 index 00000000..eada0199 --- /dev/null +++ b/testing/tests/test_hermes_voice_instrument.py @@ -0,0 +1,108 @@ +"""Shipped patch and DOM contracts for the hands-free conversation instrument.""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[2] +FIXTURE = ROOT / "testing/fixtures/hermes-webui-0.52.181" +PATCHER = ROOT / "dockerfiles/hermes-webui-atlas-patch.py" +VOICE_JS = ROOT / "dockerfiles/hermes-webui-atlas-voice.js" +VOICE_CSS = ROOT / "dockerfiles/hermes-webui-atlas-voice.css" +DOM_PROBE = ROOT / "testing/probes/hermes_voice_instrument_probe.js" + + +def _patched_fixture(tmp_path: Path) -> Path: + target = tmp_path / "hermes-webui" + shutil.copytree(FIXTURE, target) + env = os.environ.copy() + env["HERMES_WEBUI_PATCH_ROOT"] = str(target) + subprocess.run( + [sys.executable, str(PATCHER)], + cwd=ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + return target + + +def test_real_upstream_fixture_receives_visual_instrument_contract(tmp_path: Path): + """Apply the production patcher to exact fragments from pinned WebUI 0.52.181.""" + target = _patched_fixture(tmp_path) + index = (target / "static/index.html").read_text(encoding="utf-8") + + assert index.count('id="voiceInstrumentStyles"') == 1 + assert ( + 'href="static/atlas-voice.css?v=__WEBUI_VERSION__"' in index + ) + assert 'role="status"' in index + assert 'aria-live="polite"' in index + assert 'aria-atomic="true"' in index + for layer in ( + "voice-instrument-halo", + "voice-instrument-ripple", + "voice-instrument-orbit", + "voice-instrument-core", + "voice-instrument-symbol", + ): + assert layer in index + assert '