squaredcuber commited on
Commit
78cce48
·
verified ·
1 Parent(s): 8cec50e

ChalkTalk: real interactive UI on a static Space -> Modal backend

Browse files
Files changed (2) hide show
  1. README.md +15 -4
  2. index.html +439 -19
README.md CHANGED
@@ -1,10 +1,21 @@
1
  ---
2
- title: Chalktalk
3
- emoji: 📈
4
  colorFrom: indigo
5
- colorTo: green
6
  sdk: static
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: ChalkTalk
3
+ emoji: 🎓
4
  colorFrom: indigo
5
+ colorTo: purple
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
+ short_description: An AI tutor that talks and draws, live.
10
  ---
11
 
12
+ # 🎓 ChalkTalk
13
+
14
+ The AI tutor that talks **and** draws — live. This Space is the real interactive app; the
15
+ GPU backend (PersonaPlex voice + Nemotron diagram director) runs on **Modal**.
16
+
17
+ If the backend is asleep it cold-starts on first use. Demo video:
18
+
19
+ <video controls src="https://huggingface.co/datasets/squaredcuber/chalktalk-demo/resolve/main/chalktalk-demo.mp4"></video>
20
+
21
+ _Built by Codex._
index.html CHANGED
@@ -1,19 +1,439 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!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>
2
+ <style>
3
+ :root{
4
+ --bg:#080b12; --ink:#e9f0fb; --accent:#7aa2ff; --accent2:#b98bff;
5
+ --mut:#8a93a6; --ok:#46e6a0; --glass:rgba(20,26,40,.46); --line:rgba(255,255,255,.10);
6
+ }
7
+ html,body{margin:0;background:#080b12;color:var(--ink);
8
+ font-family:ui-sans-serif,system-ui,Segoe UI,Roboto,sans-serif}
9
+ /* full-screen shader canvas, behind everything */
10
+ #ctgl{position:fixed;inset:0;width:100vw;height:100vh;z-index:-2;pointer-events:none;display:block}
11
+ #ctdust{position:fixed;inset:0;width:100vw;height:100vh;z-index:-1;pointer-events:none;display:block;opacity:.5}
12
+ #ctfallback{position:fixed;inset:0;z-index:-2;display:none;
13
+ background:linear-gradient(130deg,#0a0f1f,#141033 40%,#0b1a2b 75%,#08101a);
14
+ background-size:300% 300%;animation:ctbg 24s ease infinite}
15
+ @keyframes ctbg{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
16
+
17
+ /* kill Gradio's default backdrop so the shader shows through */
18
+ gradio-app,.gradio-container{background:transparent!important;max-width:100%!important}
19
+ .gradio-container{padding:0!important}
20
+ footer.svelte-1ax1toq, .gradio-container > footer {display:none!important}
21
+ .ct-wrap{max-width:1100px;margin:0 auto;padding:10px 16px 120px}
22
+
23
+ /* animated gradient title */
24
+ .ct-head{display:flex;align-items:center;gap:12px;padding:18px 8px 6px}
25
+ .ct-title{font-size:26px;font-weight:800;letter-spacing:.3px;margin:0;
26
+ background:linear-gradient(90deg,#7aa2ff,#b98bff,#46e6a0,#7aa2ff);
27
+ background-size:300% 100%;-webkit-background-clip:text;background-clip:text;
28
+ -webkit-text-fill-color:transparent;animation:ctgrad 8s linear infinite}
29
+ @keyframes ctgrad{to{background-position:300% 0}}
30
+ .ct-sub{color:var(--mut);font-size:12.5px}
31
+ .ct-dot{width:11px;height:11px;border-radius:50%;background:#3a4154;flex:0 0 auto;
32
+ transition:.4s}
33
+ .ct-dot.live{background:var(--ok);box-shadow:0 0 12px var(--ok),0 0 26px var(--ok);
34
+ animation:ctpulsedot 1.8s ease infinite}
35
+ @keyframes ctpulsedot{50%{box-shadow:0 0 6px var(--ok)}}
36
+
37
+ /* glassmorphism board frame */
38
+ .board-wrap{position:relative;display:flex;align-items:center;justify-content:center;padding:18px}
39
+ #board{width:min(86vw,900px);aspect-ratio:4/3;background:#fbfdff;border-radius:18px;
40
+ box-shadow:0 20px 70px #000a, inset 0 0 0 1px #ffffff14, 0 0 0 1px var(--line);
41
+ overflow:hidden;position:relative}
42
+ #board svg{width:100%;height:100%;display:block;animation:fade .35s ease}
43
+ @keyframes fade{from{opacity:.4;transform:scale(.992)}to{opacity:1;transform:scale(1)}}
44
+ #board svg [data-cue]{transition:opacity .55s ease, transform .55s ease}
45
+ #board svg [data-cue].ct-hide{opacity:0;transform:translateY(8px) scale(.98)}
46
+ #board svg [data-cue].ct-pulse{animation:ctpulse 1.2s ease}
47
+ @keyframes ctpulse{0%{filter:drop-shadow(0 0 0 #7aa2ff00)}
48
+ 28%{filter:drop-shadow(0 0 10px #7aa2ffee)}100%{filter:drop-shadow(0 0 0 #7aa2ff00)}}
49
+
50
+ /* "drawing" shader effect over the whiteboard while a diagram generates */
51
+ #boardfx{position:absolute;inset:0;border-radius:18px;overflow:hidden;display:none;
52
+ pointer-events:none;z-index:6;background:#f7faff}
53
+ #boardfx.on{display:block;animation:fxin .3s ease}
54
+ @keyframes fxin{from{opacity:0}to{opacity:1}}
55
+ #fxcv{position:absolute;inset:0;width:100%;height:100%;display:block}
56
+ #boardfx .ct-sweep{position:absolute;inset:0;mix-blend-mode:overlay;
57
+ background:linear-gradient(105deg,transparent 38%,rgba(122,162,255,.55) 49%,rgba(185,139,255,.55) 51%,transparent 62%);
58
+ background-size:260% 100%;animation:ctsweep 1.4s linear infinite}
59
+ @keyframes ctsweep{from{background-position:130% 0}to{background-position:-130% 0}}
60
+ #boardfx .ct-fxlbl{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);
61
+ color:#37436b;font-size:14px;font-weight:650;letter-spacing:.2px;background:#ffffffd0;
62
+ backdrop-filter:blur(4px);padding:7px 16px;border-radius:20px;box-shadow:0 6px 18px #0001;
63
+ display:flex;align-items:center;gap:7px;white-space:nowrap}
64
+ #boardfx .ct-pen{display:inline-block;animation:ctpen 1.1s ease-in-out infinite}
65
+ @keyframes ctpen{0%,100%{transform:translate(0,0) rotate(-8deg)}50%{transform:translate(3px,-2px) rotate(6deg)}}
66
+ #boardfx .ct-dots::after{content:'';animation:ctdots 1.4s steps(1,end) infinite}
67
+ @keyframes ctdots{0%{content:''}25%{content:'.'}50%{content:'..'}75%{content:'...'}}
68
+
69
+ /* status pill + reactive orb */
70
+ .status{position:absolute;top:16px;left:50%;transform:translateX(-50%);font-size:12.5px;
71
+ color:#dbe4f5;background:var(--glass);padding:6px 16px;border-radius:22px;
72
+ backdrop-filter:blur(10px);border:1px solid var(--line);box-shadow:0 6px 20px #0006;
73
+ display:flex;align-items:center;gap:9px;white-space:nowrap;max-width:90%}
74
+ .orb{width:13px;height:13px;border-radius:50%;flex:0 0 auto;
75
+ background:radial-gradient(circle at 35% 30%,#bcd0ff,#5a7bff 60%,#3650cc);
76
+ box-shadow:0 0 10px #5a7bffcc;transition:transform .08s linear, box-shadow .12s linear}
77
+
78
+ /* transcript: constrained block BELOW the whiteboard, above the input */
79
+ #transcript{margin:12px auto 0;max-width:min(86vw,900px);width:100%;box-sizing:border-box;
80
+ max-height:90px;overflow-y:auto;text-align:center;color:#dbe4f5;font-size:14px;line-height:1.45;
81
+ background:var(--glass);backdrop-filter:blur(8px);padding:9px 16px;border-radius:12px;
82
+ border:1px solid var(--line);box-shadow:0 6px 18px #0004}
83
+ #transcript:empty{display:none}
84
+
85
+ /* footer controls — glass */
86
+ .ct-foot{position:fixed;bottom:0;left:0;right:0;display:flex;gap:10px;align-items:center;
87
+ padding:14px 18px;z-index:20;
88
+ background:linear-gradient(#080b1200,#080b12ee);}
89
+ .ct-foot-inner{max-width:1100px;margin:0 auto;width:100%;display:flex;gap:10px;align-items:center;
90
+ background:var(--glass);backdrop-filter:blur(14px);border:1px solid var(--line);
91
+ border-radius:16px;padding:10px 12px;box-shadow:0 12px 40px #0007}
92
+ .ct-foot input{flex:1;background:rgba(10,14,24,.6);border:1px solid var(--line);
93
+ color:var(--ink);padding:12px 16px;border-radius:11px;font-size:15px;outline:none;transition:.2s}
94
+ .ct-foot input:focus{border-color:var(--accent);box-shadow:0 0 0 3px #7aa2ff22}
95
+ .ct-btn{position:relative;border:0;padding:12px 18px;border-radius:11px;font-weight:700;
96
+ cursor:pointer;font-size:14px;color:#06080f;overflow:hidden;transition:transform .12s, box-shadow .2s;
97
+ background:linear-gradient(135deg,#7aa2ff,#b98bff);box-shadow:0 6px 22px #7aa2ff55}
98
+ .ct-btn:hover{transform:translateY(-2px);box-shadow:0 10px 30px #7aa2ff88}
99
+ .ct-btn:active{transform:translateY(0) scale(.97)}
100
+ .ct-btn.ghost{background:rgba(20,26,40,.7);color:var(--ink);border:1px solid var(--line);
101
+ box-shadow:none}
102
+ .ct-btn.ghost:hover{border-color:var(--accent);box-shadow:0 6px 20px #0006}
103
+ .lat{font-variant-numeric:tabular-nums;color:var(--mut);font-size:12px;min-width:80px;text-align:right}
104
+
105
+ @media (max-width:800px){
106
+ #board{width:92vw}
107
+ .ct-foot-inner{flex-wrap:wrap}
108
+ .ct-foot input{flex:1 1 100%;order:-1}
109
+ .lat{display:none}
110
+ .ct-title{font-size:21px}
111
+ }
112
+ </style>
113
+ <script>
114
+ window.ctLevel = 0; // live audio RMS, fed to the shader (voice-reactive)
115
+ /* ============================ WebGL shader background ============================ */
116
+ function ctInitShader(){
117
+ const MOBILE = window.innerWidth < 800;
118
+ const cv = document.getElementById('ctgl');
119
+ const fb = document.getElementById('ctfallback');
120
+ let gl=null; try{ gl = cv.getContext('webgl')||cv.getContext('experimental-webgl'); }catch(e){}
121
+ if(!gl){ if(fb) fb.style.display='block'; if(cv) cv.style.display='none'; return; }
122
+ const DPRCAP = MOBILE ? 1.0 : 1.5;
123
+ const SCALE = MOBILE ? 0.6 : 0.85; // render the shader below CSS res, cheap on phones
124
+ const OCT = MOBILE ? 3 : 5;
125
+ const vs = "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";
126
+ const fs = `
127
+ precision highp float;
128
+ uniform vec2 R; uniform float T; uniform float LV;
129
+ // fbm aurora/plasma — "chalkboard meets nebula"
130
+ float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
131
+ float noise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
132
+ 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);}
133
+ 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;}
134
+ void main(){
135
+ vec2 uv=(gl_FragCoord.xy-.5*R)/R.y;
136
+ float t=T*0.04;
137
+ float lv=clamp(LV,0.,1.);
138
+ vec2 q=uv*1.4;
139
+ q += vec2(fbm(q+t), fbm(q-t+5.2));
140
+ float f=fbm(q*1.8 + vec2(t*1.3,-t));
141
+ float aurora=fbm(uv*2.2 + vec2(0., t*2.0) + f*1.5);
142
+ // dark nebula palette
143
+ vec3 deep=vec3(.03,.05,.10);
144
+ vec3 blue=vec3(.16,.30,.78);
145
+ vec3 violet=vec3(.55,.32,.92);
146
+ vec3 mint=vec3(.16,.85,.62);
147
+ vec3 col=deep;
148
+ col=mix(col,blue, smoothstep(.25,.85,aurora));
149
+ col=mix(col,violet, smoothstep(.55,.95,f)*0.7);
150
+ col=mix(col,mint, smoothstep(.72,1.0,aurora)*0.35);
151
+ // vignette
152
+ col*=1.0-0.55*dot(uv,uv);
153
+ // voice-reactive bloom: glow pulses when PersonaPlex speaks
154
+ float glow=smoothstep(.4,.95,aurora);
155
+ col += (blue*.5+violet*.5)*glow*lv*1.4;
156
+ col += lv*0.06;
157
+ gl_FragColor=vec4(col,1.0);
158
+ }`;
159
+ function sh(t,s){const o=gl.createShader(t);gl.shaderSource(o,s);gl.compileShader(o);return o;}
160
+ const prog=gl.createProgram();
161
+ gl.attachShader(prog,sh(gl.VERTEX_SHADER,vs));
162
+ gl.attachShader(prog,sh(gl.FRAGMENT_SHADER,fs));
163
+ gl.linkProgram(prog);
164
+ if(!gl.getProgramParameter(prog,gl.LINK_STATUS)){ if(fb) fb.style.display='block'; cv.style.display='none'; return; }
165
+ gl.useProgram(prog);
166
+ const buf=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buf);
167
+ gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,3,-1,-1,3]),gl.STATIC_DRAW);
168
+ const pl=gl.getAttribLocation(prog,'p'); gl.enableVertexAttribArray(pl);
169
+ gl.vertexAttribPointer(pl,2,gl.FLOAT,false,0,0);
170
+ const uR=gl.getUniformLocation(prog,'R'),uT=gl.getUniformLocation(prog,'T'),uL=gl.getUniformLocation(prog,'LV');
171
+ function resize(){
172
+ const dpr=Math.min(window.devicePixelRatio||1,DPRCAP)*SCALE;
173
+ cv.width=Math.max(2,Math.floor(innerWidth*dpr));
174
+ cv.height=Math.max(2,Math.floor(innerHeight*dpr));
175
+ gl.viewport(0,0,cv.width,cv.height);
176
+ }
177
+ window.addEventListener('resize',resize); resize();
178
+ let smooth=0, hidden=false;
179
+ document.addEventListener('visibilitychange',()=>{hidden=document.hidden;});
180
+ const t0=performance.now();
181
+ function frame(){
182
+ if(!hidden){
183
+ smooth += ((window.ctLevel||0)-smooth)*0.15; // smooth the reactive level
184
+ gl.uniform2f(uR,cv.width,cv.height);
185
+ gl.uniform1f(uT,(performance.now()-t0)/1000);
186
+ gl.uniform1f(uL,smooth);
187
+ gl.drawArrays(gl.TRIANGLES,0,3);
188
+ }
189
+ requestAnimationFrame(frame);
190
+ }
191
+ requestAnimationFrame(frame);
192
+ }
193
+ /* ============================ chalk-dust motes ============================ */
194
+ function ctInitDust(){
195
+ const MOBILE=window.innerWidth<800;
196
+ const cv=document.getElementById('ctdust'); if(!cv) return;
197
+ const ctx=cv.getContext('2d'); let W,H,parts=[];
198
+ const N=MOBILE?28:70;
199
+ function resize(){W=cv.width=innerWidth;H=cv.height=innerHeight;}
200
+ window.addEventListener('resize',resize);resize();
201
+ for(let i=0;i<N;i++)parts.push({x:Math.random()*W,y:Math.random()*H,
202
+ r:Math.random()*1.8+.4,vx:(Math.random()-.5)*.18,vy:-Math.random()*.28-.05,
203
+ a:Math.random()*.5+.15});
204
+ let hidden=false;document.addEventListener('visibilitychange',()=>hidden=document.hidden);
205
+ function frame(){
206
+ if(!hidden){
207
+ ctx.clearRect(0,0,W,H);
208
+ for(const p of parts){
209
+ p.x+=p.vx;p.y+=p.vy;
210
+ if(p.y<-5){p.y=H+5;p.x=Math.random()*W;}
211
+ if(p.x<-5)p.x=W+5; if(p.x>W+5)p.x=-5;
212
+ ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,6.283);
213
+ ctx.fillStyle='rgba(210,225,255,'+p.a+')';ctx.fill();
214
+ }
215
+ }
216
+ requestAnimationFrame(frame);
217
+ }
218
+ requestAnimationFrame(frame);
219
+ }
220
+ </script>
221
+ <script>
222
+ /* ====================================================================
223
+ PORTED VERBATIM FROM web/index.html — voice + whiteboard logic.
224
+ Runs after the DOM (board/status/etc.) is mounted; we defer with a
225
+ small poll so the gr.HTML content exists before we grab elements.
226
+ ==================================================================== */
227
+ function ctBoot(){
228
+ const board=document.getElementById('board'), statusEl=document.getElementById('status'),
229
+ dot=document.getElementById('dot'), q=document.getElementById('q'),
230
+ latEl=document.getElementById('lat'), modeBtn=document.getElementById('modeBtn');
231
+ if(!board||!statusEl){ return setTimeout(ctBoot,120); } // wait for gr.HTML DOM
232
+ if(window.__ctBooted) return; window.__ctBooted=true;
233
+ const orb=document.getElementById('orb');
234
+ const boardfx=document.getElementById('boardfx'), fxtxt=document.getElementById('fxtxt');
235
+ let fxRAF=0;
236
+ (function(){ // board "drawing" shader: chalk/ink forming on the whiteboard while generating
237
+ const cv=document.getElementById('fxcv'); if(!cv) return;
238
+ let gl=null; try{gl=cv.getContext('webgl')||cv.getContext('experimental-webgl');}catch(e){}
239
+ if(!gl) return; // CSS sweep + label still animate as a fallback
240
+ const vs="attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";
241
+ const fs=`precision highp float;uniform vec2 R;uniform float T;
242
+ float h(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5);}
243
+ float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
244
+ 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);}
245
+ 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;}
246
+ void main(){
247
+ vec2 uv=gl_FragCoord.xy/R; float t=T*0.5;
248
+ float f=fbm(uv*4.0+vec2(t*.3,t*.2)); f=fbm(uv*3.0+f*1.5+vec2(0.,t*.4));
249
+ float ink=smoothstep(.46,.62,f);
250
+ vec3 col=mix(vec3(.965,.975,1.0), vec3(.42,.55,.85), ink*.45);
251
+ float sweep=smoothstep(.05,0.,abs(fract(uv.x-T*.45)-.5)-.05);
252
+ col=mix(col, vec3(.55,.42,.92), sweep*.22);
253
+ gl_FragColor=vec4(col,1.0);
254
+ }`;
255
+ function sh(tp,s){const o=gl.createShader(tp);gl.shaderSource(o,s);gl.compileShader(o);return o;}
256
+ const pr=gl.createProgram();gl.attachShader(pr,sh(gl.VERTEX_SHADER,vs));gl.attachShader(pr,sh(gl.FRAGMENT_SHADER,fs));gl.linkProgram(pr);
257
+ if(!gl.getProgramParameter(pr,gl.LINK_STATUS)) return;
258
+ gl.useProgram(pr);
259
+ 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);
260
+ const pl=gl.getAttribLocation(pr,'p');gl.enableVertexAttribArray(pl);gl.vertexAttribPointer(pl,2,gl.FLOAT,false,0,0);
261
+ const uR=gl.getUniformLocation(pr,'R'),uT=gl.getUniformLocation(pr,'T'),f0=performance.now();
262
+ window.__ctFXdraw=function(){
263
+ const w=cv.clientWidth||520,hh=cv.clientHeight||390,dpr=Math.min(devicePixelRatio||1,1.5);
264
+ const W=Math.max(2,Math.floor(w*dpr)),H=Math.max(2,Math.floor(hh*dpr));
265
+ if(cv.width!==W||cv.height!==H){cv.width=W;cv.height=H;}
266
+ gl.viewport(0,0,cv.width,cv.height);
267
+ gl.uniform2f(uR,cv.width,cv.height);gl.uniform1f(uT,(performance.now()-f0)/1000);
268
+ gl.drawArrays(gl.TRIANGLES,0,3);
269
+ };
270
+ })();
271
+ function fxLoop(){ if(boardfx&&boardfx.classList.contains('on')){ window.__ctFXdraw&&window.__ctFXdraw(); fxRAF=requestAnimationFrame(fxLoop);} else fxRAF=0; }
272
+ function showFX(t){ if(!boardfx) return; if(fxtxt&&t) fxtxt.textContent=t; boardfx.classList.add('on'); if(!fxRAF) fxRAF=requestAnimationFrame(fxLoop); }
273
+ function hideFX(){ if(boardfx) boardfx.classList.remove('on'); }
274
+ 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(); }
275
+ let t0=0, firstPaint=false;
276
+ // backend is same-origin by default, or a remote URL when window.CT_BACKEND is injected
277
+ // (e.g. the HF Space frontend pointing at the Modal GPU backend).
278
+ const CT_BACKEND=(window.CT_BACKEND||"").replace(/\/+$/,"");
279
+ const HTTPBASE=CT_BACKEND;
280
+ const WSBASE=CT_BACKEND ? CT_BACKEND.replace(/^http/,"ws") : (location.protocol==='https:'?'wss://':'ws://')+location.host;
281
+ // draw-only: silent type-to-draw of the typed text (no voice)
282
+ if(modeBtn) modeBtn.onclick=()=>{const c=(q&&q.value||'').trim(); if(c){statusEl.textContent='drawing…'; ws.send(JSON.stringify({concept:c,mode:'full'}));}};
283
+ const ws=new WebSocket(WSBASE+'/ws');
284
+ ws.onopen=()=>{dot&&dot.classList.add('live');statusEl.textContent='ready — ask me anything';};
285
+ ws.onclose=()=>{dot&&dot.classList.remove('live');statusEl.textContent='disconnected';};
286
+ ws.onmessage=(e)=>{
287
+ const m=JSON.parse(e.data);
288
+ if(m.type==='status'){statusEl.textContent=m.text;firstPaint=false;t0=performance.now(); ctFXStatus(m.text);}
289
+ else if(m.type==='svg'){
290
+ board.innerHTML=m.svg; hideFX();
291
+ if(!firstPaint){firstPaint=true;latEl&&(latEl.textContent=Math.round(performance.now()-t0)+'ms first paint');}
292
+ } else if(m.type==='done'){
293
+ latEl&&(latEl.textContent=Math.round(performance.now()-t0)+'ms total');
294
+ statusEl.textContent='ready — ask me anything'; hideFX();
295
+ }
296
+ };
297
+ // voice-driven board + transcript broadcasts
298
+ const tEl=document.getElementById('transcript');
299
+ function hideCues(){ board.querySelectorAll('svg [data-cue]').forEach(g=>g.classList.add('ct-hide')); }
300
+ function revealCue(cue){
301
+ const c=(cue||'').toLowerCase();
302
+ board.querySelectorAll('svg [data-cue]').forEach(g=>{
303
+ if((g.getAttribute('data-cue')||'').toLowerCase()===c){
304
+ g.classList.remove('ct-hide');
305
+ g.classList.add('ct-pulse'); setTimeout(()=>g.classList.remove('ct-pulse'),1100);
306
+ }
307
+ });
308
+ }
309
+ // board + transcript over /wb (diagram only; audio is on /live)
310
+ const wb=new WebSocket(WSBASE+'/wb');
311
+ wb.onmessage=(e)=>{const m=JSON.parse(e.data);
312
+ if(m.type==='svg'){ if(m.svg){ board.innerHTML=m.svg; hideFX(); if(m.reveal) hideCues(); } else board.innerHTML=''; }
313
+ else if(m.type==='cue'){revealCue(m.cue);}
314
+ else if(m.type==='status'){statusEl.textContent=m.text; ctFXStatus(m.text);}
315
+ else if(m.type==='transcript'){tEl.textContent=m.text;}
316
+ else if(m.type==='part'){ // server advanced the lesson (single source of truth)
317
+ lessonIdx=m.index; lessonTotal=m.total;
318
+ nextBtn.style.display=(m.last?'none':'');
319
+ if(m.done) setStatus('✓ lesson complete');
320
+ }};
321
+
322
+ // --- multi-part lesson: one AudioContext for playback; the /live WS cycles per part ---
323
+ const SR=24000;
324
+ const teachBtn=document.getElementById('teachBtn');
325
+ const talkBtn=document.getElementById('talkBtn');
326
+ const nextBtn=document.getElementById('nextBtn');
327
+ let ac=null, live=null, micStream=null, micNode=null, talking=false, playT=0, lastAudio=0, autoTimer=null;
328
+ let lessonTotal=0, lessonIdx=0; // lesson position (advance is in-stream now, no reconnect)
329
+ function setStatus(t){statusEl.textContent=t;}
330
+ function downsample(inp,inRate){
331
+ if(inRate===SR) return inp.slice();
332
+ const ratio=inRate/SR, n=Math.floor(inp.length/ratio), out=new Float32Array(n);
333
+ for(let i=0;i<n;i++) out[i]=inp[Math.floor(i*ratio)];
334
+ return out;
335
+ }
336
+ function playChunk(arrbuf){
337
+ if(!ac) return;
338
+ const f32=new Float32Array(arrbuf); if(!f32.length) return;
339
+ lastAudio=Date.now();
340
+ // voice-reactive: RMS of this frame -> global level for the shader/orb
341
+ let s=0; for(let i=0;i<f32.length;i++){s+=f32[i]*f32[i];}
342
+ const rms=Math.sqrt(s/f32.length);
343
+ window.ctLevel=Math.min(1,Math.max(window.ctLevel*0.8, rms*7));
344
+ if(orb){const sc=1+Math.min(1.2,rms*9);orb.style.transform='scale('+sc+')';
345
+ orb.style.boxShadow='0 0 '+(10+rms*120)+'px #7aa2ff';}
346
+ if(playT-ac.currentTime>1.5) return; // backlogged -> DROP this chunk to drain (never
347
+ // schedule on top of queued audio = overlapping voices)
348
+ const b=ac.createBuffer(1,f32.length,SR); b.copyToChannel(f32,0);
349
+ const s2=ac.createBufferSource(); s2.buffer=b; s2.connect(ac.destination);
350
+ const t=Math.max(playT,ac.currentTime+0.12); s2.start(t); playT=t+b.duration;
351
+ }
352
+ function ensureAudio(){ // create+resume AudioContext on the click gesture (once)
353
+ if(!ac) ac=new (window.AudioContext||window.webkitAudioContext)({sampleRate:SR});
354
+ ac.resume(); playT=ac.currentTime+0.12;
355
+ }
356
+ async function ensureMic(){ // mic stream + processor created once, reused across parts
357
+ if(micStream) return;
358
+ micStream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true,channelCount:1}});
359
+ const src=ac.createMediaStreamSource(micStream);
360
+ micNode=ac.createScriptProcessor(2048,1,1);
361
+ const mute=ac.createGain(); mute.gain.value=0;
362
+ src.connect(micNode); micNode.connect(mute); mute.connect(ac.destination);
363
+ const inRate=ac.sampleRate;
364
+ micNode.onaudioprocess=(e)=>{ if(live&&live.readyState===1) live.send(downsample(e.inputBuffer.getChannelData(0),inRate).buffer); };
365
+ }
366
+ function closeLiveWS(){ try{ if(live){ live.onclose=null; live.close(); } }catch(e){} live=null; }
367
+ async function openLive(sys){ // (re)connect the voice stream for a part
368
+ closeLiveWS();
369
+ const url=WSBASE+'/live'+(sys?('?sys='+encodeURIComponent(sys)):'');
370
+ live=new WebSocket(url); live.binaryType='arraybuffer';
371
+ await new Promise((res,rej)=>{live.onopen=res; live.onerror=()=>rej(new Error('stream failed to open'));});
372
+ live.onmessage=(e)=>{ if(e.data instanceof ArrayBuffer) playChunk(e.data); };
373
+ try{ await ensureMic(); }catch(e){ console.warn('mic unavailable (lesson still plays):',e); }
374
+ talking=true; lastAudio=0; talkBtn&&(talkBtn.textContent='⏹ Stop');
375
+ }
376
+ function stopAll(){
377
+ talking=false; clearInterval(autoTimer); closeLiveWS();
378
+ try{ micNode&&(micNode.onaudioprocess=null); }catch(e){}
379
+ try{ micStream&&micStream.getTracks().forEach(t=>t.stop()); }catch(e){}
380
+ try{ ac&&ac.close(); }catch(e){}
381
+ ac=micStream=micNode=null;
382
+ talkBtn&&(talkBtn.textContent='🎤 Talk'); teachBtn&&(teachBtn.disabled=false); nextBtn&&(nextBtn.style.display='none');
383
+ }
384
+ async function teach(){
385
+ const topic=(q&&q.value||'').trim(); if(!topic) return;
386
+ stopAll(); ensureAudio();
387
+ ensureMic().catch(()=>{}); // request mic NOW (within the click gesture) so it's ready by the
388
+ // time the lesson starts; the lesson plays even if it's denied/absent
389
+ teachBtn&&(teachBtn.disabled=true); setStatus('planning a lesson on “'+topic+'”…');
390
+ try{
391
+ const j=await (await fetch(HTTPBASE+'/lesson',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})})).json();
392
+ if(!j.ok){ setStatus('lesson error: '+(j.error||'?')); teachBtn&&(teachBtn.disabled=false); return; }
393
+ lessonTotal=j.total||1; lessonIdx=0;
394
+ await openLive(j.sys); // ONE connection for the whole lesson; the SERVER auto-advances
395
+ nextBtn&&(nextBtn.style.display=(lessonTotal>1?'':'none')); teachBtn&&(teachBtn.disabled=false);
396
+ }catch(err){ setStatus('error: '+err.message); teachBtn&&(teachBtn.disabled=false); }
397
+ }
398
+ function nextPart(){ // MANUAL skip: ask the server to advance now (it also auto-advances
399
+ if(!talking) return; // on its own once the tutor finishes a part). Server owns the index.
400
+ if(lessonIdx>=lessonTotal-1){ setStatus('✓ lesson complete'); nextBtn&&(nextBtn.style.display='none'); return; }
401
+ setStatus('skipping ahead…');
402
+ try{ if(live&&live.readyState===1) live.send('next'); }catch(e){}
403
+ }
404
+ function ctTalkToggle(){ if(talking){stopAll(); setStatus('stopped'); return;} ensureAudio(); openLive().then(()=>setStatus('listening — just talk')).catch(e=>setStatus('error: '+e.message)); }
405
+ // Robust click wiring via DELEGATION on document — works even if Gradio re-renders the
406
+ // footer DOM, and avoids the double-fire that two control sets caused.
407
+ document.addEventListener('click',function(e){
408
+ const b=e.target&&e.target.closest?e.target.closest('button'):null; if(!b||!b.id) return;
409
+ if(b.id==='teachBtn') teach();
410
+ else if(b.id==='nextBtn') nextPart();
411
+ else if(b.id==='talkBtn') ctTalkToggle();
412
+ });
413
+ if(q) q.addEventListener('keydown',e=>{if(e.key==='Enter')teach();});
414
+
415
+ // ---- wrappers (kept harmless; in case anything still calls them) ----
416
+ window.ctTeach=function(topic){ if(typeof topic==='string' && q){ q.value=topic; } teach(); };
417
+ window.ctNext =function(){ nextPart(); };
418
+ window.ctTalk =ctTalkToggle;
419
+ }
420
+ // Build the ENTIRE UI from JS into <body> — do not depend on Gradio rendering gr.HTML with
421
+ // intact ids (that was the dead path). Canvases + board + footer all created here.
422
+ 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>`;
423
+ 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>`;
424
+ function ctBuildUI(){
425
+ if(document.getElementById('ct-root')) return;
426
+ const r=document.createElement('div'); r.id='ct-root';
427
+ r.innerHTML='<canvas id="ctgl"></canvas><canvas id="ctdust"></canvas><div id="ctfallback"></div>'+CT_BOARD+CT_FOOTER;
428
+ document.body.appendChild(r);
429
+ }
430
+ function ctStart(){
431
+ try{ctBuildUI();}catch(e){console.error('buildUI',e);}
432
+ try{ctInitShader();}catch(e){}
433
+ try{ctInitDust();}catch(e){}
434
+ try{ctBoot();}catch(e){console.error('ctBoot',e);}
435
+ }
436
+ if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',ctStart);
437
+ else ctStart();
438
+ </script>
439
+ </head><body></body></html>