diff --git a/dockerfiles/hermes-webui-atlas-voice.css b/dockerfiles/hermes-webui-atlas-voice.css index d3e10d8e..bcad70dd 100644 --- a/dockerfiles/hermes-webui-atlas-voice.css +++ b/dockerfiles/hermes-webui-atlas-voice.css @@ -427,40 +427,37 @@ opacity: 0.85; } -/* FIX 2: the Hermes caduceus mark, a static centred watermark inside the orb. - Sits above the core wash but below no energy layer's motion — it never - animates on its own and never scales past the orb, so the breathing/sweep/ - wave animations always read over it. Monochrome via currentColor at low - opacity so it stays legible against every state tint without competing with - the accent colour. Scales with the orb because it is inset-positioned. */ -/* FIX 2: the Hermes CHARACTER avatar (static/hermes-agent-192.png — the same - mark the top app bar shows next to the conversation title), painted as a - centred, low-opacity watermark that scales with the orb. It is a raster mark - already served under /static, so it is referenced by URL rather than inlined. - It carries no animation of its own (reduced-motion safe) and stays subtle - enough to read across every state tint without masking the energy layers. */ +/* FIX 1: the Hermes CHARACTER watermark inside the orb. A pre-processed data + URI — the 512px character art, feathered to a circle so its edges fade to + transparent (the orb appears to CROP it, with no square box) and luminance- + weighted to a warm near-white so the facial features read as light. Painted + with mix-blend-mode: screen so the glyph's darks drop away and only the light + face features glow over the dark orb. It is a static, centred, inset watermark + that scales with the orb (inset-positioned), carries no animation of its own + (reduced-motion safe) and stays subtle across every state tint. Inlined as a + data URI, so there is no separate static asset to serve. */ .voice-conversation-orb-mark { position: absolute; - inset: 22%; + inset: 16%; pointer-events: none; - background-image: url(hermes-agent-192.png); + background-image: url("data:image/png;base64,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"); background-position: center; background-repeat: no-repeat; background-size: contain; - opacity: 0.16; - filter: drop-shadow(0 0 6px rgba(var(--voice-accent), 0.3)); + mix-blend-mode: screen; + opacity: 0.6; transition: opacity 200ms ease-out; } /* Speaking/thinking lift the watermark a touch so it feels alive with the turn, - still far below any level that would mask the animation. */ + still far below any level that would mask the energy layers. */ .voice-conversation[data-voice-state="speaking"] .voice-conversation-orb-mark, .voice-conversation[data-voice-state="thinking"] .voice-conversation-orb-mark { - opacity: 0.2; + opacity: 0.72; } .voice-conversation.is-muted .voice-conversation-orb-mark { - opacity: 0.1; + opacity: 0.4; } /* Idle listening: slow breathing. */ diff --git a/dockerfiles/hermes-webui-atlas-voice.js b/dockerfiles/hermes-webui-atlas-voice.js index 470d437f..aa3061e2 100644 --- a/dockerfiles/hermes-webui-atlas-voice.js +++ b/dockerfiles/hermes-webui-atlas-voice.js @@ -48,6 +48,11 @@ let sharedPlaybackWorklet=null; let selectedOutputSinkId=''; let outputDevices=[]; + // Whether the user has explicitly picked an output device this session. Until + // they do, the first device enumeration auto-defaults the shared sink to the + // loudspeaker (never the OS "communications"/earpiece route the browser picks + // while the mic is open); an explicit choice is never overridden afterwards. + let outputSinkUserChosen=false; // Continuous-capture state. The microphone stream and its AudioContext stay // hot for the whole hands-free session; each utterance is one "capture turn" // guarded by captureGeneration so a response-side barge (which bumps @@ -67,11 +72,13 @@ }catch(_){return '';} })(); const reducedMotion=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):{matches:false}; - // FIX 2: the conversation orb's centred watermark is the Hermes CHARACTER - // avatar (static/hermes-agent-192.png) — the very mark the top app bar shows - // next to the conversation title — painted by .voice-conversation-orb-mark in - // atlas-voice.css as a low-opacity, non-animating (reduced-motion safe) layer - // that scales with the orb and reads across every state tint. + // FIX 1: the conversation orb's centred watermark is the Hermes CHARACTER + // glyph — the 512px character art, feathered to a circle and luminance-weighted + // to a warm near-white, inlined as a data URI and painted by + // .voice-conversation-orb-mark in atlas-voice.css with mix-blend-mode: screen + // so the face features glow over the dark orb. A low-opacity, non-animating + // (reduced-motion safe) layer that scales with the orb and reads across every + // state tint. No separate static asset is served for it. const ERROR_VISIBLE_MS=3200; const STREAMING_CAPABILITY_URL='/api/voice/streaming/capability'; const TTS_STREAM_URL='/api/tts/stream'; @@ -305,6 +312,10 @@ return !!(typeof document!=='undefined'&&document&&document.body&&typeof document.createElement==='function'); } + function conversationWarn(message,error){ + try{if(window.console&&window.console.warn) window.console.warn('[atlas-voice] '+message,error);}catch(_){ } + } + function clearAwaitingAffordance(){ if(awaitingTimer){window.clearTimeout(awaitingTimer);awaitingTimer=null;} if(conversation&&conversation.root.classList) conversation.root.classList.remove('is-awaiting'); @@ -478,35 +489,50 @@ } } + // Never throws: on any failure it returns an inert, hidden control object whose + // .wrap is still a safe, appendable element, so the overlay can attach without + // this enhancement. langBtn/langMenu stay null and every menu helper no-ops. + function inertControl(className){ + let wrap; + try{wrap=conversationNode('div',className);wrap.style.display='none';} + catch(_){wrap=null;} + return {wrap:wrap,btn:null,menu:null,items:[]}; + } + function buildLanguageControl(){ - const wrap=conversationNode('div','voice-conversation-lang'); - const btn=conversationNode('button','voice-conversation-lang-btn',{ - type:'button','aria-haspopup':'menu','aria-expanded':'false', - 'aria-label':'Conversation language: Auto',title:'Language — Auto', - }); - btn.innerHTML=GLOBE_ICON_SVG; - const menu=conversationNode('div','voice-conversation-lang-menu',{role:'menu','aria-label':'Conversation language',hidden:''}); - menu.hidden=true; - const items=CONVERSATION_LANGUAGES.map(function(entry){ - const item=conversationNode('button','voice-conversation-lang-item',{ - type:'button',role:'menuitemradio','data-lang':entry.code, - 'aria-checked':entry.code===forcedLanguage?'true':'false', + try{ + const wrap=conversationNode('div','voice-conversation-lang'); + const btn=conversationNode('button','voice-conversation-lang-btn',{ + type:'button','aria-haspopup':'menu','aria-expanded':'false', + 'aria-label':'Conversation language: Auto',title:'Language — Auto', }); - item.textContent=entry.label; - item.addEventListener('click',function(){ - selectConversationLanguage(entry.code); - closeLanguageMenu(true); + btn.innerHTML=GLOBE_ICON_SVG; + const menu=conversationNode('div','voice-conversation-lang-menu',{role:'menu','aria-label':'Conversation language',hidden:''}); + menu.hidden=true; + const items=CONVERSATION_LANGUAGES.map(function(entry){ + const item=conversationNode('button','voice-conversation-lang-item',{ + type:'button',role:'menuitemradio','data-lang':entry.code, + 'aria-checked':entry.code===forcedLanguage?'true':'false', + }); + item.textContent=entry.label; + item.addEventListener('click',function(){ + selectConversationLanguage(entry.code); + closeLanguageMenu(true); + }); + menu.appendChild(item); + return item; }); - menu.appendChild(item); - return item; - }); - btn.addEventListener('click',function(event){ - if(event&&event.stopPropagation) event.stopPropagation(); - toggleLanguageMenu(); - }); - wrap.appendChild(btn); - wrap.appendChild(menu); - return {wrap:wrap,btn:btn,menu:menu,items:items}; + btn.addEventListener('click',function(event){ + if(event&&event.stopPropagation) event.stopPropagation(); + toggleLanguageMenu(); + }); + wrap.appendChild(btn); + wrap.appendChild(menu); + return {wrap:wrap,btn:btn,menu:menu,items:items}; + }catch(error){ + conversationWarn('language control unavailable',error); + return inertControl('voice-conversation-lang'); + } } // ── Output-device selector ──────────────────────────────────────────── @@ -564,11 +590,38 @@ } function selectOutputDevice(deviceId){ + // An explicit user choice: it is honoured for the rest of the session and is + // never overridden by the auto-default-to-loudspeaker on later refreshes. + outputSinkUserChosen=true; selectedOutputSinkId=deviceId||''; reflectOutputSelection(); applyOutputSink(); } + function pickLoudspeakerSink(devices){ + // FIX 2(a): choose the actual LOUDSPEAKER so hands-free playback does not + // follow the OS "communication" route — while getUserMedia holds the mic + // open the browser/OS puts audio in communication mode and the system + // default endpoint becomes the EARPIECE. We therefore bias to an explicit + // speaker deviceId rather than leaving the sink on the system/communications + // default. Never target the "communications" pseudo-endpoint (that IS the + // earpiece route). Prefer a labelled speaker; else the first concrete, + // non-earpiece output; else '' (fall back to the system default). + const outs=(devices||[]).filter(function(device){ + return device&&device.kind==='audiooutput'&&device.deviceId&&device.deviceId!=='communications'; + }); + const speaker=/(speaker|speakerphone|loud)/i; + const earpiece=/(earpiece|receiver|handset|headset|headphone|earbud|bluetooth|communication)/i; + const named=outs.filter(function(device){ + const label=device.label||'';return speaker.test(label)&&!earpiece.test(label); + })[0]; + if(named) return named.deviceId; + const concrete=outs.filter(function(device){ + return device.deviceId!=='default'&&!earpiece.test(device.label||''); + })[0]; + return concrete?concrete.deviceId:''; + } + async function refreshOutputDevices(){ if(!conversation||!conversation.outMenu||!conversation.outWrap) return; if(!outputRoutingSupported()){conversation.outWrap.style.display='none';return;} @@ -578,6 +631,13 @@ // browser reports is represented by our own "System default" entry. outputDevices=(devices||[]).filter(function(device){return device&&device.kind==='audiooutput'&&device.deviceId;}); if(!conversation||!conversation.outMenu) return; + // Auto-default the shared sink to the loudspeaker on first enumeration so + // hands-free speech never plays out the earpiece. Only when the user has not + // made an explicit choice and nothing is selected yet — a user choice wins. + if(!outputSinkUserChosen&&!selectedOutputSinkId){ + const speaker=pickLoudspeakerSink(devices); + if(speaker){selectedOutputSinkId=speaker;applyOutputSink();} + } // Rebuild the menu: a "System default" entry plus every routable output. conversation.outMenu.children=[]; const entries=[{deviceId:'',label:'System default'}].concat( @@ -601,39 +661,54 @@ reflectOutputSelection(); } + // Never throws (see buildLanguageControl): device access is done later, and + // asynchronously, in refreshOutputDevices — buildOutputControl only assembles + // the inert corner button, hidden until routable outputs are confirmed. function buildOutputControl(){ - const wrap=conversationNode('div','voice-conversation-out'); - // Hidden until refreshOutputDevices confirms real, routable outputs exist. - wrap.style.display='none'; - const btn=conversationNode('button','voice-conversation-out-btn',{ - type:'button','aria-haspopup':'menu','aria-expanded':'false', - 'aria-label':'Audio output: System default',title:'Output — System default', - }); - btn.innerHTML=SPEAKER_ICON_SVG; - const menu=conversationNode('div','voice-conversation-out-menu',{role:'menu','aria-label':'Audio output device',hidden:''}); - menu.hidden=true; - btn.addEventListener('click',function(event){ - if(event&&event.stopPropagation) event.stopPropagation(); - toggleOutputMenu(); - }); - wrap.appendChild(btn); - wrap.appendChild(menu); - return {wrap:wrap,btn:btn,menu:menu}; + try{ + const wrap=conversationNode('div','voice-conversation-out'); + // Hidden until refreshOutputDevices confirms real, routable outputs exist. + wrap.style.display='none'; + const btn=conversationNode('button','voice-conversation-out-btn',{ + type:'button','aria-haspopup':'menu','aria-expanded':'false', + 'aria-label':'Audio output: System default',title:'Output — System default', + }); + btn.innerHTML=SPEAKER_ICON_SVG; + const menu=conversationNode('div','voice-conversation-out-menu',{role:'menu','aria-label':'Audio output device',hidden:''}); + menu.hidden=true; + btn.addEventListener('click',function(event){ + if(event&&event.stopPropagation) event.stopPropagation(); + toggleOutputMenu(); + }); + wrap.appendChild(btn); + wrap.appendChild(menu); + return {wrap:wrap,btn:btn,menu:menu}; + }catch(error){ + conversationWarn('output control unavailable',error); + return inertControl('voice-conversation-out'); + } } function openConversationOverlay(){ if(conversation||!conversationUsable()) return; + let root=null; + // ── Phase 1: the ESSENTIAL overlay (orb + captions + mute/exit). This is the + // full-screen visualization the user relies on; it MUST attach whenever + // conversation mode activates. The corner language/output selectors are + // enhancements added in phase 2 — a failure there can never keep this from + // rendering (which would drop us back to the inline voice bar). Only a + // failure to build this core is fatal, and is the sole reason for fallback. try{ - const root=conversationNode('div','voice-conversation',{ + root=conversationNode('div','voice-conversation',{ role:'dialog','aria-modal':'true','aria-label':'Voice conversation',tabindex:'-1', }); const orb=conversationNode('div','voice-conversation-orb',{'aria-hidden':'true'}); orb.appendChild(conversationNode('span','voice-conversation-orb-halo')); orb.appendChild(conversationNode('span','voice-conversation-orb-core')); orb.appendChild(conversationNode('span','voice-conversation-orb-ring')); - // FIX 2: the Hermes character-avatar watermark, centred in the orb beneath + // FIX 1: the Hermes character glyph watermark, centred in the orb beneath // the energy layers. It is a presentation-only span; atlas-voice.css paints - // static/hermes-agent-192.png (the top-app-bar character mark) into it. + // the inlined, circle-feathered character data URI into it (screen blend). const orbMark=conversationNode('span','voice-conversation-orb-mark',{'aria-hidden':'true'}); orb.appendChild(orbMark); const stateEl=conversationNode('div','voice-conversation-state',{'aria-hidden':'true'}); @@ -654,10 +729,6 @@ exitBtn.textContent='Exit voice mode'; controls.appendChild(muteBtn); controls.appendChild(exitBtn); - const lang=buildLanguageControl(); - root.appendChild(lang.wrap); - const out=buildOutputControl(); - root.appendChild(out.wrap); root.appendChild(orb); root.appendChild(stateEl); root.appendChild(captions); @@ -665,7 +736,7 @@ muteBtn.addEventListener('click',function(){if(conversation) setConversationMuted(!conversation.muted);}); exitBtn.addEventListener('click',function(){deactivate(true);}); root.addEventListener('keydown',conversationKeydown); - // A tap anywhere outside the language menu closes it (never deactivates). + // A tap anywhere outside a menu closes it (never deactivates). root.addEventListener('click',function(event){ if(!conversation) return; const target=event&&event.target; @@ -674,17 +745,37 @@ if(conversation.outMenu&&!conversation.outMenu.hidden&&!(closest&&closest('.voice-conversation-out'))) closeOutputMenu(false); }); document.body.appendChild(root); - conversation={root:root,orb:orb,stateEl:stateEl,userCaption:userCaption,assistantCaption:assistantCaption,muteBtn:muteBtn,exitBtn:exitBtn,langBtn:lang.btn,langMenu:lang.menu,langItems:lang.items,outWrap:out.wrap,outBtn:out.btn,outMenu:out.menu,outItems:[],muted:false}; + // Selector fields start null; phase 2 fills them in if their build succeeds. + conversation={root:root,orb:orb,stateEl:stateEl,userCaption:userCaption,assistantCaption:assistantCaption,muteBtn:muteBtn,exitBtn:exitBtn,langBtn:null,langMenu:null,langItems:[],outWrap:null,outBtn:null,outMenu:null,outItems:[],muted:false}; + }catch(error){ + // The essential overlay could not even be built/attached: fall back to the + // compact voice bar as a last resort, leaving nothing half-attached. + conversationWarn('conversation overlay unavailable',error); + if(root&&root.parentNode){try{root.parentNode.removeChild(root);}catch(_e){ }} + conversation=null; + return; + } + // ── Phase 2: OPTIONAL corner selectors. Each is built and wired under its own + // guard so a synchronous failure (e.g. a mobile browser where mediaDevices + // access throws) omits just that one control — the overlay stays up. Device + // enumeration itself is async (refreshOutputDevices), run after attachment. + try{ + const lang=buildLanguageControl(); + if(lang.wrap) root.appendChild(lang.wrap); + conversation.langBtn=lang.btn;conversation.langMenu=lang.menu;conversation.langItems=lang.items||[]; reflectLanguageSelection(); - refreshOutputDevices(); + }catch(error){conversationWarn('language selector omitted',error);} + try{ + const out=buildOutputControl(); + if(out.wrap) root.appendChild(out.wrap); + conversation.outWrap=out.wrap;conversation.outBtn=out.btn;conversation.outMenu=out.menu; + // Async device enumeration; tolerate rejection without touching the overlay. + refreshOutputDevices().catch(function(error){conversationWarn('output devices unavailable',error);}); + }catch(error){conversationWarn('output selector omitted',error);} + try{ syncConversationOverlay(state); if(root.focus) root.focus(); - }catch(_){ - // A partially built overlay is never attached: document.body.appendChild - // is the last DOM mutation above, so a stub DOM simply keeps the - // compact voice bar. - conversation=null; - } + }catch(error){conversationWarn('overlay finalize warning',error);} } function removeConversationOverlay(){ @@ -901,6 +992,7 @@ stopPlayback(); closeSharedPlayback(); selectedOutputSinkId=''; + outputSinkUserChosen=false; outputDevices=[]; removeConversationOverlay(); modeBtn.classList.remove('active'); @@ -1001,6 +1093,7 @@ stopPlayback(); closeSharedPlayback(); selectedOutputSinkId=''; + outputSinkUserChosen=false; outputDevices=[]; modeBtn.classList.remove('active'); setState('idle'); @@ -2922,6 +3015,7 @@ sessionLanguage=''; forcedLanguage=''; selectedOutputSinkId=''; + outputSinkUserChosen=false; outputDevices=[]; finalizeAttempts=0; modeBtn.classList.add('active'); @@ -3016,6 +3110,14 @@ strongReplyLanguage:strongReplyLanguage, detectReplyLanguage:detectReplyLanguage, resolveReplyLanguage:resolveReplyLanguage, + // FIX 2 / overlay-regression seams. openConversationOverlay is exercised + // directly under a hostile navigator (mediaDevices undefined, or + // enumerateDevices rejecting) to prove the full-screen overlay always + // attaches; pickLoudspeakerSink locks the default-to-speaker choice. + openConversationOverlay:openConversationOverlay, + removeConversationOverlay:removeConversationOverlay, + hasConversationOverlay:function(){return !!conversation;}, + pickLoudspeakerSink:pickLoudspeakerSink, // Read-only, behaviour-neutral: lets a deterministic probe observe that the // interim fold never leaves an unspoken tail outstanding while the state // has fallen back to Thinking (the dropped-tail regression). diff --git a/testing/probes/hermes_voice_capture_probe.js b/testing/probes/hermes_voice_capture_probe.js index 479da579..f243ab9e 100644 --- a/testing/probes/hermes_voice_capture_probe.js +++ b/testing/probes/hermes_voice_capture_probe.js @@ -1272,6 +1272,158 @@ scenarios.output_selector_hidden_when_unsupported = async () => { }; }; +// Shape of the attached full-screen overlay: the essential orb + captions + +// mute/exit that MUST render whenever conversation mode opens, independent of the +// language/output corner selectors. +function overlayShape(harness) { + const overlay = harness.overlay(); + if (!overlay) return { overlayPresent: false }; + const kids = overlay.children; + const find = (sub) => kids.find((c) => String(c.className).indexOf(sub) >= 0) || null; + const orb = find('voice-conversation-orb'); + const orbMark = orb + ? orb.children.find((c) => String(c.className).indexOf('voice-conversation-orb-mark') >= 0) + : null; + const captions = find('voice-conversation-captions'); + const controls = find('voice-conversation-controls'); + const mute = controls + ? controls.children.find((c) => String(c.className).indexOf('voice-conversation-mute') >= 0) + : null; + const exit = controls + ? controls.children.find((c) => String(c.className).indexOf('voice-conversation-exit') >= 0) + : null; + return { + overlayPresent: true, + isDialog: overlay.getAttribute('role') === 'dialog', + hasOrb: !!orb, + hasOrbMark: !!orbMark, + hasCaptions: !!captions, + captionCount: captions ? captions.children.length : 0, + hasMute: !!mute, + hasExit: !!exit, + }; +} + +// REGRESSION LOCK: the full-screen overlay must ALWAYS attach when conversation +// mode opens, even when the audio-output APIs are hostile — mediaDevices absent, +// or enumerateDevices rejecting. A failure here previously discarded the whole +// overlay and dropped back to the inline voice bar. +scenarios.overlay_opens_despite_hostile_output_apis = async () => { + const results = {}; + + // (A) navigator.mediaDevices is undefined when the overlay is built. + { + const harness = makeHarness(); + await harness.flush(); + const internals = harness.context.window.__atlasVoiceInternals; + harness.context.navigator.mediaDevices = undefined; + internals.openConversationOverlay(); + results.mediaDevicesUndefined = overlayShape(harness); + const overlay = harness.overlay(); + const outWrap = overlay + ? overlay.children.find((c) => String(c.className).indexOf('voice-conversation-out') >= 0) + : null; + results.mediaDevicesUndefined.outputHidden = outWrap ? outWrap.style.display === 'none' : null; + internals.removeConversationOverlay(); + } + + // (B) enumerateDevices is present (routing LOOKS supported) but REJECTS. + { + const harness = makeHarness(); + await harness.flush(); + const internals = harness.context.window.__atlasVoiceInternals; + harness.context.navigator.mediaDevices.enumerateDevices = async () => { + throw new Error('enumerate blocked'); + }; + internals.openConversationOverlay(); + // The overlay must be attached synchronously, before the async rejection. + const immediate = overlayShape(harness); + await harness.flush(); + await harness.flush(); + results.enumerateRejects = overlayShape(harness); + results.enumerateRejects.attachedBeforeReject = immediate.overlayPresent; + internals.removeConversationOverlay(); + } + return results; +}; + +// FIX 2(a): with the mic open the OS routes the system default to the earpiece; +// the shared sink must auto-default to the LOUDSPEAKER (never "communications"), +// with no user interaction — and that default must reach real playback. +scenarios.output_defaults_to_loudspeaker = async () => { + const harness = makeHarness({ + outputs: [ + { deviceId: '', kind: 'audiooutput', label: 'System default' }, + { deviceId: 'communications', kind: 'audiooutput', label: 'Communications' }, + { deviceId: 'ear-1', kind: 'audiooutput', label: 'Earpiece' }, + { deviceId: 'spk-1', kind: 'audiooutput', label: 'Speakerphone' }, + { deviceId: 'mic-1', kind: 'audioinput', label: 'Microphone' }, + ], + }); + await harness.start(); + await harness.flush(); + await harness.flush(); + const overlay = harness.overlay(); + if (!overlay) return { overlayPresent: false }; + const outWrap = overlay.children.find((c) => String(c.className).indexOf('voice-conversation-out') >= 0); + const outBtn = outWrap ? outWrap.children.find((c) => String(c.className).indexOf('voice-conversation-out-btn') >= 0) : null; + const outMenu = outWrap ? outWrap.children.find((c) => String(c.className).indexOf('voice-conversation-out-menu') >= 0) : null; + const items = outMenu ? outMenu.children : []; + const defaultChecked = items + .filter((i) => i.getAttribute('aria-checked') === 'true') + .map((i) => i.getAttribute('data-device')); + const btnForced = outBtn ? String(outBtn.className).indexOf('is-forced') >= 0 : false; + const btnLabel = outBtn ? outBtn.getAttribute('aria-label') : null; + // The default must reach real playback with NO user interaction: a reply now + // plays through the blob fallback and must route to the auto-selected speaker. + await harness.silence(300); + await harness.speak('alpha', 1300); + await harness.silence(1500); + await harness.tick(400); + harness.setAssistantReply('A short spoken reply.'); + harness.completeResponse(); + await harness.tick(400); + const audioRoutedTo = harness.sinkCalls().filter((c) => c.kind === 'audio').map((c) => c.sink); + return { + overlayPresent: true, + defaultChecked, + btnForced, + btnLabel, + audioRoutedTo, + }; +}; + +// FIX 2(a) pure logic: the loudspeaker chooser prefers a labelled speaker, never +// the "communications" endpoint, falls back to the first concrete non-earpiece +// output, and returns '' (system default) when only an earpiece/comms exists. +scenarios.loudspeaker_selection_logic = async () => { + const harness = makeHarness(); + await harness.flush(); + const pick = harness.context.window.__atlasVoiceInternals.pickLoudspeakerSink; + return { + labelledSpeaker: pick([ + { deviceId: 'ear', kind: 'audiooutput', label: 'Earpiece' }, + { deviceId: 'spk', kind: 'audiooutput', label: 'Speakerphone' }, + ]), + skipsCommunications: pick([ + { deviceId: 'communications', kind: 'audiooutput', label: 'Communications' }, + { deviceId: 'spk', kind: 'audiooutput', label: 'Speaker' }, + ]), + concreteFallback: pick([ + { deviceId: 'default', kind: 'audiooutput', label: '' }, + { deviceId: 'dev-9', kind: 'audiooutput', label: '' }, + ]), + onlyEarpiece: pick([ + { deviceId: 'communications', kind: 'audiooutput', label: 'Communications' }, + { deviceId: 'ear', kind: 'audiooutput', label: 'Earpiece' }, + ]), + ignoresInputs: pick([ + { deviceId: 'mic', kind: 'audioinput', label: 'Speaker Mic' }, + { deviceId: 'spk', kind: 'audiooutput', label: 'Loudspeaker' }, + ]), + }; +}; + (async () => { const output = {}; for (const name of Object.keys(scenarios)) { diff --git a/testing/tests/test_hermes_voice_capture_continuity.py b/testing/tests/test_hermes_voice_capture_continuity.py index 52f73310..88b21a23 100644 --- a/testing/tests/test_hermes_voice_capture_continuity.py +++ b/testing/tests/test_hermes_voice_capture_continuity.py @@ -159,6 +159,55 @@ def test_output_selector_hidden_when_unsupported(probe_results): assert scenario["hidden"] is True +def test_conversation_overlay_opens_despite_hostile_output_apis(probe_results): + """REGRESSION LOCK: the full-screen overlay (orb + captions + mute/exit) must + ALWAYS attach when conversation mode opens, even when the audio-output APIs + are hostile — navigator.mediaDevices undefined, or enumerateDevices rejecting. + A failure building the output/language selector can never drop the user back + to the inline voice bar.""" + scenario = probe_results["overlay_opens_despite_hostile_output_apis"] + for case in ("mediaDevicesUndefined", "enumerateRejects"): + shape = scenario[case] + assert shape["overlayPresent"] is True, case + assert shape["isDialog"] is True, case + assert shape["hasOrb"] is True, case + assert shape["hasOrbMark"] is True, case + assert shape["hasCaptions"] is True, case + assert shape["captionCount"] == 2, case + assert shape["hasMute"] is True, case + assert shape["hasExit"] is True, case + # mediaDevices undefined: the output control degrades to hidden, not broken. + assert scenario["mediaDevicesUndefined"]["outputHidden"] is True + # A rejecting enumerateDevices does not even briefly withhold the overlay. + assert scenario["enumerateRejects"]["attachedBeforeReject"] is True + + +def test_output_defaults_to_loudspeaker(probe_results): + """FIX 2(a): while the mic is open the OS routes the system default to the + earpiece; the shared sink auto-defaults to the LOUDSPEAKER (never + "communications") with no user interaction, and that default reaches real + playback.""" + scenario = probe_results["output_defaults_to_loudspeaker"] + assert scenario["overlayPresent"] is True + assert scenario["defaultChecked"] == ["spk-1"] + assert scenario["btnForced"] is True + assert scenario["btnLabel"] == "Audio output: Speakerphone" + # The auto-selected speaker routes the spoken reply with no user click. + assert scenario["audioRoutedTo"] == ["spk-1"] + + +def test_loudspeaker_selection_logic(probe_results): + """FIX 2(a) pure logic: prefer a labelled speaker, never the "communications" + endpoint, fall back to the first concrete non-earpiece output, and yield '' + (system default) when only an earpiece/comms endpoint exists.""" + scenario = probe_results["loudspeaker_selection_logic"] + assert scenario["labelledSpeaker"] == "spk" + assert scenario["skipsCommunications"] == "spk" + assert scenario["concreteFallback"] == "dev-9" + assert scenario["onlyEarpiece"] == "" + assert scenario["ignoresInputs"] == "spk" + + def test_natural_fillers_unified_sink_and_output_selector_source_contract(): source = VOICE_SCRIPT.read_text(encoding="utf-8") css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text(encoding="utf-8") @@ -195,6 +244,24 @@ def test_natural_fillers_unified_sink_and_output_selector_source_contract(): assert "function buildOutputControl()" in source assert "function selectOutputDevice(deviceId)" in source assert "role:'menuitemradio','data-device':entry.deviceId" in source + + # FIX 2(a): auto-default the shared sink to the loudspeaker, never the OS + # "communications"/earpiece route, and never override an explicit user choice. + assert "function pickLoudspeakerSink(devices)" in source + assert "device.deviceId!=='communications'" in source + assert "if(!outputSinkUserChosen&&!selectedOutputSinkId){" in source + assert "outputSinkUserChosen=true;" in source # an explicit choice is sticky + + # REGRESSION: the overlay attaches in a first phase; the language/output + # selectors are added in a guarded second phase so neither can tear the + # full-screen overlay down to the inline voice bar. The builders never throw. + assert "function inertControl(className)" in source + assert "conversationWarn('language selector omitted'" in source + assert "conversationWarn('output selector omitted'" in source + assert "return inertControl('voice-conversation-out')" in source + assert "return inertControl('voice-conversation-lang')" in source + # Device enumeration happens async, after attach, tolerant of rejection. + assert "refreshOutputDevices().catch(function(error)" in source # Session-only: the chosen output never touches storage. out_region = source.split("function buildOutputControl()", 1)[1].split( "function openConversationOverlay", 1 @@ -451,15 +518,17 @@ def test_conversation_language_selector_source_and_style_contract(): def test_conversation_orb_hermes_mark_source_and_style_contract(): - """FIX 2: the orb watermark is the Hermes CHARACTER avatar (the same - hermes-agent-192.png the top app bar shows), painted as a static, low-opacity - layer that scales with the orb, respects reduced motion and never animates — - NOT the caduceus/staff SVG it used to embed.""" + """FIX 1: the orb watermark is the Hermes CHARACTER glyph, a pre-processed + data URI (feathered to a circle, luminance-weighted to warm near-white), + painted with mix-blend-mode: screen so the face features glow on the dark orb. + It is a static, low-opacity layer that scales with the orb, respects reduced + motion and never animates — NOT the muddy hermes-agent-192.png box it used to + reference, and NOT the caduceus/staff SVG before that.""" source = VOICE_SCRIPT.read_text(encoding="utf-8") css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text(encoding="utf-8") - # The span is still created, but the staff SVG is gone from both the source - # and the DOM — the character mark now comes from CSS, not inline markup. + # The span is still created, but no inline SVG staff markup remains and the + # character mark now comes entirely from CSS. assert "'voice-conversation-orb-mark'" in source assert "HERMES_MARK_SVG" not in source assert 'fill-rule="evenodd"' not in source # the old caduceus path is removed @@ -467,13 +536,18 @@ def test_conversation_orb_hermes_mark_source_and_style_contract(): assert ".voice-conversation-orb-mark" in css mark_rule = css.split(".voice-conversation-orb-mark {", 1)[1].split("}", 1)[0] - # The character avatar — the exact asset the top-bar avatar references — - # painted as a scaling, centred, low-opacity watermark with no animation. - assert "url(hermes-agent-192.png)" in mark_rule + # The character glyph is inlined as a data URI (no static asset reference) and + # painted as a scaling, centred, screen-blended watermark with no animation. + assert 'url("data:image/png;base64,' in mark_rule + assert "mix-blend-mode: screen" in mark_rule assert "background-size: contain" in mark_rule - assert "opacity: 0.16" in mark_rule + assert "background-position: center" in mark_rule + assert "background-repeat: no-repeat" in mark_rule + assert "opacity: 0.6" in mark_rule assert "animation" not in mark_rule - # The staff-era monochrome currentColor tint is gone. + # The muddy raster box (hermes-agent-192.png) is gone from the whole sheet, as + # is the staff-era monochrome currentColor tint. + assert "url(hermes-agent-192.png)" not in css assert "color: rgba(233, 244, 255, 0.9)" not in css