Spaces:
Running
Running
ChalkTalk: real interactive UI on a static Space -> Modal backend
Browse files- README.md +15 -4
- index.html +439 -19
README.md
CHANGED
|
@@ -1,10 +1,21 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: indigo
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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 & 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>
|