| <!doctype html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <script> |
| |
| window.addEventListener("error", function (e) { |
| var n = document.getElementById("status"); |
| if (n) n.textContent = "js error: " + (e.message || e.type); |
| }); |
| window.addEventListener("unhandledrejection", function (e) { |
| var n = document.getElementById("status"); |
| var r = e.reason && (e.reason.message || e.reason); |
| if (n) n.textContent = "js error: " + r; |
| }); |
| </script> |
| <script type="importmap"> |
| { "imports": { |
| "three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js/+esm", |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/", |
| "talkinghead": "https://cdn.jsdelivr.net/npm/@met4citizen/talkinghead@1.7.0/modules/talkinghead.mjs" |
| }} |
| </script> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT@9..144,500..900,40&family=Nunito:wght@600;700;800;900&display=swap" rel="stylesheet"> |
| <style> |
| :root{ |
| --sky-top:#ffe3c7; --sky-mid:#ecdcfb; --sky-bot:#d8f3ea; |
| --paper:#fff7ec; --paper2:#fdeede; --ink:#2a2250; --muted:#6b6293; --line:#efe5f3; |
| --honey:#ffb02e; --honey-d:#e08a12; --coral:#ff7a66; --coral-d:#bb3a2c; |
| --teal:#27b0c4; --teal-d:#127684; --violet:#9b7be3; --violet-d:#7253bd; |
| --green:#3fae73; --green-d:#26794e; --gold:#ffd56a; --gold-d:#e6b53e; --good:#3fae73; |
| --tw1:#241c4e; --tw2:#46337e; |
| --display:"Fraunces",Georgia,"Times New Roman",serif; |
| --body:"Nunito",system-ui,-apple-system,sans-serif; |
| } |
| *{box-sizing:border-box} |
| html,body{height:100%} |
| body{margin:0;color:var(--ink);font-family:var(--body); |
| background:linear-gradient(165deg,var(--sky-top) 0%,var(--sky-mid) 54%,var(--sky-bot) 100%);} |
| h1,h2,h3{font-family:var(--display);font-optical-sizing:auto;margin:0} |
| button{font-family:var(--body);cursor:pointer;border:none} |
| :focus-visible{outline:3px solid var(--teal);outline-offset:2px;border-radius:8px} |
| .eyebrow{font-weight:900;font-size:12px;letter-spacing:.2em;text-transform:uppercase;color:#0e6a78} |
| #stage{display:flex;gap:18px;padding:18px;box-sizing:border-box;height:100%} |
| |
| |
| #avatar-wrap{flex:1.25;position:relative;border-radius:28px;overflow:hidden;min-height:62vh; |
| background:radial-gradient(125% 95% at 50% 112%, #6a4fb0 0%, var(--tw2) 34%, var(--tw1) 80%); |
| box-shadow:0 22px 54px rgba(58,40,110,.26), inset 0 0 0 2px rgba(255,213,106,.5), inset 0 0 70px rgba(0,0,0,.34);} |
| #avatar-mount{position:absolute;inset:0;z-index:2} |
| .win-moon{position:absolute;top:30px;right:38px;width:56px;height:56px;border-radius:50%;z-index:1; |
| background:radial-gradient(circle at 35% 35%,#fff7e0,#ffe6a6);box-shadow:0 0 34px rgba(255,224,150,.55)} |
| .win-star{position:absolute;z-index:1;color:#fff;opacity:.8;filter:drop-shadow(0 0 5px rgba(255,231,160,.8)); |
| animation:tw 3.4s ease-in-out infinite} |
| @keyframes tw{0%,100%{opacity:.22;transform:scale(.8)}50%{opacity:1;transform:scale(1.12)}} |
| .win-glow{position:absolute;left:50%;bottom:-70px;transform:translateX(-50%);width:360px;height:360px; |
| border-radius:50%;z-index:1;background:radial-gradient(circle,rgba(255,196,120,.5),rgba(255,176,46,0) 62%)} |
| |
| #hud{position:absolute;left:12px;bottom:12px;z-index:5;display:flex;gap:10px;align-items:center; |
| font:11px/1.2 ui-monospace,Menlo,monospace;background:rgba(20,12,40,.5); |
| padding:5px 10px;border-radius:10px;color:#d8d2ea;max-width:calc(100% - 24px);opacity:.65} |
| #hud #status{color:#ffd9a8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| #hud .lat b{color:#fff} |
| #stars{position:absolute;right:12px;top:12px;z-index:5;display:flex;align-items:center;gap:6px; |
| background:rgba(255,247,236,.94);color:#b8860b;font-weight:900;font-size:21px; |
| padding:6px 15px;border-radius:999px;box-shadow:0 6px 16px rgba(40,20,80,.25)} |
| #stars.pop{animation:pop .5s ease} |
| @keyframes pop{0%,100%{transform:scale(1)}40%{transform:scale(1.35)}} |
| #mute{position:absolute;left:12px;top:12px;z-index:5;width:42px;height:42px;border-radius:999px; |
| background:rgba(255,247,236,.94);font-size:20px;cursor:pointer;box-shadow:0 6px 16px rgba(40,20,80,.22); |
| display:flex;align-items:center;justify-content:center} |
| |
| |
| #panel{flex:1;min-width:344px;display:flex;flex-direction:column} |
| .screen{display:none;flex:1;flex-direction:column;gap:16px;min-height:0} |
| .screen.show{display:flex} |
| .hidden{display:none !important} |
| |
| |
| #splash{align-items:center;justify-content:center;text-align:center} |
| .splash-card{background:var(--paper);border-radius:30px;padding:40px 32px;max-width:430px; |
| box-shadow:0 18px 44px rgba(58,40,110,.2);border:1.5px solid #f2deac} |
| .splash-emoji{font-size:70px;line-height:1} |
| .splash-card h1{font-family:var(--display);font-weight:900;font-size:46px;color:var(--ink);margin:8px 0 4px;font-variation-settings:"SOFT" 40} |
| .splash-card p{margin:0 0 24px;color:var(--muted);font-size:18px;font-weight:700} |
| |
| |
| .big{background:var(--honey);color:var(--ink);font-size:21px;font-weight:900;padding:15px 26px;border-radius:18px; |
| box-shadow:0 6px 0 var(--honey-d);transition:transform .05s,box-shadow .05s} |
| .big:active{transform:translateY(3px);box-shadow:0 3px 0 var(--honey-d)} |
| .big:disabled{background:#d8cfe0;box-shadow:0 6px 0 #bcb0cc;cursor:default;color:#8c82a6} |
| .big.blue{background:var(--teal);box-shadow:0 6px 0 var(--teal-d)} |
| .big.blue:active{box-shadow:0 3px 0 var(--teal-d)} |
| .ghost{background:var(--paper);color:var(--muted);font-size:15px;font-weight:800;padding:10px 16px;border-radius:14px;border:2px solid var(--line)} |
| .ghost:active{transform:translateY(2px)} |
| .hand{background:var(--coral);color:var(--ink);font-size:17px;font-weight:900;padding:13px 18px;border-radius:16px; |
| box-shadow:0 5px 0 var(--coral-d);touch-action:none;user-select:none;-webkit-user-select:none} |
| .hand.rec{background:#ff4d6d;box-shadow:0 5px 0 #d63354;animation:pulse 1.1s ease-in-out infinite} |
| @keyframes pulse{0%,100%{box-shadow:0 5px 0 #d63354,0 0 0 0 rgba(255,77,109,.5)}50%{box-shadow:0 5px 0 #d63354,0 0 0 12px rgba(255,77,109,0)}} |
| |
| |
| #home-head{display:flex;align-items:flex-end;justify-content:space-between;gap:12px} |
| #home-head h2{font-family:var(--display);font-weight:900;font-size:29px;color:var(--ink);margin:2px 0 0;font-variation-settings:"SOFT" 30} |
| #btn-book{background:var(--gold);color:#6a4a00;font-weight:900;font-size:15px;padding:10px 16px;border-radius:14px;box-shadow:0 5px 0 var(--gold-d)} |
| #btn-book:active{transform:translateY(2px);box-shadow:0 3px 0 var(--gold-d)} |
| #course-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:13px} |
| .card{position:relative;background:var(--paper);border-radius:20px;padding:15px 16px 16px;cursor:pointer; |
| box-shadow:0 8px 20px rgba(58,40,110,.1);border:1.5px solid #f0e7da;overflow:hidden; |
| transition:transform .12s,box-shadow .12s} |
| .card:hover{transform:translateY(-4px) rotate(-.4deg);box-shadow:0 16px 30px rgba(58,40,110,.16)} |
| .card .spine{position:absolute;left:0;top:14px;bottom:14px;width:6px;border-radius:0 6px 6px 0;background:var(--c,#cdb4e0)} |
| .card .got{position:absolute;top:10px;right:12px;font-size:16px} |
| .card-emoji{font-size:42px;line-height:1} |
| .card-title{font-family:var(--display);font-weight:700;font-size:18px;margin:6px 0 7px;color:var(--ink);font-variation-settings:"SOFT" 40} |
| .card-age{margin-top:2px} |
| .chip{display:inline-block;font-family:var(--body);font-weight:800;font-size:12px;padding:3px 10px;border-radius:999px;background:#f1ebff;color:var(--violet-d)} |
| .subj-science{--c:var(--teal)} .subj-nature{--c:var(--green)} .subj-math{--c:var(--coral)} |
| .subj-custom{--c:var(--violet)} .subj-story{--c:var(--honey)} .subj-other{--c:#bdb2d6} |
| .subj-science .chip{background:#e2f6f9;color:var(--teal-d)} .subj-nature .chip{background:#e7f5ec;color:var(--green-d)} |
| .subj-math .chip{background:#ffe9e4;color:var(--coral-d)} .subj-custom .chip{background:#f1ebff;color:var(--violet-d)} |
| .subj-story .chip{background:#fff1dc;color:var(--honey-d)} .subj-other .chip{background:#efe9f5;color:var(--muted)} |
| |
| .splash-btns{display:flex;align-items:center;justify-content:center;gap:12px} |
| .iby{width:48px;height:48px;border-radius:50%;border:2px solid var(--honey);background:var(--paper);color:var(--ink);font-size:23px;font-weight:900;cursor:pointer;line-height:1;flex:none} |
| .iby:disabled{opacity:.4;cursor:default} |
| .iby:hover:not(:disabled){background:var(--honey);transform:translateY(-1px)} |
| #about-head{display:flex;align-items:flex-end;justify-content:space-between;gap:12px} |
| |
| |
| |
| #about-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:20px;align-content:start;overflow-y:auto;flex:1;min-height:0;padding:6px} |
| #about-grid .feat{cursor:default;display:flex;flex-direction:column;justify-content:center;min-height:152px;padding:18px 18px;transition:transform .2s,box-shadow .2s} |
| #about-grid .feat:hover{transform:none;box-shadow:0 8px 20px rgba(58,40,110,.1)} |
| #about-grid .feat .card-emoji{font-size:50px} |
| #about-grid .feat .card-title{font-size:17px;margin:10px 0 9px} |
| #about-grid .feat .card-age{margin-top:0} |
| |
| #about-grid .feat.lit{transform:translateY(-3px) scale(1.02);box-shadow:0 0 0 3px var(--honey),0 14px 30px rgba(255,176,46,.45)} |
| #make{background:linear-gradient(180deg,var(--paper),var(--paper2));border:2px dashed var(--teal);border-radius:20px;padding:16px} |
| #make h3{font-family:var(--display);font-weight:700;color:var(--teal-d);font-size:20px;margin:0 0 10px} |
| #make .row{display:flex;gap:10px} |
| #make-input{flex:1;font-family:var(--body);font-weight:700;font-size:16px;padding:13px 15px;border-radius:14px;border:2px solid #ece2f5;background:#fff;color:var(--ink)} |
| #make-input::placeholder{color:#b6abce} |
| #make-msg{margin-top:10px;color:var(--muted);font-size:15px;font-weight:800;min-height:20px} |
| |
| |
| #lesson{gap:14px} |
| #lesson-head{display:flex;align-items:center;gap:12px} |
| #lesson-title{font-family:var(--display);font-weight:700;font-size:22px;flex:1;color:var(--ink);font-variation-settings:"SOFT" 40} |
| #progress{display:flex;gap:6px} |
| .dot{width:11px;height:11px;border-radius:50%;background:var(--line)} |
| .dot.done{background:var(--green)} |
| .dot.now{background:var(--honey);transform:scale(1.3)} |
| #caption-box{flex:1;background:var(--paper);border-radius:24px;padding:26px;border:1.5px solid #f0e7da; |
| box-shadow:0 10px 26px rgba(58,40,110,.12);display:flex;align-items:center; |
| font-family:var(--body);font-size:27px;line-height:1.5;font-weight:800;color:var(--ink);overflow-y:auto;min-height:130px} |
| #quiz{display:flex;flex-direction:column;gap:10px} |
| #quiz .q{font-family:var(--display);font-size:22px;font-weight:700;font-variation-settings:"SOFT" 40} |
| #quiz .choices{display:grid;grid-template-columns:1fr 1fr;gap:10px} |
| .choice{background:var(--teal);color:var(--ink);font-size:19px;font-weight:900;padding:16px;border-radius:16px;box-shadow:0 5px 0 var(--teal-d)} |
| .choice:active{transform:translateY(3px);box-shadow:0 2px 0 var(--teal-d)} |
| .choice.wrong{background:#ff9a8a;box-shadow:0 5px 0 #e0584a} |
| .choice.right{background:var(--green);box-shadow:0 5px 0 var(--green-d)} |
| .choice:disabled{opacity:.9;cursor:default} |
| #lesson-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap} |
| #lesson-controls .spacer{flex:1} |
| |
| |
| #book-head{display:flex;align-items:center;gap:12px} |
| #book-title{font-family:var(--display);font-weight:700;font-size:22px;flex:1} |
| #book-count{background:var(--gold);color:#6a4a00;font-weight:900;border-radius:999px;padding:6px 15px;box-shadow:0 4px 0 var(--gold-d)} |
| #book-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;overflow-y:auto;align-content:start;padding:2px} |
| .sticker{background:var(--paper);border-radius:18px;padding:13px 10px;text-align:center;border:1.5px solid #f0e7da;box-shadow:0 6px 14px rgba(58,40,110,.08);transition:transform .12s} |
| .sticker:hover{transform:translateY(-3px) rotate(-.6deg)} |
| .sticker .emoji{font-size:40px;line-height:1} |
| .sticker .name{font-family:var(--body);font-size:12px;font-weight:800;margin-top:5px;color:var(--ink)} |
| .sticker.locked{background:#efe9f5;border:2px dashed #d4c9e4} |
| .sticker.locked .emoji{filter:grayscale(1);opacity:.3} |
| .sticker.locked .name{color:#a99fbf;font-weight:700} |
| .sticker.make{background:#e6f7fa;border:2px dashed var(--teal);cursor:pointer} |
| .sticker.make .name{color:var(--teal-d)} |
| |
| |
| #earn{align-items:center;justify-content:center;text-align:center} |
| .earn-card{background:radial-gradient(120% 95% at 50% 0,#fff,var(--paper2));border-radius:26px;padding:28px 24px;max-width:440px;width:100%; |
| box-shadow:0 18px 44px rgba(58,40,110,.2);border:1.5px solid #f2deac} |
| .earn-label{font-family:var(--body);font-weight:900;font-size:13px;letter-spacing:.16em;text-transform:uppercase;color:var(--coral-d)} |
| .earn-sticker{font-size:84px;line-height:1;margin:6px 0;display:inline-block;animation:stickerPop .6s cubic-bezier(.2,1.4,.4,1)} |
| @keyframes stickerPop{0%{transform:scale(0) rotate(-30deg);opacity:0}100%{transform:scale(1) rotate(-6deg);opacity:1}} |
| .earn-name{font-family:var(--display);font-weight:800;font-size:26px;font-variation-settings:"SOFT" 40} |
| .earn-stars{font-size:30px;letter-spacing:6px;margin:8px 0} |
| .earn-recap{background:#fff;border-radius:14px;padding:12px 14px;display:inline-block;font-family:var(--body);font-weight:700;font-size:15px;color:var(--ink);box-shadow:0 4px 12px rgba(58,40,110,.1)} |
| .earn-who-row{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:14px;font-family:var(--body);font-weight:800;color:var(--ink)} |
| .earn-who-row input{font-family:var(--body);font-weight:700;font-size:15px;padding:6px 10px;border-radius:10px;border:1.5px solid #f2deac;max-width:160px;text-align:center;background:#fff} |
| .earn-btns{display:flex;gap:10px;justify-content:center;margin-top:18px;flex-wrap:wrap} |
| |
| |
| #confetti{position:fixed;inset:0;pointer-events:none;overflow:hidden;z-index:50} |
| .confetti-bit{position:absolute;top:-40px;animation:fall linear forwards} |
| @keyframes fall{to{transform:translateY(110vh) rotate(540deg);opacity:.85}} |
| |
| @media (max-width:920px){ |
| #stage{flex-direction:column;height:auto} |
| #avatar-wrap{min-height:46vh} |
| #course-grid{grid-template-columns:1fr} |
| #about-grid{grid-template-columns:1fr} |
| #book-grid{grid-template-columns:repeat(3,1fr)} |
| } |
| @media (prefers-reduced-motion:reduce){ |
| .win-star,.earn-sticker,#stars.pop{animation:none} |
| .win-star{opacity:.6} |
| .hand.rec{animation:none} |
| .confetti-bit{display:none} |
| .card:hover{transform:none} |
| } |
| </style> |
| </head> |
| <body> |
| <div id="stage"> |
| <div id="avatar-wrap"> |
| <div class="win-moon"></div> |
| <span class="win-star" style="top:60px;left:64px;font-size:13px">✦</span> |
| <span class="win-star" style="top:122px;left:180px;font-size:10px;animation-delay:.6s">✦</span> |
| <span class="win-star" style="top:84px;left:280px;font-size:12px;animation-delay:1.1s">✦</span> |
| <span class="win-star" style="top:190px;left:110px;font-size:9px;animation-delay:1.7s">✦</span> |
| <span class="win-star" style="top:220px;left:250px;font-size:12px;animation-delay:.4s">✦</span> |
| <div class="win-glow"></div> |
| <div id="avatar-mount"></div> |
| <div id="stars">⭐ <span id="star-count">0</span></div> |
| <button id="mute" title="Sound on/off">🔊</button> |
| <div id="hud"> |
| <span id="status">booting…</span> |
| <span class="lat">ears <b id="lat-ears">—</b></span> |
| <span class="lat">brain <b id="lat-brain">—</b></span> |
| <span class="lat">voice <b id="lat-voice">—</b></span> |
| </div> |
| </div> |
|
|
| <div id="panel"> |
| |
| <div id="splash" class="screen show"> |
| <div class="splash-card"> |
| <div class="splash-emoji">🔭</div> |
| <div class="eyebrow">✦ a window of wonders</div> |
| <h1>Professor Pip</h1> |
| <p>Peek into Pip's world and explore a wonder.</p> |
| <div class="splash-btns"> |
| <button id="btn-start" class="big" disabled>Loading…</button> |
| <button id="btn-about" class="iby" disabled title="What makes Pip special?">ⓘ</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="home" class="screen"> |
| <div id="home-head"> |
| <div> |
| <div class="eyebrow">✦ a window of wonders</div> |
| <h2>Which wonder shall we explore?</h2> |
| </div> |
| <button id="btn-book">📔 My Stickers</button> |
| </div> |
| <div id="course-grid"></div> |
| <div id="make"> |
| <h3>✨ Dream up a lesson</h3> |
| <div class="row"> |
| <input id="make-input" placeholder="Anything you wonder about…" autocomplete="off"/> |
| <button id="make-btn" class="big blue">Make it</button> |
| </div> |
| <div id="make-msg"></div> |
| </div> |
| </div> |
|
|
| |
| <div id="lesson" class="screen"> |
| <div id="lesson-head"> |
| <button id="btn-home" class="ghost">← Menu</button> |
| <span id="lesson-title"></span> |
| <div id="progress"></div> |
| </div> |
| <div id="caption-box"><span id="caption"></span></div> |
| <div id="quiz" class="hidden"></div> |
| <div id="lesson-controls"> |
| <button id="btn-hand" class="hand">🙋 Ask me!</button> |
| <button id="btn-pause" class="ghost">⏸ Pause</button> |
| <span class="spacer"></span> |
| <button id="btn-replay" class="ghost hidden">↺ Say again</button> |
| <button id="btn-next" class="big">Skip ▶</button> |
| </div> |
| </div> |
|
|
| |
| <div id="book" class="screen"> |
| <div id="book-head"> |
| <button id="btn-book-back" class="ghost">← Menu</button> |
| <span id="book-title">📔 My Sticker Book</span> |
| <span id="book-count">0 / 0</span> |
| </div> |
| <div id="book-grid"></div> |
| </div> |
|
|
| |
| <div id="earn" class="screen"> |
| <div class="earn-card"> |
| <div class="earn-label">NEW STICKER UNLOCKED!</div> |
| <div id="earn-sticker" class="earn-sticker">🌈</div> |
| <div id="earn-name" class="earn-name"></div> |
| <div id="earn-stars" class="earn-stars">⭐</div> |
| <div class="earn-recap">Today I learned: <b id="earn-recap"></b></div> |
| <div class="earn-who-row"><span>🎓 For:</span><input id="earn-who" maxlength="20" placeholder="your name" autocomplete="off"/></div> |
| <div class="earn-btns"> |
| <button id="earn-save" class="ghost">💾 Save my certificate</button> |
| <button id="earn-book" class="ghost">📔 See my book</button> |
| <button id="earn-again" class="ghost">↺ Play again</button> |
| <button id="earn-menu" class="big">🏠 Menu</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="about" class="screen"> |
| <div id="about-head"> |
| <div> |
| <div class="eyebrow">✦ a window of wonders</div> |
| <h2>What makes Pip special</h2> |
| </div> |
| <button id="about-back" class="ghost">← Back</button> |
| </div> |
| <div id="about-grid"></div> |
| <button id="about-go" class="big">Let's explore! →</button> |
| </div> |
| </div> |
| </div> |
|
|
| <div id="confetti"></div> |
|
|
| <script> |
| |
| const AVATAR_SOURCES = __AVATAR_SOURCES__; |
| const COURSES = __COURSES__; |
| const SYSTEM = __SYSTEM__; |
| const VOICE = __VOICE_DEFAULT__; |
| |
| |
| |
| |
| |
| const TALKINGHEAD_URL = "https://cdn.jsdelivr.net/npm/@met4citizen/talkinghead@1.7.0/modules/talkinghead.mjs"; |
| const GRADIO_CLIENT_URL = "https://cdn.jsdelivr.net/npm/@gradio/client/+esm"; |
| const THREE_URL = "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js/+esm"; |
| let TalkingHead, Client; |
| |
| const HF_TOKEN = (() => { |
| try { |
| const m = (window.parent.location.hash || "").match(/hf_token=(hf_[A-Za-z0-9]+)/); |
| return m ? m[1] : null; |
| } catch (e){ return null; } |
| })(); |
| const ROOT = (() => { |
| try { return window.parent.location.origin; } catch (e) { return window.location.origin; } |
| })(); |
| |
| const $ = (id) => document.getElementById(id); |
| const esc = (s) => { const d = document.createElement("div"); d.textContent = s == null ? "" : s; return d.innerHTML; }; |
| let head, client, rec, parts, PREMADE = []; |
| |
| |
| const state = { course: null, idx: 0, lessonStars: 0, speakToken: 0, quizTries: 0, busy: false, recording: false, autoplay: true, paused: false, asking: false }; |
| const ADVANCE_GAP_MS = 1000; |
| |
| function status(s){ $("status").textContent = s; } |
| function lat(id, ms){ $(id).textContent = (ms == null) ? "—" : Math.round(ms) + " ms"; } |
| function showScreen(name){ |
| ["splash","home","lesson","book","earn","about"].forEach(s => $(s).classList.toggle("show", s === name)); |
| } |
| function addStar(n){ |
| n = n || 1; store.stars += n; state.lessonStars += n; saveStore(); |
| $("star-count").textContent = store.stars; |
| const b = $("stars"); b.classList.remove("pop"); void b.offsetWidth; b.classList.add("pop"); |
| } |
| |
| async function callApi(name, data){ |
| const r = await client.predict(name, data); |
| return r.data[0]; |
| } |
| |
| |
| const STORE_KEY = "pip.v1"; |
| function loadStore(){ |
| try { return Object.assign({ stickers:{}, stars:0, muted:false, name:"" }, JSON.parse(localStorage.getItem(STORE_KEY) || "{}")); } |
| catch (e){ return { stickers:{}, stars:0, muted:false, name:"" }; } |
| } |
| function saveStore(){ try { localStorage.setItem(STORE_KEY, JSON.stringify(store)); } catch (e){} } |
| const store = loadStore(); |
| let lastEarn = null; |
| |
| const STICKER_NAMES = { |
| "sky-blue":"Rainbow Maker", "photosynthesis":"Plant Chef", "odd-even":"Sock Sorter", |
| "water-cycle":"Rain Rider", "day-night":"Earth Spinner", |
| "butterfly":"Butterfly Buddy", "bees-honey":"Honey Helper", "five-senses":"Sense Detective", |
| "shapes":"Shape Spotter", "magnets":"Magnet Master" |
| }; |
| function stickerFor(c){ return { emoji: c.emoji || "⭐", name: STICKER_NAMES[c.id] || c.title || "My Lesson" }; } |
| |
| |
| function tone(freq, start, dur, type, gain){ |
| if (store.muted || !head || !head.audioCtx) return; |
| const ctx = head.audioCtx, t0 = ctx.currentTime + start; |
| const o = ctx.createOscillator(), g = ctx.createGain(); |
| o.type = type || "sine"; o.frequency.value = freq; |
| g.gain.setValueAtTime(0.0001, t0); |
| g.gain.exponentialRampToValueAtTime(gain || 0.18, t0 + 0.02); |
| g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); |
| o.connect(g); g.connect(ctx.destination); o.start(t0); o.stop(t0 + dur + 0.03); |
| } |
| const sfx = { |
| click(){ tone(420, 0, 0.08, "triangle", 0.10); }, |
| ding(){ [660,880,1320].forEach((f,i) => tone(f, i*0.07, 0.20, "sine", 0.16)); }, |
| sparkle(){ [880,1175,1568,2093].forEach((f,i) => tone(f, i*0.06, 0.18, "triangle", 0.13)); }, |
| boop(){ tone(300, 0, 0.16, "sine", 0.12); tone(235, 0.10, 0.20, "sine", 0.10); }, |
| tada(){ [523,659,784,1047].forEach((f,i) => tone(f, i*0.12, 0.45, "triangle", 0.16)); tone(1047, 0.50, 0.5, "sine", 0.12); } |
| }; |
| |
| |
| function confetti(n, emojis){ |
| const layer = $("confetti"); if (!layer) return; |
| const set = emojis || ["⭐","✨","🌈","🎉","💫"]; |
| for (let i = 0; i < n; i++){ |
| const b = document.createElement("div"); |
| b.className = "confetti-bit"; |
| b.textContent = set[Math.floor(Math.random() * set.length)]; |
| b.style.left = Math.round(Math.random() * 100) + "%"; |
| b.style.animationDuration = (1.4 + Math.random() * 1.2) + "s"; |
| b.style.animationDelay = (Math.random() * 0.2) + "s"; |
| b.style.fontSize = (20 + Math.random() * 20) + "px"; |
| layer.appendChild(b); |
| setTimeout(() => b.remove(), 3000); |
| } |
| } |
| |
| function setMute(m){ store.muted = m; saveStore(); const el = $("mute"); if (el) el.textContent = m ? "🔇" : "🔊"; } |
| |
| |
| function openBook(){ |
| const grid = $("book-grid"); grid.innerHTML = ""; |
| const slots = PREMADE.slice(); |
| Object.keys(store.stickers).forEach(id => { |
| if (!slots.some(c => c.id === id)) slots.push(Object.assign({ id: id, custom: true }, store.stickers[id])); |
| }); |
| let earned = 0; |
| slots.forEach(c => { |
| const s = c.custom ? { emoji: c.emoji, name: c.name } : stickerFor(c); |
| const d = document.createElement("div"); |
| if (store.stickers[c.id]){ earned++; d.className = "sticker"; |
| d.innerHTML = '<div class="emoji">' + esc(s.emoji) + '</div><div class="name">' + esc(s.name) + '</div>'; } |
| else { d.className = "sticker locked"; |
| d.innerHTML = '<div class="emoji">' + esc(s.emoji) + '</div><div class="name">? locked</div>'; } |
| grid.appendChild(d); |
| }); |
| const mk = document.createElement("div"); |
| mk.className = "sticker make"; |
| mk.innerHTML = '<div class="emoji">✨</div><div class="name">Make your own!</div>'; |
| mk.onclick = () => { sfx.click(); showScreen("home"); const mi = $("make-input"); if (mi) mi.focus(); }; |
| grid.appendChild(mk); |
| $("book-count").textContent = earned + " / " + slots.length + " ⭐ " + store.stars; |
| showScreen("book"); |
| } |
| |
| |
| function audioReady(){ return head && head.audioCtx && head.audioCtx.state === "running"; } |
| function unlockAudio(){ |
| try { |
| if (!head || !head.audioCtx) return; |
| if (head.audioCtx.state !== "running") head.audioCtx.resume(); |
| const b = head.audioCtx.createBuffer(1, 1, head.audioCtx.sampleRate || 24000); |
| const s = head.audioCtx.createBufferSource(); |
| s.buffer = b; s.connect(head.audioCtx.destination); s.start(0); |
| if (audioReady()) document.removeEventListener("pointerdown", unlockAudio, true); |
| } catch (e) {} |
| } |
| async function ensureAudio(){ |
| unlockAudio(); |
| if (audioReady()) return; |
| await new Promise(res => { |
| const h = () => { unlockAudio(); if (audioReady()){ document.removeEventListener("pointerdown", h, true); res(); } }; |
| document.addEventListener("pointerdown", h, true); |
| }); |
| } |
| |
| |
| |
| |
| function stopSpeech(){ |
| state.speakToken++; |
| try { if (head && head.stopSpeaking) head.stopSpeaking(); } catch (e) {} |
| } |
| |
| |
| const audioCache = new Map(); |
| function getAudio(text){ |
| const existing = audioCache.get(text); |
| if (existing) return Promise.resolve(existing); |
| const p = (async () => { |
| try { |
| const out = JSON.parse(await callApi("/speak", [text, VOICE])); |
| if (out.error) throw new Error(out.error); |
| const bytes = Uint8Array.from(atob(out.audio_b64), c => c.charCodeAt(0)); |
| const buf = await head.audioCtx.decodeAudioData(bytes.buffer); |
| const rec = { buf: buf, words: out.words, wtimes: out.wtimes, wdurations: out.wdurations }; |
| audioCache.set(text, rec); |
| return rec; |
| } catch (e){ audioCache.delete(text); throw e; } |
| })(); |
| audioCache.set(text, p); |
| return p; |
| } |
| function prefetch(text){ if (text) getAudio(text).catch(() => {}); } |
| |
| |
| |
| |
| async function speak(text, mood, gesture){ |
| if (!text) return; |
| const myToken = state.speakToken; |
| status("voicing…"); |
| let rec; |
| try { |
| const t0 = performance.now(); |
| rec = await getAudio(text); |
| lat("lat-voice", performance.now() - t0); |
| } catch (e){ if (myToken === state.speakToken) status("voice error"); return; } |
| if (myToken !== state.speakToken) return; |
| await ensureAudio(); |
| if (myToken !== state.speakToken) return; |
| let durMs = 1200; |
| try { |
| durMs = rec.buf.duration * 1000 + 250; |
| if (mood && head.setMood) head.setMood(mood); |
| if (gesture && head.playGesture) head.playGesture(gesture, 3); |
| head.speakAudio({ audio: rec.buf, words: rec.words, wtimes: rec.wtimes, wdurations: rec.wdurations }, { lipsyncLang: "en" }); |
| status("speaking…"); |
| } catch (e){ status("audio error: " + (e && e.message ? e.message : e)); durMs = 600; } |
| await new Promise(r => setTimeout(r, durMs)); |
| if (myToken === state.speakToken) status("ready"); |
| } |
| |
| |
| |
| async function say(text, mood, gesture){ |
| stopSpeech(); |
| const token = state.speakToken; |
| state.busy = true; |
| try { await speak(text, mood, gesture); } |
| finally { if (token === state.speakToken) state.busy = false; } |
| return token === state.speakToken; |
| } |
| |
| |
| function renderHome(){ |
| const grid = $("course-grid"); |
| grid.innerHTML = ""; |
| COURSES.forEach(c => { |
| const card = document.createElement("div"); |
| card.className = "card subj-" + (c.subject || "other"); |
| const subj = c.subject || "lesson"; |
| const subjCap = subj.charAt(0).toUpperCase() + subj.slice(1); |
| card.innerHTML = '<span class="spine"></span>' + |
| (store.stickers[c.id] ? '<span class="got">⭐</span>' : '') + |
| '<div class="card-emoji">' + esc(c.emoji || "📘") + '</div>' + |
| '<div class="card-title">' + esc(c.title) + '</div>' + |
| '<div class="card-age"><span class="chip">' + esc(subjCap) + ' · Ages ' + esc(c.age_band || "5-8") + '</span></div>'; |
| card.onclick = () => { sfx.click(); startCourse(c); }; |
| grid.appendChild(card); |
| }); |
| } |
| |
| |
| const ABOUT_INTRO = { text: "Hi! I'm Professor Pip. Peek into my world and see what makes me special!", mood: "happy", gesture: "handup" }; |
| const ABOUT_OUTRO = { text: "That's me! Ready to explore? Let's go!", mood: "love", gesture: "thumbup" }; |
| const ABOUT_FEATURES = [ |
| { emoji: "🪄", title: "Live in your browser", sub: "3D avatar · 60fps · zero GPU on the face", subj: "science", say: "My face is drawn right inside your screen, sixty times a second!", mood: "happy", gesture: "index" }, |
| { emoji: "🗣️", title: "I talk and listen", sub: "voice lessons + raise-your-hand", subj: "story", say: "I speak every lesson, and you can ask me anything out loud!", mood: "happy", gesture: "side" }, |
| { emoji: "🧠", title: "A tiny clever brain", sub: "fine-tuned MiniCPM-1B", subj: "math", say: "My brain is teeny-tiny, taught specially to teach just like me!", mood: "happy", gesture: "index" }, |
| { emoji: "🎭", title: "I really emote", sub: "the model picks my mood + gestures", subj: "custom", say: "My little brain even picks how I smile and wave my hands while I talk!", mood: "happy", gesture: "handup" }, |
| { emoji: "⚡", title: "Powered by Modal", sub: "trained and served with llama.cpp", subj: "science", say: "I was trained, and I live, up in the Modal cloud!", mood: "happy", gesture: "side" }, |
| { emoji: "📚", title: "Ten lessons and your own", sub: "premade courses + make-your-own", subj: "nature", say: "Pick from ten lessons, or dream up your very own!", mood: "happy", gesture: "index" }, |
| { emoji: "🎉", title: "Stars, stickers & a certificate", sub: "quizzes · confetti · a keepsake certificate", subj: "story", say: "Win stars, stickers, and confetti, and take home a certificate of all you learned!", mood: "love", gesture: "thumbup" }, |
| { emoji: "🔒", title: "Kind and safe", sub: "server-side safety · no data kept", subj: "nature", say: "And I only ever talk about kind, safe, happy things!", mood: "happy", gesture: "namaste" }, |
| ]; |
| function renderAbout(){ |
| const grid = $("about-grid"); grid.innerHTML = ""; |
| ABOUT_FEATURES.forEach((f, i) => { |
| const card = document.createElement("div"); |
| card.className = "card feat subj-" + f.subj; |
| card.dataset.idx = i; |
| card.innerHTML = '<span class="spine"></span>' + |
| '<div class="card-emoji">' + esc(f.emoji) + '</div>' + |
| '<div class="card-title">' + esc(f.title) + '</div>' + |
| '<div class="card-age"><span class="chip">' + esc(f.sub) + '</span></div>'; |
| grid.appendChild(card); |
| }); |
| } |
| function litTile(i){ |
| document.querySelectorAll("#about-grid .feat").forEach(el => el.classList.toggle("lit", Number(el.dataset.idx) === i)); |
| } |
| async function narrateAbout(){ |
| stopSpeech(); |
| const token = state.speakToken; |
| state.busy = true; |
| try { |
| await speak(ABOUT_INTRO.text, ABOUT_INTRO.mood, ABOUT_INTRO.gesture); |
| for (let i = 0; i < ABOUT_FEATURES.length; i++){ |
| if (state.speakToken !== token) return; |
| litTile(i); |
| const f = ABOUT_FEATURES[i]; |
| await speak(f.say, f.mood, f.gesture); |
| } |
| if (state.speakToken !== token) return; |
| litTile(-1); |
| await speak(ABOUT_OUTRO.text, ABOUT_OUTRO.mood, ABOUT_OUTRO.gesture); |
| } finally { |
| if (state.speakToken === token){ state.busy = false; litTile(-1); } |
| } |
| } |
| function startAbout(){ |
| if (typeof unlockAudio === "function") unlockAudio(); |
| showScreen("about"); |
| renderAbout(); |
| narrateAbout(); |
| } |
| |
| async function makeOwn(){ |
| const topic = $("make-input").value.trim(); |
| if (!topic) return; |
| const btn = $("make-btn"); |
| btn.disabled = true; |
| $("make-msg").textContent = "Professor Pip is writing your lesson and recording her voice…"; |
| try { |
| const out = JSON.parse(await callApi("/make_course", [topic])); |
| if (out.rejected){ |
| $("make-msg").textContent = out.message; |
| say(out.message, "happy", "shrug"); |
| } else { |
| $("make-msg").textContent = ""; |
| $("make-input").value = ""; |
| if (!COURSES.some(c => c.id === out.id)){ COURSES.push(out); renderHome(); } |
| startCourse(out); |
| } |
| } catch (e){ |
| $("make-msg").textContent = "Oops, let's try that again in a moment."; |
| } finally { btn.disabled = false; } |
| } |
| |
| |
| function renderProgress(){ |
| const p = $("progress"); p.innerHTML = ""; |
| state.course.segments.forEach((_, i) => { |
| const d = document.createElement("div"); |
| d.className = "dot" + (i < state.idx ? " done" : i === state.idx ? " now" : ""); |
| p.appendChild(d); |
| }); |
| } |
| function hideLessonControls(){ |
| $("btn-replay").classList.add("hidden"); |
| $("quiz").classList.add("hidden"); $("quiz").innerHTML = ""; |
| } |
| function caption(text){ $("caption").textContent = text; } |
| |
| async function startCourse(c){ |
| cancelAsk(); |
| stopSpeech(); |
| state.course = c; state.idx = 0; state.lessonStars = 0; state.busy = false; |
| setPaused(false); |
| ["btn-hand","btn-pause","btn-next"].forEach(id => $(id).classList.remove("hidden")); |
| $("lesson-title").textContent = c.title; |
| showScreen("lesson"); |
| c.segments.forEach(s => prefetch(s.say)); |
| if (c.recap) prefetch(c.recap); |
| await ensureAudio(); |
| playSegment(0); |
| } |
| |
| async function playSegment(i){ |
| const c = state.course; |
| if (i >= c.segments.length) return finishCourse(); |
| state.idx = i; state.quizTries = 0; |
| const seg = c.segments[i]; |
| renderProgress(); |
| hideLessonControls(); |
| caption(seg.say); |
| if (c.segments[i + 1]) prefetch(c.segments[i + 1].say); |
| stopSpeech(); |
| const token = state.speakToken; |
| state.busy = true; |
| try { await speak(seg.say, seg.mood, seg.gesture); } |
| finally { if (token === state.speakToken) state.busy = false; } |
| if (token !== state.speakToken) return; |
| $("btn-replay").classList.remove("hidden"); |
| if (seg.quiz){ showQuiz(seg.quiz); return; } |
| if (state.autoplay && !state.paused && !state.asking){ |
| setTimeout(() => { |
| if (token === state.speakToken && !state.paused && !state.asking) playSegment(i + 1); |
| }, ADVANCE_GAP_MS); |
| } |
| } |
| |
| function showQuiz(quiz){ |
| const box = $("quiz"); |
| box.classList.remove("hidden"); |
| box.innerHTML = '<div class="q">' + esc(quiz.question) + '</div>'; |
| const choices = document.createElement("div"); |
| choices.className = "choices"; |
| quiz.choices.forEach(ch => { |
| const b = document.createElement("button"); |
| b.className = "choice"; b.textContent = ch; |
| b.onclick = () => answerQuiz(b, ch, quiz); |
| choices.appendChild(b); |
| }); |
| box.appendChild(choices); |
| } |
| |
| async function answerQuiz(btn, choice, quiz){ |
| if (state.busy || state.paused) return; |
| const buttons = [...btn.parentElement.querySelectorAll("button")]; |
| buttons.forEach(b => b.disabled = true); |
| stopSpeech(); |
| const token = state.speakToken; |
| state.busy = true; |
| try { |
| if (choice === quiz.answer){ |
| btn.classList.add("right"); |
| addStar(); |
| sfx.ding(); confetti(16, ["⭐","✨","🎉"]); |
| await speak("Yes! That's right! Great job!", "love", "thumbup"); |
| if (token !== state.speakToken) return; |
| playSegment(state.idx + 1); |
| } else { |
| btn.classList.add("wrong"); |
| sfx.boop(); |
| state.quizTries++; |
| if (state.quizTries < 2){ |
| await speak("So close! Let's try once more.", "happy", "shrug"); |
| if (token !== state.speakToken) return; |
| buttons.forEach(b => { if (!b.classList.contains("wrong")) b.disabled = false; }); |
| } else { |
| buttons.forEach(b => { if (b.textContent === quiz.answer) b.classList.add("right"); }); |
| await speak("Good try! The answer is " + quiz.answer + ". Now you know!", "happy", "index"); |
| if (token !== state.speakToken) return; |
| playSegment(state.idx + 1); |
| } |
| } |
| } finally { |
| if (token === state.speakToken) state.busy = false; |
| } |
| } |
| |
| async function finishCourse(){ |
| hideLessonControls(); |
| const c = state.course; |
| const recap = c.recap || "You finished the whole lesson! Amazing work!"; |
| addStar(2); |
| const s = stickerFor(c); |
| store.stickers[c.id] = { emoji: s.emoji, name: s.name, recap: recap }; |
| saveStore(); |
| |
| $("earn-sticker").textContent = s.emoji; |
| $("earn-name").textContent = s.name; |
| $("earn-stars").textContent = "⭐".repeat(Math.max(1, Math.min(state.lessonStars, 5))); |
| $("earn-recap").textContent = recap.length > 160 ? recap.slice(0, 160) + "…" : recap; |
| lastEarn = { title: c.title || s.name, emoji: s.emoji, sticker: s.name, recap: recap, stars: Math.max(1, Math.min(state.lessonStars, 5)) }; |
| $("earn-who").value = store.name || ""; |
| showScreen("earn"); |
| confetti(44); sfx.tada(); setTimeout(() => sfx.sparkle(), 320); |
| |
| stopSpeech(); |
| const token = state.speakToken; |
| state.busy = true; |
| try { |
| if (head.setMood) head.setMood("love"); |
| if (head.playGesture) head.playGesture("thumbup", 4); |
| await speak("Hooray! You finished the lesson and earned the " + s.name + " sticker! I'm so proud of you!", "love", "handup"); |
| } finally { if (token === state.speakToken) state.busy = false; } |
| } |
| |
| function _rr(x,a,b,w,h,r){ x.beginPath(); x.moveTo(a+r,b); x.arcTo(a+w,b,a+w,b+h,r); x.arcTo(a+w,b+h,a,b+h,r); x.arcTo(a,b+h,a,b,r); x.arcTo(a,b,a+w,b,r); x.closePath(); } |
| function _wrap(x,text,cx,cy,maxW,lh){ |
| const words=String(text).split(" "); let line="", y=cy; |
| for (const w of words){ const t=line+w+" "; if (x.measureText(t).width>maxW && line){ x.fillText(line.trim(),cx,y); line=w+" "; y+=lh; } else line=t; } |
| x.fillText(line.trim(),cx,y); return y; |
| } |
| function saveCertificate(){ |
| sfx.click(); |
| const e = lastEarn; if (!e){ return; } |
| const W=1080, H=1350, cv=document.createElement("canvas"); cv.width=W; cv.height=H; |
| const x=cv.getContext("2d"); |
| const g=x.createLinearGradient(0,0,0,H); g.addColorStop(0,"#ffe3c7"); g.addColorStop(.5,"#ecdcfb"); g.addColorStop(1,"#d8f3ea"); |
| x.fillStyle=g; x.fillRect(0,0,W,H); |
| x.fillStyle="#fff7ec"; _rr(x,70,90,W-140,H-180,40); x.fill(); |
| x.lineWidth=8; x.strokeStyle="#ffb02e"; _rr(x,70,90,W-140,H-180,40); x.stroke(); |
| x.textAlign="center"; |
| x.fillStyle="#2a2250"; x.font="800 42px Nunito,Arial,sans-serif"; x.fillText("Professor Pip", W/2, 214); |
| x.fillStyle="#ff7a66"; x.font="700 26px Nunito,Arial,sans-serif"; x.fillText("CERTIFICATE OF LEARNING", W/2, 258); |
| x.font="190px serif"; x.fillText(e.emoji, W/2, 480); |
| x.fillStyle="#2a2250"; x.font="800 56px Nunito,Arial,sans-serif"; x.fillText(e.sticker, W/2, 570); |
| x.font="700 32px Nunito,Arial,sans-serif"; x.fillText("Awarded to", W/2, 648); |
| x.font="800 58px Georgia,serif"; x.fillText((store.name||"a clever explorer").slice(0,20), W/2, 716); |
| x.font="40px serif"; x.fillText("⭐".repeat(e.stars), W/2, 792); |
| x.fillStyle="#2a2250"; x.font="700 30px Nunito,Arial,sans-serif"; _wrap(x, "Today I learned: "+e.recap, W/2, 884, W-240, 42); |
| x.fillStyle="#6b5fa0"; x.font="600 26px Nunito,Arial,sans-serif"; x.fillText(e.title, W/2, H-186); |
| x.font="600 22px Nunito,Arial,sans-serif"; x.fillText("Professor Pip · build-small-hackathon", W/2, H-132); |
| cv.toBlob(b => { const a=document.createElement("a"); a.href=URL.createObjectURL(b); a.download="professor-pip-certificate.png"; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),5000); }); |
| if (sfx.sparkle) sfx.sparkle(); |
| } |
| function nextSegment(){ playSegment(state.idx + 1); } |
| |
| |
| let askStream = null, askAnalyser = null, askMaxTimer = null; |
| |
| function resetAskBtn(){ const b = $("btn-hand"); b.classList.remove("rec"); b.textContent = "🙋 Ask me!"; } |
| |
| function setPaused(p){ |
| state.paused = p; |
| $("btn-pause").textContent = p ? "▶ Play" : "⏸ Pause"; |
| } |
| |
| function cancelAsk(){ |
| if (askMaxTimer){ clearTimeout(askMaxTimer); askMaxTimer = null; } |
| askAnalyser = null; |
| state.asking = false; |
| if (rec && rec.state === "recording"){ rec.onstop = null; try { rec.stop(); } catch (e){} } |
| if (askStream){ askStream.getTracks().forEach(t => t.stop()); askStream = null; } |
| resetAskBtn(); |
| } |
| |
| function listenForSilence(stream){ |
| try { |
| const ctx = head.audioCtx; |
| const src = ctx.createMediaStreamSource(stream); |
| askAnalyser = ctx.createAnalyser(); |
| askAnalyser.fftSize = 512; |
| src.connect(askAnalyser); |
| const data = new Uint8Array(askAnalyser.fftSize); |
| let heard = false, quietSince = 0; |
| const poll = () => { |
| if (!state.asking || !askAnalyser) return; |
| askAnalyser.getByteTimeDomainData(data); |
| let sum = 0; |
| for (let k = 0; k < data.length; k++){ const v = (data[k] - 128) / 128; sum += v * v; } |
| const rms = Math.sqrt(sum / data.length); |
| const now = performance.now(); |
| if (rms > 0.05){ heard = true; quietSince = 0; } |
| else if (heard){ |
| if (!quietSince) quietSince = now; |
| else if (now - quietSince > 1200){ stopAsk(); return; } |
| } |
| requestAnimationFrame(poll); |
| }; |
| requestAnimationFrame(poll); |
| } catch (e){ } |
| } |
| |
| async function startAsk(){ |
| if (state.asking) return; |
| state.asking = true; |
| stopSpeech(); |
| state.busy = false; |
| const btn = $("btn-hand"); |
| btn.classList.add("rec"); btn.textContent = "● Listening… tap to send"; |
| status("listening…"); |
| let stream; |
| try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } |
| catch (err){ state.asking = false; resetAskBtn(); status("mic blocked — open the direct URL (⋯ → Embed)"); return; } |
| if (!state.asking){ stream.getTracks().forEach(t => t.stop()); return; } |
| askStream = stream; |
| const mime = (window.MediaRecorder && MediaRecorder.isTypeSupported("audio/webm")) ? "audio/webm" : ""; |
| try { |
| parts = []; |
| rec = new MediaRecorder(stream, mime ? { mimeType: mime } : {}); |
| rec.ondataavailable = ev => parts.push(ev.data); |
| rec.onstop = onAskStop; |
| rec.start(); |
| } catch (e){ cancelAsk(); status("mic error"); return; } |
| listenForSilence(stream); |
| askMaxTimer = setTimeout(() => stopAsk(), 10000); |
| } |
| |
| function stopAsk(){ |
| if (askMaxTimer){ clearTimeout(askMaxTimer); askMaxTimer = null; } |
| askAnalyser = null; |
| if (rec && rec.state === "recording") rec.stop(); |
| else cancelAsk(); |
| } |
| |
| async function onAskStop(){ |
| const token = state.speakToken; |
| if (askStream){ askStream.getTracks().forEach(t => t.stop()); askStream = null; } |
| resetAskBtn(); |
| const blob = new Blob(parts, { type: rec.mimeType || "audio/webm" }); |
| const b64 = await new Promise(res => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.readAsDataURL(blob); }); |
| status("listening…"); |
| let q = ""; |
| try { |
| const t0 = performance.now(); |
| q = await callApi("/asr", [b64, blob.type || ""]); |
| lat("lat-ears", performance.now() - t0); |
| } catch (e){ status("ears error"); } |
| q = (q || "").trim(); |
| if (!state.asking || token !== state.speakToken) return; |
| if (!q){ state.asking = false; if (!state.paused) playSegment(state.idx); return; } |
| await answerQuestion(q); |
| } |
| |
| async function answerQuestion(question){ |
| const seg = state.course.segments[state.idx]; |
| stopSpeech(); |
| const token = state.speakToken; |
| state.busy = true; |
| try { |
| |
| |
| const ctx = 'We are in a lesson called "' + state.course.title + '". You just said: "' + |
| seg.say + '". A child asks: "' + question + '". Answer the child simply and warmly in your own voice, then we will keep learning.'; |
| status("thinking…"); |
| const t0 = performance.now(); |
| const out = JSON.parse(await callApi("/brain", [SYSTEM, JSON.stringify([{ role: "user", content: ctx }]), 120])); |
| lat("lat-brain", performance.now() - t0); |
| if (token !== state.speakToken) return; |
| caption(out.text); |
| await speak(out.text, out.mood, out.gesture); |
| if (token !== state.speakToken) return; |
| await speak("Okay, let's keep learning!", "happy", "handup"); |
| } catch (e){ status("brain error"); } |
| finally { if (token === state.speakToken) state.busy = false; } |
| if (token !== state.speakToken) return; |
| state.asking = false; |
| if (!state.paused) playSegment(state.idx); |
| } |
| |
| |
| async function loadAvatar(){ |
| for (const s of AVATAR_SOURCES){ |
| try { |
| status("avatar: trying " + s.label + "…"); |
| const pre = await fetch(s.url, { cache: "force-cache" }); |
| if (!pre.ok) throw new Error("HTTP " + pre.status); |
| await head.showAvatar( |
| { url: s.url, body: s.body || "F", avatarMood: "happy", lipsyncLang: "en" }, |
| (p) => { if (p && p.total) status("avatar " + Math.round(100 * p.loaded / p.total) + "%"); } |
| ); |
| return true; |
| } catch (e){ } |
| } |
| status("no avatar source could load"); |
| return false; |
| } |
| |
| async function init(){ |
| status("loading avatar…"); |
| head = new TalkingHead($("avatar-mount"), { |
| ttsEndpoint: "none", lipsyncModules: ["en"], cameraView: "upper", |
| cameraRotateEnable: false, lightAmbientIntensity: 2, lightDirectIntensity: 24 |
| }); |
| |
| document.addEventListener("pointerdown", unlockAudio, true); |
| if (!(await loadAvatar())) return; |
| status("connecting engine…"); |
| client = await Client.connect(ROOT, HF_TOKEN ? { hf_token: HF_TOKEN } : {}); |
| |
| |
| renderHome(); |
| PREMADE = COURSES.slice(); |
| $("star-count").textContent = store.stars; |
| setMute(store.muted); |
| $("mute").onclick = () => { setMute(!store.muted); if (!store.muted) sfx.click(); }; |
| $("btn-book").onclick = () => { sfx.click(); openBook(); }; |
| $("btn-book-back").onclick = () => { sfx.click(); showScreen("home"); }; |
| $("earn-book").onclick = () => { sfx.click(); openBook(); }; |
| $("earn-menu").onclick = () => { sfx.click(); showScreen("home"); }; |
| $("earn-again").onclick = () => { sfx.click(); startCourse(state.course); }; |
| $("earn-save").onclick = saveCertificate; |
| $("earn-who").oninput = (ev) => { store.name = ev.target.value.trim().slice(0, 20); saveStore(); }; |
| $("btn-hand").onclick = () => { sfx.click(); if (state.asking) stopAsk(); else startAsk(); }; |
| $("btn-pause").onclick = () => { |
| sfx.click(); |
| if (state.paused){ setPaused(false); cancelAsk(); playSegment(state.idx); } |
| else { setPaused(true); cancelAsk(); stopSpeech(); state.busy = false; status("paused"); } |
| }; |
| $("btn-next").onclick = () => { sfx.click(); cancelAsk(); setPaused(false); playSegment(state.idx + 1); }; |
| $("btn-replay").onclick = () => { |
| if (state.busy || state.asking) return; |
| sfx.click(); |
| const s = state.course.segments[state.idx]; |
| caption(s.say); |
| say(s.say, s.mood, s.gesture); |
| }; |
| $("btn-home").onclick = () => { sfx.click(); cancelAsk(); setPaused(false); stopSpeech(); state.busy = false; showScreen("home"); }; |
| $("make-btn").onclick = () => { sfx.click(); makeOwn(); }; |
| $("make-input").addEventListener("keydown", e => { if (e.key === "Enter") makeOwn(); }); |
| |
| const start = $("btn-start"); |
| start.disabled = false; start.textContent = "▶ Tap to start"; |
| start.onclick = () => { |
| unlockAudio(); |
| showScreen("home"); |
| say("Hi friend! I'm Professor Pip. Pick a lesson, or make your own. Let's learn together!", "happy", "handup"); |
| }; |
| const about = $("btn-about"); |
| about.disabled = false; |
| about.onclick = () => { sfx.click(); startAbout(); }; |
| $("about-back").onclick = () => { sfx.click(); stopSpeech(); state.busy = false; litTile(-1); showScreen("splash"); }; |
| $("about-go").onclick = () => { sfx.click(); stopSpeech(); state.busy = false; litTile(-1); showScreen("home"); say("Hi friend! Pick a lesson, or make your own. Let's learn together!", "happy", "handup"); }; |
| |
| prefetch(ABOUT_INTRO.text); ABOUT_FEATURES.forEach(f => prefetch(f.say)); prefetch(ABOUT_OUTRO.text); |
| status("ready — tap to start"); |
| } |
| |
| async function preflight(label, url){ |
| const r = await fetch(url, { method: "GET", cache: "force-cache" }); |
| if (!r.ok) throw new Error(label + " HTTP " + r.status); |
| } |
| |
| async function boot(){ |
| try { |
| status("loading modules…"); |
| await preflight("three.js", THREE_URL); |
| await preflight("talkinghead", TALKINGHEAD_URL); |
| await preflight("gradio client", GRADIO_CLIENT_URL); |
| TalkingHead = (await import("talkinghead")).TalkingHead; |
| Client = (await import(GRADIO_CLIENT_URL)).Client; |
| await init(); |
| } catch (e){ |
| const msg = (e && e.message) ? e.message : String(e); |
| status("boot failed: " + msg); |
| } |
| } |
| boot(); |
| </script> |
| </body> |
| </html> |
|
|