chalktalk / index.html
squaredcuber's picture
ChalkTalk: real interactive UI on a static Space -> Modal backend
78cce48 verified
Raw
History Blame Contribute Delete
26.8 kB
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ChalkTalk — the AI tutor that talks &amp; draws</title><script>window.CT_BACKEND='https://carpediemhari--chalktalk-full-serve.modal.run';</script>
<style>
:root{
--bg:#080b12; --ink:#e9f0fb; --accent:#7aa2ff; --accent2:#b98bff;
--mut:#8a93a6; --ok:#46e6a0; --glass:rgba(20,26,40,.46); --line:rgba(255,255,255,.10);
}
html,body{margin:0;background:#080b12;color:var(--ink);
font-family:ui-sans-serif,system-ui,Segoe UI,Roboto,sans-serif}
/* full-screen shader canvas, behind everything */
#ctgl{position:fixed;inset:0;width:100vw;height:100vh;z-index:-2;pointer-events:none;display:block}
#ctdust{position:fixed;inset:0;width:100vw;height:100vh;z-index:-1;pointer-events:none;display:block;opacity:.5}
#ctfallback{position:fixed;inset:0;z-index:-2;display:none;
background:linear-gradient(130deg,#0a0f1f,#141033 40%,#0b1a2b 75%,#08101a);
background-size:300% 300%;animation:ctbg 24s ease infinite}
@keyframes ctbg{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
/* kill Gradio's default backdrop so the shader shows through */
gradio-app,.gradio-container{background:transparent!important;max-width:100%!important}
.gradio-container{padding:0!important}
footer.svelte-1ax1toq, .gradio-container > footer {display:none!important}
.ct-wrap{max-width:1100px;margin:0 auto;padding:10px 16px 120px}
/* animated gradient title */
.ct-head{display:flex;align-items:center;gap:12px;padding:18px 8px 6px}
.ct-title{font-size:26px;font-weight:800;letter-spacing:.3px;margin:0;
background:linear-gradient(90deg,#7aa2ff,#b98bff,#46e6a0,#7aa2ff);
background-size:300% 100%;-webkit-background-clip:text;background-clip:text;
-webkit-text-fill-color:transparent;animation:ctgrad 8s linear infinite}
@keyframes ctgrad{to{background-position:300% 0}}
.ct-sub{color:var(--mut);font-size:12.5px}
.ct-dot{width:11px;height:11px;border-radius:50%;background:#3a4154;flex:0 0 auto;
transition:.4s}
.ct-dot.live{background:var(--ok);box-shadow:0 0 12px var(--ok),0 0 26px var(--ok);
animation:ctpulsedot 1.8s ease infinite}
@keyframes ctpulsedot{50%{box-shadow:0 0 6px var(--ok)}}
/* glassmorphism board frame */
.board-wrap{position:relative;display:flex;align-items:center;justify-content:center;padding:18px}
#board{width:min(86vw,900px);aspect-ratio:4/3;background:#fbfdff;border-radius:18px;
box-shadow:0 20px 70px #000a, inset 0 0 0 1px #ffffff14, 0 0 0 1px var(--line);
overflow:hidden;position:relative}
#board svg{width:100%;height:100%;display:block;animation:fade .35s ease}
@keyframes fade{from{opacity:.4;transform:scale(.992)}to{opacity:1;transform:scale(1)}}
#board svg [data-cue]{transition:opacity .55s ease, transform .55s ease}
#board svg [data-cue].ct-hide{opacity:0;transform:translateY(8px) scale(.98)}
#board svg [data-cue].ct-pulse{animation:ctpulse 1.2s ease}
@keyframes ctpulse{0%{filter:drop-shadow(0 0 0 #7aa2ff00)}
28%{filter:drop-shadow(0 0 10px #7aa2ffee)}100%{filter:drop-shadow(0 0 0 #7aa2ff00)}}
/* "drawing" shader effect over the whiteboard while a diagram generates */
#boardfx{position:absolute;inset:0;border-radius:18px;overflow:hidden;display:none;
pointer-events:none;z-index:6;background:#f7faff}
#boardfx.on{display:block;animation:fxin .3s ease}
@keyframes fxin{from{opacity:0}to{opacity:1}}
#fxcv{position:absolute;inset:0;width:100%;height:100%;display:block}
#boardfx .ct-sweep{position:absolute;inset:0;mix-blend-mode:overlay;
background:linear-gradient(105deg,transparent 38%,rgba(122,162,255,.55) 49%,rgba(185,139,255,.55) 51%,transparent 62%);
background-size:260% 100%;animation:ctsweep 1.4s linear infinite}
@keyframes ctsweep{from{background-position:130% 0}to{background-position:-130% 0}}
#boardfx .ct-fxlbl{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);
color:#37436b;font-size:14px;font-weight:650;letter-spacing:.2px;background:#ffffffd0;
backdrop-filter:blur(4px);padding:7px 16px;border-radius:20px;box-shadow:0 6px 18px #0001;
display:flex;align-items:center;gap:7px;white-space:nowrap}
#boardfx .ct-pen{display:inline-block;animation:ctpen 1.1s ease-in-out infinite}
@keyframes ctpen{0%,100%{transform:translate(0,0) rotate(-8deg)}50%{transform:translate(3px,-2px) rotate(6deg)}}
#boardfx .ct-dots::after{content:'';animation:ctdots 1.4s steps(1,end) infinite}
@keyframes ctdots{0%{content:''}25%{content:'.'}50%{content:'..'}75%{content:'...'}}
/* status pill + reactive orb */
.status{position:absolute;top:16px;left:50%;transform:translateX(-50%);font-size:12.5px;
color:#dbe4f5;background:var(--glass);padding:6px 16px;border-radius:22px;
backdrop-filter:blur(10px);border:1px solid var(--line);box-shadow:0 6px 20px #0006;
display:flex;align-items:center;gap:9px;white-space:nowrap;max-width:90%}
.orb{width:13px;height:13px;border-radius:50%;flex:0 0 auto;
background:radial-gradient(circle at 35% 30%,#bcd0ff,#5a7bff 60%,#3650cc);
box-shadow:0 0 10px #5a7bffcc;transition:transform .08s linear, box-shadow .12s linear}
/* transcript: constrained block BELOW the whiteboard, above the input */
#transcript{margin:12px auto 0;max-width:min(86vw,900px);width:100%;box-sizing:border-box;
max-height:90px;overflow-y:auto;text-align:center;color:#dbe4f5;font-size:14px;line-height:1.45;
background:var(--glass);backdrop-filter:blur(8px);padding:9px 16px;border-radius:12px;
border:1px solid var(--line);box-shadow:0 6px 18px #0004}
#transcript:empty{display:none}
/* footer controls — glass */
.ct-foot{position:fixed;bottom:0;left:0;right:0;display:flex;gap:10px;align-items:center;
padding:14px 18px;z-index:20;
background:linear-gradient(#080b1200,#080b12ee);}
.ct-foot-inner{max-width:1100px;margin:0 auto;width:100%;display:flex;gap:10px;align-items:center;
background:var(--glass);backdrop-filter:blur(14px);border:1px solid var(--line);
border-radius:16px;padding:10px 12px;box-shadow:0 12px 40px #0007}
.ct-foot input{flex:1;background:rgba(10,14,24,.6);border:1px solid var(--line);
color:var(--ink);padding:12px 16px;border-radius:11px;font-size:15px;outline:none;transition:.2s}
.ct-foot input:focus{border-color:var(--accent);box-shadow:0 0 0 3px #7aa2ff22}
.ct-btn{position:relative;border:0;padding:12px 18px;border-radius:11px;font-weight:700;
cursor:pointer;font-size:14px;color:#06080f;overflow:hidden;transition:transform .12s, box-shadow .2s;
background:linear-gradient(135deg,#7aa2ff,#b98bff);box-shadow:0 6px 22px #7aa2ff55}
.ct-btn:hover{transform:translateY(-2px);box-shadow:0 10px 30px #7aa2ff88}
.ct-btn:active{transform:translateY(0) scale(.97)}
.ct-btn.ghost{background:rgba(20,26,40,.7);color:var(--ink);border:1px solid var(--line);
box-shadow:none}
.ct-btn.ghost:hover{border-color:var(--accent);box-shadow:0 6px 20px #0006}
.lat{font-variant-numeric:tabular-nums;color:var(--mut);font-size:12px;min-width:80px;text-align:right}
@media (max-width:800px){
#board{width:92vw}
.ct-foot-inner{flex-wrap:wrap}
.ct-foot input{flex:1 1 100%;order:-1}
.lat{display:none}
.ct-title{font-size:21px}
}
</style>
<script>
window.ctLevel = 0; // live audio RMS, fed to the shader (voice-reactive)
/* ============================ WebGL shader background ============================ */
function ctInitShader(){
const MOBILE = window.innerWidth < 800;
const cv = document.getElementById('ctgl');
const fb = document.getElementById('ctfallback');
let gl=null; try{ gl = cv.getContext('webgl')||cv.getContext('experimental-webgl'); }catch(e){}
if(!gl){ if(fb) fb.style.display='block'; if(cv) cv.style.display='none'; return; }
const DPRCAP = MOBILE ? 1.0 : 1.5;
const SCALE = MOBILE ? 0.6 : 0.85; // render the shader below CSS res, cheap on phones
const OCT = MOBILE ? 3 : 5;
const vs = "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";
const fs = `
precision highp float;
uniform vec2 R; uniform float T; uniform float LV;
// fbm aurora/plasma — "chalkboard meets nebula"
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
float noise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);}
float fbm(vec2 p){float v=0.,a=.5;for(int i=0;i<${OCT};i++){v+=a*noise(p);p*=2.02;a*=.5;}return v;}
void main(){
vec2 uv=(gl_FragCoord.xy-.5*R)/R.y;
float t=T*0.04;
float lv=clamp(LV,0.,1.);
vec2 q=uv*1.4;
q += vec2(fbm(q+t), fbm(q-t+5.2));
float f=fbm(q*1.8 + vec2(t*1.3,-t));
float aurora=fbm(uv*2.2 + vec2(0., t*2.0) + f*1.5);
// dark nebula palette
vec3 deep=vec3(.03,.05,.10);
vec3 blue=vec3(.16,.30,.78);
vec3 violet=vec3(.55,.32,.92);
vec3 mint=vec3(.16,.85,.62);
vec3 col=deep;
col=mix(col,blue, smoothstep(.25,.85,aurora));
col=mix(col,violet, smoothstep(.55,.95,f)*0.7);
col=mix(col,mint, smoothstep(.72,1.0,aurora)*0.35);
// vignette
col*=1.0-0.55*dot(uv,uv);
// voice-reactive bloom: glow pulses when PersonaPlex speaks
float glow=smoothstep(.4,.95,aurora);
col += (blue*.5+violet*.5)*glow*lv*1.4;
col += lv*0.06;
gl_FragColor=vec4(col,1.0);
}`;
function sh(t,s){const o=gl.createShader(t);gl.shaderSource(o,s);gl.compileShader(o);return o;}
const prog=gl.createProgram();
gl.attachShader(prog,sh(gl.VERTEX_SHADER,vs));
gl.attachShader(prog,sh(gl.FRAGMENT_SHADER,fs));
gl.linkProgram(prog);
if(!gl.getProgramParameter(prog,gl.LINK_STATUS)){ if(fb) fb.style.display='block'; cv.style.display='none'; return; }
gl.useProgram(prog);
const buf=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buf);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,3,-1,-1,3]),gl.STATIC_DRAW);
const pl=gl.getAttribLocation(prog,'p'); gl.enableVertexAttribArray(pl);
gl.vertexAttribPointer(pl,2,gl.FLOAT,false,0,0);
const uR=gl.getUniformLocation(prog,'R'),uT=gl.getUniformLocation(prog,'T'),uL=gl.getUniformLocation(prog,'LV');
function resize(){
const dpr=Math.min(window.devicePixelRatio||1,DPRCAP)*SCALE;
cv.width=Math.max(2,Math.floor(innerWidth*dpr));
cv.height=Math.max(2,Math.floor(innerHeight*dpr));
gl.viewport(0,0,cv.width,cv.height);
}
window.addEventListener('resize',resize); resize();
let smooth=0, hidden=false;
document.addEventListener('visibilitychange',()=>{hidden=document.hidden;});
const t0=performance.now();
function frame(){
if(!hidden){
smooth += ((window.ctLevel||0)-smooth)*0.15; // smooth the reactive level
gl.uniform2f(uR,cv.width,cv.height);
gl.uniform1f(uT,(performance.now()-t0)/1000);
gl.uniform1f(uL,smooth);
gl.drawArrays(gl.TRIANGLES,0,3);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
/* ============================ chalk-dust motes ============================ */
function ctInitDust(){
const MOBILE=window.innerWidth<800;
const cv=document.getElementById('ctdust'); if(!cv) return;
const ctx=cv.getContext('2d'); let W,H,parts=[];
const N=MOBILE?28:70;
function resize(){W=cv.width=innerWidth;H=cv.height=innerHeight;}
window.addEventListener('resize',resize);resize();
for(let i=0;i<N;i++)parts.push({x:Math.random()*W,y:Math.random()*H,
r:Math.random()*1.8+.4,vx:(Math.random()-.5)*.18,vy:-Math.random()*.28-.05,
a:Math.random()*.5+.15});
let hidden=false;document.addEventListener('visibilitychange',()=>hidden=document.hidden);
function frame(){
if(!hidden){
ctx.clearRect(0,0,W,H);
for(const p of parts){
p.x+=p.vx;p.y+=p.vy;
if(p.y<-5){p.y=H+5;p.x=Math.random()*W;}
if(p.x<-5)p.x=W+5; if(p.x>W+5)p.x=-5;
ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,6.283);
ctx.fillStyle='rgba(210,225,255,'+p.a+')';ctx.fill();
}
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
</script>
<script>
/* ====================================================================
PORTED VERBATIM FROM web/index.html — voice + whiteboard logic.
Runs after the DOM (board/status/etc.) is mounted; we defer with a
small poll so the gr.HTML content exists before we grab elements.
==================================================================== */
function ctBoot(){
const board=document.getElementById('board'), statusEl=document.getElementById('status'),
dot=document.getElementById('dot'), q=document.getElementById('q'),
latEl=document.getElementById('lat'), modeBtn=document.getElementById('modeBtn');
if(!board||!statusEl){ return setTimeout(ctBoot,120); } // wait for gr.HTML DOM
if(window.__ctBooted) return; window.__ctBooted=true;
const orb=document.getElementById('orb');
const boardfx=document.getElementById('boardfx'), fxtxt=document.getElementById('fxtxt');
let fxRAF=0;
(function(){ // board "drawing" shader: chalk/ink forming on the whiteboard while generating
const cv=document.getElementById('fxcv'); if(!cv) return;
let gl=null; try{gl=cv.getContext('webgl')||cv.getContext('experimental-webgl');}catch(e){}
if(!gl) return; // CSS sweep + label still animate as a fallback
const vs="attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";
const fs=`precision highp float;uniform vec2 R;uniform float T;
float h(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5);}
float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(h(i),h(i+vec2(1,0)),f.x),mix(h(i+vec2(0,1)),h(i+vec2(1,1)),f.x),f.y);}
float fbm(vec2 p){float v=0.,a=.5;for(int i=0;i<5;i++){v+=a*n(p);p*=2.03;a*=.5;}return v;}
void main(){
vec2 uv=gl_FragCoord.xy/R; float t=T*0.5;
float f=fbm(uv*4.0+vec2(t*.3,t*.2)); f=fbm(uv*3.0+f*1.5+vec2(0.,t*.4));
float ink=smoothstep(.46,.62,f);
vec3 col=mix(vec3(.965,.975,1.0), vec3(.42,.55,.85), ink*.45);
float sweep=smoothstep(.05,0.,abs(fract(uv.x-T*.45)-.5)-.05);
col=mix(col, vec3(.55,.42,.92), sweep*.22);
gl_FragColor=vec4(col,1.0);
}`;
function sh(tp,s){const o=gl.createShader(tp);gl.shaderSource(o,s);gl.compileShader(o);return o;}
const pr=gl.createProgram();gl.attachShader(pr,sh(gl.VERTEX_SHADER,vs));gl.attachShader(pr,sh(gl.FRAGMENT_SHADER,fs));gl.linkProgram(pr);
if(!gl.getProgramParameter(pr,gl.LINK_STATUS)) return;
gl.useProgram(pr);
const bb=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,bb);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,3,-1,-1,3]),gl.STATIC_DRAW);
const pl=gl.getAttribLocation(pr,'p');gl.enableVertexAttribArray(pl);gl.vertexAttribPointer(pl,2,gl.FLOAT,false,0,0);
const uR=gl.getUniformLocation(pr,'R'),uT=gl.getUniformLocation(pr,'T'),f0=performance.now();
window.__ctFXdraw=function(){
const w=cv.clientWidth||520,hh=cv.clientHeight||390,dpr=Math.min(devicePixelRatio||1,1.5);
const W=Math.max(2,Math.floor(w*dpr)),H=Math.max(2,Math.floor(hh*dpr));
if(cv.width!==W||cv.height!==H){cv.width=W;cv.height=H;}
gl.viewport(0,0,cv.width,cv.height);
gl.uniform2f(uR,cv.width,cv.height);gl.uniform1f(uT,(performance.now()-f0)/1000);
gl.drawArrays(gl.TRIANGLES,0,3);
};
})();
function fxLoop(){ if(boardfx&&boardfx.classList.contains('on')){ window.__ctFXdraw&&window.__ctFXdraw(); fxRAF=requestAnimationFrame(fxLoop);} else fxRAF=0; }
function showFX(t){ if(!boardfx) return; if(fxtxt&&t) fxtxt.textContent=t; boardfx.classList.add('on'); if(!fxRAF) fxRAF=requestAnimationFrame(fxLoop); }
function hideFX(){ if(boardfx) boardfx.classList.remove('on'); }
function ctFXStatus(text){ if(/draw|sketch|generat|plan/i.test(text)) showFX(/plan/i.test(text)?'planning your lesson':'drawing your diagram'); else if(/listening|ready|complete|stopped|error/i.test(text)) hideFX(); }
let t0=0, firstPaint=false;
// backend is same-origin by default, or a remote URL when window.CT_BACKEND is injected
// (e.g. the HF Space frontend pointing at the Modal GPU backend).
const CT_BACKEND=(window.CT_BACKEND||"").replace(/\/+$/,"");
const HTTPBASE=CT_BACKEND;
const WSBASE=CT_BACKEND ? CT_BACKEND.replace(/^http/,"ws") : (location.protocol==='https:'?'wss://':'ws://')+location.host;
// draw-only: silent type-to-draw of the typed text (no voice)
if(modeBtn) modeBtn.onclick=()=>{const c=(q&&q.value||'').trim(); if(c){statusEl.textContent='drawing…'; ws.send(JSON.stringify({concept:c,mode:'full'}));}};
const ws=new WebSocket(WSBASE+'/ws');
ws.onopen=()=>{dot&&dot.classList.add('live');statusEl.textContent='ready — ask me anything';};
ws.onclose=()=>{dot&&dot.classList.remove('live');statusEl.textContent='disconnected';};
ws.onmessage=(e)=>{
const m=JSON.parse(e.data);
if(m.type==='status'){statusEl.textContent=m.text;firstPaint=false;t0=performance.now(); ctFXStatus(m.text);}
else if(m.type==='svg'){
board.innerHTML=m.svg; hideFX();
if(!firstPaint){firstPaint=true;latEl&&(latEl.textContent=Math.round(performance.now()-t0)+'ms first paint');}
} else if(m.type==='done'){
latEl&&(latEl.textContent=Math.round(performance.now()-t0)+'ms total');
statusEl.textContent='ready — ask me anything'; hideFX();
}
};
// voice-driven board + transcript broadcasts
const tEl=document.getElementById('transcript');
function hideCues(){ board.querySelectorAll('svg [data-cue]').forEach(g=>g.classList.add('ct-hide')); }
function revealCue(cue){
const c=(cue||'').toLowerCase();
board.querySelectorAll('svg [data-cue]').forEach(g=>{
if((g.getAttribute('data-cue')||'').toLowerCase()===c){
g.classList.remove('ct-hide');
g.classList.add('ct-pulse'); setTimeout(()=>g.classList.remove('ct-pulse'),1100);
}
});
}
// board + transcript over /wb (diagram only; audio is on /live)
const wb=new WebSocket(WSBASE+'/wb');
wb.onmessage=(e)=>{const m=JSON.parse(e.data);
if(m.type==='svg'){ if(m.svg){ board.innerHTML=m.svg; hideFX(); if(m.reveal) hideCues(); } else board.innerHTML=''; }
else if(m.type==='cue'){revealCue(m.cue);}
else if(m.type==='status'){statusEl.textContent=m.text; ctFXStatus(m.text);}
else if(m.type==='transcript'){tEl.textContent=m.text;}
else if(m.type==='part'){ // server advanced the lesson (single source of truth)
lessonIdx=m.index; lessonTotal=m.total;
nextBtn.style.display=(m.last?'none':'');
if(m.done) setStatus('✓ lesson complete');
}};
// --- multi-part lesson: one AudioContext for playback; the /live WS cycles per part ---
const SR=24000;
const teachBtn=document.getElementById('teachBtn');
const talkBtn=document.getElementById('talkBtn');
const nextBtn=document.getElementById('nextBtn');
let ac=null, live=null, micStream=null, micNode=null, talking=false, playT=0, lastAudio=0, autoTimer=null;
let lessonTotal=0, lessonIdx=0; // lesson position (advance is in-stream now, no reconnect)
function setStatus(t){statusEl.textContent=t;}
function downsample(inp,inRate){
if(inRate===SR) return inp.slice();
const ratio=inRate/SR, n=Math.floor(inp.length/ratio), out=new Float32Array(n);
for(let i=0;i<n;i++) out[i]=inp[Math.floor(i*ratio)];
return out;
}
function playChunk(arrbuf){
if(!ac) return;
const f32=new Float32Array(arrbuf); if(!f32.length) return;
lastAudio=Date.now();
// voice-reactive: RMS of this frame -> global level for the shader/orb
let s=0; for(let i=0;i<f32.length;i++){s+=f32[i]*f32[i];}
const rms=Math.sqrt(s/f32.length);
window.ctLevel=Math.min(1,Math.max(window.ctLevel*0.8, rms*7));
if(orb){const sc=1+Math.min(1.2,rms*9);orb.style.transform='scale('+sc+')';
orb.style.boxShadow='0 0 '+(10+rms*120)+'px #7aa2ff';}
if(playT-ac.currentTime>1.5) return; // backlogged -> DROP this chunk to drain (never
// schedule on top of queued audio = overlapping voices)
const b=ac.createBuffer(1,f32.length,SR); b.copyToChannel(f32,0);
const s2=ac.createBufferSource(); s2.buffer=b; s2.connect(ac.destination);
const t=Math.max(playT,ac.currentTime+0.12); s2.start(t); playT=t+b.duration;
}
function ensureAudio(){ // create+resume AudioContext on the click gesture (once)
if(!ac) ac=new (window.AudioContext||window.webkitAudioContext)({sampleRate:SR});
ac.resume(); playT=ac.currentTime+0.12;
}
async function ensureMic(){ // mic stream + processor created once, reused across parts
if(micStream) return;
micStream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true,channelCount:1}});
const src=ac.createMediaStreamSource(micStream);
micNode=ac.createScriptProcessor(2048,1,1);
const mute=ac.createGain(); mute.gain.value=0;
src.connect(micNode); micNode.connect(mute); mute.connect(ac.destination);
const inRate=ac.sampleRate;
micNode.onaudioprocess=(e)=>{ if(live&&live.readyState===1) live.send(downsample(e.inputBuffer.getChannelData(0),inRate).buffer); };
}
function closeLiveWS(){ try{ if(live){ live.onclose=null; live.close(); } }catch(e){} live=null; }
async function openLive(sys){ // (re)connect the voice stream for a part
closeLiveWS();
const url=WSBASE+'/live'+(sys?('?sys='+encodeURIComponent(sys)):'');
live=new WebSocket(url); live.binaryType='arraybuffer';
await new Promise((res,rej)=>{live.onopen=res; live.onerror=()=>rej(new Error('stream failed to open'));});
live.onmessage=(e)=>{ if(e.data instanceof ArrayBuffer) playChunk(e.data); };
try{ await ensureMic(); }catch(e){ console.warn('mic unavailable (lesson still plays):',e); }
talking=true; lastAudio=0; talkBtn&&(talkBtn.textContent='⏹ Stop');
}
function stopAll(){
talking=false; clearInterval(autoTimer); closeLiveWS();
try{ micNode&&(micNode.onaudioprocess=null); }catch(e){}
try{ micStream&&micStream.getTracks().forEach(t=>t.stop()); }catch(e){}
try{ ac&&ac.close(); }catch(e){}
ac=micStream=micNode=null;
talkBtn&&(talkBtn.textContent='🎤 Talk'); teachBtn&&(teachBtn.disabled=false); nextBtn&&(nextBtn.style.display='none');
}
async function teach(){
const topic=(q&&q.value||'').trim(); if(!topic) return;
stopAll(); ensureAudio();
ensureMic().catch(()=>{}); // request mic NOW (within the click gesture) so it's ready by the
// time the lesson starts; the lesson plays even if it's denied/absent
teachBtn&&(teachBtn.disabled=true); setStatus('planning a lesson on “'+topic+'”…');
try{
const j=await (await fetch(HTTPBASE+'/lesson',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})})).json();
if(!j.ok){ setStatus('lesson error: '+(j.error||'?')); teachBtn&&(teachBtn.disabled=false); return; }
lessonTotal=j.total||1; lessonIdx=0;
await openLive(j.sys); // ONE connection for the whole lesson; the SERVER auto-advances
nextBtn&&(nextBtn.style.display=(lessonTotal>1?'':'none')); teachBtn&&(teachBtn.disabled=false);
}catch(err){ setStatus('error: '+err.message); teachBtn&&(teachBtn.disabled=false); }
}
function nextPart(){ // MANUAL skip: ask the server to advance now (it also auto-advances
if(!talking) return; // on its own once the tutor finishes a part). Server owns the index.
if(lessonIdx>=lessonTotal-1){ setStatus('✓ lesson complete'); nextBtn&&(nextBtn.style.display='none'); return; }
setStatus('skipping ahead…');
try{ if(live&&live.readyState===1) live.send('next'); }catch(e){}
}
function ctTalkToggle(){ if(talking){stopAll(); setStatus('stopped'); return;} ensureAudio(); openLive().then(()=>setStatus('listening — just talk')).catch(e=>setStatus('error: '+e.message)); }
// Robust click wiring via DELEGATION on document — works even if Gradio re-renders the
// footer DOM, and avoids the double-fire that two control sets caused.
document.addEventListener('click',function(e){
const b=e.target&&e.target.closest?e.target.closest('button'):null; if(!b||!b.id) return;
if(b.id==='teachBtn') teach();
else if(b.id==='nextBtn') nextPart();
else if(b.id==='talkBtn') ctTalkToggle();
});
if(q) q.addEventListener('keydown',e=>{if(e.key==='Enter')teach();});
// ---- wrappers (kept harmless; in case anything still calls them) ----
window.ctTeach=function(topic){ if(typeof topic==='string' && q){ q.value=topic; } teach(); };
window.ctNext =function(){ nextPart(); };
window.ctTalk =ctTalkToggle;
}
// Build the ENTIRE UI from JS into <body> — do not depend on Gradio rendering gr.HTML with
// intact ids (that was the dead path). Canvases + board + footer all created here.
const CT_BOARD = `<div class="ct-wrap"><div class="ct-head"><span class="ct-dot live" id="dot"></span><div><div class="ct-title">ChalkTalk</div><div class="ct-sub">the AI tutor that talks &amp; draws · Nemotron + PersonaPlex</div></div></div><div class="board-wrap"><div class="status" id="status"><span class="orb" id="orb"></span><span>connecting…</span></div><div id="board"></div><div id="boardfx"><canvas id="fxcv"></canvas><div class="ct-sweep"></div><div class="ct-fxlbl"><span class="ct-pen">✎</span> <span id="fxtxt">drawing your diagram</span><span class="ct-dots"></span></div></div></div><div id="transcript"></div></div>`;
const CT_FOOTER = `<div class="ct-foot"><div class="ct-foot-inner"><input id="q" placeholder="What do you want to learn? (e.g. the water cycle)" autofocus/><button class="ct-btn" id="teachBtn">🎓 Teach me</button><button class="ct-btn" id="nextBtn" style="display:none">Next part ▶</button><button class="ct-btn ghost" id="talkBtn">🎤 Talk</button><button class="ct-btn ghost" id="modeBtn">Draw ▶</button><span class="lat" id="lat"></span></div></div>`;
function ctBuildUI(){
if(document.getElementById('ct-root')) return;
const r=document.createElement('div'); r.id='ct-root';
r.innerHTML='<canvas id="ctgl"></canvas><canvas id="ctdust"></canvas><div id="ctfallback"></div>'+CT_BOARD+CT_FOOTER;
document.body.appendChild(r);
}
function ctStart(){
try{ctBuildUI();}catch(e){console.error('buildUI',e);}
try{ctInitShader();}catch(e){}
try{ctInitDust();}catch(e){}
try{ctBoot();}catch(e){console.error('ctBoot',e);}
}
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',ctStart);
else ctStart();
</script>
</head><body></body></html>