first
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user