feat(voice): browser voice client for talking to the on-board agent

Single-file client: browser-side STT + TTS (Web Speech API, no keys), drives the
lean demo agent over /ws/chat (?agent=demo, no auth on the workshop board) to change
the LED-matrix animation by voice. Push-to-talk, live transcript, spoken reply, tool
activity display. README covers the localhost-serve secure-context requirement and the
ElevenLabs/voice-duplex upgrade path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-20 21:54:10 -07:00
co-authored by Claude Opus 4.8
parent 62e4a3b688
commit 1c762e51ba
2 changed files with 200 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# APESS Voice → Node client
A self-contained, single-file voice client that lets you **talk to the on-board agent
and change the LED-matrix animation by voice** — same agent, same `matrix_pattern` tool
as web chat and Telegram.
It does **STT and TTS entirely in the browser** (Web Speech API), so it needs **no keys,
no ElevenLabs, and no server-side TTS**. It talks to the node's `/ws/chat` WebSocket
(`?agent=demo`), which requires no auth on the workshop board (`require_pairing = false`).
## Run it (needs a secure context for mic access)
`getUserMedia`/`SpeechRecognition` only work on `https://` or `http://localhost`. Serve the
folder from localhost on the demo laptop:
```bash
cd deploy/voice-client
python3 -m http.server 8090
# open http://localhost:8090 in Chrome
```
Then in the page:
1. Set the board address (e.g. `192.168.x.x:8080`) and agent (`demo`), click **Connect**
(the dot goes green).
2. **Hold** the circle, say *"show the wave animation"*, release.
3. The node runs the lean on-board agent → `matrix_pattern` → the matrix changes, and the
reply is spoken back.
The `ws://…` connection works fine from an `http://localhost` page (only `https` pages
block insecure `ws`). Chrome is required (Web Speech API).
## Upgrading to ElevenLabs (later)
Browser TTS is functional but robotic. For ElevenLabs-quality speech, switch to the
gateway's **voice-duplex** path (`gateway-voice-duplex` binary already built): configure
`[channels.voice_duplex.default]` + an ElevenLabs `tts_provider`, set the demo agent's
`tts_provider`, and have the client emit `speech_end {transcript}` and play the server's
`tts_chunk` frames instead of using `speechSynthesis`. The STT stays browser-side.
</content>
+161
View File
@@ -0,0 +1,161 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APESS · Voice → Node</title>
<style>
:root { --bg:#0d0d0f; --fg:#eae6df; --dim:#8a857c; --accent:#e8543f; --line:#26242a; }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
min-height:100vh; display:flex; flex-direction:column; align-items:center; }
header { width:100%; border-bottom:1px solid var(--line); padding:14px 18px;
text-transform:uppercase; letter-spacing:.18em; font-size:12px; color:var(--dim);
display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; }
.cfg { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.cfg input { background:#141317; color:var(--fg); border:1px solid var(--line);
padding:6px 8px; font:inherit; font-size:12px; border-radius:4px; }
.cfg input#ip { width:150px; }
.cfg input#agent { width:80px; }
button { font:inherit; cursor:pointer; }
.conn { padding:6px 10px; border:1px solid var(--line); border-radius:4px;
background:transparent; color:var(--fg); font-size:12px; text-transform:uppercase; letter-spacing:.1em; }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:#5a5750; margin-right:6px; vertical-align:middle; }
.dot.on { background:#4caf72; } .dot.err { background:var(--accent); }
main { flex:1; width:100%; max-width:720px; padding:24px 18px; display:flex; flex-direction:column; gap:18px; }
.mic { align-self:center; width:150px; height:150px; border-radius:50%; border:2px solid var(--line);
background:#141317; color:var(--fg); font-size:13px; text-transform:uppercase; letter-spacing:.12em;
display:flex; align-items:center; justify-content:center; transition:all .15s; user-select:none; }
.mic:hover { border-color:var(--dim); }
.mic.live { border-color:var(--accent); background:#2a1512; box-shadow:0 0 0 6px rgba(232,84,63,.12); }
.mic:disabled { opacity:.4; cursor:not-allowed; }
.hint { text-align:center; color:var(--dim); font-size:12px; margin-top:-8px; }
.log { display:flex; flex-direction:column; gap:12px; }
.row { border:1px solid var(--line); border-radius:6px; padding:12px 14px; }
.row .who { font-size:10px; text-transform:uppercase; letter-spacing:.16em; color:var(--dim); margin-bottom:6px; }
.row.you { border-color:#2f3a44; } .row.node { border-color:#3a2f2c; }
.row.tool { border-style:dashed; color:var(--dim); font-size:12px; }
.txt { font-size:15px; line-height:1.5; white-space:pre-wrap; }
.interim { color:var(--dim); font-style:italic; }
</style>
</head>
<body>
<header>
<span>APESS · VOICE → NODE</span>
<div class="cfg">
<input id="ip" placeholder="board-ip:8080" />
<input id="agent" value="demo" />
<button class="conn" id="connect"><span class="dot" id="dot"></span><span id="connlabel">Connect</span></button>
</div>
</header>
<main>
<button class="mic" id="mic" disabled>Hold&nbsp;to&nbsp;talk</button>
<div class="hint" id="hint">Connect to your node, then hold the circle and speak.</div>
<div class="log" id="log"></div>
</main>
<script>
(() => {
const $ = id => document.getElementById(id);
const ipEl=$('ip'), agentEl=$('agent'), micEl=$('mic'), logEl=$('log'),
dot=$('dot'), connLabel=$('connlabel'), hint=$('hint'), connectBtn=$('connect');
// Persist the board address between sessions.
ipEl.value = localStorage.getItem('apess_ip') || location.hostname + ':8080';
let ws=null, connected=false, nodeRow=null, nodeText='';
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { hint.textContent = 'This browser has no Web Speech API — use Chrome.'; }
function setConn(state){ // 'on' | 'err' | ''
dot.className = 'dot' + (state ? ' '+state : '');
connected = state==='on';
connLabel.textContent = connected ? 'Connected' : (state==='err'?'Retry':'Connect');
micEl.disabled = !connected || !SR;
if (connected) hint.textContent = 'Hold the circle, speak, release. The node acts and talks back.';
}
function addRow(cls, who, text){
const r=document.createElement('div'); r.className='row '+cls;
r.innerHTML=`<div class="who">${who}</div><div class="txt"></div>`;
r.querySelector('.txt').textContent=text; logEl.appendChild(r);
r.scrollIntoView({behavior:'smooth',block:'end'}); return r;
}
function connect(){
const ip = ipEl.value.trim(); const agent = agentEl.value.trim() || 'demo';
localStorage.setItem('apess_ip', ip);
try { ws && ws.close(); } catch(e){}
setConn('');
ws = new WebSocket(`ws://${ip}/ws/chat?agent=${encodeURIComponent(agent)}`);
ws.onopen = () => setConn('on');
ws.onclose = () => setConn('');
ws.onerror = () => setConn('err');
ws.onmessage = ev => {
let m; try { m = JSON.parse(ev.data); } catch(e){ return; }
switch (m.type) {
case 'chunk':
if (!nodeRow){ nodeRow=addRow('node','Node',''); nodeText=''; }
nodeText += (m.content||''); nodeRow.querySelector('.txt').textContent = nodeText; break;
case 'tool_call':
addRow('tool','tool', '→ '+(m.tool||m.name||'tool')+ (m.args?(' '+JSON.stringify(m.args)):'')); break;
case 'agent_end':
case 'done': {
const reply = (m.full_response!=null? m.full_response : nodeText).trim();
if (reply && !nodeRow) addRow('node','Node',reply);
else if (reply && nodeRow) nodeRow.querySelector('.txt').textContent = reply;
speak(reply); nodeRow=null; nodeText=''; break;
}
case 'error':
addRow('tool','error', m.message||m.error||'error'); nodeRow=null; break;
}
};
}
function speak(text){
if (!text || !window.speechSynthesis) return;
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.rate = 1.02; u.pitch = 1.0; window.speechSynthesis.speak(u);
}
function send(text){
if (!text) return;
addRow('you','You',text);
if (ws && ws.readyState===1) ws.send(JSON.stringify({type:'message', content:text}));
else addRow('tool','error','not connected');
}
// --- push-to-talk: hold the circle, speak, release ---
let rec=null, listening=false, finalText='';
function startListen(){
if (!SR || !connected || listening) return;
finalText=''; listening=true; micEl.classList.add('live'); micEl.textContent='Listening…';
rec = new SR(); rec.lang='en-US'; rec.interimResults=true; rec.continuous=false;
rec.onresult = e => {
let interim='';
for (let i=e.resultIndex;i<e.results.length;i++){
const t=e.results[i][0].transcript;
if (e.results[i].isFinal) finalText+=t; else interim+=t;
}
hint.innerHTML = '<span class="interim">'+(finalText+interim||'…')+'</span>';
};
rec.onerror = () => {};
rec.onend = () => { listening=false; micEl.classList.remove('live'); micEl.textContent='Hold to talk';
const t=finalText.trim(); hint.textContent='Hold the circle and speak.'; if (t) send(t); };
try { rec.start(); } catch(e){ listening=false; }
}
function stopListen(){ if (rec && listening) { try{ rec.stop(); }catch(e){} } }
micEl.addEventListener('mousedown', startListen);
micEl.addEventListener('mouseup', stopListen);
micEl.addEventListener('mouseleave', stopListen);
micEl.addEventListener('touchstart', e=>{e.preventDefault();startListen();},{passive:false});
micEl.addEventListener('touchend', e=>{e.preventDefault();stopListen();},{passive:false});
connectBtn.addEventListener('click', connect);
setConn('');
})();
</script>
</body>
</html>