feat(hermes-voice): add premium conversation instrument #29
@ -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 \
|
||||
|
||||
@ -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,
|
||||
'<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">',
|
||||
'<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">\n'
|
||||
'<link id="voiceInstrumentStyles" rel="stylesheet" '
|
||||
'href="static/atlas-voice.css?v=__WEBUI_VERSION__">',
|
||||
)
|
||||
replace_exact(
|
||||
index,
|
||||
'<option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option>',
|
||||
@ -26,6 +34,32 @@ replace_exact(
|
||||
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>',
|
||||
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>\n<script src="static/atlas-voice.js?v=__WEBUI_VERSION__" defer></script>',
|
||||
)
|
||||
replace_exact(
|
||||
index,
|
||||
''' <div class="voice-mode-bar" id="voiceModeBar" style="display:none">
|
||||
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
|
||||
<span class="voice-mode-label" id="voiceModeLabel"></span>
|
||||
</div>''',
|
||||
''' <div class="voice-mode-bar" id="voiceModeBar" style="display:none" role="status" aria-live="polite" aria-atomic="true">
|
||||
<span class="voice-mode-indicator idle" id="voiceModeIndicator" aria-hidden="true">
|
||||
<span class="voice-instrument-halo"></span>
|
||||
<span class="voice-instrument-ripple"></span>
|
||||
<span class="voice-instrument-orbit"></span>
|
||||
<span class="voice-instrument-core">
|
||||
<span class="voice-instrument-symbol">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<g class="voice-symbol voice-symbol-listening"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M6.5 11.5a5.5 5.5 0 0 0 11 0M12 17v3M9 20h6"/></g>
|
||||
<g class="voice-symbol voice-symbol-transcribing"><path d="M5 7h14M5 12h10M5 17h7"/><path d="M18 15v5m-2.5-2.5L18 20l2.5-2.5"/></g>
|
||||
<g class="voice-symbol voice-symbol-thinking"><path d="M12 3l1.15 4.1L17 8.5l-3.85 1.4L12 14l-1.15-4.1L7 8.5l3.85-1.4L12 3Z"/><path d="M18.5 13.5l.65 2.35 2.35.65-2.35.65-.65 2.35-.65-2.35-2.35-.65 2.35-.65.65-2.35Z"/><path d="M5.5 14l.45 1.55L7.5 16l-1.55.45L5.5 18l-.45-1.55L3.5 16l1.55-.45L5.5 14Z"/></g>
|
||||
<g class="voice-symbol voice-symbol-speaking"><path d="M5 10v4h3l4 3V7L8 10H5Z"/><path d="M15.5 9.25a4 4 0 0 1 0 5.5M18 7a7 7 0 0 1 0 10"/></g>
|
||||
<g class="voice-symbol voice-symbol-error"><path d="M12 4 21 20H3L12 4Z"/><path d="M12 9v5M12 17.2v.1"/></g>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="voice-mode-label" id="voiceModeLabel"></span>
|
||||
</div>''',
|
||||
)
|
||||
|
||||
ui = ROOT / "static/ui.js"
|
||||
replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){")
|
||||
|
||||
336
dockerfiles/hermes-webui-atlas-voice.css
Normal file
336
dockerfiles/hermes-webui-atlas-voice.css
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
12
testing/fixtures/hermes-webui-0.52.181/SOURCE.md
Normal file
12
testing/fixtures/hermes-webui-0.52.181/SOURCE.md
Normal file
@ -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.
|
||||
3
testing/fixtures/hermes-webui-0.52.181/api/routes.py
Normal file
3
testing/fixtures/hermes-webui-0.52.181/api/routes.py
Normal file
@ -0,0 +1,3 @@
|
||||
def tts(handler, engine):
|
||||
# ── ElevenLabs TTS ──────────────────────────────────────────────────
|
||||
return False
|
||||
18
testing/fixtures/hermes-webui-0.52.181/static/index.html
Normal file
18
testing/fixtures/hermes-webui-0.52.181/static/index.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
|
||||
</head>
|
||||
<body>
|
||||
<select id="settingsTtsEngine"><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option></select>
|
||||
<div class="composer-box" id="composerBox">
|
||||
<div class="voice-mode-bar" id="voiceModeBar" style="display:none">
|
||||
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
|
||||
<span class="voice-mode-label" id="voiceModeLabel"></span>
|
||||
</div>
|
||||
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
||||
<button class="icon-btn voice-mode-btn has-tooltip" id="btnVoiceMode" data-tooltip="Voice mode" data-i18n-title="voice_mode_toggle" style="display:none"></button>
|
||||
</div>
|
||||
<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
13
testing/fixtures/hermes-webui-0.52.181/static/ui.js
Normal file
13
testing/fixtures/hermes-webui-0.52.181/static/ui.js
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
356
testing/probes/hermes_voice_instrument_probe.js
Normal file
356
testing/probes/hermes_voice_instrument_probe.js
Normal file
@ -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 <atlas-voice.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;
|
||||
});
|
||||
108
testing/tests/test_hermes_voice_instrument.py
Normal file
108
testing/tests/test_hermes_voice_instrument.py
Normal file
@ -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 '<button' not in index[index.index('id="voiceModeBar"') : index.index('<textarea')]
|
||||
|
||||
|
||||
def test_visual_states_have_distinct_layers_finite_error_and_reduced_motion():
|
||||
css = VOICE_CSS.read_text(encoding="utf-8")
|
||||
|
||||
for state in ("listening", "transcribing", "thinking", "speaking", "error"):
|
||||
assert f".voice-mode-indicator.{state}" in css
|
||||
for animation in (
|
||||
"voice-instrument-breathe",
|
||||
"voice-instrument-orbit",
|
||||
"voice-instrument-speaking-pulse",
|
||||
):
|
||||
assert f"@keyframes {animation}" in css
|
||||
assert (
|
||||
".voice-mode-indicator.speaking.is-playing .voice-instrument-halo"
|
||||
in css
|
||||
)
|
||||
|
||||
error_rules = "\n".join(
|
||||
match.group(0)
|
||||
for match in re.finditer(r"[^{}]*\.error[^{}]*\{[^{}]*\}", css)
|
||||
)
|
||||
assert "animation:" not in error_rules
|
||||
assert "@media (prefers-reduced-motion: reduce)" in css
|
||||
reduced = css.split("@media (prefers-reduced-motion: reduce)", 1)[1]
|
||||
assert "animation: none !important" in reduced
|
||||
assert "transition: none !important" in reduced
|
||||
|
||||
|
||||
def test_dom_probe_exercises_actual_injected_voice_script():
|
||||
result = subprocess.run(
|
||||
["node", str(DOM_PROBE), str(VOICE_JS)],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.stdout.strip() == "voice instrument DOM contract passed"
|
||||
|
||||
|
||||
def test_visual_slice_preserves_private_voice_request_and_capture_contract():
|
||||
script = VOICE_JS.read_text(encoding="utf-8")
|
||||
|
||||
assert script.count("navigator.mediaDevices.getUserMedia(") == 1
|
||||
assert "form.append('file',new File([blob],'voice-input.'+ext" in script
|
||||
assert "fetch('/api/transcribe',{method:'POST',body:form})" in script
|
||||
assert "body:JSON.stringify({text:chunk,engine:'atlas'})" in script
|
||||
assert "speakResponse(generation)" in script
|
||||
assert "window._voiceModeImmediateSend" in script
|
||||
assert "mute" not in script.lower()
|
||||
assert "language" not in script.lower()
|
||||
Loading…
x
Reference in New Issue
Block a user