This commit is contained in:
valenti
2026-10-17 22:01:31 +01:00
parent bfed42c3b3
commit 79f927d968
53 changed files with 2158 additions and 78 deletions
+192 -2
View File
@@ -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');
}