This commit is contained in:
valenti
2026-10-16 12:33:56 +01:00
parent 236fd509ca
commit 430997bd2e
11 changed files with 291 additions and 24 deletions
+2 -2
View File
@@ -84,7 +84,7 @@ pub struct Config {
/// per turn.
pub filler_delay_ms: u64,
/// TTS jitter buffer (ms) the mixer must fill before the response audio
/// starts playing (`TTS_PREBUFFER_MS`, default `900`). Larger values absorb
/// starts playing (`TTS_PREBUFFER_MS`, default `450`). Larger values absorb
/// uneven TTS chunk arrival (fewer drop-outs) at the cost of added
/// time-to-first-audio. The full buffer is only required at the start of a
/// reply; after a mid-reply underrun the mixer resumes as soon as any audio
@@ -184,7 +184,7 @@ impl Default for Config {
fillers_dir: PathBuf::from("assets/fillers"),
filler_enabled: true,
filler_delay_ms: 2500,
tts_prebuffer_ms: 900,
tts_prebuffer_ms: 450,
tts_pipeline_depth: 3,
filler_gain_idle: 1.0,
filler_gain_duck: 0.15,
+98 -11
View File
@@ -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();