Spaces:
Running
Running
| <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 & 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;max-width:100%} | |
| .gradio-container{padding:0} | |
| footer.svelte-1ax1toq, .gradio-container > footer {display:none} | |
| .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 & 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> |