This commit is contained in:
valenti
2026-10-16 11:36:02 +01:00
commit 236fd509ca
59 changed files with 18858 additions and 0 deletions
+525
View File
@@ -0,0 +1,525 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mivocero — Speech-to-Speech Agent</title>
<style>
:root {
--bg: #0b0f1a;
--panel: #141b2d;
--accent: #4f8cff;
--accent-dim: #2a4a8a;
--ok: #3ddc97;
--warn: #ffb454;
--err: #ff5470;
--text: #e6edf7;
--muted: #8a94a6;
--border: #243049;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
justify-content: center;
padding: 18px;
}
.app {
display: flex;
flex-direction: column;
width: 100%;
max-width: 820px;
height: calc(100vh - 36px);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
}
header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
header h1 { font-size: 16px; margin: 0; flex: 1; }
.status { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); }
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--muted); }
.dot.on { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
.dot.err { background: var(--err); box-shadow: 0 0 8px var(--err); }
button {
background: var(--accent-dim);
color: var(--text);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
transition: all .15s;
}
button:hover:not(:disabled) { background: var(--accent); }
button:disabled { opacity: .4; cursor: not-allowed; }
button.danger { border-color: var(--err); background: rgba(255,84,112,.15); }
button.danger:hover:not(:disabled) { background: var(--err); }
#chat {
flex: 1;
overflow-y: auto;
padding: 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.msg { display: flex; flex-direction: column; gap: 4px; max-width: 80%; }
.msg .who { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); }
.msg .bubble {
padding: 10px 13px;
border-radius: 12px;
font-size: 14px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.msg.user { align-self: flex-end; align-items: flex-end; }
.msg.user .bubble { background: var(--accent-dim); border: 1px solid var(--accent); }
.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; } }
.hint { color: var(--muted); font-size: 13px; text-align: center; margin: auto; }
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; }
.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; }
.composer { display: flex; gap: 8px; }
.composer input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
font-size: 14px;
}
#log {
display: none;
max-height: 110px;
overflow-y: auto;
margin-top: 10px;
padding: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
font-family: ui-monospace, Menlo, monospace;
font-size: 11px;
color: var(--muted);
white-space: pre-wrap;
}
</style>
</head>
<body>
<div class="app">
<header>
<h1>🎙️ Mivocero</h1>
<span class="status"><span class="dot" id="connDot"></span><span id="connText">disconnected</span></span>
<button id="btnConnect" class="ok">Connect</button>
<button id="btnInterrupt" class="danger" disabled>Stop</button>
</header>
<div id="chat">
<div class="hint" id="emptyHint">
Press <b>Connect</b>, allow microphone access and start talking.<br />
The agent replies with synthesized speech and the transcript appears here.
</div>
</div>
<footer>
<div class="meters">
<span>🎤</span><div class="meter"><div id="micMeter"></div></div>
<span>🔊</span><div class="meter"><div id="outMeter"></div></div>
</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>
</form>
<div id="log"></div>
</footer>
</div>
<script>
// ---------------------------------------------------------------------------
// Mivocero — speech-to-speech agent client
//
// mic -> AudioWorklet (44.1 kHz, 16-bit LE mono, 20 ms frames) -> WebSocket
// WebSocket binary frames -> playback queue -> AudioWorklet -> speakers
//
// Text frames received from the server are JSON chat events:
// {"type":"user","text":"…"} recognized user speech
// {"type":"assistant_delta","text":"…"} incremental assistant reply
// {"type":"assistant_end"} assistant finished
// {"type":"error","text":"…"} pipeline error
// ---------------------------------------------------------------------------
const SAMPLE_RATE_OUT = 44100; // server <-> client (matches the TTS rate)
const SAMPLE_RATE_IN = 44100;
const FRAME_MS = 20;
const FRAME_SAMPLES = SAMPLE_RATE_IN * FRAME_MS / 1000; // 882
const $ = (id) => document.getElementById(id);
const wsDot = $('connDot');
const wsText = $('connText');
const btnConnect = $('btnConnect');
const btnInterrupt = $('btnInterrupt');
const micMeter = $('micMeter');
const outMeter = $('outMeter');
const chatEl = $('chat');
const emptyHint = $('emptyHint');
const composer = $('composer');
const textInput = $('textInput');
const btnSend = $('btnSend');
const logEl = $('log');
let ws = null;
let audioCtx = null;
let micStream = null;
let captureNode = null;
let playbackNode = null;
let outputGain = null;
let agentBubble = null; // bubble being streamed by the assistant
let agentText = ''; // accumulated assistant text for that bubble
function log(msg) {
const ts = new Date().toLocaleTimeString();
logEl.textContent = `[${ts}] ${msg}\n` + logEl.textContent;
if (logEl.textContent.length > 4000) logEl.textContent = logEl.textContent.slice(0, 4000);
}
function setConn(state, text) {
wsDot.className = 'dot ' + (state === 'on' ? 'on' : state === 'err' ? 'err' : '');
wsText.textContent = text;
}
function atBottom() {
return chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 60;
}
function scrollDown(force) {
if (force || atBottom()) chatEl.scrollTop = chatEl.scrollHeight;
}
// --- Chat rendering --------------------------------------------------------
function addMessage(role, text, cls = '') {
emptyHint?.remove();
const wrap = document.createElement('div');
wrap.className = 'msg ' + role + (cls ? ' ' + cls : '');
const who = document.createElement('div');
who.className = 'who';
who.textContent = role === 'user' ? 'You' : role === 'error' ? 'Error' : 'Agent';
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.textContent = text;
wrap.append(who, bubble);
chatEl.appendChild(wrap);
scrollDown(true);
return bubble;
}
function startAgentMessage() {
agentText = '';
agentBubble = addMessage('agent', '');
agentBubble.classList.add('pending');
}
function appendAgentDelta(text) {
if (!agentBubble) startAgentMessage();
agentText += text;
agentBubble.textContent = agentText;
scrollDown(false);
}
function endAgentMessage() {
if (agentBubble) agentBubble.classList.remove('pending');
agentBubble = null;
agentText = '';
}
// --- Audio worklets --------------------------------------------------------
const CAPTURE_PROCESSOR = `
class CaptureProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buf = [];
this.frameSize = ${FRAME_SAMPLES};
this.targetRate = ${SAMPLE_RATE_IN};
this.ratio = this.targetRate / sampleRate;
this.phase = 0;
this.prev = 0;
}
process(inputs) {
const inp = inputs[0][0];
if (!inp) return true;
let acc = 0;
for (let i = 0; i < inp.length; i++) {
const cur = inp[i];
acc += cur * cur;
while (this.phase < i + 1) {
const frac = this.phase - Math.floor(this.phase);
const s = this.prev + (cur - this.prev) * frac;
this.buf.push(s);
if (this.buf.length >= this.frameSize) this.flush();
this.phase += this.ratio;
}
this.prev = cur;
}
this.phase -= inp.length;
const rms = Math.sqrt(acc / inp.length);
this.port.postMessage({ rms });
return true;
}
flush() {
const n = this.frameSize;
const buf = new ArrayBuffer(n * 2);
const dv = new DataView(buf);
for (let i = 0; i < n; i++) {
let v = Math.max(-1, Math.min(1, this.buf[i]));
dv.setInt16(i * 2, v < 0 ? v * 0x8000 : v * 0x7FFF, true);
}
this.buf.splice(0, n);
this.port.postMessage({ pcm: buf }, [buf]);
}
}
registerProcessor('capture-processor', CaptureProcessor);
`;
const PLAYBACK_PROCESSOR = `
class PlaybackProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buf = new Float32Array(48000 * 2); // ~2 s ring @ 44.1 kHz
this.head = 0;
this.tail = 0;
this.preBuffer = Math.floor(sampleRate * 0.30); // 300 ms jitter buffer
this.buffering = true;
this.underrunSamples = 0;
this.underrunThreshold = Math.floor(sampleRate * 0.10);
this.port.onmessage = (e) => {
const pcm = e.data;
for (let i = 0; i < pcm.length; i++) {
this.buf[this.head] = pcm[i];
this.head = (this.head + 1) % this.buf.length;
if (this.head === this.tail) this.tail = (this.tail + 1) % this.buf.length;
}
};
}
available() { return (this.head - this.tail + this.buf.length) % this.buf.length; }
process(_inputs, outputs) {
const out = outputs[0][0];
let level = 0;
for (let i = 0; i < out.length; i++) {
let s = 0;
if (this.buffering) {
if (this.available() >= this.preBuffer) { this.buffering = false; this.underrunSamples = 0; }
}
if (!this.buffering) {
if (this.tail !== this.head) {
s = this.buf[this.tail];
this.tail = (this.tail + 1) % this.buf.length;
this.underrunSamples = 0;
} else {
this.underrunSamples++;
if (this.underrunSamples >= this.underrunThreshold) { this.buffering = true; this.underrunSamples = 0; }
}
}
out[i] = s;
level += s * s;
}
this.port.postMessage({ rms: Math.sqrt(level / out.length) });
return true;
}
}
registerProcessor('playback-processor', PlaybackProcessor);
`;
async function startAudio() {
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE_OUT });
if (audioCtx.state !== 'running') {
try { await audioCtx.resume(); } catch (e) {}
}
await audioCtx.audioWorklet.addModule(URL.createObjectURL(new Blob([CAPTURE_PROCESSOR], { type: 'application/javascript' })));
await audioCtx.audioWorklet.addModule(URL.createObjectURL(new Blob([PLAYBACK_PROCESSOR], { type: 'application/javascript' })));
micStream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true }
});
const source = audioCtx.createMediaStreamSource(micStream);
captureNode = new AudioWorkletNode(audioCtx, 'capture-processor');
captureNode.port.onmessage = (e) => {
if (e.data.rms !== undefined) {
micMeter.style.width = Math.min(100, e.data.rms * 200) + '%';
} else if (e.data.pcm && ws && ws.readyState === WebSocket.OPEN) {
ws.send(e.data.pcm);
}
};
source.connect(captureNode);
const silent = audioCtx.createGain();
silent.gain.value = 0;
captureNode.connect(silent).connect(audioCtx.destination);
playbackNode = new AudioWorkletNode(audioCtx, 'playback-processor');
playbackNode.port.onmessage = (e) => {
if (e.data.rms !== undefined) outMeter.style.width = Math.min(100, e.data.rms * 200) + '%';
};
outputGain = audioCtx.createGain();
outputGain.gain.value = 3.0; // Fish Audio output level is fairly low
playbackNode.connect(outputGain).connect(audioCtx.destination);
}
function stopAudio() {
try { captureNode?.disconnect(); } catch (e) {}
try { playbackNode?.disconnect(); } catch (e) {}
try { outputGain?.disconnect(); } catch (e) {}
micStream?.getTracks().forEach((t) => t.stop());
audioCtx?.close();
audioCtx = null;
micStream = null;
captureNode = playbackNode = outputGain = null;
micMeter.style.width = '0%';
outMeter.style.width = '0%';
}
function feedPlayback(arrayBuffer) {
const view = new DataView(arrayBuffer);
const n = Math.floor(view.byteLength / 2);
const f32 = new Float32Array(n);
for (let i = 0; i < n; i++) f32[i] = view.getInt16(i * 2, true) / 32768;
playbackNode?.port.postMessage(f32);
}
// --- WebSocket -------------------------------------------------------------
function handleServerText(text) {
let event = null;
try { event = JSON.parse(text); } catch (e) { log('server: ' + text); return; }
switch (event.type) {
case 'user':
endAgentMessage(); // a new turn starts
addMessage('user', event.text);
break;
case 'assistant_delta':
appendAgentDelta(event.text);
break;
case 'assistant_end':
endAgentMessage();
break;
case 'error':
endAgentMessage();
addMessage('error', event.text, 'error');
log('error: ' + event.text);
break;
default:
log('server: ' + text);
}
}
async function connect() {
if (!navigator.mediaDevices?.getUserMedia) {
const why = window.isSecureContext
? 'navigator.mediaDevices is unavailable in this browser'
: `insecure origin (${location.origin}) — the microphone requires HTTPS or localhost`;
addMessage('error', 'Microphone unavailable: ' + why, 'error');
setConn('err', 'mic error');
return;
}
try {
await startAudio();
} catch (e) {
addMessage('error', 'Microphone access failed: ' + (e.name ? e.name + ': ' : '') + (e.message || e), 'error');
setConn('err', 'mic error');
return;
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/ws`;
log('connecting to ' + url);
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
setConn('on', 'connected');
log('connected');
btnConnect.disabled = true;
btnInterrupt.disabled = false;
textInput.disabled = false;
btnSend.disabled = false;
};
ws.onmessage = (e) => {
if (e.data instanceof ArrayBuffer) {
feedPlayback(e.data);
} else {
handleServerText(e.data);
}
};
ws.onerror = () => { log('websocket error'); setConn('err', 'error'); };
ws.onclose = () => {
log('disconnected');
setConn('', 'disconnected');
btnConnect.disabled = false;
btnInterrupt.disabled = true;
textInput.disabled = true;
btnSend.disabled = true;
endAgentMessage();
stopAudio();
};
}
function disconnect() {
try { ws?.close(); } catch (e) {}
stopAudio();
}
function sendJson(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
return true;
}
return false;
}
btnConnect.onclick = () => (ws ? disconnect() : connect());
btnInterrupt.onclick = () => {
if (sendJson({ type: 'interrupt' })) {
log('→ interrupt');
endAgentMessage();
}
};
composer.onsubmit = (e) => {
e.preventDefault();
const text = textInput.value.trim();
if (!text) return;
if (sendJson({ type: 'user_text', text })) {
endAgentMessage();
addMessage('user', text);
textInput.value = '';
log('→ user_text');
}
};
// Optional verbose log: click the header status to toggle.
wsText.style.cursor = 'pointer';
wsText.onclick = () => { logEl.style.display = logEl.style.display === 'block' ? 'none' : 'block'; };
</script>
</body>
</html>