Snapshot: full project state
This commit is contained in:
325
static/index.html
Normal file
325
static/index.html
Normal file
@@ -0,0 +1,325 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>The Collector</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0:#07070f; --bg1:#0d0d1f; --purple:#7b5cff; --teal:#3cc8a0;
|
||||
--ink:#e9eaf5; --dim:#9aa0b8; --panel:rgba(255,255,255,.045);
|
||||
--edge:rgba(255,255,255,.08); --accent:#7b5cff;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
html,body { margin:0; height:100%; }
|
||||
body {
|
||||
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
color:var(--ink); background:
|
||||
radial-gradient(1100px 700px at 15% -10%, #1c1540 0%, transparent 60%),
|
||||
radial-gradient(900px 600px at 100% 110%, #0e2b3d 0%, transparent 55%),
|
||||
linear-gradient(160deg, var(--bg0), var(--bg1));
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
overflow:hidden; height:100dvh;
|
||||
}
|
||||
/* slow drifting nebula (calm, not chaotic) */
|
||||
.glow { position:fixed; border-radius:50%; filter:blur(90px); opacity:.5; pointer-events:none; }
|
||||
.glow.a { width:520px;height:520px;left:-120px;top:-80px; background:#4b3ad0; animation:drift1 26s ease-in-out infinite; }
|
||||
.glow.b { width:420px;height:420px;right:-100px;bottom:-60px; background:#1d5f86; animation:drift2 32s ease-in-out infinite; }
|
||||
@keyframes drift1 { 0%,100%{transform:translate(0,0)} 50%{transform:translate(60px,40px)} }
|
||||
@keyframes drift2 { 0%,100%{transform:translate(0,0)} 50%{transform:translate(-50px,-30px)} }
|
||||
|
||||
.card {
|
||||
position:relative; width:min(520px,94vw); padding:34px 30px 28px;
|
||||
text-align:center; border-radius:28px;
|
||||
background:var(--panel); border:1px solid var(--edge);
|
||||
backdrop-filter:blur(14px); box-shadow:0 30px 80px rgba(0,0,0,.5);
|
||||
}
|
||||
h1 { margin:0; font-size:26px; font-weight:700; letter-spacing:.5px; }
|
||||
.sub { color:var(--dim); font-size:12.5px; margin:5px 0 22px; letter-spacing:.4px; }
|
||||
|
||||
.stage { position:relative; width:230px; height:230px; margin:0 auto 22px; }
|
||||
canvas#ring { position:absolute; inset:0; width:100%; height:100%; }
|
||||
.orb {
|
||||
position:absolute; inset:34px; border-radius:50%;
|
||||
background:radial-gradient(circle at 34% 28%, #a68aff, #6a4be0 48%, #241457 80%);
|
||||
box-shadow:0 0 60px rgba(123,92,255,.5), inset 0 0 46px rgba(255,255,255,.10);
|
||||
transition:box-shadow .5s ease, background .5s ease;
|
||||
animation:breathe 4.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.045)} }
|
||||
.orb.listening { background:radial-gradient(circle at 34% 28%, #c09cff, #7b5cff 48%, #2c1a6e 80%); box-shadow:0 0 95px rgba(123,92,255,.95), inset 0 0 50px rgba(255,255,255,.18); }
|
||||
.orb.speaking { background:radial-gradient(circle at 34% 28%, #7df2d0, #2fae8b 48%, #0c4a3c 80%); box-shadow:0 0 95px rgba(60,200,160,.85), inset 0 0 50px rgba(255,255,255,.16); }
|
||||
|
||||
.status { display:flex; align-items:center; justify-content:center; gap:8px; font-size:12.5px; color:var(--dim); margin-bottom:18px; min-height:18px; }
|
||||
.dot { width:8px; height:8px; border-radius:50%; background:#555; }
|
||||
.dot.on { background:var(--teal); box-shadow:0 0 10px var(--teal); }
|
||||
|
||||
button {
|
||||
border:0; cursor:pointer; border-radius:999px; font-weight:600;
|
||||
transition:filter .15s, transform .1s, background .2s;
|
||||
}
|
||||
button:hover { filter:brightness(1.14); }
|
||||
button:active { transform:scale(.97); }
|
||||
#btn { padding:13px 40px; font-size:16px; background:var(--purple); color:#fff; box-shadow:0 8px 26px rgba(123,92,255,.4); }
|
||||
#btn.off { background:#252537; color:var(--dim); box-shadow:none; }
|
||||
|
||||
.tools { display:flex; justify-content:center; gap:10px; margin-top:12px; }
|
||||
.tools button { width:42px; height:42px; font-size:18px; background:rgba(255,255,255,.06); color:var(--dim); border:1px solid var(--edge); }
|
||||
.tools button.on { color:#fff; background:rgba(123,92,255,.25); border-color:var(--purple); }
|
||||
|
||||
.panel { display:none; margin-top:18px; text-align:left; background:rgba(0,0,0,.22); border:1px solid var(--edge); border-radius:16px; padding:16px 18px; }
|
||||
.panel.open { display:block; }
|
||||
.row { display:flex; align-items:center; justify-content:space-between; margin:10px 0; gap:12px; }
|
||||
.row label { font-size:13px; color:var(--dim); white-space:nowrap; }
|
||||
.row .val { font-size:13px; font-weight:600; color:var(--ink); min-width:34px; text-align:right; }
|
||||
input[type=range] { -webkit-appearance:none; flex:1; height:5px; border-radius:3px; background:rgba(255,255,255,.12); outline:none; }
|
||||
input[type=range]::-webkit-slider-thumb { -webkit-appearance:none; width:18px; height:18px; border-radius:50%; background:var(--purple); cursor:pointer; box-shadow:0 0 10px rgba(123,92,255,.7); }
|
||||
input[type=text] {
|
||||
flex:1; background:rgba(255,255,255,.06); border:1px solid var(--edge); border-radius:10px;
|
||||
color:var(--ink); padding:9px 12px; font-size:13.5px; outline:none;
|
||||
}
|
||||
input[type=text]:focus { border-color:var(--purple); }
|
||||
.callbtn { width:100%; margin-top:12px; padding:11px; font-size:14px; background:var(--teal); color:#04241a; }
|
||||
|
||||
.transcript {
|
||||
margin-top:20px; text-align:left; max-height:220px; overflow-y:auto;
|
||||
font-size:14px; line-height:1.65; display:flex; flex-direction:column; gap:9px; padding:2px;
|
||||
}
|
||||
.msg { max-width:88%; padding:9px 13px; border-radius:14px; }
|
||||
.msg.u { align-self:flex-end; background:rgba(123,92,255,.16); border:1px solid rgba(123,92,255,.25); }
|
||||
.msg.a { align-self:flex-start; background:rgba(60,200,160,.10); border:1px solid rgba(60,200,160,.22); }
|
||||
.msg .who { font-size:10.5px; font-weight:700; letter-spacing:.6px; text-transform:uppercase; margin-bottom:2px; }
|
||||
.msg.u .who { color:#a68aff; } .msg.a .who { color:var(--teal); }
|
||||
.transcript::-webkit-scrollbar { width:6px; }
|
||||
.transcript::-webkit-scrollbar-thumb { background:rgba(255,255,255,.14); border-radius:3px; }
|
||||
.hint { color:#5f6478; font-size:11.5px; margin-top:14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow a"></div><div class="glow b"></div>
|
||||
|
||||
<div class="card">
|
||||
<h1>The Collector</h1>
|
||||
<div class="sub">smooth operator · local voice</div>
|
||||
|
||||
<div class="stage">
|
||||
<canvas id="ring" width="460" height="460"></canvas>
|
||||
<div class="orb" id="orb"></div>
|
||||
</div>
|
||||
|
||||
<div class="status"><span class="dot" id="dot"></span><span id="status">offline</span></div>
|
||||
|
||||
<button id="btn" onclick="toggle()">Start</button>
|
||||
|
||||
<div class="tools">
|
||||
<button id="gearBtn" onclick="togglePanel('settings')" title="Settings">⚙</button>
|
||||
<button id="callBtn" onclick="togglePanel('call')" title="Make a call">📞</button>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="settingsPanel">
|
||||
<div class="row"><label>Temperature</label><input type="range" id="temp" min="0" max="1.5" step="0.05" value="0.5"><span class="val" id="tempVal">0.50</span></div>
|
||||
<div class="row"><label>Voice speed</label><input type="range" id="speed" min="0.5" max="2.0" step="0.05" value="1.05"><span class="val" id="speedVal">1.05</span></div>
|
||||
<div class="row" style="margin-top:14px;font-size:12px;color:#6b7086;justify-content:space-between">
|
||||
<span>model</span><span id="modelInfo">ornith-1.5:9b-64k</span>
|
||||
</div>
|
||||
<div class="row" style="font-size:12px;color:#6b7086;justify-content:space-between">
|
||||
<span>voice</span><span>lessac-high · STT on GPU</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="callPanel">
|
||||
<div class="row"><label>Number</label><input type="text" id="phone" placeholder="+14252800023"></div>
|
||||
<div class="row"><label>What to say</label><input type="text" id="task" placeholder="Introduce yourself as The Collector"></div>
|
||||
<button class="callbtn" onclick="makeCall()">Call</button>
|
||||
<div id="callStatus" style="font-size:12px;color:var(--dim);margin-top:10px;text-align:center"></div>
|
||||
</div>
|
||||
|
||||
<div class="transcript" id="log"></div>
|
||||
<div class="hint" id="hint">tap Start and talk — it listens, thinks, and speaks back</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const orb = document.getElementById('orb'), btn = document.getElementById('btn');
|
||||
const logEl = document.getElementById('log'), statusEl = document.getElementById('status');
|
||||
const dot = document.getElementById('dot'), hint = document.getElementById('hint');
|
||||
const canvas = document.getElementById('ring'), ctx = canvas.getContext('2d');
|
||||
|
||||
let running=false, ws=null, audio=null, micNode=null, playNode=null;
|
||||
let micLevel=0, playLevel=0; // 0..1 current (smoothed)
|
||||
const BARS=72;
|
||||
|
||||
/* ---- audio ring ---- */
|
||||
function drawRing(){
|
||||
ctx.clearRect(0,0,canvas.width,canvas.height);
|
||||
const cx=canvas.width/2, cy=canvas.height/2, r0=88, r1=112;
|
||||
for(let i=0;i<BARS;i++){
|
||||
const a = (i/BARS)*Math.PI*2 - Math.PI/2;
|
||||
// listening = mic level, speaking = play level, else tiny idle
|
||||
let lvl = running ? Math.max(micLevel, playLevel) : 0.06;
|
||||
if(!running) lvl = 0.05;
|
||||
const h = 4 + lvl*38;
|
||||
const x0=cx+Math.cos(a)*r0, y0=cy+Math.sin(a)*r0;
|
||||
const x1=cx+Math.cos(a)*r1, y1=cy+Math.sin(a)*r1;
|
||||
ctx.strokeStyle = running ? (playLevel>micLevel ? '#3cc8a0' : '#a68aff') : 'rgba(160,160,200,.25)';
|
||||
ctx.lineWidth=3; ctx.lineCap='round';
|
||||
ctx.beginPath(); ctx.moveTo(x0,y0); ctx.lineTo(x1,y1); ctx.stroke();
|
||||
}
|
||||
}
|
||||
function tick(){ micLevel*=.90; playLevel*=.88; drawRing(); requestAnimationFrame(tick); }
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
function rmsOf(i16){ let s=0; const n=i16.length; for(let i=0;i<n;i+=4){const v=i16[i]/32768; s+=v*v;} return Math.sqrt(s/(n/4)); }
|
||||
|
||||
/* ---- worklets ---- */
|
||||
const MIC_SRC = `
|
||||
class MicProc extends AudioWorkletProcessor {
|
||||
constructor(){ super(); this._buf = new Float32Array(); }
|
||||
process(inputs){
|
||||
const ch = inputs[0] && inputs[0][0];
|
||||
if(!ch) return true;
|
||||
const m = new Float32Array(this._buf.length + ch.length);
|
||||
m.set(this._buf); m.set(ch, this._buf.length);
|
||||
const ratio = sampleRate/16000;
|
||||
const out = new Int16Array(Math.floor(m.length/ratio));
|
||||
for(let i=0;i<out.length;i++){ const s=Math.max(-1,Math.min(1,m[Math.floor(i*ratio)])); out[i]=s<0?s*0x8000:s*0x7fff; }
|
||||
this._buf = new Float32Array(0);
|
||||
this.port.postMessage(out.buffer,[out.buffer]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('mic-proc', MicProc);`;
|
||||
|
||||
// Playback worklet: receives Int16/16kHz PCM (as Float32), upsamples to the
|
||||
// AudioContext rate, streams to output.
|
||||
const PLAY_SRC = `
|
||||
class PlayProc extends AudioWorkletProcessor {
|
||||
constructor(){
|
||||
super();
|
||||
this._buf = new Float32Array(0);
|
||||
this._pos = 0;
|
||||
this.port.onmessage = (e) => {
|
||||
const inc = new Float32Array(e.data);
|
||||
const m = new Float32Array(this._buf.length + inc.length);
|
||||
m.set(this._buf);
|
||||
m.set(inc, this._buf.length);
|
||||
this._buf = m;
|
||||
};
|
||||
}
|
||||
process(_, outputs){
|
||||
const ch = outputs[0] && outputs[0][0];
|
||||
if(ch){
|
||||
const step = 16000 / sampleRate; // upsample 16k -> context rate
|
||||
for(let i=0;i<ch.length;i++){
|
||||
if(this._buf.length === 0){ ch[i]=0; continue; }
|
||||
const fi = Math.floor(this._pos);
|
||||
const frac = this._pos - fi;
|
||||
const a = this._buf[fi] || 0;
|
||||
const b = (fi + 1 < this._buf.length) ? this._buf[fi+1] : a;
|
||||
ch[i] = a + (b - a) * frac;
|
||||
this._pos += step;
|
||||
}
|
||||
const consumed = Math.floor(this._pos);
|
||||
if(consumed > 0){ this._buf = this._buf.subarray(consumed); this._pos -= consumed; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('play-proc', PlayProc);`;
|
||||
|
||||
const blob = s => URL.createObjectURL(new Blob([s],{type:'application/javascript'}));
|
||||
|
||||
function setState(s){ statusEl.textContent=s; orb.classList.toggle('listening',s==='listening'); orb.classList.toggle('speaking',s==='speaking'); dot.classList.toggle('on',s!=='offline'); }
|
||||
|
||||
async function toggle(){
|
||||
if(running){ stop(); return; }
|
||||
running=true; btn.textContent='Stop'; btn.classList.remove('off'); setState('connecting');
|
||||
audio = new (window.AudioContext||window.webkitAudioContext)();
|
||||
if(audio.state === 'suspended'){ await audio.resume(); }
|
||||
await audio.audioWorklet.addModule(blob(MIC_SRC));
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio:true});
|
||||
const src = audio.createMediaStreamSource(stream);
|
||||
micNode = new AudioWorkletNode(audio,'mic-proc'); src.connect(micNode);
|
||||
await audio.audioWorklet.addModule(blob(PLAY_SRC));
|
||||
playNode = new AudioWorkletNode(audio,'play-proc'); playNode.connect(audio.destination);
|
||||
|
||||
const proto = location.protocol==='https:'?'wss':'ws';
|
||||
ws = new WebSocket(proto+'://'+location.host+'/ws'); ws.binaryType='arraybuffer';
|
||||
ws.onopen = ()=>{ setState('listening'); hint.textContent='listening — just talk'; };
|
||||
ws.onmessage = ev => {
|
||||
if(typeof ev.data==='string'){
|
||||
try{ const m=JSON.parse(ev.data);
|
||||
if(m.type==='transcript'){ addMsg(m.role,m.text);
|
||||
if(m.role==='assistant'){ setState('speaking'); setTimeout(()=>running&&setState('listening'),1400); }
|
||||
}
|
||||
}catch(e){}
|
||||
} else {
|
||||
const i16=new Int16Array(ev.data); const f32=new Float32Array(i16.length);
|
||||
for(let i=0;i<i16.length;i++) f32[i]=i16[i]/32768;
|
||||
playLevel=Math.max(playLevel,rmsOf(i16));
|
||||
playNode.port.postMessage(f32.buffer,[f32.buffer]);
|
||||
}
|
||||
};
|
||||
ws.onclose=()=>{ if(running) stop(); };
|
||||
ws.onerror=()=>{ statusEl.textContent='error'; };
|
||||
micNode.port.onmessage = ev => {
|
||||
const i16=new Int16Array(ev.data); micLevel=Math.max(micLevel,rmsOf(i16));
|
||||
if(ws&&ws.readyState===1) ws.send(ev.data);
|
||||
};
|
||||
}
|
||||
|
||||
function stop(){
|
||||
running=false; btn.textContent='Start'; btn.classList.add('off'); setState('offline');
|
||||
hint.textContent='tap Start and talk — it listens, thinks, and speaks back';
|
||||
if(ws){try{ws.close();}catch(e){} ws=null;}
|
||||
if(micNode){try{micNode.disconnect();}catch(e){} micNode=null;}
|
||||
if(playNode){try{playNode.disconnect();}catch(e){} playNode=null;}
|
||||
if(audio){try{audio.close();}catch(e){} audio=null;}
|
||||
}
|
||||
|
||||
function addMsg(role,text){
|
||||
const who = role==='user' ? 'You' : 'Collector';
|
||||
const d=document.createElement('div'); d.className='msg '+role;
|
||||
d.innerHTML='<div class="who">'+who+'</div>'+escapeHtml(text);
|
||||
logEl.appendChild(d); logEl.scrollTop=logEl.scrollHeight;
|
||||
}
|
||||
|
||||
/* ---- settings ---- */
|
||||
function togglePanel(which){
|
||||
const s=document.getElementById('settingsPanel'), c=document.getElementById('callPanel');
|
||||
const g=document.getElementById('gearBtn'), cb=document.getElementById('callBtn');
|
||||
if(which==='settings'){ s.classList.toggle('open'); c.classList.remove('open'); g.classList.toggle('on',s.classList.contains('open')); cb.classList.remove('on'); }
|
||||
else { c.classList.toggle('open'); s.classList.remove('open'); cb.classList.toggle('on',c.classList.contains('open')); g.classList.remove('on'); }
|
||||
}
|
||||
const temp=document.getElementById('temp'), speed=document.getElementById('speed');
|
||||
function postCfg(o){ fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(o)}).catch(()=>{}); }
|
||||
temp.oninput=()=>{ document.getElementById('tempVal').textContent=Number(temp.value).toFixed(2); };
|
||||
temp.onchange=()=>postCfg({temperature:parseFloat(temp.value)});
|
||||
speed.oninput=()=>{ document.getElementById('speedVal').textContent=Number(speed.value).toFixed(2); };
|
||||
speed.onchange=()=>postCfg({speed:parseFloat(speed.value)});
|
||||
|
||||
/* ---- call ---- */
|
||||
async function makeCall(){
|
||||
const to=document.getElementById('phone').value.trim();
|
||||
const task=document.getElementById('task').value.trim();
|
||||
const st=document.getElementById('callStatus');
|
||||
if(!to){ st.textContent='enter a number'; return; }
|
||||
st.textContent='dialing…';
|
||||
try{
|
||||
const r=await fetch('/api/call',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to,task})});
|
||||
const d=await r.json();
|
||||
st.textContent = d.ok ? ('call placed — '+(task||'intro')) : ('error: '+(d.error||''));
|
||||
}catch(e){ st.textContent='call failed'; }
|
||||
}
|
||||
|
||||
/* ---- load current settings ---- */
|
||||
fetch('/api/settings').then(r=>r.json()).then(d=>{
|
||||
temp.value=d.temperature||0.5; document.getElementById('tempVal').textContent=Number(temp.value).toFixed(2);
|
||||
speed.value=d.speed||1.05; document.getElementById('speedVal').textContent=Number(speed.value).toFixed(2);
|
||||
document.getElementById('modelInfo').textContent=d.model||'ornith';
|
||||
}).catch(()=>{});
|
||||
|
||||
function escapeHtml(s){ return s.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); }
|
||||
</script>
|
||||
<a href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener" style="display:block;text-align:center;padding:12px;color:#888;font-size:13px;text-decoration:none">☕ Support on Buy Me a Coffee</a>
|
||||
</body>
|
||||
</html>
|
||||
324
static/index.html.bak-bmac-20260923
Normal file
324
static/index.html.bak-bmac-20260923
Normal file
@@ -0,0 +1,324 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>The Collector</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0:#07070f; --bg1:#0d0d1f; --purple:#7b5cff; --teal:#3cc8a0;
|
||||
--ink:#e9eaf5; --dim:#9aa0b8; --panel:rgba(255,255,255,.045);
|
||||
--edge:rgba(255,255,255,.08); --accent:#7b5cff;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
html,body { margin:0; height:100%; }
|
||||
body {
|
||||
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
color:var(--ink); background:
|
||||
radial-gradient(1100px 700px at 15% -10%, #1c1540 0%, transparent 60%),
|
||||
radial-gradient(900px 600px at 100% 110%, #0e2b3d 0%, transparent 55%),
|
||||
linear-gradient(160deg, var(--bg0), var(--bg1));
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
overflow:hidden; height:100dvh;
|
||||
}
|
||||
/* slow drifting nebula (calm, not chaotic) */
|
||||
.glow { position:fixed; border-radius:50%; filter:blur(90px); opacity:.5; pointer-events:none; }
|
||||
.glow.a { width:520px;height:520px;left:-120px;top:-80px; background:#4b3ad0; animation:drift1 26s ease-in-out infinite; }
|
||||
.glow.b { width:420px;height:420px;right:-100px;bottom:-60px; background:#1d5f86; animation:drift2 32s ease-in-out infinite; }
|
||||
@keyframes drift1 { 0%,100%{transform:translate(0,0)} 50%{transform:translate(60px,40px)} }
|
||||
@keyframes drift2 { 0%,100%{transform:translate(0,0)} 50%{transform:translate(-50px,-30px)} }
|
||||
|
||||
.card {
|
||||
position:relative; width:min(520px,94vw); padding:34px 30px 28px;
|
||||
text-align:center; border-radius:28px;
|
||||
background:var(--panel); border:1px solid var(--edge);
|
||||
backdrop-filter:blur(14px); box-shadow:0 30px 80px rgba(0,0,0,.5);
|
||||
}
|
||||
h1 { margin:0; font-size:26px; font-weight:700; letter-spacing:.5px; }
|
||||
.sub { color:var(--dim); font-size:12.5px; margin:5px 0 22px; letter-spacing:.4px; }
|
||||
|
||||
.stage { position:relative; width:230px; height:230px; margin:0 auto 22px; }
|
||||
canvas#ring { position:absolute; inset:0; width:100%; height:100%; }
|
||||
.orb {
|
||||
position:absolute; inset:34px; border-radius:50%;
|
||||
background:radial-gradient(circle at 34% 28%, #a68aff, #6a4be0 48%, #241457 80%);
|
||||
box-shadow:0 0 60px rgba(123,92,255,.5), inset 0 0 46px rgba(255,255,255,.10);
|
||||
transition:box-shadow .5s ease, background .5s ease;
|
||||
animation:breathe 4.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.045)} }
|
||||
.orb.listening { background:radial-gradient(circle at 34% 28%, #c09cff, #7b5cff 48%, #2c1a6e 80%); box-shadow:0 0 95px rgba(123,92,255,.95), inset 0 0 50px rgba(255,255,255,.18); }
|
||||
.orb.speaking { background:radial-gradient(circle at 34% 28%, #7df2d0, #2fae8b 48%, #0c4a3c 80%); box-shadow:0 0 95px rgba(60,200,160,.85), inset 0 0 50px rgba(255,255,255,.16); }
|
||||
|
||||
.status { display:flex; align-items:center; justify-content:center; gap:8px; font-size:12.5px; color:var(--dim); margin-bottom:18px; min-height:18px; }
|
||||
.dot { width:8px; height:8px; border-radius:50%; background:#555; }
|
||||
.dot.on { background:var(--teal); box-shadow:0 0 10px var(--teal); }
|
||||
|
||||
button {
|
||||
border:0; cursor:pointer; border-radius:999px; font-weight:600;
|
||||
transition:filter .15s, transform .1s, background .2s;
|
||||
}
|
||||
button:hover { filter:brightness(1.14); }
|
||||
button:active { transform:scale(.97); }
|
||||
#btn { padding:13px 40px; font-size:16px; background:var(--purple); color:#fff; box-shadow:0 8px 26px rgba(123,92,255,.4); }
|
||||
#btn.off { background:#252537; color:var(--dim); box-shadow:none; }
|
||||
|
||||
.tools { display:flex; justify-content:center; gap:10px; margin-top:12px; }
|
||||
.tools button { width:42px; height:42px; font-size:18px; background:rgba(255,255,255,.06); color:var(--dim); border:1px solid var(--edge); }
|
||||
.tools button.on { color:#fff; background:rgba(123,92,255,.25); border-color:var(--purple); }
|
||||
|
||||
.panel { display:none; margin-top:18px; text-align:left; background:rgba(0,0,0,.22); border:1px solid var(--edge); border-radius:16px; padding:16px 18px; }
|
||||
.panel.open { display:block; }
|
||||
.row { display:flex; align-items:center; justify-content:space-between; margin:10px 0; gap:12px; }
|
||||
.row label { font-size:13px; color:var(--dim); white-space:nowrap; }
|
||||
.row .val { font-size:13px; font-weight:600; color:var(--ink); min-width:34px; text-align:right; }
|
||||
input[type=range] { -webkit-appearance:none; flex:1; height:5px; border-radius:3px; background:rgba(255,255,255,.12); outline:none; }
|
||||
input[type=range]::-webkit-slider-thumb { -webkit-appearance:none; width:18px; height:18px; border-radius:50%; background:var(--purple); cursor:pointer; box-shadow:0 0 10px rgba(123,92,255,.7); }
|
||||
input[type=text] {
|
||||
flex:1; background:rgba(255,255,255,.06); border:1px solid var(--edge); border-radius:10px;
|
||||
color:var(--ink); padding:9px 12px; font-size:13.5px; outline:none;
|
||||
}
|
||||
input[type=text]:focus { border-color:var(--purple); }
|
||||
.callbtn { width:100%; margin-top:12px; padding:11px; font-size:14px; background:var(--teal); color:#04241a; }
|
||||
|
||||
.transcript {
|
||||
margin-top:20px; text-align:left; max-height:220px; overflow-y:auto;
|
||||
font-size:14px; line-height:1.65; display:flex; flex-direction:column; gap:9px; padding:2px;
|
||||
}
|
||||
.msg { max-width:88%; padding:9px 13px; border-radius:14px; }
|
||||
.msg.u { align-self:flex-end; background:rgba(123,92,255,.16); border:1px solid rgba(123,92,255,.25); }
|
||||
.msg.a { align-self:flex-start; background:rgba(60,200,160,.10); border:1px solid rgba(60,200,160,.22); }
|
||||
.msg .who { font-size:10.5px; font-weight:700; letter-spacing:.6px; text-transform:uppercase; margin-bottom:2px; }
|
||||
.msg.u .who { color:#a68aff; } .msg.a .who { color:var(--teal); }
|
||||
.transcript::-webkit-scrollbar { width:6px; }
|
||||
.transcript::-webkit-scrollbar-thumb { background:rgba(255,255,255,.14); border-radius:3px; }
|
||||
.hint { color:#5f6478; font-size:11.5px; margin-top:14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow a"></div><div class="glow b"></div>
|
||||
|
||||
<div class="card">
|
||||
<h1>The Collector</h1>
|
||||
<div class="sub">smooth operator · local voice</div>
|
||||
|
||||
<div class="stage">
|
||||
<canvas id="ring" width="460" height="460"></canvas>
|
||||
<div class="orb" id="orb"></div>
|
||||
</div>
|
||||
|
||||
<div class="status"><span class="dot" id="dot"></span><span id="status">offline</span></div>
|
||||
|
||||
<button id="btn" onclick="toggle()">Start</button>
|
||||
|
||||
<div class="tools">
|
||||
<button id="gearBtn" onclick="togglePanel('settings')" title="Settings">⚙</button>
|
||||
<button id="callBtn" onclick="togglePanel('call')" title="Make a call">📞</button>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="settingsPanel">
|
||||
<div class="row"><label>Temperature</label><input type="range" id="temp" min="0" max="1.5" step="0.05" value="0.5"><span class="val" id="tempVal">0.50</span></div>
|
||||
<div class="row"><label>Voice speed</label><input type="range" id="speed" min="0.5" max="2.0" step="0.05" value="1.05"><span class="val" id="speedVal">1.05</span></div>
|
||||
<div class="row" style="margin-top:14px;font-size:12px;color:#6b7086;justify-content:space-between">
|
||||
<span>model</span><span id="modelInfo">ornith-1.5:9b-64k</span>
|
||||
</div>
|
||||
<div class="row" style="font-size:12px;color:#6b7086;justify-content:space-between">
|
||||
<span>voice</span><span>lessac-high · STT on GPU</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="callPanel">
|
||||
<div class="row"><label>Number</label><input type="text" id="phone" placeholder="+14252800023"></div>
|
||||
<div class="row"><label>What to say</label><input type="text" id="task" placeholder="Introduce yourself as The Collector"></div>
|
||||
<button class="callbtn" onclick="makeCall()">Call</button>
|
||||
<div id="callStatus" style="font-size:12px;color:var(--dim);margin-top:10px;text-align:center"></div>
|
||||
</div>
|
||||
|
||||
<div class="transcript" id="log"></div>
|
||||
<div class="hint" id="hint">tap Start and talk — it listens, thinks, and speaks back</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const orb = document.getElementById('orb'), btn = document.getElementById('btn');
|
||||
const logEl = document.getElementById('log'), statusEl = document.getElementById('status');
|
||||
const dot = document.getElementById('dot'), hint = document.getElementById('hint');
|
||||
const canvas = document.getElementById('ring'), ctx = canvas.getContext('2d');
|
||||
|
||||
let running=false, ws=null, audio=null, micNode=null, playNode=null;
|
||||
let micLevel=0, playLevel=0; // 0..1 current (smoothed)
|
||||
const BARS=72;
|
||||
|
||||
/* ---- audio ring ---- */
|
||||
function drawRing(){
|
||||
ctx.clearRect(0,0,canvas.width,canvas.height);
|
||||
const cx=canvas.width/2, cy=canvas.height/2, r0=88, r1=112;
|
||||
for(let i=0;i<BARS;i++){
|
||||
const a = (i/BARS)*Math.PI*2 - Math.PI/2;
|
||||
// listening = mic level, speaking = play level, else tiny idle
|
||||
let lvl = running ? Math.max(micLevel, playLevel) : 0.06;
|
||||
if(!running) lvl = 0.05;
|
||||
const h = 4 + lvl*38;
|
||||
const x0=cx+Math.cos(a)*r0, y0=cy+Math.sin(a)*r0;
|
||||
const x1=cx+Math.cos(a)*r1, y1=cy+Math.sin(a)*r1;
|
||||
ctx.strokeStyle = running ? (playLevel>micLevel ? '#3cc8a0' : '#a68aff') : 'rgba(160,160,200,.25)';
|
||||
ctx.lineWidth=3; ctx.lineCap='round';
|
||||
ctx.beginPath(); ctx.moveTo(x0,y0); ctx.lineTo(x1,y1); ctx.stroke();
|
||||
}
|
||||
}
|
||||
function tick(){ micLevel*=.90; playLevel*=.88; drawRing(); requestAnimationFrame(tick); }
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
function rmsOf(i16){ let s=0; const n=i16.length; for(let i=0;i<n;i+=4){const v=i16[i]/32768; s+=v*v;} return Math.sqrt(s/(n/4)); }
|
||||
|
||||
/* ---- worklets ---- */
|
||||
const MIC_SRC = `
|
||||
class MicProc extends AudioWorkletProcessor {
|
||||
constructor(){ super(); this._buf = new Float32Array(); }
|
||||
process(inputs){
|
||||
const ch = inputs[0] && inputs[0][0];
|
||||
if(!ch) return true;
|
||||
const m = new Float32Array(this._buf.length + ch.length);
|
||||
m.set(this._buf); m.set(ch, this._buf.length);
|
||||
const ratio = sampleRate/16000;
|
||||
const out = new Int16Array(Math.floor(m.length/ratio));
|
||||
for(let i=0;i<out.length;i++){ const s=Math.max(-1,Math.min(1,m[Math.floor(i*ratio)])); out[i]=s<0?s*0x8000:s*0x7fff; }
|
||||
this._buf = new Float32Array(0);
|
||||
this.port.postMessage(out.buffer,[out.buffer]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('mic-proc', MicProc);`;
|
||||
|
||||
// Playback worklet: receives Int16/16kHz PCM (as Float32), upsamples to the
|
||||
// AudioContext rate, streams to output.
|
||||
const PLAY_SRC = `
|
||||
class PlayProc extends AudioWorkletProcessor {
|
||||
constructor(){
|
||||
super();
|
||||
this._buf = new Float32Array(0);
|
||||
this._pos = 0;
|
||||
this.port.onmessage = (e) => {
|
||||
const inc = new Float32Array(e.data);
|
||||
const m = new Float32Array(this._buf.length + inc.length);
|
||||
m.set(this._buf);
|
||||
m.set(inc, this._buf.length);
|
||||
this._buf = m;
|
||||
};
|
||||
}
|
||||
process(_, outputs){
|
||||
const ch = outputs[0] && outputs[0][0];
|
||||
if(ch){
|
||||
const step = 16000 / sampleRate; // upsample 16k -> context rate
|
||||
for(let i=0;i<ch.length;i++){
|
||||
if(this._buf.length === 0){ ch[i]=0; continue; }
|
||||
const fi = Math.floor(this._pos);
|
||||
const frac = this._pos - fi;
|
||||
const a = this._buf[fi] || 0;
|
||||
const b = (fi + 1 < this._buf.length) ? this._buf[fi+1] : a;
|
||||
ch[i] = a + (b - a) * frac;
|
||||
this._pos += step;
|
||||
}
|
||||
const consumed = Math.floor(this._pos);
|
||||
if(consumed > 0){ this._buf = this._buf.subarray(consumed); this._pos -= consumed; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('play-proc', PlayProc);`;
|
||||
|
||||
const blob = s => URL.createObjectURL(new Blob([s],{type:'application/javascript'}));
|
||||
|
||||
function setState(s){ statusEl.textContent=s; orb.classList.toggle('listening',s==='listening'); orb.classList.toggle('speaking',s==='speaking'); dot.classList.toggle('on',s!=='offline'); }
|
||||
|
||||
async function toggle(){
|
||||
if(running){ stop(); return; }
|
||||
running=true; btn.textContent='Stop'; btn.classList.remove('off'); setState('connecting');
|
||||
audio = new (window.AudioContext||window.webkitAudioContext)();
|
||||
if(audio.state === 'suspended'){ await audio.resume(); }
|
||||
await audio.audioWorklet.addModule(blob(MIC_SRC));
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio:true});
|
||||
const src = audio.createMediaStreamSource(stream);
|
||||
micNode = new AudioWorkletNode(audio,'mic-proc'); src.connect(micNode);
|
||||
await audio.audioWorklet.addModule(blob(PLAY_SRC));
|
||||
playNode = new AudioWorkletNode(audio,'play-proc'); playNode.connect(audio.destination);
|
||||
|
||||
const proto = location.protocol==='https:'?'wss':'ws';
|
||||
ws = new WebSocket(proto+'://'+location.host+'/ws'); ws.binaryType='arraybuffer';
|
||||
ws.onopen = ()=>{ setState('listening'); hint.textContent='listening — just talk'; };
|
||||
ws.onmessage = ev => {
|
||||
if(typeof ev.data==='string'){
|
||||
try{ const m=JSON.parse(ev.data);
|
||||
if(m.type==='transcript'){ addMsg(m.role,m.text);
|
||||
if(m.role==='assistant'){ setState('speaking'); setTimeout(()=>running&&setState('listening'),1400); }
|
||||
}
|
||||
}catch(e){}
|
||||
} else {
|
||||
const i16=new Int16Array(ev.data); const f32=new Float32Array(i16.length);
|
||||
for(let i=0;i<i16.length;i++) f32[i]=i16[i]/32768;
|
||||
playLevel=Math.max(playLevel,rmsOf(i16));
|
||||
playNode.port.postMessage(f32.buffer,[f32.buffer]);
|
||||
}
|
||||
};
|
||||
ws.onclose=()=>{ if(running) stop(); };
|
||||
ws.onerror=()=>{ statusEl.textContent='error'; };
|
||||
micNode.port.onmessage = ev => {
|
||||
const i16=new Int16Array(ev.data); micLevel=Math.max(micLevel,rmsOf(i16));
|
||||
if(ws&&ws.readyState===1) ws.send(ev.data);
|
||||
};
|
||||
}
|
||||
|
||||
function stop(){
|
||||
running=false; btn.textContent='Start'; btn.classList.add('off'); setState('offline');
|
||||
hint.textContent='tap Start and talk — it listens, thinks, and speaks back';
|
||||
if(ws){try{ws.close();}catch(e){} ws=null;}
|
||||
if(micNode){try{micNode.disconnect();}catch(e){} micNode=null;}
|
||||
if(playNode){try{playNode.disconnect();}catch(e){} playNode=null;}
|
||||
if(audio){try{audio.close();}catch(e){} audio=null;}
|
||||
}
|
||||
|
||||
function addMsg(role,text){
|
||||
const who = role==='user' ? 'You' : 'Collector';
|
||||
const d=document.createElement('div'); d.className='msg '+role;
|
||||
d.innerHTML='<div class="who">'+who+'</div>'+escapeHtml(text);
|
||||
logEl.appendChild(d); logEl.scrollTop=logEl.scrollHeight;
|
||||
}
|
||||
|
||||
/* ---- settings ---- */
|
||||
function togglePanel(which){
|
||||
const s=document.getElementById('settingsPanel'), c=document.getElementById('callPanel');
|
||||
const g=document.getElementById('gearBtn'), cb=document.getElementById('callBtn');
|
||||
if(which==='settings'){ s.classList.toggle('open'); c.classList.remove('open'); g.classList.toggle('on',s.classList.contains('open')); cb.classList.remove('on'); }
|
||||
else { c.classList.toggle('open'); s.classList.remove('open'); cb.classList.toggle('on',c.classList.contains('open')); g.classList.remove('on'); }
|
||||
}
|
||||
const temp=document.getElementById('temp'), speed=document.getElementById('speed');
|
||||
function postCfg(o){ fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(o)}).catch(()=>{}); }
|
||||
temp.oninput=()=>{ document.getElementById('tempVal').textContent=Number(temp.value).toFixed(2); };
|
||||
temp.onchange=()=>postCfg({temperature:parseFloat(temp.value)});
|
||||
speed.oninput=()=>{ document.getElementById('speedVal').textContent=Number(speed.value).toFixed(2); };
|
||||
speed.onchange=()=>postCfg({speed:parseFloat(speed.value)});
|
||||
|
||||
/* ---- call ---- */
|
||||
async function makeCall(){
|
||||
const to=document.getElementById('phone').value.trim();
|
||||
const task=document.getElementById('task').value.trim();
|
||||
const st=document.getElementById('callStatus');
|
||||
if(!to){ st.textContent='enter a number'; return; }
|
||||
st.textContent='dialing…';
|
||||
try{
|
||||
const r=await fetch('/api/call',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to,task})});
|
||||
const d=await r.json();
|
||||
st.textContent = d.ok ? ('call placed — '+(task||'intro')) : ('error: '+(d.error||''));
|
||||
}catch(e){ st.textContent='call failed'; }
|
||||
}
|
||||
|
||||
/* ---- load current settings ---- */
|
||||
fetch('/api/settings').then(r=>r.json()).then(d=>{
|
||||
temp.value=d.temperature||0.5; document.getElementById('tempVal').textContent=Number(temp.value).toFixed(2);
|
||||
speed.value=d.speed||1.05; document.getElementById('speedVal').textContent=Number(speed.value).toFixed(2);
|
||||
document.getElementById('modelInfo').textContent=d.model||'ornith';
|
||||
}).catch(()=>{});
|
||||
|
||||
function escapeHtml(s){ return s.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user