good
This commit is contained in:
+98
-11
@@ -89,12 +89,10 @@
|
||||
.msg.agent { align-self: flex-start; }
|
||||
.msg.agent .bubble { background: var(--bg); border: 1px solid var(--border); }
|
||||
.msg.error .bubble { border-color: var(--err); color: var(--err); }
|
||||
.msg .bubble.pending::after {
|
||||
content: "▍";
|
||||
animation: blink 1s step-start infinite;
|
||||
color: var(--accent);
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
/* Karaoke: words light up as they are spoken by the agent. */
|
||||
.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); }
|
||||
.hint { color: var(--muted); font-size: 13px; text-align: center; margin: auto; }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 12px 18px; }
|
||||
@@ -198,6 +196,17 @@ let playbackNode = null;
|
||||
let outputGain = null;
|
||||
let agentBubble = null; // bubble being streamed by the assistant
|
||||
let agentText = ''; // accumulated assistant text for that bubble
|
||||
let agentWords = []; // [{el, start}] word spans of the current reply
|
||||
let agentPlayedAt = 0; // playback sample counter when the reply started
|
||||
let agentEnded = false; // assistant_end received for the current reply
|
||||
let playedSamples = 0; // cumulative samples emitted by the playback worklet
|
||||
let karaokeRAF = 0; // requestAnimationFrame id while the reply streams
|
||||
let karaokePrevPlayed = 0;
|
||||
let karaokePrevTs = 0;
|
||||
let karaokeIdleMs = 0;
|
||||
// Approximate speaking rate used to map the audio playhead onto characters.
|
||||
// Only drives the visual highlight — the spoken audio is unaffected.
|
||||
const KARAOKE_CPS = 15;
|
||||
|
||||
function log(msg) {
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
@@ -237,20 +246,95 @@ function addMessage(role, text, cls = '') {
|
||||
|
||||
function startAgentMessage() {
|
||||
agentText = '';
|
||||
agentWords = [];
|
||||
agentBubble = addMessage('agent', '');
|
||||
agentBubble.classList.add('pending');
|
||||
agentBubble.classList.add('karaoke');
|
||||
agentPlayedAt = playedSamples; // count only the audio spoken after this point
|
||||
startKaraoke();
|
||||
}
|
||||
|
||||
function appendAgentDelta(text) {
|
||||
if (!agentBubble) startAgentMessage();
|
||||
agentText += text;
|
||||
agentBubble.textContent = agentText;
|
||||
renderKaraoke();
|
||||
applyKaraoke(karaokeChars());
|
||||
scrollDown(false);
|
||||
}
|
||||
|
||||
// Rebuild the bubble as one <span> per word, keeping the whitespace between
|
||||
// them so the layout is identical to plain text.
|
||||
function renderKaraoke() {
|
||||
if (!agentBubble) return;
|
||||
agentWords = [];
|
||||
agentBubble.textContent = '';
|
||||
let idx = 0;
|
||||
for (const tok of agentText.split(/(\s+)/)) {
|
||||
if (tok === '') continue;
|
||||
if (/^\s+$/.test(tok)) {
|
||||
agentBubble.appendChild(document.createTextNode(tok));
|
||||
} else {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'kw';
|
||||
span.textContent = tok;
|
||||
agentBubble.appendChild(span);
|
||||
agentWords.push({ el: span, start: idx });
|
||||
}
|
||||
idx += tok.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Characters the playhead has "reached", from the audio actually emitted.
|
||||
function karaokeChars() {
|
||||
const played = Math.max(0, playedSamples - agentPlayedAt);
|
||||
return Math.floor((played / SAMPLE_RATE_OUT) * KARAOKE_CPS);
|
||||
}
|
||||
|
||||
function applyKaraoke(chars) {
|
||||
for (const w of agentWords) {
|
||||
const end = w.start + w.el.textContent.length;
|
||||
w.el.classList.toggle('done', end <= chars);
|
||||
w.el.classList.toggle('now', w.start < chars && end > chars);
|
||||
}
|
||||
}
|
||||
|
||||
function startKaraoke() {
|
||||
stopKaraoke();
|
||||
agentEnded = false;
|
||||
karaokePrevPlayed = playedSamples;
|
||||
karaokeIdleMs = 0;
|
||||
karaokePrevTs = performance.now();
|
||||
karaokeRAF = requestAnimationFrame(karaokeTick);
|
||||
}
|
||||
|
||||
function stopKaraoke() {
|
||||
if (karaokeRAF) cancelAnimationFrame(karaokeRAF);
|
||||
karaokeRAF = 0;
|
||||
}
|
||||
|
||||
function karaokeTick(ts) {
|
||||
karaokeRAF = 0;
|
||||
if (!agentBubble) return;
|
||||
const dt = ts - karaokePrevTs;
|
||||
karaokePrevTs = ts;
|
||||
const played = Math.max(0, playedSamples - agentPlayedAt);
|
||||
if (played > karaokePrevPlayed) { karaokePrevPlayed = played; karaokeIdleMs = 0; }
|
||||
else karaokeIdleMs += dt;
|
||||
|
||||
// Once the text has ended and the audio has drained (playback idle for a
|
||||
// beat after it had actually started), light up anything still pending so
|
||||
// the reply always ends fully highlighted.
|
||||
if (agentEnded && karaokePrevPlayed > 0 && karaokeIdleMs > 600) {
|
||||
for (const w of agentWords) { w.el.classList.add('done'); w.el.classList.remove('now'); }
|
||||
return;
|
||||
}
|
||||
applyKaraoke(karaokeChars());
|
||||
karaokeRAF = requestAnimationFrame(karaokeTick);
|
||||
}
|
||||
|
||||
function endAgentMessage() {
|
||||
if (agentBubble) agentBubble.classList.remove('pending');
|
||||
stopKaraoke();
|
||||
agentBubble = null;
|
||||
agentWords = [];
|
||||
agentText = '';
|
||||
}
|
||||
|
||||
@@ -314,6 +398,7 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
||||
this.buffering = true;
|
||||
this.underrunSamples = 0;
|
||||
this.underrunThreshold = Math.floor(sampleRate * 0.10);
|
||||
this.played = 0; // samples actually emitted (drives the karaoke highlight)
|
||||
this.port.onmessage = (e) => {
|
||||
const pcm = e.data;
|
||||
for (let i = 0; i < pcm.length; i++) {
|
||||
@@ -337,6 +422,7 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
||||
s = this.buf[this.tail];
|
||||
this.tail = (this.tail + 1) % this.buf.length;
|
||||
this.underrunSamples = 0;
|
||||
this.played++;
|
||||
} else {
|
||||
this.underrunSamples++;
|
||||
if (this.underrunSamples >= this.underrunThreshold) { this.buffering = true; this.underrunSamples = 0; }
|
||||
@@ -345,7 +431,7 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
||||
out[i] = s;
|
||||
level += s * s;
|
||||
}
|
||||
this.port.postMessage({ rms: Math.sqrt(level / out.length) });
|
||||
this.port.postMessage({ rms: Math.sqrt(level / out.length), played: this.played });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -379,6 +465,7 @@ async function startAudio() {
|
||||
|
||||
playbackNode = new AudioWorkletNode(audioCtx, 'playback-processor');
|
||||
playbackNode.port.onmessage = (e) => {
|
||||
if (e.data.played !== undefined) playedSamples = e.data.played;
|
||||
if (e.data.rms !== undefined) outMeter.style.width = Math.min(100, e.data.rms * 200) + '%';
|
||||
};
|
||||
outputGain = audioCtx.createGain();
|
||||
@@ -421,7 +508,7 @@ function handleServerText(text) {
|
||||
appendAgentDelta(event.text);
|
||||
break;
|
||||
case 'assistant_end':
|
||||
endAgentMessage();
|
||||
agentEnded = true; // keep the bubble: karaoke finishes when audio drains
|
||||
break;
|
||||
case 'error':
|
||||
endAgentMessage();
|
||||
|
||||
Reference in New Issue
Block a user