Files
Void-Homelab/public/components/dross_bubble.js
root 1d94dcae97 fix(voice): amplitude meter was masked by the dross-rec keyframe animation (2.14.1)
CSS animations override normal declarations — the old box-shadow pulse painted
over the level-driven shadow. .metered now disables the fallback pulse; added
sqrt gain so speech registers visibly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:48:02 +10:00

168 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// public/components/dross_bubble.js
// Global floating Dross companion. Replaces the per-Space right rail.
import { el, mount } from '../dom.js';
import { api } from '../api.js';
import { state } from '../state.js';
import { wireAgentChat } from './agent_chat.js';
import { drossAvatar } from './dross_avatar.js';
const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change', propose_improvement: '🎨 drafting an improvement to the Void' };
let cfg = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
function applyAccent(node, hex) {
node.style.setProperty('--dross', hex);
}
export async function renderDrossBubble() {
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { /* defaults */ }
const fab = el('div', { class: 'dross-fab', title: 'Dross' },
el('div', { class: 'dross-ping', style: { display: 'none' } }, ''), drossAvatar(cfg.avatar, 60));
const log = el('div', { class: 'dross-log' });
const input = el('textarea', { rows: 1, placeholder: 'Ask Dross…' });
const sendBtn = el('button', { class: 'dross-send', title: 'Send' },
el('span', { html: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>' }));
const micLabel = el('span', {}, 'Tap to record');
const mic = el('button', { class: 'dross-mic', title: 'Record a voice note' },
el('span', { html: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>' }), micLabel);
const closeBtn = el('button', { class: 'dross-x', title: 'Close' }, '');
const header = el('div', { class: 'dross-hd' }, drossAvatar(cfg.avatar, 30),
el('div', { class: 'dross-who' }, 'Dross', el('small', {}, 'always here, regrettably')), closeBtn);
const collapse = el('div', { class: 'dross-collapse', title: 'Collapse' },
el('span', { class: 'grip' }), el('span', {}, '⌄ collapse'), el('span', { class: 'grip' }));
const panel = el('div', { class: 'dross-panel' }, header, log,
el('div', { class: 'dross-inwrap' }, input, el('div', { class: 'dross-btnrow' }, mic, sendBtn)), collapse);
document.getElementById('shell').append(fab, panel);
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
// autogrow: 1 line at rest, expands with content up to ~5 lines
function autogrow() {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
}
input.addEventListener('input', autogrow);
const chat = wireAgentChat({
logEl: log, inputEl: input, sendBtnEl: sendBtn,
historyUrl: '/api/dross', turnUrl: '/api/dross/turn',
agentName: 'Dross', showDrafts: true, toolLabels: TOOL_LABELS,
turnBody: (text) => ({ text, view: state.view || null })
});
let loaded = false;
function openPanel() {
const r = fab.getBoundingClientRect();
panel.classList.add('open'); fab.style.display = 'none';
const pr = panel.getBoundingClientRect();
const left = Math.max(8, Math.min(r.right - pr.width, innerWidth - pr.width - 8));
const top = Math.max(8, Math.min(r.bottom - pr.height, innerHeight - pr.height - 8));
panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.left = left + 'px'; panel.style.top = top + 'px';
if (!loaded) { loaded = true; chat.load(); }
// NB: do NOT auto-focus the input — on mobile that pops the keyboard every
// time Dross opens. The keyboard should only appear when the user taps the box.
}
function closePanel() { panel.classList.remove('open'); fab.style.display = 'block'; }
fab.addEventListener('click', () => { if (fab._moved) { fab._moved = false; return; } openPanel(); });
closeBtn.addEventListener('click', closePanel);
collapse.addEventListener('click', closePanel);
// Topbar ◆ button (and any caller) can summon/dismiss Dross.
window.addEventListener('dross-toggle', () => panel.classList.contains('open') ? closePanel() : openPanel());
drag(fab, fab, true); drag(header, panel, false);
// ---- voice: tap mic to record, tap again to stop → transcribe → review-and-send ----
let media = null, chunks = [], recording = false;
function setMic(label, rec) { micLabel.textContent = label; mic.classList.toggle('rec', !!rec); }
async function startRec() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];
const opt = (window.MediaRecorder && MediaRecorder.isTypeSupported('audio/webm;codecs=opus'))
? { mimeType: 'audio/webm;codecs=opus' } : {};
media = new MediaRecorder(stream, opt);
media.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
media.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
await sendClip(new Blob(chunks, { type: media.mimeType || 'audio/webm' }));
};
media.start();
recording = true; setMic('● Recording… tap to stop', true);
// live level meter: actual mic amplitude drives the pulse (visual proof it hears you)
try {
const actx = new (window.AudioContext || window.webkitAudioContext)();
const src = actx.createMediaStreamSource(stream);
const analyser = actx.createAnalyser(); analyser.fftSize = 256;
src.connect(analyser);
const buf = new Uint8Array(analyser.frequencyBinCount);
mic.classList.add('metered'); // disables the fallback pulse; amplitude takes over
const tick = () => {
if (!recording) {
actx.close().catch(() => {});
mic.style.removeProperty('--voicelevel'); mic.classList.remove('metered');
return;
}
analyser.getByteTimeDomainData(buf);
let peak = 0;
for (const v of buf) peak = Math.max(peak, Math.abs(v - 128));
// sqrt curve + gain: normal speech peaks ~0.10.4 raw, which read as barely-alive
mic.style.setProperty('--voicelevel', Math.min(1, Math.sqrt(peak / 48)).toFixed(3));
requestAnimationFrame(tick);
};
tick();
} catch { /* meter is decorative — recording works without it */ }
} catch {
setMic('Mic blocked', false); setTimeout(() => setMic('Tap to record', false), 1800);
}
}
function stopRec() {
if (media && recording) { recording = false; setMic('Transcribing…', false); media.stop(); }
}
async function sendClip(blob) {
try {
const fd = new FormData(); fd.append('audio', blob, 'clip.webm');
const res = await fetch('/api/voice/transcribe', {
method: 'POST', headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') }, body: fd
});
if (!res.ok) throw new Error('stt');
const { text } = await res.json();
setMic('Tap to record', false);
if (text) {
input.value = input.value ? (input.value + ' ' + text) : text;
autogrow();
// Focus only on fine-pointer devices — on mobile this popped the keyboard
// right after every voice note (owner-reported). A brief highlight instead.
if (matchMedia('(pointer: fine)').matches) input.focus();
else { input.classList.add('flash'); setTimeout(() => input.classList.remove('flash'), 900); }
}
// voiceMode 'handsfree'/'action' (Phase 2b+) would branch here.
} catch {
setMic('Transcribe failed', false); setTimeout(() => setMic('Tap to record', false), 2000);
}
}
mic.addEventListener('click', () => recording ? stopRec() : startRec());
window.addEventListener('dross-settings-changed', async () => {
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { return; }
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
mount(fab, el('div', { class: 'dross-ping', style: { display: 'none' } }), drossAvatar(cfg.avatar, 60));
header.replaceChild(drossAvatar(cfg.avatar, 30), header.firstChild);
});
}
function drag(handle, target, isFab) {
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('.dross-x') || e.target.closest('.dross-mic') || e.target.closest('.dross-send')) return;
e.preventDefault();
const r = target.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; let moved = false;
target.style.right = 'auto'; target.style.bottom = 'auto'; target.style.left = r.left + 'px'; target.style.top = r.top + 'px';
const mv = (ev) => {
const dx = ev.clientX - sx, dy = ev.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
target.style.left = Math.max(4, Math.min(innerWidth - r.width - 4, r.left + dx)) + 'px';
target.style.top = Math.max(4, Math.min(innerHeight - r.height - 4, r.top + dy)) + 'px';
};
const up = () => { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); if (isFab) target._moved = moved; };
document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up);
});
}