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:
parent
b99952f16c
commit
1baab8e014
File diff suppressed because one or more lines are too long
@ -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).
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user