hermes(voice): restore always-opening overlay, character watermark, speaker default

- REGRESSION FIX: the full-screen conversation overlay stopped opening
  (fell back to the inline bar) because building the language/output
  selectors inside the overlay's single try/catch could throw on a
  phone (navigator.mediaDevices/setSinkId). Overlay build is now two
  phases: the essential orb+captions+controls attach first; the
  selectors attach after, each guarded, so a selector failure omits only
  that control and never the visualization. Control builders can no
  longer throw (inert hidden fallback); device enumeration is async
  after attach. Regression test covers mediaDevices-undefined and
  enumerate-rejects.
- Orb watermark is the processed Hermes character glyph (feathered,
  circle-cropped, screen-blended so the face glows on the dark orb), no
  more white/grey box.
- Output selector defaults to the loudspeaker (excludes the
  communications/earpiece endpoint that the open mic otherwise forces);
  explicit choice sticks; hidden gracefully where setSinkId is
  unsupported. 291 voice tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
jenkins 2026-08-24 20:24:31 -03:00
parent b99952f16c
commit 1baab8e014
4 changed files with 419 additions and 94 deletions

File diff suppressed because one or more lines are too long

View File

@ -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).

View File

@ -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)) {

View File

@ -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