step3
This commit is contained in:
+192
-2
@@ -93,8 +93,29 @@
|
||||
.bubble.karaoke .kw { color: var(--muted); transition: color .18s ease; }
|
||||
.bubble.karaoke .kw.done { color: var(--text); }
|
||||
.bubble.karaoke .kw.now { color: var(--accent); text-shadow: 0 0 8px rgba(79,140,255,.55); }
|
||||
/* Same karaoke style on the user's own bubbles, tuned for the blue bubble. */
|
||||
.msg.user .bubble.karaoke .kw { color: rgba(230,237,247,.5); }
|
||||
.msg.user .bubble.karaoke .kw.done { color: #ffffff; }
|
||||
.msg.user .bubble.karaoke .kw.now { color: #ffffff; text-shadow: 0 0 9px rgba(255,255,255,.85); }
|
||||
.hint { color: var(--muted); font-size: 13px; text-align: center; margin: auto; }
|
||||
|
||||
/* Barge-in indicator: flashes red whenever the agent is interrupted. */
|
||||
.bargein {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 12px; color: var(--muted);
|
||||
border: 1px solid var(--border); border-radius: 999px;
|
||||
padding: 5px 11px; white-space: nowrap;
|
||||
transition: border-color .2s, box-shadow .2s, color .2s;
|
||||
}
|
||||
.bargein .bi-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%; flex: none;
|
||||
background: var(--err); opacity: .25;
|
||||
transition: opacity .15s, box-shadow .15s, transform .15s;
|
||||
}
|
||||
.bargein b { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.bargein.active { border-color: var(--err); color: var(--err); box-shadow: 0 0 12px -2px var(--err); }
|
||||
.bargein.active .bi-dot { opacity: 1; box-shadow: 0 0 9px var(--err); transform: scale(1.35); }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 12px 18px; }
|
||||
.meters { display: flex; align-items: center; gap: 14px; margin-bottom: 10px; }
|
||||
.meter { height: 8px; background: var(--bg); border-radius: 4px; overflow: hidden; flex: 1; }
|
||||
@@ -225,13 +246,82 @@
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* --- Shared side menu --- */
|
||||
.menu-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 6px 11px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-btn:hover { background: var(--bg); }
|
||||
.sidemenu {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100%;
|
||||
width: 240px;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform .2s ease;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 18px 14px;
|
||||
gap: 6px;
|
||||
}
|
||||
.sidemenu.open { transform: translateX(0); }
|
||||
.sidemenu h2 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .7px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 10px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.sidemenu a {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.sidemenu a:hover { background: var(--bg); }
|
||||
.sidemenu a.active { background: var(--accent-dim); border-color: var(--accent); }
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.45);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .2s;
|
||||
z-index: 30;
|
||||
}
|
||||
.backdrop.open { opacity: 1; pointer-events: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="backdrop" id="menuBackdrop"></div>
|
||||
<nav class="sidemenu" id="sidemenu">
|
||||
<h2>Menu</h2>
|
||||
<a href="/mivocero" class="active">🎙️ Mivocero</a>
|
||||
<a href="/customvoice">🎨 Custom Voice</a>
|
||||
</nav>
|
||||
|
||||
<div class="app">
|
||||
<header>
|
||||
<button class="menu-btn" id="menuBtn" title="Menu">☰</button>
|
||||
<h1>🎙️ Mivocero</h1>
|
||||
<span class="status"><span class="dot" id="connDot"></span><span id="connText">disconnected</span></span>
|
||||
<span class="bargein" id="bargeinInd" title="Agent interrupted by the user (VAD barge-in or the Stop button)">
|
||||
<span class="bi-dot"></span>Barge-in <b id="bargeinCount">0</b>
|
||||
</span>
|
||||
<button id="btnConnect" class="ok">Connect</button>
|
||||
<button id="btnInterrupt" class="danger" disabled>Stop</button>
|
||||
</header>
|
||||
@@ -307,11 +397,24 @@
|
||||
// {"type":"audio_source","state":"…", …} external audio source status
|
||||
// {"type":"cue","kind":"…","name":"…"} filler / start filler / backchannel emitted
|
||||
// {"type":"cues","lang":"…","config":{…}} live cue policy (sent at start)
|
||||
// {"type":"barge_in","source":"…"} agent interrupted (VAD onset)
|
||||
//
|
||||
// The session closes itself after IDLE_TIMEOUT_MS without any traffic in
|
||||
// either direction (see the idle guard below).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- Shared side menu (chat / custom voice navigation) ---
|
||||
(() => {
|
||||
const menu = document.getElementById('sidemenu');
|
||||
const backdrop = document.getElementById('menuBackdrop');
|
||||
const setOpen = (open) => {
|
||||
menu.classList.toggle('open', open);
|
||||
backdrop.classList.toggle('open', open);
|
||||
};
|
||||
document.getElementById('menuBtn').addEventListener('click', () => setOpen(true));
|
||||
backdrop.addEventListener('click', () => setOpen(false));
|
||||
})();
|
||||
|
||||
const SAMPLE_RATE_OUT = 44100; // server <-> client (matches the TTS rate)
|
||||
const SAMPLE_RATE_IN = 44100;
|
||||
const FRAME_MS = 20;
|
||||
@@ -347,6 +450,11 @@ const cueEls = {
|
||||
};
|
||||
const cueCounts = { filler: 0, start_filler: 0, backchannel: 0 };
|
||||
const cueTimers = { filler: 0, start_filler: 0, backchannel: 0 };
|
||||
// Barge-in indicator (header pill): flashes red on each interruption.
|
||||
const bargeinInd = $('bargeinInd');
|
||||
const bargeinCountEl = $('bargeinCount');
|
||||
let bargeinTotal = 0;
|
||||
let bargeinTimer = 0;
|
||||
|
||||
let ws = null;
|
||||
let audioCtx = null;
|
||||
@@ -498,6 +606,61 @@ function endAgentMessage() {
|
||||
agentText = '';
|
||||
}
|
||||
|
||||
// --- User-message karaoke --------------------------------------------------
|
||||
// The user's transcript reaches the client only once the utterance has ended
|
||||
// (the STT runs server-side on the whole segment), so there is no per-word
|
||||
// audio timing to follow. The words are instead lit up by a short left-to-right
|
||||
// sweep, giving the same karaoke feel as the agent's reply.
|
||||
const USER_REVEAL_MS_PER_WORD = 90;
|
||||
let userRevealRAF = 0;
|
||||
let userRevealWords = null;
|
||||
|
||||
function addUserMessage(text) {
|
||||
const bubble = addMessage('user', '');
|
||||
bubble.classList.add('karaoke');
|
||||
const words = [];
|
||||
for (const tok of text.split(/(\s+)/)) {
|
||||
if (tok === '') continue;
|
||||
if (/^\s+$/.test(tok)) {
|
||||
bubble.appendChild(document.createTextNode(tok));
|
||||
} else {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'kw';
|
||||
span.textContent = tok;
|
||||
bubble.appendChild(span);
|
||||
words.push(span);
|
||||
}
|
||||
}
|
||||
revealWords(words);
|
||||
return bubble;
|
||||
}
|
||||
|
||||
function revealWords(words) {
|
||||
if (userRevealRAF) { cancelAnimationFrame(userRevealRAF); userRevealRAF = 0; }
|
||||
if (userRevealWords) {
|
||||
for (const w of userRevealWords) { w.classList.add('done'); w.classList.remove('now'); }
|
||||
userRevealWords = null;
|
||||
}
|
||||
if (!words.length) return;
|
||||
const start = performance.now();
|
||||
userRevealWords = words;
|
||||
const tick = (now) => {
|
||||
const p = Math.floor((now - start) / USER_REVEAL_MS_PER_WORD) + 1;
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
words[i].classList.toggle('done', i < p - 1);
|
||||
words[i].classList.toggle('now', i === p - 1);
|
||||
}
|
||||
if (p <= words.length) {
|
||||
userRevealRAF = requestAnimationFrame(tick);
|
||||
} else {
|
||||
for (const w of words) { w.classList.add('done'); w.classList.remove('now'); }
|
||||
userRevealWords = null;
|
||||
userRevealRAF = 0;
|
||||
}
|
||||
};
|
||||
userRevealRAF = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// --- Audio worklets --------------------------------------------------------
|
||||
|
||||
const CAPTURE_PROCESSOR = `
|
||||
@@ -680,6 +843,25 @@ function resetCues() {
|
||||
}
|
||||
}
|
||||
|
||||
// Barge-in indicator: bump the counter and flash the pill. Fired by the
|
||||
// server "barge_in" event (VAD onset) and by the local Stop button.
|
||||
function flashBargeIn(source) {
|
||||
bargeinTotal += 1;
|
||||
bargeinCountEl.textContent = bargeinTotal;
|
||||
if (source) bargeinInd.title = 'Last barge-in: ' + source;
|
||||
bargeinInd.classList.add('active');
|
||||
clearTimeout(bargeinTimer);
|
||||
bargeinTimer = setTimeout(() => bargeinInd.classList.remove('active'), 700);
|
||||
}
|
||||
|
||||
function resetBargeIn() {
|
||||
bargeinTotal = 0;
|
||||
clearTimeout(bargeinTimer);
|
||||
bargeinInd.classList.remove('active');
|
||||
bargeinInd.title = 'Agent interrupted by the user (VAD barge-in or the Stop button)';
|
||||
bargeinCountEl.textContent = '0';
|
||||
}
|
||||
|
||||
// --- Cue methods panel -----------------------------------------------------
|
||||
// The method (how each cue decides to fire) plus the live parameters reported
|
||||
// by the server in the "cues" event at session start. `params` turns the
|
||||
@@ -784,7 +966,7 @@ function handleServerText(text) {
|
||||
switch (event.type) {
|
||||
case 'user':
|
||||
endAgentMessage(); // a new turn starts
|
||||
addMessage('user', event.text);
|
||||
addUserMessage(event.text);
|
||||
break;
|
||||
case 'assistant_delta':
|
||||
appendAgentDelta(event.text);
|
||||
@@ -792,6 +974,12 @@ function handleServerText(text) {
|
||||
case 'assistant_end':
|
||||
agentEnded = true; // keep the bubble: karaoke finishes when audio drains
|
||||
break;
|
||||
case 'barge_in':
|
||||
// The server cut the reply (VAD onset): flash the indicator and stop the
|
||||
// karaoke sweep — the audio is gone, so the reply is left as spoken.
|
||||
flashBargeIn(event.source || 'vad');
|
||||
endAgentMessage();
|
||||
break;
|
||||
case 'error':
|
||||
endAgentMessage();
|
||||
addMessage('error', event.text, 'error');
|
||||
@@ -883,6 +1071,7 @@ async function connect() {
|
||||
setConn('on', 'connected');
|
||||
log('connected');
|
||||
resetCues();
|
||||
resetBargeIn();
|
||||
btnConnect.disabled = true;
|
||||
btnInterrupt.disabled = false;
|
||||
textInput.disabled = false;
|
||||
@@ -936,6 +1125,7 @@ btnConnect.onclick = () => (ws ? disconnect() : connect());
|
||||
btnInterrupt.onclick = () => {
|
||||
if (sendJson({ type: 'interrupt' })) {
|
||||
log('→ interrupt');
|
||||
flashBargeIn('client');
|
||||
endAgentMessage();
|
||||
}
|
||||
};
|
||||
@@ -966,7 +1156,7 @@ composer.onsubmit = (e) => {
|
||||
if (!text) return;
|
||||
if (sendJson({ type: 'user_text', text })) {
|
||||
endAgentMessage();
|
||||
addMessage('user', text);
|
||||
addUserMessage(text);
|
||||
textInput.value = '';
|
||||
log('→ user_text');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user