Compare commits
No commits in common. "043aa9ee89c13fc0f4fb270f489c09a0e2f18b12" and "eebc5e6de621d8d3356b5818e769dd154e1c8e66" have entirely different histories.
043aa9ee89
...
eebc5e6de6
@ -101,12 +101,8 @@ def render_hux_build_metadata(
|
|||||||
"HUX_IMAGE_DIGEST": digest,
|
"HUX_IMAGE_DIGEST": digest,
|
||||||
}
|
}
|
||||||
rendered = source
|
rendered = source
|
||||||
# Block style only: Flux setters cannot attach to values inside flow
|
|
||||||
# mappings, so the manifest keeps these as two-line entries with the
|
|
||||||
# marker comment on the value scalar. The renderer stays a belt on top
|
|
||||||
# of the Flux :tag/:digest setters and binds the same values.
|
|
||||||
present = {
|
present = {
|
||||||
name: rendered.count(f"- name: {name}\n") for name in replacements
|
name: rendered.count(f"- {{name: {name}, value:") for name in replacements
|
||||||
}
|
}
|
||||||
if set(present.values()) == {0}:
|
if set(present.values()) == {0}:
|
||||||
return rendered
|
return rendered
|
||||||
@ -114,13 +110,13 @@ def render_hux_build_metadata(
|
|||||||
raise ValueError(f"incomplete HUX build binding fields: {present}")
|
raise ValueError(f"incomplete HUX build binding fields: {present}")
|
||||||
for name, value in replacements.items():
|
for name, value in replacements.items():
|
||||||
pattern = re.compile(
|
pattern = re.compile(
|
||||||
rf"^(?P<head>(?P<indent>\s*)- name: {name}\n(?P=indent) value: )"
|
rf"^(?P<indent>\s*)- \{{name: {name}, value: '[^'\n]+'\}}(?P<suffix>[^\n]*)$",
|
||||||
rf"[^#\n]+(?P<suffix> #[^\n]*)?$",
|
|
||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
)
|
)
|
||||||
rendered, count = pattern.subn(
|
rendered, count = pattern.subn(
|
||||||
lambda match: (
|
lambda match: (
|
||||||
f"{match.group('head')}{value}{match.group('suffix') or ''}"
|
f"{match.group('indent')}- {{name: {name}, value: '{value}'}}"
|
||||||
|
f"{match.group('suffix')}"
|
||||||
),
|
),
|
||||||
rendered,
|
rendered,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -334,237 +334,3 @@
|
|||||||
transform: scale(1.03);
|
transform: scale(1.03);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Conversation mode overlay ─────────────────────────────────────────── */
|
|
||||||
/* Full-viewport hands-free conversation surface. Created lazily by
|
|
||||||
atlas-voice.js on activation and removed on deactivate. The orb breathes in
|
|
||||||
idle listening, follows live microphone energy through
|
|
||||||
--conversation-level, and pulses while Hermes speaks (.is-playing, driven
|
|
||||||
from TTS playback activity). State accents reuse the indicator palette. */
|
|
||||||
.voice-conversation {
|
|
||||||
--voice-accent: 69, 218, 207;
|
|
||||||
--voice-accent-secondary: 73, 169, 209;
|
|
||||||
--conversation-level: 0;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 420;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 24px;
|
|
||||||
padding: 34px 22px calc(28px + env(safe-area-inset-bottom, 0px));
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 50% 30%, rgba(var(--voice-accent), 0.11), transparent 52%),
|
|
||||||
radial-gradient(circle at 22% 92%, rgba(157, 142, 224, 0.07), transparent 44%),
|
|
||||||
linear-gradient(160deg, #0b1120, #070a12 55%, #081018);
|
|
||||||
color: rgba(240, 247, 255, 0.94);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation[data-voice-state="transcribing"] {
|
|
||||||
--voice-accent: 73, 193, 218;
|
|
||||||
--voice-accent-secondary: 93, 129, 224;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation[data-voice-state="thinking"] {
|
|
||||||
--voice-accent: 135, 111, 244;
|
|
||||||
--voice-accent-secondary: 81, 62, 190;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation[data-voice-state="speaking"] {
|
|
||||||
--voice-accent: 249, 169, 105;
|
|
||||||
--voice-accent-secondary: 236, 112, 137;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation[data-voice-state="error"] {
|
|
||||||
--voice-accent: 235, 135, 88;
|
|
||||||
--voice-accent-secondary: 198, 74, 78;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-orb {
|
|
||||||
position: relative;
|
|
||||||
width: min(46vmin, 220px);
|
|
||||||
height: min(46vmin, 220px);
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-orb-halo,
|
|
||||||
.voice-conversation-orb-core,
|
|
||||||
.voice-conversation-orb-ring {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: block;
|
|
||||||
border-radius: 50%;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-orb-halo {
|
|
||||||
background: radial-gradient(circle, rgba(var(--voice-accent), 0.34), rgba(var(--voice-accent-secondary), 0.1) 52%, transparent 74%);
|
|
||||||
filter: blur(10px);
|
|
||||||
opacity: 0.75;
|
|
||||||
transform: scale(calc(1 + (var(--conversation-level) * 0.3)));
|
|
||||||
transition: transform 130ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-orb-core {
|
|
||||||
inset: 12%;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.34), transparent 34%),
|
|
||||||
radial-gradient(circle at 52% 68%, rgba(var(--voice-accent), 0.62), rgba(var(--voice-accent-secondary), 0.4) 62%, transparent 86%),
|
|
||||||
linear-gradient(150deg, rgba(34, 44, 63, 0.95), rgba(9, 13, 24, 0.98));
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 2px rgba(255, 255, 255, 0.28),
|
|
||||||
0 10px 42px rgba(0, 0, 0, 0.45),
|
|
||||||
0 0 44px rgba(var(--voice-accent), 0.3);
|
|
||||||
transform: scale(calc(1 + (var(--conversation-level) * 0.14)));
|
|
||||||
transition: transform 110ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-orb-ring {
|
|
||||||
inset: 3%;
|
|
||||||
border: 1.5px solid rgba(var(--voice-accent), 0.4);
|
|
||||||
box-shadow: 0 0 22px rgba(var(--voice-accent), 0.16);
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Idle listening: slow breathing. */
|
|
||||||
.voice-conversation[data-voice-state="listening"] .voice-conversation-orb-halo {
|
|
||||||
animation: voice-conversation-breathe 4.4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transcribing/thinking: the ring sweeps. */
|
|
||||||
.voice-conversation[data-voice-state="transcribing"] .voice-conversation-orb-ring,
|
|
||||||
.voice-conversation[data-voice-state="thinking"] .voice-conversation-orb-ring {
|
|
||||||
border-top-color: rgba(var(--voice-accent), 0.95);
|
|
||||||
border-right-color: rgba(var(--voice-accent-secondary), 0.4);
|
|
||||||
animation: voice-conversation-sweep 2.6s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hermes speaking: the halo waves outward while playback is live. */
|
|
||||||
.voice-conversation.is-playing .voice-conversation-orb-halo {
|
|
||||||
animation: voice-conversation-wave 1.9s ease-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation.is-playing .voice-conversation-orb-core {
|
|
||||||
animation: voice-conversation-swell 1.9s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-state {
|
|
||||||
min-height: 20px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 650;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(238, 246, 255, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-captions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
width: min(100%, 40rem);
|
|
||||||
max-height: 30vh;
|
|
||||||
overflow: hidden;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-caption-user {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: rgba(178, 196, 214, 0.88);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-caption-assistant {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: rgba(240, 247, 255, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-controls button {
|
|
||||||
font: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: rgba(233, 243, 252, 0.92);
|
|
||||||
background: rgba(255, 255, 255, 0.07);
|
|
||||||
border: 1px solid rgba(var(--voice-accent), 0.36);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 10px 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-controls button:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-controls button:focus-visible {
|
|
||||||
outline: 2px solid rgb(var(--voice-accent));
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation.is-muted .voice-conversation-mute {
|
|
||||||
color: #ffd9c7;
|
|
||||||
border-color: rgba(235, 135, 88, 0.6);
|
|
||||||
background: rgba(235, 135, 88, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation.is-muted .voice-conversation-orb-halo,
|
|
||||||
.voice-conversation.is-muted .voice-conversation-orb-core {
|
|
||||||
animation: none;
|
|
||||||
transform: none;
|
|
||||||
filter: blur(10px) saturate(0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation.is-muted .voice-conversation-orb-core {
|
|
||||||
filter: saturate(0.35) brightness(0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes voice-conversation-breathe {
|
|
||||||
0%, 100% { opacity: 0.55; transform: scale(calc(0.96 + (var(--conversation-level) * 0.3))); }
|
|
||||||
50% { opacity: 0.85; transform: scale(calc(1.05 + (var(--conversation-level) * 0.3))); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes voice-conversation-sweep {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes voice-conversation-wave {
|
|
||||||
0% { opacity: 0.8; transform: scale(0.96); }
|
|
||||||
55% { opacity: 0.5; transform: scale(1.12); }
|
|
||||||
100% { opacity: 0.75; transform: scale(0.98); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes voice-conversation-swell {
|
|
||||||
0%, 100% { transform: scale(0.99); }
|
|
||||||
50% { transform: scale(1.05); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.voice-conversation { gap: 18px; }
|
|
||||||
.voice-conversation-captions { max-height: 34vh; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduced motion: static orb states — the accent color and the state caption
|
|
||||||
still communicate every phase without animation or level-driven motion. */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.voice-conversation *,
|
|
||||||
.voice-conversation *::before,
|
|
||||||
.voice-conversation *::after {
|
|
||||||
animation: none !important;
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-conversation-orb-halo,
|
|
||||||
.voice-conversation-orb-core {
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -72,23 +72,6 @@
|
|||||||
// A response cancelled by barge-in leaves the user mid-thought: the next
|
// A response cancelled by barge-in leaves the user mid-thought: the next
|
||||||
// finalized utterance inside this window is sent as one stitched message.
|
// finalized utterance inside this window is sent as one stitched message.
|
||||||
const STITCH_WINDOW_MS=20000;
|
const STITCH_WINDOW_MS=20000;
|
||||||
// Endpointing. A short utterance ("The…") is usually a sentence still being
|
|
||||||
// formed, so while less than VAD_COMMITTED_SPEECH_MS of voiced audio has
|
|
||||||
// been collected the endpoint silence window stretches to
|
|
||||||
// VAD_EARLY_SILENCE_MS: a thinking pause right after the first word can no
|
|
||||||
// longer commit a one-word fragment of the sentence.
|
|
||||||
const VAD_EARLY_SILENCE_MS=1800;
|
|
||||||
const VAD_COMMITTED_SPEECH_MS=1200;
|
|
||||||
// The private STT server freezes a speculative end-of-speech snapshot after
|
|
||||||
// 650ms of server-side silence even when this client never sent speculate.
|
|
||||||
// Speech resuming after a gap that long always sends an explicit resume so
|
|
||||||
// a frozen first-word snapshot can never survive into the commit.
|
|
||||||
const SERVER_EOS_SILENCE_MS=650;
|
|
||||||
// Never speculate on the first inter-word gap of a young utterance: a
|
|
||||||
// wasted Whisper pass over one word occupies the Jetson for seconds and
|
|
||||||
// stalls the real commit that follows (observed live as multi-second
|
|
||||||
// commit pending waits).
|
|
||||||
const SPECULATE_MIN_SPEECH_MS=700;
|
|
||||||
const TTS_SPEED_DEFAULT=1.15;
|
const TTS_SPEED_DEFAULT=1.15;
|
||||||
const TTS_SPEED_MIN=0.5;
|
const TTS_SPEED_MIN=0.5;
|
||||||
const TTS_SPEED_MAX=2;
|
const TTS_SPEED_MAX=2;
|
||||||
@ -150,7 +133,6 @@
|
|||||||
label.textContent=customLabel||STATE_LABELS[next]||'';
|
label.textContent=customLabel||STATE_LABELS[next]||'';
|
||||||
bar.style.display=(active&&next!=='idle')||next==='error'?'':'none';
|
bar.style.display=(active&&next!=='idle')||next==='error'?'':'none';
|
||||||
resetInputLevel();
|
resetInputLevel();
|
||||||
syncConversationOverlay(next,customLabel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetInputLevel(){
|
function resetInputLevel(){
|
||||||
@ -165,146 +147,6 @@
|
|||||||
visualInputLevel=(visualInputLevel*0.72)+(target*0.28);
|
visualInputLevel=(visualInputLevel*0.72)+(target*0.28);
|
||||||
indicator.style.setProperty('--voice-ripple-scale',(1.035+(visualInputLevel*0.16)).toFixed(3));
|
indicator.style.setProperty('--voice-ripple-scale',(1.035+(visualInputLevel*0.16)).toFixed(3));
|
||||||
indicator.style.setProperty('--voice-ripple-opacity',(0.26+(visualInputLevel*0.48)).toFixed(3));
|
indicator.style.setProperty('--voice-ripple-opacity',(0.26+(visualInputLevel*0.48)).toFixed(3));
|
||||||
if(conversation) conversation.root.style.setProperty('--conversation-level',visualInputLevel.toFixed(3));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Conversation mode overlay ─────────────────────────────────────────
|
|
||||||
// Hands-free is a full-screen conversation: a breathing orb that follows
|
|
||||||
// microphone energy while listening and TTS playback while speaking, the
|
|
||||||
// live state caption, streaming user/assistant captions, and mute/exit
|
|
||||||
// controls. The overlay is created lazily on activation, removed completely
|
|
||||||
// on deactivate, keeps no storage, and is silently skipped in environments
|
|
||||||
// without a usable DOM (headless contract probes).
|
|
||||||
let conversation=null;
|
|
||||||
|
|
||||||
function conversationUsable(){
|
|
||||||
return !!(typeof document!=='undefined'&&document&&document.body&&typeof document.createElement==='function');
|
|
||||||
}
|
|
||||||
|
|
||||||
function conversationNode(tag,className,attributes){
|
|
||||||
const node=document.createElement(tag);
|
|
||||||
if(className) node.className=className;
|
|
||||||
if(attributes) Object.keys(attributes).forEach(function(name){node.setAttribute(name,attributes[name]);});
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncConversationOverlay(next,customLabel){
|
|
||||||
if(!conversation) return;
|
|
||||||
conversation.root.dataset.voiceState=next;
|
|
||||||
conversation.stateEl.textContent=customLabel||STATE_LABELS[next]||'';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setConversationUserCaption(text){
|
|
||||||
if(conversation) conversation.userCaption.textContent=String(text||'').slice(-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setConversationAssistantCaption(text){
|
|
||||||
if(conversation) conversation.assistantCaption.textContent=String(text||'').slice(-900);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setConversationPlaying(playing){
|
|
||||||
if(!conversation) return;
|
|
||||||
if(playing) conversation.root.classList.add('is-playing');
|
|
||||||
else conversation.root.classList.remove('is-playing');
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyConversationMute(){
|
|
||||||
// True microphone mute on the session's single retained getUserMedia
|
|
||||||
// stream: disabled tracks deliver silence to the recorder, the VAD and
|
|
||||||
// the streaming STT worklet alike. No second capture is ever opened.
|
|
||||||
if(!conversation) return;
|
|
||||||
try{
|
|
||||||
const tracks=stream&&stream.getAudioTracks?stream.getAudioTracks():[];
|
|
||||||
tracks.forEach(function(track){track.enabled=!conversation.muted;});
|
|
||||||
}catch(_){ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function setConversationMuted(muted){
|
|
||||||
if(!conversation) return;
|
|
||||||
conversation.muted=muted;
|
|
||||||
applyConversationMute();
|
|
||||||
try{
|
|
||||||
conversation.muteBtn.setAttribute('aria-pressed',muted?'true':'false');
|
|
||||||
conversation.muteBtn.textContent=muted?'Unmute microphone':'Mute microphone';
|
|
||||||
if(muted) conversation.root.classList.add('is-muted');
|
|
||||||
else conversation.root.classList.remove('is-muted');
|
|
||||||
}catch(_){ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function conversationKeydown(event){
|
|
||||||
if(!conversation) return;
|
|
||||||
if(event.key==='Escape'){
|
|
||||||
if(event.preventDefault) event.preventDefault();
|
|
||||||
deactivate(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(event.key==='Tab'){
|
|
||||||
// Minimal focus trap across the two overlay controls.
|
|
||||||
const stops=[conversation.muteBtn,conversation.exitBtn];
|
|
||||||
const current=stops.indexOf(document.activeElement);
|
|
||||||
const index=current<0?(event.shiftKey?0:stops.length-1):current;
|
|
||||||
const next=stops[(index+(event.shiftKey?stops.length-1:1))%stops.length];
|
|
||||||
if(event.preventDefault) event.preventDefault();
|
|
||||||
if(next&&next.focus) next.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openConversationOverlay(){
|
|
||||||
if(conversation||!conversationUsable()) return;
|
|
||||||
try{
|
|
||||||
const 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'));
|
|
||||||
const stateEl=conversationNode('div','voice-conversation-state',{'aria-hidden':'true'});
|
|
||||||
const captions=conversationNode('div','voice-conversation-captions',{'aria-live':'polite'});
|
|
||||||
const userCaption=conversationNode('p','voice-conversation-caption-user');
|
|
||||||
const assistantCaption=conversationNode('p','voice-conversation-caption-assistant');
|
|
||||||
captions.appendChild(userCaption);
|
|
||||||
captions.appendChild(assistantCaption);
|
|
||||||
const controls=conversationNode('div','voice-conversation-controls');
|
|
||||||
const muteBtn=conversationNode('button','voice-conversation-mute',{type:'button','aria-pressed':'false'});
|
|
||||||
muteBtn.textContent='Mute microphone';
|
|
||||||
const exitBtn=conversationNode('button','voice-conversation-exit',{type:'button'});
|
|
||||||
exitBtn.textContent='Exit voice mode';
|
|
||||||
controls.appendChild(muteBtn);
|
|
||||||
controls.appendChild(exitBtn);
|
|
||||||
root.appendChild(orb);
|
|
||||||
root.appendChild(stateEl);
|
|
||||||
root.appendChild(captions);
|
|
||||||
root.appendChild(controls);
|
|
||||||
muteBtn.addEventListener('click',function(){if(conversation) setConversationMuted(!conversation.muted);});
|
|
||||||
exitBtn.addEventListener('click',function(){deactivate(true);});
|
|
||||||
root.addEventListener('keydown',conversationKeydown);
|
|
||||||
document.body.appendChild(root);
|
|
||||||
conversation={root:root,orb:orb,stateEl:stateEl,userCaption:userCaption,assistantCaption:assistantCaption,muteBtn:muteBtn,exitBtn:exitBtn,muted:false};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeConversationOverlay(){
|
|
||||||
const overlay=conversation;
|
|
||||||
conversation=null;
|
|
||||||
if(!overlay) return;
|
|
||||||
try{
|
|
||||||
if(overlay.root.removeEventListener) overlay.root.removeEventListener('keydown',conversationKeydown);
|
|
||||||
if(overlay.root.parentNode) overlay.root.parentNode.removeChild(overlay.root);
|
|
||||||
}catch(_){ }
|
|
||||||
// The session's tracks are always left enabled on the way out; a real
|
|
||||||
// deactivation stops them entirely via releaseMicrophone().
|
|
||||||
try{
|
|
||||||
const tracks=stream&&stream.getAudioTracks?stream.getAudioTracks():[];
|
|
||||||
tracks.forEach(function(track){track.enabled=true;});
|
|
||||||
}catch(_){ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearErrorTimer(){
|
function clearErrorTimer(){
|
||||||
@ -487,7 +329,6 @@
|
|||||||
cancelSpeechTurn();
|
cancelSpeechTurn();
|
||||||
releaseMicrophone();
|
releaseMicrophone();
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
removeConversationOverlay();
|
|
||||||
modeBtn.classList.remove('active');
|
modeBtn.classList.remove('active');
|
||||||
setState('error',message);
|
setState('error',message);
|
||||||
clearErrorTimer();
|
clearErrorTimer();
|
||||||
@ -568,7 +409,6 @@
|
|||||||
thinkingSession=null;
|
thinkingSession=null;
|
||||||
thinkingTurnId='';
|
thinkingTurnId='';
|
||||||
clearErrorTimer();
|
clearErrorTimer();
|
||||||
removeConversationOverlay();
|
|
||||||
clearSttLanguage();
|
clearSttLanguage();
|
||||||
suppressAutoRead=false;
|
suppressAutoRead=false;
|
||||||
pendingStitch=null;
|
pendingStitch=null;
|
||||||
@ -632,16 +472,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function recordPendingStitch(){
|
function recordPendingStitch(){
|
||||||
// Called at the moment a model turn is cancelled by user speech. The
|
// Called at the moment a model turn is cancelled by user speech. Stitching
|
||||||
// interrupted send left the user mid-thought regardless of how much of an
|
// only applies when the cancelled turn produced NO visible assistant text:
|
||||||
// answer had already streamed — responses start streaming almost
|
// the model never answered, so the next utterance restates the complete
|
||||||
// immediately, so gating on visible output made stitching almost never
|
// thought as one message. Any partial answer means the interruption stands
|
||||||
// fire. The next finalized utterance inside STITCH_WINDOW_MS is therefore
|
// on its own and the next utterance is sent alone.
|
||||||
// always sent together with the interrupted transcript as one message.
|
|
||||||
// Normal completion, deactivation and window expiry still clear it.
|
|
||||||
const sent=lastSentTranscript;
|
const sent=lastSentTranscript;
|
||||||
lastSentTranscript=null;
|
lastSentTranscript=null;
|
||||||
if(!sent||!sent.text) return;
|
if(!sent||!sent.text) return;
|
||||||
|
if(currentAssistantText()){pendingStitch=null;return;}
|
||||||
pendingStitch={text:sent.text,at:Date.now()};
|
pendingStitch={text:sent.text,at:Date.now()};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -664,8 +503,6 @@
|
|||||||
pendingStitch=null;
|
pendingStitch=null;
|
||||||
composer.value=stitch?stitch.text+' '+text:text;
|
composer.value=stitch?stitch.text+' '+text:text;
|
||||||
if(typeof window.autoResize==='function') window.autoResize();
|
if(typeof window.autoResize==='function') window.autoResize();
|
||||||
setConversationUserCaption(composer.value);
|
|
||||||
setConversationAssistantCaption('');
|
|
||||||
setState('thinking');
|
setState('thinking');
|
||||||
// The live VAD/STT capture is the barge-in detector while it runs; the
|
// The live VAD/STT capture is the barge-in detector while it runs; the
|
||||||
// energy-only monitor remains only as a fallback when capture is down.
|
// energy-only monitor remains only as a fallback when capture is down.
|
||||||
@ -880,7 +717,6 @@
|
|||||||
if(visible&&active&&captureTurnId===turnId&&state==='listening'){
|
if(visible&&active&&captureTurnId===turnId&&state==='listening'){
|
||||||
const preview=visible.length>72?visible.slice(0,69)+'…':visible;
|
const preview=visible.length>72?visible.slice(0,69)+'…':visible;
|
||||||
label.textContent='Listening · '+preview+(stable?'':' · provisional');
|
label.textContent='Listening · '+preview+(stable?'':' · provisional');
|
||||||
setConversationUserCaption(visible);
|
|
||||||
if(stable!==lastPreflightText){
|
if(stable!==lastPreflightText){
|
||||||
if(stable){
|
if(stable){
|
||||||
lastPreflightText=stable;
|
lastPreflightText=stable;
|
||||||
@ -933,11 +769,7 @@
|
|||||||
sendJson({type:'speculate',turn_id:turnId});
|
sendJson({type:'speculate',turn_id:turnId});
|
||||||
},
|
},
|
||||||
resume:function(){
|
resume:function(){
|
||||||
// Deliberately not gated on a prior speculate() from this client: the
|
if(settled||committed||!speculative) return;
|
||||||
// server also freezes an EOS snapshot on its own silence detector, so
|
|
||||||
// resume must always reach the wire when speech restarts. The server
|
|
||||||
// treats a redundant resume as a harmless epoch bump.
|
|
||||||
if(settled||committed) return;
|
|
||||||
speculative=false;
|
speculative=false;
|
||||||
sendJson({type:'resume',turn_id:turnId});
|
sendJson({type:'resume',turn_id:turnId});
|
||||||
},
|
},
|
||||||
@ -1255,7 +1087,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stream=capture;
|
stream=capture;
|
||||||
applyConversationMute();
|
|
||||||
const captureAec=captureAecIsUsable(stream);
|
const captureAec=captureAecIsUsable(stream);
|
||||||
const Context=window.AudioContext||window.webkitAudioContext;
|
const Context=window.AudioContext||window.webkitAudioContext;
|
||||||
if(reusedCapture&&reusedCapture.context){
|
if(reusedCapture&&reusedCapture.context){
|
||||||
@ -1302,9 +1133,6 @@
|
|||||||
const chunks=[];
|
const chunks=[];
|
||||||
let heardSpeech=!!(reusedCapture&&reusedCapture.heardSpeech);
|
let heardSpeech=!!(reusedCapture&&reusedCapture.heardSpeech);
|
||||||
let voiceFrames=heardSpeech?3:0;
|
let voiceFrames=heardSpeech?3:0;
|
||||||
// Voiced milliseconds collected this utterance. A barge handoff already
|
|
||||||
// carries confirmed speech, so it starts past the young-utterance hold.
|
|
||||||
let speechMs=heardSpeech?VAD_COMMITTED_SPEECH_MS:0;
|
|
||||||
let noiseFloor=0.008;
|
let noiseFloor=0.008;
|
||||||
let lastSpeech=Date.now();
|
let lastSpeech=Date.now();
|
||||||
let speculative=false;
|
let speculative=false;
|
||||||
@ -1394,16 +1222,10 @@
|
|||||||
bargeArmAt=now+100;
|
bargeArmAt=now+100;
|
||||||
}
|
}
|
||||||
playbackWasLive=playbackLive;
|
playbackWasLive=playbackLive;
|
||||||
setConversationPlaying(playbackLive);
|
|
||||||
const speechThreshold=Math.max(0.04,noiseFloor*2.4+0.006);
|
const speechThreshold=Math.max(0.04,noiseFloor*2.4+0.006);
|
||||||
const voiceNow=rms>speechThreshold&&(!playbackLive||(captureAec&&now>=bargeArmAt));
|
const voiceNow=rms>speechThreshold&&(!playbackLive||(captureAec&&now>=bargeArmAt));
|
||||||
// Adapt the floor only toward energies at or below the current
|
if(!heardSpeech&&!voiceNow){noiseFloor=(noiseFloor*0.94)+(rms*0.06);}
|
||||||
// threshold (like the fallback energy monitor): echo residue during
|
|
||||||
// continuous capture must never ratchet the threshold above real
|
|
||||||
// speech and deafen onset detection for the next utterance.
|
|
||||||
if(!heardSpeech&&!voiceNow){noiseFloor=(noiseFloor*0.94)+(Math.min(rms,speechThreshold)*0.06);}
|
|
||||||
voiceFrames=voiceNow?Math.min(voiceFrames+1,5):Math.max(voiceFrames-1,0);
|
voiceFrames=voiceNow?Math.min(voiceFrames+1,5):Math.max(voiceFrames-1,0);
|
||||||
if(voiceNow) speechMs+=100;
|
|
||||||
if(playbackLive){
|
if(playbackLive){
|
||||||
if(voiceFrames>=BARGE_DUCK_FRAMES&&!ducked){ducked=true;setPlaybackDucked(true);}
|
if(voiceFrames>=BARGE_DUCK_FRAMES&&!ducked){ducked=true;setPlaybackDucked(true);}
|
||||||
if(!voiceFrames&&ducked){ducked=false;setPlaybackDucked(false);}
|
if(!voiceFrames&&ducked){ducked=false;setPlaybackDucked(false);}
|
||||||
@ -1421,21 +1243,14 @@
|
|||||||
// cancel the response side only; this capture keeps running.
|
// cancel the response side only; this capture keeps running.
|
||||||
if(state==='thinking'||state==='speaking') bargeFromLiveCapture();
|
if(state==='thinking'||state==='speaking') bargeFromLiveCapture();
|
||||||
}else if(heardSpeech&&voiceNow){
|
}else if(heardSpeech&&voiceNow){
|
||||||
const speechGapMs=now-lastSpeech;
|
|
||||||
lastSpeech=now;
|
lastSpeech=now;
|
||||||
// Resume after our own speculate AND after any gap long enough for
|
if(speculative&&streamingStt){streamingStt.resume();speculative=false;}
|
||||||
// the server's independent 650ms end-of-speech detector: either
|
|
||||||
// party may have frozen a partial-utterance snapshot by now.
|
|
||||||
if((speculative||speechGapMs>=SERVER_EOS_SILENCE_MS)&&streamingStt){streamingStt.resume();speculative=false;}
|
|
||||||
}
|
}
|
||||||
if(heardSpeech&&!voiceNow&&!speculative&&speechMs>=SPECULATE_MIN_SPEECH_MS&&(now-lastSpeech)>=speculateMs&&streamingStt){
|
if(heardSpeech&&!voiceNow&&!speculative&&(now-lastSpeech)>=speculateMs&&streamingStt){
|
||||||
streamingStt.speculate();
|
streamingStt.speculate();
|
||||||
speculative=true;
|
speculative=true;
|
||||||
}
|
}
|
||||||
// A young utterance holds its endpoint longer: pausing to think right
|
const finished=heardSpeech&&(now-lastSpeech)>=silenceMs;
|
||||||
// after the first word must not send a one-word fragment.
|
|
||||||
const endpointSilenceMs=speechMs<VAD_COMMITTED_SPEECH_MS?Math.max(silenceMs,VAD_EARLY_SILENCE_MS):silenceMs;
|
|
||||||
const finished=heardSpeech&&(now-lastSpeech)>=endpointSilenceMs;
|
|
||||||
const timedOut=now-started>=90000;
|
const timedOut=now-started>=90000;
|
||||||
const idle=(!heardSpeech)&&(now-started)>=20000;
|
const idle=(!heardSpeech)&&(now-started)>=20000;
|
||||||
if(finished||timedOut||idle){
|
if(finished||timedOut||idle){
|
||||||
@ -2014,7 +1829,6 @@
|
|||||||
if(isFinal){thinkingSession=null;thinkingTurnId='';clearSttLanguage();stopResponseObserver();cancelThinkingCues();restartSoon(token,250);}
|
if(isFinal){thinkingSession=null;thinkingTurnId='';clearSttLanguage();stopResponseObserver();cancelThinkingCues();restartSoon(token,250);}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setConversationAssistantCaption(text);
|
|
||||||
// Any answer text, even a not-yet-speakable partial clause, owns the audio
|
// Any answer text, even a not-yet-speakable partial clause, owns the audio
|
||||||
// timeline from this point forward. A cue must never overlap or become part
|
// timeline from this point forward. A cue must never overlap or become part
|
||||||
// of the answer queue.
|
// of the answer queue.
|
||||||
@ -2101,7 +1915,6 @@
|
|||||||
clearSttLanguage();
|
clearSttLanguage();
|
||||||
modeBtn.classList.add('active');
|
modeBtn.classList.add('active');
|
||||||
toast('Hands-free private voice mode on');
|
toast('Hands-free private voice mode on');
|
||||||
openConversationOverlay();
|
|
||||||
if(typeof window.stopTTS==='function') window.stopTTS();
|
if(typeof window.stopTTS==='function') window.stopTTS();
|
||||||
if(typeof S!=='undefined'&&S.busy){setState('thinking');return;}
|
if(typeof S!=='undefined'&&S.busy){setState('thinking');return;}
|
||||||
startListening(token);
|
startListening(token);
|
||||||
|
|||||||
25
dockerfiles/hermes-webui-hux/bootstrap.css
vendored
25
dockerfiles/hermes-webui-hux/bootstrap.css
vendored
@ -94,28 +94,3 @@
|
|||||||
border-color: CanvasText;
|
border-color: CanvasText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Theme bridge for the Workspace chrome ─────────────────────────────── */
|
|
||||||
/* The pinned upstream WebUI theme (and hermes-brand.css on top of it) defines
|
|
||||||
--bg/--text but never --bg-primary/--text-primary, so every color-mix()
|
|
||||||
surface above resolved its fallback constant (#0a101b): with the drawer
|
|
||||||
open that painted a large flat dark rectangle that visibly broke the page's
|
|
||||||
themed background. Bridge the tokens to the app theme so the drawer, its
|
|
||||||
toggle and the shell inherit the real page background (light and dark) —
|
|
||||||
layout and component styling are deliberately untouched. */
|
|
||||||
.hux-workspace-toggle,
|
|
||||||
.hux-workspace-drawer,
|
|
||||||
.hux-shell {
|
|
||||||
--bg-primary: var(--bg, #0a101b);
|
|
||||||
--text-primary: var(--text, #e7eef6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The header previously painted a hard-coded dark wash and sampled the page
|
|
||||||
through backdrop-filter; blurring the backdrop of a sticky header inside a
|
|
||||||
fixed overflow scroller is also a known Chromium compositor hazard on this
|
|
||||||
app's gradient background (see the stale-tile note in hermes-brand.css).
|
|
||||||
Track the theme instead and let the opaque drawer carry the surface. */
|
|
||||||
.hux-workspace-drawer__header {
|
|
||||||
backdrop-filter: none;
|
|
||||||
background: color-mix(in srgb, var(--bg, #0a101b) 92%, transparent);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,786 +0,0 @@
|
|||||||
// Deterministic continuous-capture probe for dockerfiles/hermes-webui-atlas-voice.js.
|
|
||||||
//
|
|
||||||
// Drives the real script through complete streaming hands-free turns against a
|
|
||||||
// stub STT WebSocket whose VAD, speculative EOS-freeze, resume and commit
|
|
||||||
// semantics faithfully mirror dockerfiles/hermes-jetson-stt-server.py. Words
|
|
||||||
// are encoded as distinct constant PCM amplitudes so the "transcript" of any
|
|
||||||
// audio snapshot is exactly the ordered set of words whose samples survived
|
|
||||||
// endpointing — a first-word-only regression is directly observable as a
|
|
||||||
// truncated transcript in the sent composer text.
|
|
||||||
//
|
|
||||||
// node hermes_voice_capture_probe.js <path-to-atlas-voice.js>
|
|
||||||
//
|
|
||||||
// Prints one JSON object per scenario to stdout; the python wrapper asserts.
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const vm = require('vm');
|
|
||||||
|
|
||||||
const SCRIPT_PATH = process.argv[2];
|
|
||||||
if (!SCRIPT_PATH) {
|
|
||||||
throw new Error('usage: hermes_voice_capture_probe.js <atlas-voice.js>');
|
|
||||||
}
|
|
||||||
const SOURCE = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
||||||
|
|
||||||
class FakeURL extends URL {}
|
|
||||||
FakeURL.createObjectURL = () => 'blob:capture-probe';
|
|
||||||
FakeURL.revokeObjectURL = () => {};
|
|
||||||
|
|
||||||
const SAMPLE_RATE = 16000;
|
|
||||||
const TICK_MS = 100;
|
|
||||||
const TICK_SAMPLES = (SAMPLE_RATE * TICK_MS) / 1000;
|
|
||||||
|
|
||||||
// Server constants mirrored from hermes-jetson-stt-server.py.
|
|
||||||
const SERVER_END_SILENCE_SAMPLES = (SAMPLE_RATE * 650) / 1000;
|
|
||||||
const SERVER_TAIL_SAMPLES = (SAMPLE_RATE * 220) / 1000;
|
|
||||||
|
|
||||||
function flush() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let hops = 0;
|
|
||||||
(function hop() {
|
|
||||||
hops += 1;
|
|
||||||
if (hops > 16) { resolve(); return; }
|
|
||||||
setImmediate(hop);
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const element = {
|
|
||||||
id,
|
|
||||||
value: '',
|
|
||||||
textContent: '',
|
|
||||||
className: '',
|
|
||||||
dataset: {},
|
|
||||||
attributes: {},
|
|
||||||
children: [],
|
|
||||||
style: {
|
|
||||||
values: new Map(),
|
|
||||||
display: '',
|
|
||||||
setProperty(name, value) { this.values.set(name, String(value)); },
|
|
||||||
getPropertyValue(name) { return this.values.get(name) || ''; },
|
|
||||||
removeProperty(name) { this.values.delete(name); },
|
|
||||||
},
|
|
||||||
listeners: [],
|
|
||||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
|
||||||
getAttribute(name) {
|
|
||||||
return Object.prototype.hasOwnProperty.call(this.attributes, name)
|
|
||||||
? this.attributes[name] : null;
|
|
||||||
},
|
|
||||||
addEventListener(type, handler) { this.listeners.push({ type, handler }); },
|
|
||||||
removeEventListener(type, handler) {
|
|
||||||
element.listeners = element.listeners.filter((entry) => entry.handler !== handler);
|
|
||||||
},
|
|
||||||
appendChild(child) { element.children.push(child); child.parentNode = element; return child; },
|
|
||||||
removeChild(child) {
|
|
||||||
element.children = element.children.filter((entry) => entry !== child);
|
|
||||||
child.parentNode = null;
|
|
||||||
return child;
|
|
||||||
},
|
|
||||||
contains() { return false; },
|
|
||||||
focus() {},
|
|
||||||
querySelector() { return null; },
|
|
||||||
querySelectorAll() { return []; },
|
|
||||||
insertBefore() {},
|
|
||||||
click() {
|
|
||||||
const event = { preventDefault() {}, stopImmediatePropagation() {} };
|
|
||||||
element.listeners
|
|
||||||
.filter((entry) => entry.type === 'click')
|
|
||||||
.forEach((entry) => entry.handler(event));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
element.classList = {
|
|
||||||
add(name) {
|
|
||||||
const names = new Set(element.className.split(/\s+/).filter(Boolean));
|
|
||||||
names.add(name);
|
|
||||||
element.className = [...names].join(' ');
|
|
||||||
},
|
|
||||||
remove(name) {
|
|
||||||
element.className = element.className
|
|
||||||
.split(/\s+/).filter((value) => value && value !== name).join(' ');
|
|
||||||
},
|
|
||||||
contains(name) { return element.className.split(/\s+/).includes(name); },
|
|
||||||
toggle(name, force) {
|
|
||||||
const present = this.contains(name);
|
|
||||||
const next = force === undefined ? !present : !!force;
|
|
||||||
if (next) this.add(name); else this.remove(name);
|
|
||||||
return next;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Faithful port of StreamingTranscription's endpointing/speculation contract.
|
|
||||||
class StubSttServer {
|
|
||||||
constructor(words) {
|
|
||||||
// words: {amplitude(int16) -> word string}
|
|
||||||
this.words = words;
|
|
||||||
this.chunks = []; // Int16Array chunks in arrival order
|
|
||||||
this.totalSamples = 0;
|
|
||||||
this.noiseFloor = 0.004;
|
|
||||||
this.heardSpeech = false;
|
|
||||||
this.atEos = false;
|
|
||||||
this.silenceSamples = 0;
|
|
||||||
this.lastSpeechSample = 0;
|
|
||||||
this.clientActive = false;
|
|
||||||
this.epoch = 0;
|
|
||||||
this.frozenSnapshotSamples = -1;
|
|
||||||
this.committed = false;
|
|
||||||
this.events = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshotSamples() {
|
|
||||||
if (this.atEos && this.frozenSnapshotSamples >= 0) return this.frozenSnapshotSamples;
|
|
||||||
let cutoff = this.totalSamples;
|
|
||||||
if (this.heardSpeech && this.lastSpeechSample) {
|
|
||||||
cutoff = Math.min(cutoff, this.lastSpeechSample + SERVER_TAIL_SAMPLES);
|
|
||||||
}
|
|
||||||
return cutoff;
|
|
||||||
}
|
|
||||||
|
|
||||||
append(int16) {
|
|
||||||
if (this.committed) throw new Error('server: audio after commit');
|
|
||||||
this.chunks.push(int16);
|
|
||||||
this.totalSamples += int16.length;
|
|
||||||
let energy = 0;
|
|
||||||
for (let index = 0; index < int16.length; index += 1) {
|
|
||||||
const value = int16[index] / 32768;
|
|
||||||
energy += value * value;
|
|
||||||
}
|
|
||||||
const rms = int16.length ? Math.sqrt(energy / int16.length) : 0;
|
|
||||||
const threshold = Math.max(0.012, this.noiseFloor * 2.5 + 0.004);
|
|
||||||
const speech = this.clientActive || rms >= threshold;
|
|
||||||
if (!this.heardSpeech && !speech) {
|
|
||||||
this.noiseFloor = this.noiseFloor * 0.96 + rms * 0.04;
|
|
||||||
}
|
|
||||||
if (speech) {
|
|
||||||
if (this.atEos) {
|
|
||||||
this.epoch += 1;
|
|
||||||
this.frozenSnapshotSamples = -1;
|
|
||||||
this.events.push('recover-by-rms');
|
|
||||||
}
|
|
||||||
this.heardSpeech = true;
|
|
||||||
this.atEos = false;
|
|
||||||
this.silenceSamples = 0;
|
|
||||||
this.lastSpeechSample = this.totalSamples;
|
|
||||||
} else if (this.heardSpeech) {
|
|
||||||
this.silenceSamples += int16.length;
|
|
||||||
if (this.silenceSamples >= SERVER_END_SILENCE_SAMPLES && !this.atEos) {
|
|
||||||
this.atEos = true;
|
|
||||||
this.frozenSnapshotSamples = this.snapshotSamples();
|
|
||||||
this.events.push('auto-eos-freeze');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
speculate() {
|
|
||||||
this.clientActive = false;
|
|
||||||
this.atEos = true;
|
|
||||||
this.frozenSnapshotSamples = this.snapshotSamples();
|
|
||||||
this.events.push('speculate-freeze');
|
|
||||||
}
|
|
||||||
|
|
||||||
resume() {
|
|
||||||
this.atEos = false;
|
|
||||||
this.silenceSamples = 0;
|
|
||||||
this.epoch += 1;
|
|
||||||
this.clientActive = true;
|
|
||||||
this.frozenSnapshotSamples = -1;
|
|
||||||
this.events.push('resume-clear');
|
|
||||||
}
|
|
||||||
|
|
||||||
transcript() {
|
|
||||||
const cutoff = this.snapshotSamples();
|
|
||||||
const decoded = [];
|
|
||||||
let position = 0;
|
|
||||||
let currentWord = null;
|
|
||||||
let runLength = 0;
|
|
||||||
const flushRun = () => {
|
|
||||||
// Ignore sub-40ms runs: resampler transition samples, not words.
|
|
||||||
if (currentWord && runLength >= SAMPLE_RATE * 0.04) decoded.push(currentWord);
|
|
||||||
currentWord = null;
|
|
||||||
runLength = 0;
|
|
||||||
};
|
|
||||||
for (const chunk of this.chunks) {
|
|
||||||
for (let index = 0; index < chunk.length; index += 1) {
|
|
||||||
if (position >= cutoff) { flushRun(); return decoded.join(' '); }
|
|
||||||
position += 1;
|
|
||||||
const magnitude = Math.abs(chunk[index]);
|
|
||||||
let word = null;
|
|
||||||
for (const [amplitude, name] of Object.entries(this.words)) {
|
|
||||||
if (Math.abs(magnitude - Number(amplitude)) <= 200) { word = name; break; }
|
|
||||||
}
|
|
||||||
if (word === currentWord) {
|
|
||||||
runLength += 1;
|
|
||||||
} else {
|
|
||||||
flushRun();
|
|
||||||
currentWord = word;
|
|
||||||
runLength = word ? 1 : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushRun();
|
|
||||||
return decoded.join(' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeHarness(options = {}) {
|
|
||||||
const clock = { now: 1000000 };
|
|
||||||
const timeouts = [];
|
|
||||||
const intervals = new Map();
|
|
||||||
let timerId = 1;
|
|
||||||
|
|
||||||
const sends = [];
|
|
||||||
const toasts = [];
|
|
||||||
const transcribeUploads = [];
|
|
||||||
const servers = [];
|
|
||||||
const captureNodes = [];
|
|
||||||
const analysers = [];
|
|
||||||
const recorders = [];
|
|
||||||
let assistantRows = [];
|
|
||||||
let streamCounter = 0;
|
|
||||||
|
|
||||||
const words = options.words || { 8000: 'alpha', 12000: 'bravo', 16000: 'charlie' };
|
|
||||||
const micTracks = [];
|
|
||||||
|
|
||||||
const elements = {};
|
|
||||||
['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg']
|
|
||||||
.forEach((id) => { elements[id] = makeElement(id); });
|
|
||||||
const bodyElement = makeElement('body');
|
|
||||||
|
|
||||||
// ── Microphone model ────────────────────────────────────────────────────
|
|
||||||
// Per tick the mic has one (analyser level, pcm amplitude) pair. The level
|
|
||||||
// feeds the script's VAD analyser; the amplitude becomes the PCM the
|
|
||||||
// capture worklet delivers to the streaming session.
|
|
||||||
const mic = { level: 0, amplitude: 0 };
|
|
||||||
|
|
||||||
class FakeAnalyser {
|
|
||||||
constructor() { this.fftSize = 0; analysers.push(this); }
|
|
||||||
getByteTimeDomainData(samples) {
|
|
||||||
for (let index = 0; index < samples.length; index += 1) {
|
|
||||||
const offset = Math.round(mic.level * 127);
|
|
||||||
samples[index] = 128 + (index % 2 ? offset : -offset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
disconnect() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeAudioContext {
|
|
||||||
constructor() {
|
|
||||||
this.sampleRate = SAMPLE_RATE;
|
|
||||||
this.destination = {};
|
|
||||||
this.audioWorklet = { addModule: async () => {} };
|
|
||||||
this.closed = false;
|
|
||||||
}
|
|
||||||
createAnalyser() { return new FakeAnalyser(); }
|
|
||||||
createBiquadFilter() {
|
|
||||||
return { type: '', frequency: { value: 0 }, Q: { value: 0 }, connect() {}, disconnect() {} };
|
|
||||||
}
|
|
||||||
createMediaStreamSource() { return { connect() {}, disconnect() {} }; }
|
|
||||||
createGain() { return { gain: { value: 0, setTargetAtTime() {} }, connect() {}, disconnect() {} }; }
|
|
||||||
resume() { return Promise.resolve(); }
|
|
||||||
close() { this.closed = true; return Promise.resolve(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeAudioWorkletNode {
|
|
||||||
constructor(_context, name) {
|
|
||||||
this.name = name;
|
|
||||||
this.connected = true;
|
|
||||||
const node = this;
|
|
||||||
this.port = {
|
|
||||||
onmessage: null,
|
|
||||||
postMessage(message) {
|
|
||||||
if ((message || {}).type === 'flush') {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (node.port.onmessage) node.port.onmessage({ data: { type: 'flushed' } });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (name === 'atlas-pcm-capture') captureNodes.push(this);
|
|
||||||
}
|
|
||||||
connect() {}
|
|
||||||
disconnect() { this.connected = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeMediaRecorder {
|
|
||||||
static isTypeSupported() { return true; }
|
|
||||||
constructor(stream, recorderOptions) {
|
|
||||||
this.stream = stream;
|
|
||||||
this.state = 'inactive';
|
|
||||||
this.mimeType = (recorderOptions && recorderOptions.mimeType) || 'audio/webm;codecs=opus';
|
|
||||||
this.ondataavailable = null;
|
|
||||||
this.onstop = null;
|
|
||||||
recorders.push(this);
|
|
||||||
}
|
|
||||||
start() { this.state = 'recording'; }
|
|
||||||
stop() {
|
|
||||||
if (this.state === 'inactive') return;
|
|
||||||
this.state = 'inactive';
|
|
||||||
if (this.ondataavailable) {
|
|
||||||
this.ondataavailable({ data: { size: 512, type: 'audio/webm;codecs=opus' } });
|
|
||||||
}
|
|
||||||
const recorder = this;
|
|
||||||
// Real recorders finalize asynchronously.
|
|
||||||
queueMicrotask(() => { if (recorder.onstop) recorder.onstop(); });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeWebSocket {
|
|
||||||
constructor(url, protocols) {
|
|
||||||
this.url = url;
|
|
||||||
this.protocols = protocols;
|
|
||||||
this.readyState = 0;
|
|
||||||
this.bufferedAmount = 0;
|
|
||||||
this.binaryType = '';
|
|
||||||
this.onopen = null;
|
|
||||||
this.onmessage = null;
|
|
||||||
this.onerror = null;
|
|
||||||
this.onclose = null;
|
|
||||||
this.server = new StubSttServer(words);
|
|
||||||
this.turnId = '';
|
|
||||||
servers.push(this);
|
|
||||||
const socket = this;
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (socket.readyState !== 0) return;
|
|
||||||
socket.readyState = 1;
|
|
||||||
if (socket.onopen) socket.onopen();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
deliver(payload) {
|
|
||||||
const socket = this;
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (socket.readyState !== 1) return;
|
|
||||||
if (socket.onmessage) socket.onmessage({ data: JSON.stringify(payload) });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
send(data) {
|
|
||||||
if (this.readyState !== 1) throw new Error('socket not open');
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
const message = JSON.parse(data);
|
|
||||||
if (message.type === 'start') { this.turnId = message.turn_id; return; }
|
|
||||||
if (message.type === 'speculate') { this.server.speculate(); return; }
|
|
||||||
if (message.type === 'resume') { this.server.resume(); return; }
|
|
||||||
if (message.type === 'cancel') { this.server.events.push('cancel'); return; }
|
|
||||||
if (message.type === 'commit') {
|
|
||||||
this.server.committed = true;
|
|
||||||
this.deliver({
|
|
||||||
type: 'final',
|
|
||||||
turn_id: this.turnId,
|
|
||||||
transcript: this.server.transcript(),
|
|
||||||
language: 'en',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.server.append(new Int16Array(data));
|
|
||||||
}
|
|
||||||
close(code) { this.readyState = 3; this.closeCode = code; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchStub(url, init) {
|
|
||||||
if (url === '/api/transcribe/capability') {
|
|
||||||
return { ok: true, status: 200, json: async () => ({ available: true, provider: 'local_command' }) };
|
|
||||||
}
|
|
||||||
if (url === '/api/voice/streaming/capability') {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
tts: { available: false },
|
|
||||||
stt: { available: true, transport: 'websocket', format: 'pcm_s16le', sample_rate: 16000 },
|
|
||||||
preflight: { available: false },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (url === '/api/transcribe') {
|
|
||||||
transcribeUploads.push({ body: init && init.body });
|
|
||||||
return { ok: true, status: 200, json: async () => ({ transcript: 'CONTAINER-FALLBACK', language: 'en' }) };
|
|
||||||
}
|
|
||||||
if (url === '/api/tts') {
|
|
||||||
return { ok: true, status: 200, blob: async () => ({ synthetic: true }), json: async () => ({}) };
|
|
||||||
}
|
|
||||||
if (String(url).indexOf('api/chat/cancel') >= 0) {
|
|
||||||
context.S.busy = false;
|
|
||||||
context.S.activeStreamId = null;
|
|
||||||
if (context.S.session) context.S.session.active_stream_id = null;
|
|
||||||
return { ok: true, status: 200, json: async () => ({ cancelled: true }) };
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected fetch: ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const storage = new Map();
|
|
||||||
const context = {
|
|
||||||
AbortController,
|
|
||||||
console,
|
|
||||||
Uint8Array,
|
|
||||||
Int16Array,
|
|
||||||
Float32Array,
|
|
||||||
ArrayBuffer,
|
|
||||||
DataView,
|
|
||||||
Promise,
|
|
||||||
Math,
|
|
||||||
JSON,
|
|
||||||
String,
|
|
||||||
Number,
|
|
||||||
Error,
|
|
||||||
parseInt,
|
|
||||||
parseFloat,
|
|
||||||
isNaN,
|
|
||||||
Set,
|
|
||||||
Map,
|
|
||||||
Array,
|
|
||||||
Object,
|
|
||||||
URL: FakeURL,
|
|
||||||
queueMicrotask,
|
|
||||||
Date: { now: () => clock.now },
|
|
||||||
Blob: function Blob(parts, blobOptions) {
|
|
||||||
this.parts = parts;
|
|
||||||
this.type = (blobOptions || {}).type || '';
|
|
||||||
this.size = 512;
|
|
||||||
},
|
|
||||||
File: function File(parts, name, fileOptions) {
|
|
||||||
this.parts = parts; this.name = name; this.type = (fileOptions || {}).type || '';
|
|
||||||
},
|
|
||||||
FormData: function FormData() {
|
|
||||||
this.entries = []; this.append = (key, value) => this.entries.push([key, value]);
|
|
||||||
},
|
|
||||||
Audio: function Audio() {
|
|
||||||
this.play = () => Promise.resolve();
|
|
||||||
this.pause = () => {};
|
|
||||||
this.onended = null;
|
|
||||||
this.onerror = null;
|
|
||||||
},
|
|
||||||
WebSocket: FakeWebSocket,
|
|
||||||
AudioWorkletNode: FakeAudioWorkletNode,
|
|
||||||
MediaRecorder: FakeMediaRecorder,
|
|
||||||
AudioContext: FakeAudioContext,
|
|
||||||
fetch: fetchStub,
|
|
||||||
localStorage: {
|
|
||||||
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
|
|
||||||
setItem: (key, value) => { storage.set(key, String(value)); },
|
|
||||||
removeItem: (key) => { storage.delete(key); },
|
|
||||||
},
|
|
||||||
navigator: {
|
|
||||||
mediaDevices: {
|
|
||||||
getSupportedConstraints: () => ({}),
|
|
||||||
getUserMedia: async () => {
|
|
||||||
const track = {
|
|
||||||
stop() {},
|
|
||||||
enabled: true,
|
|
||||||
getSettings: () => ({ echoCancellation: true }),
|
|
||||||
};
|
|
||||||
micTracks.push(track);
|
|
||||||
return { getTracks: () => [track], getAudioTracks: () => [track] };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
document: {
|
|
||||||
baseURI: 'https://chat.test/',
|
|
||||||
body: bodyElement,
|
|
||||||
getElementById: (id) => elements[id] || null,
|
|
||||||
querySelectorAll: () => assistantRows,
|
|
||||||
createElement: (tag) => makeElement(`created-${tag}`),
|
|
||||||
addEventListener() {},
|
|
||||||
removeEventListener() {},
|
|
||||||
},
|
|
||||||
location: { protocol: 'https:', host: 'chat.test', href: 'https://chat.test/' },
|
|
||||||
crypto: { getRandomValues(bytes) { for (let i = 0; i < bytes.length; i += 1) bytes[i] = i + 1; } },
|
|
||||||
S: { session: { session_id: 'session-1' }, busy: false, activeStreamId: null },
|
|
||||||
setTimeout: (fn, delay) => {
|
|
||||||
const id = timerId; timerId += 1;
|
|
||||||
timeouts.push({ id, fn, at: clock.now + (delay || 0) });
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
clearTimeout: (id) => {
|
|
||||||
const index = timeouts.findIndex((entry) => entry.id === id);
|
|
||||||
if (index >= 0) timeouts.splice(index, 1);
|
|
||||||
},
|
|
||||||
setInterval: (fn, delay) => {
|
|
||||||
const id = timerId; timerId += 1;
|
|
||||||
intervals.set(id, { fn, delay: delay || 0 });
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
clearInterval: (id) => { intervals.delete(id); },
|
|
||||||
};
|
|
||||||
context.window = context;
|
|
||||||
context.window.location = context.location;
|
|
||||||
context.window.crypto = context.crypto;
|
|
||||||
context.showToast = (message) => { toasts.push(message); };
|
|
||||||
context.send = () => {
|
|
||||||
streamCounter += 1;
|
|
||||||
sends.push(elements.msg.value);
|
|
||||||
context.S.busy = true;
|
|
||||||
context.S.activeStreamId = `stream-${streamCounter}`;
|
|
||||||
if (context.S.session) context.S.session.active_stream_id = context.S.activeStreamId;
|
|
||||||
};
|
|
||||||
context.autoResize = () => {};
|
|
||||||
context.stopTTS = () => {};
|
|
||||||
context._stripForTTS = (text) => text;
|
|
||||||
|
|
||||||
vm.createContext(context);
|
|
||||||
vm.runInContext(SOURCE, context, { filename: 'atlas-voice.js' });
|
|
||||||
|
|
||||||
function runDueTimeouts() {
|
|
||||||
const due = timeouts.filter((entry) => entry.at <= clock.now);
|
|
||||||
due.forEach((entry) => {
|
|
||||||
const index = timeouts.indexOf(entry);
|
|
||||||
if (index >= 0) timeouts.splice(index, 1);
|
|
||||||
entry.fn();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentCaptureNode() {
|
|
||||||
for (let index = captureNodes.length - 1; index >= 0; index -= 1) {
|
|
||||||
if (captureNodes[index].connected) return captureNodes[index];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deliverMicFrame() {
|
|
||||||
const node = currentCaptureNode();
|
|
||||||
if (!node || !node.port.onmessage) return;
|
|
||||||
const samples = new Float32Array(TICK_SAMPLES);
|
|
||||||
const value = mic.amplitude / 32768;
|
|
||||||
for (let index = 0; index < samples.length; index += 1) {
|
|
||||||
samples[index] = index % 2 ? value : -value;
|
|
||||||
}
|
|
||||||
node.port.onmessage({ data: { type: 'pcm', samples: samples.buffer } });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tick(ms) {
|
|
||||||
const steps = Math.max(1, Math.round(ms / TICK_MS));
|
|
||||||
for (let step = 0; step < steps; step += 1) {
|
|
||||||
clock.now += TICK_MS;
|
|
||||||
deliverMicFrame();
|
|
||||||
Array.from(intervals.values()).forEach((entry) => entry.fn());
|
|
||||||
runDueTimeouts();
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function speak(word, ms) {
|
|
||||||
const amplitude = Number(Object.keys(words).find((key) => words[key] === word));
|
|
||||||
if (!amplitude) throw new Error(`unknown word: ${word}`);
|
|
||||||
mic.level = Math.min(0.9, amplitude / 32768 + 0.25);
|
|
||||||
mic.amplitude = amplitude;
|
|
||||||
await tick(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function silence(ms) {
|
|
||||||
mic.level = 0;
|
|
||||||
mic.amplitude = 0;
|
|
||||||
await tick(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
context,
|
|
||||||
elements,
|
|
||||||
sends,
|
|
||||||
toasts,
|
|
||||||
transcribeUploads,
|
|
||||||
servers,
|
|
||||||
recorders,
|
|
||||||
clock,
|
|
||||||
tick,
|
|
||||||
speak,
|
|
||||||
silence,
|
|
||||||
flush,
|
|
||||||
runDueTimeouts,
|
|
||||||
setAssistantReply(text) { assistantRows = [{ dataset: { rawText: text } }]; },
|
|
||||||
clearAssistantRows() { assistantRows = []; },
|
|
||||||
state() { return elements.voiceModeBar.dataset.voiceState || ''; },
|
|
||||||
body() { return bodyElement; },
|
|
||||||
trackStates() { return micTracks.map((track) => track.enabled); },
|
|
||||||
overlay() {
|
|
||||||
return bodyElement.children.find(
|
|
||||||
(child) => String(child.className).indexOf('voice-conversation') >= 0,
|
|
||||||
) || null;
|
|
||||||
},
|
|
||||||
completeResponse() {
|
|
||||||
context.S.busy = false;
|
|
||||||
context.S.activeStreamId = null;
|
|
||||||
if (context.S.session) context.S.session.active_stream_id = null;
|
|
||||||
context.autoReadLastAssistant();
|
|
||||||
},
|
|
||||||
async start() {
|
|
||||||
await flush();
|
|
||||||
elements.btnVoiceMode.click();
|
|
||||||
await flush();
|
|
||||||
await flush();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const scenarios = {};
|
|
||||||
|
|
||||||
// The reported live regression: a normal multi-word utterance with a natural
|
|
||||||
// inter-word pause long enough for the client to speculate (>=605ms at the
|
|
||||||
// default 1100ms endpoint) must still transcribe completely.
|
|
||||||
scenarios.multiword_utterance_with_interword_pause = async () => {
|
|
||||||
const harness = makeHarness();
|
|
||||||
await harness.start();
|
|
||||||
await harness.silence(300);
|
|
||||||
await harness.speak('alpha', 400);
|
|
||||||
await harness.silence(700); // client speculates at ~605ms
|
|
||||||
await harness.speak('bravo', 600);
|
|
||||||
await harness.silence(1400); // past the 1100ms endpoint
|
|
||||||
await harness.tick(400); // commit retries + final delivery
|
|
||||||
return {
|
|
||||||
sends: harness.sends,
|
|
||||||
state: harness.state(),
|
|
||||||
serverEvents: harness.servers.map((socket) => socket.server.events),
|
|
||||||
toasts: harness.toasts,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Continuous capture: the mic stays hot after an utterance is dispatched, and
|
|
||||||
// the next utterance (spoken while the first response is still in flight)
|
|
||||||
// barges in and is transcribed completely, including words after a pause.
|
|
||||||
scenarios.second_utterance_during_response_is_complete = async () => {
|
|
||||||
const harness = makeHarness();
|
|
||||||
await harness.start();
|
|
||||||
await harness.silence(300);
|
|
||||||
await harness.speak('alpha', 500);
|
|
||||||
await harness.silence(1400);
|
|
||||||
await harness.tick(400); // first send dispatched
|
|
||||||
const firstSends = harness.sends.slice();
|
|
||||||
// The model is now answering (S.busy). Assistant text starts streaming.
|
|
||||||
harness.setAssistantReply('Partial answer already visible.');
|
|
||||||
await harness.tick(200);
|
|
||||||
// User talks over the response with a two-word interruption + pause.
|
|
||||||
await harness.speak('bravo', 500);
|
|
||||||
await harness.silence(700);
|
|
||||||
await harness.speak('charlie', 500);
|
|
||||||
await harness.silence(1400);
|
|
||||||
await harness.tick(400);
|
|
||||||
return {
|
|
||||||
firstSends,
|
|
||||||
sends: harness.sends,
|
|
||||||
state: harness.state(),
|
|
||||||
serverEvents: harness.servers.map((socket) => socket.server.events),
|
|
||||||
toasts: harness.toasts,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// The mic never re-acquires getUserMedia across utterances and a second
|
|
||||||
// utterance right after a normally completed response is sent alone: a
|
|
||||||
// normal completion clears any stitch context.
|
|
||||||
scenarios.back_to_back_utterances_stay_hot = async () => {
|
|
||||||
const harness = makeHarness();
|
|
||||||
await harness.start();
|
|
||||||
await harness.silence(300);
|
|
||||||
await harness.speak('alpha', 1300);
|
|
||||||
await harness.silence(1500);
|
|
||||||
await harness.tick(400); // first send dispatched (past the endpoint)
|
|
||||||
const sendsAfterFirst = harness.sends.slice();
|
|
||||||
harness.completeResponse();
|
|
||||||
await harness.tick(400);
|
|
||||||
await harness.speak('bravo', 1300);
|
|
||||||
await harness.silence(1500);
|
|
||||||
await harness.tick(400);
|
|
||||||
return {
|
|
||||||
sendsAfterFirst,
|
|
||||||
sends: harness.sends,
|
|
||||||
micAcquisitions: harness.trackStates().length,
|
|
||||||
recorderCount: harness.recorders.length,
|
|
||||||
serverCount: harness.servers.length,
|
|
||||||
toasts: harness.toasts,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// The exact live regression: "The … <thinking pause> rest of the sentence"
|
|
||||||
// used to endpoint on the first inter-word gap (>=1100ms) and send the first
|
|
||||||
// word alone. A young utterance now holds its endpoint to 1800ms, so the
|
|
||||||
// pause stays inside one utterance and one complete message is sent.
|
|
||||||
scenarios.thinking_pause_after_first_word_does_not_split = async () => {
|
|
||||||
const harness = makeHarness();
|
|
||||||
await harness.start();
|
|
||||||
await harness.silence(300);
|
|
||||||
await harness.speak('alpha', 400);
|
|
||||||
await harness.silence(1300); // longer than the old 1100ms endpoint
|
|
||||||
await harness.speak('bravo', 700);
|
|
||||||
await harness.silence(2000);
|
|
||||||
await harness.tick(400);
|
|
||||||
return {
|
|
||||||
sends: harness.sends,
|
|
||||||
serverEvents: harness.servers.map((socket) => socket.server.events),
|
|
||||||
toasts: harness.toasts,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Conversation mode: activation builds the full-screen overlay, captions
|
|
||||||
// follow the turn, mute drives the real capture track, and both the exit
|
|
||||||
// control and Escape tear the overlay down completely.
|
|
||||||
scenarios.conversation_overlay_lifecycle = async () => {
|
|
||||||
const harness = makeHarness();
|
|
||||||
await harness.start();
|
|
||||||
const overlay = harness.overlay();
|
|
||||||
if (!overlay) return { overlayPresent: false };
|
|
||||||
const find = (root, needle) => root.children.find(
|
|
||||||
(child) => String(child.className).indexOf(needle) >= 0,
|
|
||||||
);
|
|
||||||
const role = overlay.getAttribute('role');
|
|
||||||
const ariaModal = overlay.getAttribute('aria-modal');
|
|
||||||
await harness.silence(300);
|
|
||||||
const listeningState = overlay.dataset.voiceState;
|
|
||||||
await harness.speak('alpha', 1300);
|
|
||||||
await harness.silence(1500);
|
|
||||||
await harness.tick(400);
|
|
||||||
const captions = find(overlay, 'voice-conversation-captions');
|
|
||||||
const userCaption = captions ? captions.children[0].textContent : '';
|
|
||||||
const captionsLive = captions ? captions.getAttribute('aria-live') : '';
|
|
||||||
harness.setAssistantReply('A visible answer.');
|
|
||||||
await harness.tick(300);
|
|
||||||
const assistantCaption = captions ? captions.children[1].textContent : '';
|
|
||||||
const thinkingState = overlay.dataset.voiceState;
|
|
||||||
const controls = find(overlay, 'voice-conversation-controls');
|
|
||||||
const muteBtn = controls.children[0];
|
|
||||||
const exitBtn = controls.children[1];
|
|
||||||
muteBtn.click();
|
|
||||||
const mutedPressed = muteBtn.getAttribute('aria-pressed');
|
|
||||||
const mutedTracks = harness.trackStates();
|
|
||||||
muteBtn.click();
|
|
||||||
const unmutedTracks = harness.trackStates();
|
|
||||||
exitBtn.click();
|
|
||||||
await harness.flush();
|
|
||||||
const removedOnExit = !harness.overlay();
|
|
||||||
const inactiveAfterExit = !harness.context._voiceModeActive();
|
|
||||||
// Re-activate and leave through Escape instead.
|
|
||||||
harness.elements.btnVoiceMode.click();
|
|
||||||
await harness.flush();
|
|
||||||
const overlayAgain = harness.overlay();
|
|
||||||
const keydown = overlayAgain.listeners.find((entry) => entry.type === 'keydown');
|
|
||||||
keydown.handler({ key: 'Escape', preventDefault() {} });
|
|
||||||
await harness.flush();
|
|
||||||
return {
|
|
||||||
overlayPresent: true,
|
|
||||||
role,
|
|
||||||
ariaModal,
|
|
||||||
listeningState,
|
|
||||||
thinkingState,
|
|
||||||
userCaption,
|
|
||||||
assistantCaption,
|
|
||||||
captionsLive,
|
|
||||||
mutedPressed,
|
|
||||||
mutedTracks,
|
|
||||||
unmutedTracks,
|
|
||||||
removedOnExit,
|
|
||||||
inactiveAfterExit,
|
|
||||||
removedOnEscape: !harness.overlay(),
|
|
||||||
sends: harness.sends,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const output = {};
|
|
||||||
for (const name of Object.keys(scenarios)) {
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
output[name] = await scenarios[name]();
|
|
||||||
}
|
|
||||||
process.stdout.write(JSON.stringify(output, null, 1));
|
|
||||||
})().catch((error) => {
|
|
||||||
process.stderr.write(String((error && error.stack) || error));
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
"""Continuous-capture contracts: no first-word clipping, stitching, overlay.
|
|
||||||
|
|
||||||
The scenarios drive the real ``atlas-voice.js`` against a stub streaming STT
|
|
||||||
WebSocket whose VAD, speculative EOS-freeze, resume and commit semantics
|
|
||||||
mirror ``hermes-jetson-stt-server.py``. Words are encoded as distinct PCM
|
|
||||||
amplitudes, so the transcript of a commit is exactly the words whose audio
|
|
||||||
survived endpointing — the live "only the first word was sent" regression is
|
|
||||||
directly observable in the composer text the probe records.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
VOICE_SCRIPT = ROOT / "dockerfiles" / "hermes-webui-atlas-voice.js"
|
|
||||||
PROBE = ROOT / "testing" / "probes" / "hermes_voice_capture_probe.js"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def probe_results() -> dict:
|
|
||||||
node = shutil.which("node")
|
|
||||||
if not node:
|
|
||||||
pytest.skip("node is required to drive the capture continuity contract")
|
|
||||||
completed = subprocess.run(
|
|
||||||
[node, str(PROBE), str(VOICE_SCRIPT)],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=180,
|
|
||||||
)
|
|
||||||
assert completed.returncode == 0, completed.stderr
|
|
||||||
return json.loads(completed.stdout)
|
|
||||||
|
|
||||||
|
|
||||||
def test_multiword_utterance_with_interword_pause_is_complete(probe_results):
|
|
||||||
scenario = probe_results["multiword_utterance_with_interword_pause"]
|
|
||||||
assert scenario["sends"] == ["alpha bravo"]
|
|
||||||
assert scenario["state"] == "thinking"
|
|
||||||
|
|
||||||
|
|
||||||
def test_thinking_pause_after_first_word_does_not_split(probe_results):
|
|
||||||
"""A >1100ms pause right after the first word stays inside one utterance."""
|
|
||||||
scenario = probe_results["thinking_pause_after_first_word_does_not_split"]
|
|
||||||
assert scenario["sends"] == ["alpha bravo"]
|
|
||||||
# The pause froze a first-word EOS snapshot on the server; resumed speech
|
|
||||||
# must have cleared it before the commit.
|
|
||||||
events = scenario["serverEvents"][0]
|
|
||||||
assert "resume-clear" in events or "recover-by-rms" in events
|
|
||||||
|
|
||||||
|
|
||||||
def test_barge_in_stitches_regardless_of_partial_assistant_output(probe_results):
|
|
||||||
scenario = probe_results["second_utterance_during_response_is_complete"]
|
|
||||||
assert scenario["firstSends"] == ["alpha"]
|
|
||||||
# Assistant text was already visible when the user talked over the
|
|
||||||
# response: the interrupted thought and the follow-up still form one
|
|
||||||
# stitched message.
|
|
||||||
assert scenario["sends"] == ["alpha", "alpha bravo charlie"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_normal_completion_clears_stitch_and_mic_stays_hot(probe_results):
|
|
||||||
scenario = probe_results["back_to_back_utterances_stay_hot"]
|
|
||||||
assert scenario["sendsAfterFirst"] == ["alpha"]
|
|
||||||
assert scenario["sends"] == ["alpha", "bravo"]
|
|
||||||
# The whole session runs on one getUserMedia lease.
|
|
||||||
assert scenario["micAcquisitions"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_conversation_overlay_lifecycle(probe_results):
|
|
||||||
scenario = probe_results["conversation_overlay_lifecycle"]
|
|
||||||
assert scenario["overlayPresent"] is True
|
|
||||||
assert scenario["role"] == "dialog"
|
|
||||||
assert scenario["ariaModal"] == "true"
|
|
||||||
assert scenario["captionsLive"] == "polite"
|
|
||||||
assert scenario["listeningState"] == "listening"
|
|
||||||
assert scenario["thinkingState"] in {"thinking", "speaking"}
|
|
||||||
assert scenario["userCaption"] == "alpha"
|
|
||||||
assert scenario["assistantCaption"] == "A visible answer."
|
|
||||||
# Mute drives the real capture track and is fully reversible.
|
|
||||||
assert scenario["mutedPressed"] == "true"
|
|
||||||
assert scenario["mutedTracks"] == [False]
|
|
||||||
assert scenario["unmutedTracks"] == [True]
|
|
||||||
# Both exit paths remove the overlay completely and end hands-free mode.
|
|
||||||
assert scenario["removedOnExit"] is True
|
|
||||||
assert scenario["inactiveAfterExit"] is True
|
|
||||||
assert scenario["removedOnEscape"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_overlay_source_contract():
|
|
||||||
source = VOICE_SCRIPT.read_text(encoding="utf-8")
|
|
||||||
css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text(
|
|
||||||
encoding="utf-8"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Lazily created, fully removed, storage-free, single-capture overlay.
|
|
||||||
assert "function openConversationOverlay()" in source
|
|
||||||
assert "function removeConversationOverlay()" in source
|
|
||||||
assert source.count("navigator.mediaDevices.getUserMedia(") == 1
|
|
||||||
overlay_region = source.split("Conversation mode overlay", 1)[1].split(
|
|
||||||
"function clearErrorTimer", 1
|
|
||||||
)[0]
|
|
||||||
assert "localStorage" not in overlay_region
|
|
||||||
assert "track.enabled=!conversation.muted" in overlay_region
|
|
||||||
assert "'aria-label':'Voice conversation'" in overlay_region
|
|
||||||
assert "'aria-live':'polite'" in overlay_region
|
|
||||||
assert "event.key==='Escape'" in overlay_region
|
|
||||||
assert "event.key==='Tab'" in overlay_region
|
|
||||||
|
|
||||||
# Orb styling: breathing, level-driven, playback-driven, reduced-motion.
|
|
||||||
assert ".voice-conversation" in css
|
|
||||||
assert "--conversation-level" in css
|
|
||||||
assert "voice-conversation-breathe" in css
|
|
||||||
assert ".voice-conversation.is-playing .voice-conversation-orb-halo" in css
|
|
||||||
reduced = css.split("@media (prefers-reduced-motion: reduce)", 1)[1]
|
|
||||||
assert ".voice-conversation *" in reduced
|
|
||||||
|
|
||||||
|
|
||||||
def test_endpointing_and_streaming_hardening_source_contract():
|
|
||||||
source = VOICE_SCRIPT.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
# Young utterances hold their endpoint past a thinking pause.
|
|
||||||
assert "const VAD_EARLY_SILENCE_MS=1800" in source
|
|
||||||
assert "const VAD_COMMITTED_SPEECH_MS=1200" in source
|
|
||||||
assert (
|
|
||||||
"const endpointSilenceMs=speechMs<VAD_COMMITTED_SPEECH_MS"
|
|
||||||
"?Math.max(silenceMs,VAD_EARLY_SILENCE_MS):silenceMs" in source
|
|
||||||
)
|
|
||||||
# Resume always reaches the wire after a server-length silence gap.
|
|
||||||
assert "const SERVER_EOS_SILENCE_MS=650" in source
|
|
||||||
assert "speechGapMs>=SERVER_EOS_SILENCE_MS" in source
|
|
||||||
assert "if(settled||committed||!speculative) return;" not in source.split(
|
|
||||||
"resume:function()", 1
|
|
||||||
)[1].split("speculate:function()", 1)[0]
|
|
||||||
# No speculative Whisper pass on a one-word fragment.
|
|
||||||
assert "const SPECULATE_MIN_SPEECH_MS=700" in source
|
|
||||||
assert "speechMs>=SPECULATE_MIN_SPEECH_MS" in source
|
|
||||||
# Echo residue can no longer ratchet the onset threshold above speech.
|
|
||||||
assert "Math.min(rms,speechThreshold)*0.06" in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_stitch_gate_ignores_partial_assistant_output():
|
|
||||||
source = VOICE_SCRIPT.read_text(encoding="utf-8")
|
|
||||||
region = source.split("function recordPendingStitch()", 1)[1].split(
|
|
||||||
"async function sendTranscript", 1
|
|
||||||
)[0]
|
|
||||||
assert "currentAssistantText()" not in region
|
|
||||||
assert "pendingStitch={text:sent.text,at:Date.now()}" in region
|
|
||||||
# Normal completion still clears the stitch context.
|
|
||||||
assert (
|
|
||||||
"if(isFinal){\n // The completion callback marks a normally "
|
|
||||||
"completed response" in source
|
|
||||||
)
|
|
||||||
@ -165,10 +165,7 @@ def test_visual_slice_preserves_private_voice_request_and_capture_contract():
|
|||||||
assert "if(language) request.language=language" in script
|
assert "if(language) request.language=language" in script
|
||||||
assert "speakResponse(generation)" in script
|
assert "speakResponse(generation)" in script
|
||||||
assert "window._voiceModeImmediateSend" in script
|
assert "window._voiceModeImmediateSend" in script
|
||||||
# Conversation-mode mute must silence the retained capture tracks rather
|
assert "mute" not in script.lower()
|
||||||
# than opening a second microphone lease or a speaker-choice surface.
|
|
||||||
assert "track.enabled=!conversation.muted" in script
|
|
||||||
assert "hermes-tts-voice" not in script
|
|
||||||
assert "const TTS_LANGUAGES=['en','ru','es']" in script
|
assert "const TTS_LANGUAGES=['en','ru','es']" in script
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -195,15 +195,11 @@ def test_renderer_updates_exact_chat_and_dashboard_webui_only(tmp_path: Path) ->
|
|||||||
)
|
)
|
||||||
assert patch.count(f"+ image: {module.DEFAULT_IMAGE}@{digest}") == chat_consumers + 1
|
assert patch.count(f"+ image: {module.DEFAULT_IMAGE}@{digest}") == chat_consumers + 1
|
||||||
if "HUX_IMAGE_TAG" in CHAT.read_text(encoding="utf-8"):
|
if "HUX_IMAGE_TAG" in CHAT.read_text(encoding="utf-8"):
|
||||||
tag = f"git-{kwargs['source_revision']}-build-{kwargs['build_number']}-release"
|
assert (
|
||||||
assert any(
|
"+ - {name: HUX_IMAGE_TAG, value: "
|
||||||
line.startswith("+") and line.lstrip("+ ").startswith(f"value: {tag}")
|
f"'git-{kwargs['source_revision']}-build-{kwargs['build_number']}-release'}}"
|
||||||
for line in patch.splitlines()
|
) in patch
|
||||||
), patch
|
assert f"+ - {{name: HUX_IMAGE_DIGEST, value: '{digest}'}}" in patch
|
||||||
assert any(
|
|
||||||
line.startswith("+") and line.lstrip("+ ").startswith(f"value: {digest}")
|
|
||||||
for line in patch.splitlines()
|
|
||||||
), patch
|
|
||||||
assert "services/hermes/chat-statefulset.yaml" in patch
|
assert "services/hermes/chat-statefulset.yaml" in patch
|
||||||
assert "services/hermes/deployment.yaml" in patch
|
assert "services/hermes/deployment.yaml" in patch
|
||||||
assert "hermes-agent@sha256" not in "\n".join(
|
assert "hermes-agent@sha256" not in "\n".join(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user