step2
This commit is contained in:
@@ -100,6 +100,83 @@
|
||||
.meter { height: 8px; background: var(--bg); border-radius: 4px; overflow: hidden; flex: 1; }
|
||||
.meter > div { height: 100%; width: 0%; background: linear-gradient(90deg, var(--ok), var(--warn), var(--err)); transition: width .05s; }
|
||||
.meters span { font-size: 11px; color: var(--muted); white-space: nowrap; }
|
||||
/* Cue indicators: flash + counter for filler / start filler / backchannel. */
|
||||
.cues { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||
.cue {
|
||||
--cue: var(--accent);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 9px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
transition: border-color .25s, box-shadow .25s;
|
||||
}
|
||||
.cue[data-kind="filler"] { --cue: var(--ok); }
|
||||
.cue[data-kind="start_filler"] { --cue: var(--warn); }
|
||||
.cue[data-kind="backchannel"] { --cue: var(--accent); }
|
||||
.cue .cue-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%; flex: none;
|
||||
background: var(--cue); opacity: .25;
|
||||
transition: opacity .15s, box-shadow .15s, transform .15s;
|
||||
}
|
||||
.cue.active { border-color: var(--cue); box-shadow: 0 0 10px -2px var(--cue); }
|
||||
.cue.active .cue-dot { opacity: 1; box-shadow: 0 0 8px var(--cue); transform: scale(1.3); }
|
||||
.cue .cue-label { text-transform: uppercase; letter-spacing: .4px; white-space: nowrap; }
|
||||
.cue .cue-last {
|
||||
flex: 1 1 auto; min-width: 0; text-align: right;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.cue .cue-count { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
/* Cue methods: collapsible panel with the live policy of each cue. */
|
||||
.cuemethods {
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cuemethods > summary {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 10px; cursor: pointer; list-style: none; color: var(--muted);
|
||||
}
|
||||
.cuemethods > summary::-webkit-details-marker { display: none; }
|
||||
.cuemethods > summary::before {
|
||||
content: '\25B8'; font-size: 10px; transition: transform .15s;
|
||||
}
|
||||
.cuemethods[open] > summary::before { transform: rotate(90deg); }
|
||||
.cuemethods .cm-title { text-transform: uppercase; letter-spacing: .4px; color: var(--text); font-weight: 600; }
|
||||
.cuemethods .cm-hint { margin-left: auto; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cm-cards {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 8px; padding: 0 10px 10px; max-height: 246px; overflow-y: auto;
|
||||
}
|
||||
.cm-empty { color: var(--muted); padding: 2px 0 4px; }
|
||||
.cm-card { --cue: var(--accent); border: 1px solid var(--border); border-left: 3px solid var(--cue); border-radius: 8px; padding: 8px 10px; }
|
||||
.cm-card[data-kind="filler"] { --cue: var(--ok); }
|
||||
.cm-card[data-kind="start_filler"] { --cue: var(--warn); }
|
||||
.cm-card[data-kind="backchannel"] { --cue: var(--accent); }
|
||||
.cm-head { display: flex; align-items: center; gap: 7px; margin-bottom: 5px; }
|
||||
.cm-head .cm-name { text-transform: uppercase; letter-spacing: .4px; font-size: 11px; color: var(--cue); font-weight: 600; }
|
||||
.cm-head .cm-badge {
|
||||
margin-left: auto; font-size: 10px; padding: 1px 7px; border-radius: 999px;
|
||||
border: 1px solid var(--border); color: var(--muted);
|
||||
}
|
||||
.cm-head .cm-badge.on { border-color: var(--cue); color: var(--cue); }
|
||||
.cm-head .cm-badge.off { border-color: var(--err); color: var(--err); }
|
||||
.cm-desc { color: var(--muted); line-height: 1.45; margin-bottom: 6px; }
|
||||
.cm-params { display: flex; flex-wrap: wrap; gap: 4px 6px; }
|
||||
.cm-param {
|
||||
font-size: 11px; color: var(--text);
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 1px 6px;
|
||||
}
|
||||
.cm-param b { color: var(--muted); font-weight: 600; }
|
||||
.composer { display: flex; gap: 8px; }
|
||||
.composer input {
|
||||
flex: 1;
|
||||
@@ -110,6 +187,30 @@
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
.mediabar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mediabar input[type="text"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.mediabar input[type="range"] { width: 88px; accent-color: var(--accent); }
|
||||
.mediabar .state {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
min-width: 72px;
|
||||
text-align: right;
|
||||
}
|
||||
#log {
|
||||
display: none;
|
||||
max-height: 110px;
|
||||
@@ -147,6 +248,42 @@
|
||||
<span>🎤</span><div class="meter"><div id="micMeter"></div></div>
|
||||
<span>🔊</span><div class="meter"><div id="outMeter"></div></div>
|
||||
</div>
|
||||
<div class="cues">
|
||||
<div class="cue" data-kind="filler" id="cueFiller" title="End-of-turn filler (latency mask)">
|
||||
<span class="cue-dot"></span>
|
||||
<span class="cue-label">Filler</span>
|
||||
<span class="cue-last">—</span>
|
||||
<span class="cue-count">0</span>
|
||||
</div>
|
||||
<div class="cue" data-kind="start_filler" id="cueStartFiller" title="Turn-start acknowledgment (latency mask)">
|
||||
<span class="cue-dot"></span>
|
||||
<span class="cue-label">Start filler</span>
|
||||
<span class="cue-last">—</span>
|
||||
<span class="cue-count">0</span>
|
||||
</div>
|
||||
<div class="cue" data-kind="backchannel" id="cueBackchannel" title="Backchannel cue while the user speaks">
|
||||
<span class="cue-dot"></span>
|
||||
<span class="cue-label">Backchannel</span>
|
||||
<span class="cue-last">—</span>
|
||||
<span class="cue-count">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<details class="cuemethods" id="cueMethods">
|
||||
<summary>
|
||||
<span class="cm-title">Cue methods</span>
|
||||
<span class="cm-hint" id="cueMethodsHint">connect to load the live policy</span>
|
||||
</summary>
|
||||
<div class="cm-cards" id="cueCards">
|
||||
<div class="cm-empty">Connect to load the live cue policy from the server.</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="mediabar">
|
||||
<input type="text" id="mediaUrl" placeholder="🎵 Audio URL / radio / YouTube…" disabled autocomplete="off" />
|
||||
<button type="button" id="btnMediaPlay" disabled>Play</button>
|
||||
<button type="button" id="btnMediaStop" class="danger" disabled>Stop</button>
|
||||
<input type="range" id="mediaVol" min="0" max="150" value="60" title="External audio volume" disabled />
|
||||
<span class="state" id="mediaState">idle</span>
|
||||
</div>
|
||||
<form class="composer" id="composer">
|
||||
<input type="text" id="textInput" placeholder="Or type a message…" disabled autocomplete="off" />
|
||||
<button type="submit" id="btnSend" disabled>Send</button>
|
||||
@@ -167,6 +304,12 @@
|
||||
// {"type":"assistant_delta","text":"…"} incremental assistant reply
|
||||
// {"type":"assistant_end"} assistant finished
|
||||
// {"type":"error","text":"…"} pipeline error
|
||||
// {"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)
|
||||
//
|
||||
// The session closes itself after IDLE_TIMEOUT_MS without any traffic in
|
||||
// either direction (see the idle guard below).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_RATE_OUT = 44100; // server <-> client (matches the TTS rate)
|
||||
@@ -174,6 +317,10 @@ const SAMPLE_RATE_IN = 44100;
|
||||
const FRAME_MS = 20;
|
||||
const FRAME_SAMPLES = SAMPLE_RATE_IN * FRAME_MS / 1000; // 882
|
||||
|
||||
// Disconnect the session (releasing the microphone) after this much time
|
||||
// without activity.
|
||||
const IDLE_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const wsDot = $('connDot');
|
||||
const wsText = $('connText');
|
||||
@@ -186,7 +333,20 @@ const emptyHint = $('emptyHint');
|
||||
const composer = $('composer');
|
||||
const textInput = $('textInput');
|
||||
const btnSend = $('btnSend');
|
||||
const mediaUrl = $('mediaUrl');
|
||||
const btnMediaPlay = $('btnMediaPlay');
|
||||
const btnMediaStop = $('btnMediaStop');
|
||||
const mediaVol = $('mediaVol');
|
||||
const mediaState = $('mediaState');
|
||||
const logEl = $('log');
|
||||
// Cue indicators: one tile per filler type, flashed on each server "cue" event.
|
||||
const cueEls = {
|
||||
filler: $('cueFiller'),
|
||||
start_filler: $('cueStartFiller'),
|
||||
backchannel: $('cueBackchannel'),
|
||||
};
|
||||
const cueCounts = { filler: 0, start_filler: 0, backchannel: 0 };
|
||||
const cueTimers = { filler: 0, start_filler: 0, backchannel: 0 };
|
||||
|
||||
let ws = null;
|
||||
let audioCtx = null;
|
||||
@@ -496,6 +656,128 @@ function feedPlayback(arrayBuffer) {
|
||||
|
||||
// --- WebSocket -------------------------------------------------------------
|
||||
|
||||
// Cue indicators: flash the matching tile and bump its counter. `kind` is one
|
||||
// of "filler" | "start_filler" | "backchannel"; the detail line shows the
|
||||
// backchannel category or the clip name.
|
||||
function updateCue(ev) {
|
||||
const el = cueEls[ev.kind];
|
||||
if (!el) return;
|
||||
cueCounts[ev.kind] += 1;
|
||||
el.querySelector('.cue-count').textContent = cueCounts[ev.kind];
|
||||
el.querySelector('.cue-last').textContent = ev.category || ev.name || '';
|
||||
el.classList.add('active');
|
||||
clearTimeout(cueTimers[ev.kind]);
|
||||
cueTimers[ev.kind] = setTimeout(() => el.classList.remove('active'), 400);
|
||||
}
|
||||
|
||||
function resetCues() {
|
||||
for (const kind of Object.keys(cueCounts)) {
|
||||
cueCounts[kind] = 0;
|
||||
clearTimeout(cueTimers[kind]);
|
||||
cueEls[kind].classList.remove('active');
|
||||
cueEls[kind].querySelector('.cue-count').textContent = '0';
|
||||
cueEls[kind].querySelector('.cue-last').textContent = '—';
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
// server payload into [label, value] chips.
|
||||
const fmt2 = (v) => (typeof v === 'number' ? v.toFixed(2) : '—');
|
||||
|
||||
const CUE_METHODS = {
|
||||
filler: {
|
||||
name: 'Filler',
|
||||
desc: 'End-of-turn latency mask. At most one clip per turn, and only when the '
|
||||
+ 'reply audio has not started within the delay — otherwise it is dropped, so '
|
||||
+ 'it never talks over the answer. Picked round-robin from the active bank.',
|
||||
params: (c) => [
|
||||
['delay', c.delay_ms + ' ms'],
|
||||
['gain idle', fmt2(c.gain_idle)],
|
||||
['gain duck', fmt2(c.gain_duck)],
|
||||
['clips', c.clips],
|
||||
['bank', c.bank],
|
||||
],
|
||||
},
|
||||
start_filler: {
|
||||
name: 'Start filler',
|
||||
desc: 'Turn-start acknowledgment. Fires only if the first audio chunk of the '
|
||||
+ 'reply is still missing after its (shorter) delay; when audio arrives in '
|
||||
+ 'time the cue is dropped. A clip is picked at random from the bank.',
|
||||
params: (c) => [
|
||||
['go delay', c.go_delay_ms + ' ms'],
|
||||
['clips', c.clips],
|
||||
['bank', c.bank],
|
||||
],
|
||||
},
|
||||
backchannel: {
|
||||
name: 'Backchannel',
|
||||
desc: 'While the user keeps speaking (and the agent is silent): a short ack '
|
||||
+ 'mixed at low gain. Fires only inside a natural micro-pause, at a random '
|
||||
+ 'depth in the pause window, through a probabilistic gate, with randomized '
|
||||
+ 'spacing and volume and a per-turn cap. Categories progress ack → agree.',
|
||||
params: (c) => [
|
||||
['gain', fmt2(c.gain)],
|
||||
['min speech', c.min_speech_ms + ' ms'],
|
||||
['interval', c.interval_ms + ' ms'],
|
||||
['jitter', c.jitter_ms + ' ms'],
|
||||
['pause', c.pause_min_ms + '–' + c.pause_max_ms + ' ms'],
|
||||
['prob', fmt2(c.prob)],
|
||||
['vol jitter', fmt2(c.volume_jitter)],
|
||||
['max/turn', c.max_per_turn],
|
||||
['categories', (c.categories || []).join(', ')],
|
||||
['clips', c.clips],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Render the three cards from the server "cues" payload (live params + method).
|
||||
function renderCues(lang, cfg) {
|
||||
const host = $('cueCards');
|
||||
if (!host || !cfg) return;
|
||||
host.textContent = '';
|
||||
for (const kind of ['filler', 'start_filler', 'backchannel']) {
|
||||
const m = CUE_METHODS[kind];
|
||||
const c = cfg[kind];
|
||||
if (!m || !c) continue;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'cm-card';
|
||||
card.dataset.kind = kind;
|
||||
|
||||
const head = document.createElement('div');
|
||||
head.className = 'cm-head';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'cm-name';
|
||||
name.textContent = m.name;
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'cm-badge ' + (c.enabled ? 'on' : 'off');
|
||||
badge.textContent = c.enabled ? 'on' : 'off';
|
||||
head.append(name, badge);
|
||||
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'cm-desc';
|
||||
desc.textContent = m.desc;
|
||||
|
||||
const params = document.createElement('div');
|
||||
params.className = 'cm-params';
|
||||
for (const [k, v] of m.params(c)) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'cm-param';
|
||||
const b = document.createElement('b');
|
||||
b.textContent = k + ' ';
|
||||
chip.append(b, document.createTextNode(String(v)));
|
||||
params.append(chip);
|
||||
}
|
||||
|
||||
card.append(head, desc, params);
|
||||
host.append(card);
|
||||
}
|
||||
const hint = $('cueMethodsHint');
|
||||
if (hint) hint.textContent = (lang ? 'lang ' + lang + ' · ' : '') + 'filler · start filler · backchannel';
|
||||
}
|
||||
|
||||
function handleServerText(text) {
|
||||
let event = null;
|
||||
try { event = JSON.parse(text); } catch (e) { log('server: ' + text); return; }
|
||||
@@ -515,11 +797,65 @@ function handleServerText(text) {
|
||||
addMessage('error', event.text, 'error');
|
||||
log('error: ' + event.text);
|
||||
break;
|
||||
case 'audio_source':
|
||||
updateMediaState(event);
|
||||
break;
|
||||
case 'cue':
|
||||
updateCue(event);
|
||||
break;
|
||||
case 'cues':
|
||||
renderCues(event.lang, event.config);
|
||||
break;
|
||||
default:
|
||||
log('server: ' + text);
|
||||
}
|
||||
}
|
||||
|
||||
// External audio source status → status label + button states.
|
||||
function updateMediaState(event) {
|
||||
const st = event.state || '';
|
||||
mediaState.textContent =
|
||||
st === 'playing' ? 'playing' : st === 'loading' ? 'loading…' : st === 'error' ? 'error' : 'idle';
|
||||
btnMediaStop.disabled = !(st === 'playing' || st === 'loading');
|
||||
if (st === 'error') {
|
||||
const msg = event.text || 'unknown error';
|
||||
log('audio source error: ' + msg);
|
||||
addMessage('error', 'Audio source error: ' + msg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function setMediaControlsEnabled(on) {
|
||||
mediaUrl.disabled = !on;
|
||||
mediaVol.disabled = !on;
|
||||
btnMediaPlay.disabled = !on;
|
||||
if (!on) btnMediaStop.disabled = true;
|
||||
}
|
||||
|
||||
// --- Idle session guard ----------------------------------------------------
|
||||
// Reset on any traffic (server audio / events) and on every user action. When
|
||||
// it expires the session is closed, which stops the microphone and re-enables
|
||||
// the Connect button. A playing external source keeps sending audio frames, so
|
||||
// it counts as activity too.
|
||||
let idleTimer = 0;
|
||||
|
||||
function touchActivity() {
|
||||
if (!ws) return;
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(onIdleTimeout, IDLE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function stopIdleWatch() {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = 0;
|
||||
}
|
||||
|
||||
function onIdleTimeout() {
|
||||
if (!ws) return;
|
||||
log(`idle timeout — no activity for ${IDLE_TIMEOUT_MS / 60000} min, disconnecting`);
|
||||
addMessage('error', 'Session closed after 2 minutes of inactivity. Press Connect to resume.', 'error');
|
||||
disconnect();
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
const why = window.isSecureContext
|
||||
@@ -546,12 +882,19 @@ async function connect() {
|
||||
ws.onopen = () => {
|
||||
setConn('on', 'connected');
|
||||
log('connected');
|
||||
resetCues();
|
||||
btnConnect.disabled = true;
|
||||
btnInterrupt.disabled = false;
|
||||
textInput.disabled = false;
|
||||
btnSend.disabled = false;
|
||||
setMediaControlsEnabled(true);
|
||||
mediaState.textContent = 'idle';
|
||||
touchActivity();
|
||||
// Sync the server's external-audio gain with the slider.
|
||||
sendJson({ type: 'audio_source', action: 'volume', gain: mediaVol.value / 100 });
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
touchActivity();
|
||||
if (e.data instanceof ArrayBuffer) {
|
||||
feedPlayback(e.data);
|
||||
} else {
|
||||
@@ -560,12 +903,16 @@ async function connect() {
|
||||
};
|
||||
ws.onerror = () => { log('websocket error'); setConn('err', 'error'); };
|
||||
ws.onclose = () => {
|
||||
stopIdleWatch();
|
||||
ws = null; // allow reconnecting from the Connect button
|
||||
log('disconnected');
|
||||
setConn('', 'disconnected');
|
||||
btnConnect.disabled = false;
|
||||
btnInterrupt.disabled = true;
|
||||
textInput.disabled = true;
|
||||
btnSend.disabled = true;
|
||||
setMediaControlsEnabled(false);
|
||||
mediaState.textContent = 'idle';
|
||||
endAgentMessage();
|
||||
stopAudio();
|
||||
};
|
||||
@@ -579,6 +926,7 @@ function disconnect() {
|
||||
function sendJson(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(obj));
|
||||
touchActivity();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -592,6 +940,26 @@ btnInterrupt.onclick = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// External audio source (URL / radio / YouTube) controls.
|
||||
btnMediaPlay.onclick = () => {
|
||||
const url = mediaUrl.value.trim();
|
||||
if (!url) return;
|
||||
if (sendJson({ type: 'audio_source', action: 'play', url })) {
|
||||
mediaState.textContent = 'loading…';
|
||||
btnMediaStop.disabled = false;
|
||||
log('→ audio_source play ' + url);
|
||||
}
|
||||
};
|
||||
btnMediaStop.onclick = () => {
|
||||
if (sendJson({ type: 'audio_source', action: 'stop' })) log('→ audio_source stop');
|
||||
};
|
||||
mediaUrl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); btnMediaPlay.click(); }
|
||||
});
|
||||
mediaVol.oninput = () => {
|
||||
sendJson({ type: 'audio_source', action: 'volume', gain: mediaVol.value / 100 });
|
||||
};
|
||||
|
||||
composer.onsubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const text = textInput.value.trim();
|
||||
|
||||
Reference in New Issue
Block a user