A page type for krueng.ai. A Lab teaches one substantial subject by making the student read it, hear it, say it, and operate it โ vocabulary first, then a video overview, then a set of interactive machines they drive themselves. Everything is bilingual English + ไธญๆ, everything can be spoken aloud by the page, and the student's own speech is scored.
Sibling page types: a Discussion lesson โ long-form, trilingual, built around evidence and argument, with three open questions closing every segment โ and a Hybrid lesson, a Discussion with a console of this page's machines dropped into one segment. Reach for a Lab when the student should operate the subject, a Discussion when they should argue about it, and a Hybrid when they should adjudicate โ argue using instruments they drove themselves.
The reference implementation is HTML/neuro_lab.html. It is a single
self-contained file โ no build step, no dependencies, no external CSS or JS. Copy it, gut the
content, keep the systems. This page documents what those systems are and why each one is
shaped the way it is.
| Build a Lab | Build something else |
|---|---|
| A subject with real internal mechanism โ something that moves and can be driven (neurons firing, genes copying, forces balancing). | A vocabulary set or a grammar point. Use the lesson-builder skill. |
| Enough substance for 5โ8 distinct sub-topics, each worth its own interactive figure. | A narrative or a story. See star_on_the_mountain.html. |
| Where being wrong on the internet is common and worth correcting explicitly. | A game with win conditions and state. See rpg.html. |
The order is the pedagogy. Do not rearrange it casually.
<header> chrome: title, ไธญๆ, sound, voice picker, mic test
<div class="wrap">
.rail jump links to the three top-level sections
#micBanner the single notification surface for the whole page
.panel intro โ what this is and how to work through it
.panel #sec-words 1 ยท VOCABULARY (+ summary + Listen)
.panel #drillPanel the speaking drill, revealed on demand
.panel #sec-video 2 ยท OVERVIEW VIDEO (+ summary + Listen)
.panel #sec-lab 3 ยท THE LAB โ tab bar + N .lesson blocks
.panel Points to Remember
</div>
<button id="toTop">
<script> everything, in one block, at the end
The script is a single block at the end of <body>, so there is no
DOMContentLoaded wrapper โ the DOM already exists. Boot is bare top-level calls:
renderVocab();
renderLabTabs();
showLesson("neuron"); // whichever tab is first
populateVoicePicker();
preflight(); // announces missing mic / insecure context
<header>
<div class="hbar">
<div class="logo">๐ง NEURO LAB</div>
<span class="spacer"></span>
<button class="tbtn" id="cnBtn" onclick="toggleCN()">ไธญๆ</button>
<button class="tbtn on" id="sndBtn" onclick="toggleSound()">๐ Sound</button>
<select class="tbtn" id="voiceSel" title="English voice"
onchange="onVoicePicked(this.value)"></select>
<button class="tbtn" onclick="runMicCheck(true)">๐ค Mic test</button>
</div>
</header>
Sticky, z-index:60, blurred backdrop. The four controls are always reachable โ
that is the whole reason it is sticky, because a student two thirds down the page still needs
to turn on ไธญๆ.
English is the page. Chinese is a support layer that appears in place, inline, next to the thing it translates.
<p>Voltage is a difference between two points.
<span class="cn">็ตๅๆฏไธค็นไน้ด็ๅทฎๅผใ</span></p>
.cn{color:var(--muted);font-size:.85em}
body:not(.show-cn) .cn{display:none}
let cnOn=false;
function toggleCN(){
cnOn=!cnOn;
document.body.classList.toggle("show-cn",cnOn);
document.getElementById("cnBtn").classList.toggle("on",cnOn);
}
gene_lab.html) keeps Thai in a 125 KB JS lookup object and rewrites
.cn nodes through a MutationObserver. That buys a third language and translation
of dynamically-rendered text โ at the cost that every render path must be idempotent and every
caption must avoid rewriting itself per frame. A Lab with two languages does not need it.
Write the Chinese inline and let CSS hide it.Consequences worth knowing:
.cn span:
`${en}<span class="cn"><br>${zh}</span>`.body.th-on .cn{line-height:1.85} โ the tone marks stack and collide at default
leading.One soundOn flag gates every audio path, so muting is silent
rather than merely quieter.
let soundOn=true, actx=null;
function toggleSound(){
soundOn=!soundOn;
document.getElementById("sndBtn").classList.toggle("on",soundOn);
document.getElementById("sndBtn").textContent = soundOn ? "๐ Sound" : "๐ Muted";
// muting must also cancel speech in flight AND reset any active Listen
// button, or it sits there claiming to be playing
if(!soundOn){ window.speechSynthesis.cancel(); clearHear(); }
}
A lazily-created AudioContext โ lazy because browsers refuse to create one
outside a user gesture.
function tone(f,ms,type){
if(!soundOn) return;
try{
actx = actx || new (window.AudioContext||window.webkitAudioContext)();
const o=actx.createOscillator(), g=actx.createGain();
o.type=type||"sine"; o.frequency.value=f;
g.gain.setValueAtTime(.0001,actx.currentTime);
g.gain.exponentialRampToValueAtTime(.18,actx.currentTime+.01);
g.gain.exponentialRampToValueAtTime(.0001,actx.currentTime+ms/1000);
o.connect(g); g.connect(actx.destination);
o.start(); o.stop(actx.currentTime+ms/1000);
}catch(e){}
}
const ding=()=>{tone(880,120);setTimeout(()=>tone(1320,140),110);};
const buzz=()=>tone(160,220,"square");
getVoices()[0]. On a stock Windows box that is
Microsoft David or Zira โ 2013-era concatenative voices that mispronounce half the technical
vocabulary. Students hear that as "the site says it wrong." Rank by name instead.function scoreVoice(v){
const n=(v.name||"").toLowerCase();
let s=0;
if(/natural|neural|online/.test(n)) s+=6; // the modern families
if(/google/.test(n)) s+=4;
if(/aria|jenny|guy|zira|libby|sonia/.test(n)) s+=3;
if(/en-us/i.test(v.lang)) s+=2;
if(/en-gb/i.test(v.lang)) s+=1;
if(v.localService) s+=1;
return s;
}
window.speechSynthesis.onvoiceschanged = populateVoicePicker;
getVoices() is empty until the engine finishes loading, hence the
onvoiceschanged hook. The picker is exposed to the student because no ranking
beats letting them hear it and choose.
speak()function speak(text, rate, lang){
if(!soundOn || !window.speechSynthesis) return;
window.speechSynthesis.cancel();
// split on sentence punctuation: some engines truncate one long utterance,
// and short ones let a student stop halfway through
String(text).split(/(?<=[.!?])\s+/).filter(Boolean).forEach(part=>{
const u=new SpeechSynthesisUtterance(part);
u.rate=rate||0.92; u.lang=lang||"en-US";
if(chosenVoice && !lang) u.voice=chosenVoice;
window.speechSynthesis.speak(u);
});
}
| Context | Rate |
|---|---|
| A single vocabulary word | 0.85 |
| An example sentence | 0.86 |
| A paragraph of explanation | 0.92 |
| A section summary | 0.9 |
Slower than default throughout, because the audience is learning English as a second language and the words are technical.
Every major section carries a two-or-three sentence summary in both languages and a button that reads the English aloud.
<h3>โก The spike</h3>
<div class="sechead">
<button class="hear" type="button" onclick="speakSummary(this)"
aria-label="Listen to a summary of this section"><span class="htag">Summary</span><span
class="hlbl">๐ Listen</span></button>
</div>
<div class="sum sum-en"><span class="tag">Summary</span><p>At about minus fifty-five
millivolts the sodium gates snap open and the cell fires. โฆ</p></div>
<div class="sum sum-zh"><span class="tag">ๆ่ฆ</span><p>ๅจ็บฆ โ55 ๆฏซไผๆถโฆ</p></div>
.sum{padding:.7rem .9rem;background:var(--panel2);border:1px solid var(--line);
border-left:3px solid var(--alien);border-radius:0 11px 11px 0;margin:0 0 .7rem}
.sum-zh{display:none;border-left-color:var(--violet)}
body.show-cn .sum-zh{display:block}
let hearBtn=null;
function hearLabel(btn,t){ const l=btn.querySelector(".hlbl"); if(l) l.textContent=t;
else btn.textContent=t; }
function clearHear(){
if(hearBtn){ hearBtn.classList.remove("on"); hearLabel(hearBtn,"๐ Listen"); hearBtn=null; }
}
function speakSummary(btn){
if(!window.speechSynthesis) return;
const wasMe=(hearBtn===btn);
window.speechSynthesis.cancel(); clearHear();
if(wasMe || !soundOn) return; // second press = stop
const host=btn.closest(".lesson, .panel"); // NOT <section> on this page type
const p=host && host.querySelector(".sum-en p");
if(!p) return;
(p.textContent.match(/[^.!?]+[.!?]*/g)||[p.textContent]).forEach(ch=>{
const t=ch.trim(); if(!t) return;
const u=new SpeechSynthesisUtterance(t);
if(chosenVoice) u.voice=chosenVoice;
u.lang="en-US"; u.rate=0.9;
window.speechSynthesis.speak(u);
});
hearBtn=btn; btn.classList.add("on"); hearLabel(btn,"โน Stop");
const poll=()=>{ // onend is unreliable when queued
if(hearBtn!==btn) return;
if(!speechSynthesis.speaking && !speechSynthesis.pending){ clearHear(); return; }
setTimeout(poll,400);
};
setTimeout(poll,400);
}
closest() must match a container
that actually wraps both the button and the .sum-en. Get it wrong and the
function finds no paragraph and returns without a sound or an error. And a section with a
Listen button but no .sum-en p does the same.20โ25 terms. Each has three layers: a one-line definition on the card, a paragraph behind tell me more, and one sentence to say aloud.
const VOCAB=[
{id:"vagus", en:"vagus nerve", zh:"่ฟท่ตฐ็ฅ็ป",
df:"The main cable between the gut and the brain.",
deep:"The vagus wanders from the brainstem down through the heart, lungs and gut โ "+
"'vagus' is Latin for 'wandering'. The traffic on it is lopsided: roughly 80 to 90 "+
"percent of its fibres carry information upward, from body to brain. โฆ",
eg:"The vagus nerve carries more signals up than down."},
โฆ
];
const V_BY_ID=Object.fromEntries(VOCAB.map(v=>[v.id,v]));
function renderVocab(){
document.getElementById("vocabGrid").innerHTML = VOCAB.map(v=>`
<div class="vcard" data-id="${v.id}">
<div class="en">${v.en}</div>
<div class="zh">${v.zh}</div>
<div class="df">${v.df}</div>
<div class="deep hidden" id="deep-${v.id}">
<p>${v.deep}</p>
<p class="egline">๐ฃ๏ธ <b>Say it:</b> โ${v.eg}โ
<button class="say" onclick="speak(${JSON.stringify(v.eg)},0.86)">๐</button></p>
</div>
<div class="row" style="gap:.3rem">
<button class="say" onclick="speak('${v.en}',0.85)">๐ say it</button>
<button class="say" onclick="toggleDeep('${v.id}',this)">๐ tell me more</button>
</div>
</div>`).join("");
}
function toggleDeep(id,btn){
// classList.toggle returns true when the class was ADDED, so `hid` reads
// "is now hidden" โ the ternaries look backwards but are correct
const hid=document.getElementById("deep-"+id).classList.toggle("hidden");
btn.textContent = hid ? "๐ tell me more" : "๐ less";
if(!hid) speak(V_BY_ID[id].deep, 0.92); // the deep layer is also a listening drill
}
The grid is repeat(auto-fill,minmax(178px,1fr)), so it reflows from six columns
to two without a media query.
A hands-free carousel: the page says a word, highlights its card, then gives the student a silent window to repeat it.
let cycling=false;
async function toggleCycle(){
cycling=!cycling;
document.getElementById("cycleBtn").textContent = cycling ? "โน Stop" : "โถ Listen & repeat";
const cue=document.getElementById("cycleCue");
if(!cycling){ cue.textContent=""; cue.className="cue";
document.querySelectorAll(".vcard").forEach(c=>c.classList.remove("hl","turn")); return; }
for(const v of VOCAB){
if(!cycling) break;
const card=document.querySelector(`.vcard[data-id="${v.id}"]`);
document.querySelectorAll(".vcard").forEach(c=>c.classList.remove("hl","turn"));
card.classList.add("hl");
card.scrollIntoView({block:"nearest",behavior:"smooth"});
cue.className="cue"; cue.textContent="๐ listenโฆ " + v.en;
speak(v.en,0.82);
await sleep(1600);
if(!cycling) break;
card.classList.remove("hl"); card.classList.add("turn");
cue.className="cue turn"; cue.textContent="๐ฃ๏ธ your turn โ " + v.en;
ding();
await sleep(2400);
}
if(cycling) toggleCycle(); // ran to the end: reset the button
}
Two colours carry the state: teal .hl while the page speaks, violet
.turn while the student does. The ding() marks the handover so the
student does not have to watch the screen.
Five words at a time, said in any order, then graded.
const DRILL_SINK={heard:"drillHeard", live:"drillLive", btn:"drillMic",
onEnd:()=>{ document.getElementById("drillCheck").classList.remove("hidden"); }};
function startDrill(){
if(!preflight()) return;
drillPool=VOCAB.slice().sort(()=>Math.random()-0.5);
document.getElementById("drillPanel").classList.remove("hidden");
drillNext();
}
function drillMicToggle(){ wantsRecording ? micStop() : micStart(DRILL_SINK); }
function drillGrade(){
micStop();
const heard=heardText();
const res=drillTargets.map(v=>({v, ok: wordHits(v.en,heard).every(h=>h.ok)}));
const n=res.filter(r=>r.ok).length;
// repaint the chips green/red, show a percentage and a meter
n===res.length ? ding() : buzz();
}
A sink is just the set of element ids the shared recogniser should paint into. That is what lets one engine serve several consumers without any of them knowing about the others.
One lazily-created recogniser for the whole page. Every gotcha below cost real debugging โ keep the comments when you copy it.
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
let recog=null, recActive=false, wantsRecording=false, recStopping=false;
function ensureRecog(){
if(!SR) return null;
if(recog) return recog;
recog=new SR();
recog.lang="en-US";
recog.continuous=true; // false auto-stops after ~2s of silence
recog.interimResults=true;
recog.onresult=e=>{ /* accumulate finals, replace interims, repaint */ };
recog.onerror=e=>{ showMicError(e.error||"unknown"); wantsRecording=false; };
recog.onend=()=>{
recActive=false;
// continuous=true STILL ends on long silence in Chrome. Restart while the
// student has not pressed stop. recStopping hard-blocks that restart, or a
// stop landing mid-startup loses the race and the mic never really stops.
if(wantsRecording && !recStopping){ try{ recog.start(); recActive=true; return; }catch(e){} }
recStopping=false;
paintLive(false);
if(sinkTarget && sinkTarget.onEnd) sinkTarget.onEnd();
};
return recog;
}
micStart() must stay synchronous. An await
inside it โ probing getUserMedia first, say โ breaks the user-gesture chain, and
the browser then intermittently refuses to start. It works when permission is already warm and
fails otherwise, which reads to the student as "the mic button does nothing."micStop() uses abort(), not
stop(). abort() tears down a session that is still starting;
stop() waits politely for one that may never finish.const MIC_ERRORS={
"not-allowed":["Microphone blocked",
"Tap the padlock in the address bar and set Microphone to Allow, then try again.",
"็นๅปๅฐๅๆ ็้ๅฝขๅพๆ ๏ผๆ้บฆๅ
้ฃ่ฎพไธบโๅ
่ฎธโ๏ผ็ถๅๅ่ฏไธๆฌกใ"],
"no-speech":["I did not hear anything",
"Speak a little louder, and keep going until you press stop.",
"่ฏดๅคงๅฃฐไธ็น๏ผๅนถไธไธ็ด่ฏดๅฐไฝ ๆไธๅๆญขไธบๆญขใ"],
"aborted":[null,null,null] // deliberate silence: we caused this one
};
Every entry is something the student can do. preflight() covers the two
cases that are not errors at all โ a browser with no recogniser, and a page served over plain
http:// โ and says so before anything is clicked.
// Levenshtein with a FREE prefix and FREE suffix, so the target may sit
// anywhere inside a longer utterance and "um โฆ ok" is not punished.
function bestEditDistance(target,heard){
if(!target.length) return 0;
let prev=new Array(heard.length+1).fill(0), cur=new Array(heard.length+1).fill(0);
for(let i=1;i<=target.length;i++){
cur[0]=i;
for(let j=1;j<=heard.length;j++)
cur[j]=Math.min(prev[j]+1, cur[j-1]+1, prev[j-1]+(target[i-1]===heard[j-1]?0:1));
[prev,cur]=[cur,prev];
}
return Math.min.apply(null,prev); // row 0 all zeros = free prefix
}
const accuracy=(t,h)=>clamp01(1 - bestEditDistance(norm(t),norm(h))/norm(t).length);
// per-word hit test; fuzzy above 3 characters so one slurred letter does not
// fail an otherwise correct word
function wordHits(target,heard){
const hs=norm(heard).split(" ").filter(Boolean);
return norm(target).split(" ").filter(Boolean).map(w=>({w,
ok: hs.some(h => h===w ||
(w.length>3 && Math.abs(h.length-w.length)<=1 && bestEditDistance(w,h)<=1))}));
}
const PERFECT=0.85, PARTIAL=0.65;
norm() lowercases, strips to a-z0-9, and maps digits to words โ
the recogniser returns "9" where the target says "nine".
const LESSONS=[["neuron","๐ The cell"],["voltage","๐ What is voltage?"],
["signal","โก The spike"],["synapse","๐ The gap"],
["regions","๐บ๏ธ The map"],["plastic","๐ฑ Plasticity"],
["gut","๐ฆ The gut brain"]];
function showLesson(id){
LESSONS.forEach(([l])=>document.getElementById("lesson-"+l)
.classList.toggle("hidden", l!==id));
document.querySelectorAll("#labTabs button")
.forEach(b=>b.classList.toggle("on", b.dataset.l===id));
// Animations must be stopped on the way OUT, not just started on the way in โ
// a hidden rAF loop otherwise keeps running behind the next lesson.
if(id!=="signal") apStop();
if(id!=="synapse") synStop();
if(id!=="gut") gutStop();
if(id!=="neuron") nrStop();
if(id==="signal") { apBuild(); apDraw(); if(!reduceMotion && apP===0) apPlay(); }
if(id==="neuron") { neuPick(neuCur||"dendrite"); nrBuild(); nrDraw(); }
โฆ
}
The contract: tab id X โ <div class="lesson hidden"
id="lesson-X">. Visibility is purely .hidden{display:none!important}.
Every lesson with a running animation needs a matching stop call.
All hand-authored inline SVG. No chart library, no canvas. Colours are
var(--โฆ) inside SVG attributes โ CSS custom properties cascade into inline
SVG, so the figures re-theme for free.
For anything that is a process with a timeline: an action potential, transmitter crossing a synapse, a signal going up the vagus nerve.
let apP=0, apRAF=null;
function apDraw(){
/* build the geometry purely from apP โ no other state */
const st=AP_STAGES.find(s=>apP<s[0])||AP_STAGES[AP_STAGES.length-1];
const want=`${st[1]}<span class="cn"><br>${st[2]}</span>`;
if(cap && cap.innerHTML!==want) cap.innerHTML=want; // only on CHANGE
const sc=document.getElementById("apScrub");
if(sc && Math.abs(sc.value/1000-apP)>0.002) sc.value=Math.round(apP*1000); // dead-band
}
function apSet(p){ apStop(); apP=clamp01(+p); apBuild(); apDraw(); }
function apPlay(){
if(apRAF) return;
if(apP>=1) apP=0;
const step=()=>{
const box=document.getElementById("apViz");
if(!box || !box.offsetParent){ apStop(); return; } // self-halt when hidden
apP+=0.0055;
if(apP>=1){ apP=1; apDraw(); apStop(); return; }
apDraw(); apRAF=requestAnimationFrame(step);
};
apRAF=requestAnimationFrame(step);
}
Rules that make it work:
xxxSet() stops the loop first, so a drag always wins over playback.[threshold, EN, ZH] triples picked by
find(s=>p<s[0]), written only when the text actually changes.innerHTML in a loop.For anything whose point is that it makes a decision. A timeline is the wrong model for a neuron; a decision needs inputs the student chooses.
function nrStep(){
nrPulses.forEach(p=>p.t+=0.026);
const arrived=nrPulses.filter(p=>p.t>=1);
nrPulses=nrPulses.filter(p=>p.t<1);
if(!nrSpike && nrRefr<=0)
arrived.forEach(p=>{ nrV += p.sign>0?NR_EPSP:NR_IPSP; });
if(nrRefr>0){ nrRefr--; nrV += (NR_REST-nrV)*0.06; }
else if(!nrSpike) nrV += (NR_REST-nrV)*0.030; // THE LEAK
โฆ
}
function nrIdle(){ // park the loop once everything settles
return !nrPulses.length && !nrSpike && nrRefr<=0 && !nrRain &&
Math.abs(nrV-NR_REST)<0.35;
}
Park the loop when idle rather than burning a frame callback forever, and give the figure a test hook so its physics can be driven deterministically:
window.__neurolab={ nrTick:n=>{ for(let i=0;i<n;i++) nrStep(); },
nrState:()=>({v:nrV, spiking:!!nrSpike, fires:nrFires}) };
For anatomy and parts lists: the neuron's components, the brain map's eighteen regions.
<g class="npart" id="rp-broca" onclick="regPick('broca')">
<ellipse cx="152" cy="152" rx="20" ry="12" โฆ />
</g>
.npart{cursor:pointer;opacity:.75;transition:opacity .15s}
.npart:hover{opacity:1}
.npart.on{opacity:1;filter:drop-shadow(0 0 6px var(--amber))}
const REG={ broca:["Broca's area ๅธ่ฅๅกๅบ","Producing speech. โฆ","่ฏญ่จไบงๅบใโฆ",
"Broca's area is for producing speech."], โฆ };
function regPick(id){
regCur=id;
document.querySelectorAll("#lesson-regions .npart")
.forEach(g=>g.classList.toggle("on", g.id==="rp-"+id));
const d=REG[id];
document.getElementById("regInfo").innerHTML =
`<h4>${d[0]}</h4><div class="need">${d[1]}<span class="cn"><br>${d[2]}</span></div>
<button class="say" onclick="speak(${JSON.stringify(d[3])},0.88)">๐ say it</button>`;
}
If JS sets an on class, CSS must render it. A control that toggles invisibly
looks broken even when it works:
/* a ring, not a fill, so each button keeps its own colour coding */
.btn.on{border-color:var(--alien);box-shadow:0 0 0 2px var(--alien)}
s3://krueng.ai/. HTML deploys through the GitHub repo and only through it. Never
aws s3 cp an HTML file.| Kind | S3 path | Referenced from HTML as |
|---|---|---|
| Images | s3://krueng.ai/imgs/ | ../imgs/โฆ |
| Video | s3://krueng.ai/video/ | ../video/โฆ |
| Audio | s3://krueng.ai/audio/ | ../audio/โฆ |
All generated visuals go through python/video_gen/spark_media.py โ SDXL and
Wan 2.2 running on the DGX Spark over LAN HTTP. No API keys, no cost, nothing leaving the
house.
import spark_media
spark_media.t2i(prompt, out) # SDXL still, ~40โ60s
spark_media.i2v(still, prompt, out) # image โ video, ~15 min
spark_media.clip(prompt, out) # t2i then i2v
When you do generate, name the failure in the negative prompt rather than the positive one. A positive "no cilia" tends to summon cilia; the negative gives CFG something concrete to push away from.
imgs/.neuro_lab.html
states that the narration is synthetic and every illustration was drawn or generated rather
than photographed. That is not a disclaimer, it is part of the teaching.Three to five minutes, narrated, bilingual subtitles, built by a generic script from a per-page config. Do not fork the builder.
python build_page_explainer.py neuro_lab
Content lives in python/video_gen/page_explainers/<name>.py:
VOICE = "en-US-AriaNeural"
RATE = "-6%"
LINES = [
("Many axons are wrapped in myelin, a fatty sheath with gaps along it.",
"่ฎธๅค่ฝด็ชๅ
่ฃน็้ซ้โโไธๅฑๅธฆๆ้ด้็่่ดจ้ใ",
("figure", "myelin", "Myelin and the nodes of Ranvier")),
โฆ
]
FIGURES = {"myelin": draw_myelin} # a PIL function, for anything that must be right
| Visual kind | Use for |
|---|---|
("card", eyebrow, headline) | A typographic slide. Numbers, contrasts, statements. |
("photo", filename, caption) | A real photograph already credited on the page. |
("spark", prompt, caption[, avoid]) | An SDXL still. Optional 4th element appends to the negative prompt. |
("figure", key, caption) | A drawn diagram โ dispatches to FIGURES[key] in the same config. |
The builder narrates every line first, measures the real audio with ffprobe, and
sizes each Ken Burns clip to match โ which is why the subtitles land exactly. Timings are
derived from rendered audio, never estimated. TTS output is cached, so editing a picture and
rebuilding leaves every cue's timing untouched and the VTTs already on S3 stay valid.
<video class="hero" controls preload="none" playsinline
poster="../video/neuro_lab_explainer_poster.jpg">
<source src="../video/neuro_lab_explainer.mp4" type="video/mp4">
<track kind="subtitles" src="../video/neuro_lab_explainer.en.vtt" srclang="en"
label="English" default>
<track kind="subtitles" src="../video/neuro_lab_explainer.zh.vtt" srclang="zh" label="ไธญๆ">
Your browser cannot play this video.
</video>
preload="none" plus a poster, so a student who never scrolls to it pays nothing.
playsinline for iOS.
-profile:v main -level 4.0 -movflags
+faststart -pix_fmt yuv420p. Default ffmpeg output decodes on a PC and is silently
refused by Android and by the LINE in-app player.After re-uploading a video, invalidate CloudFront or the old one keeps serving:
aws cloudfront create-invalidation --distribution-id E2F1VRCSRT1FEV \
--paths "/video/<name>.mp4" --profile claude-deploy
neuro_lab.html. Keep the head CSS, the chrome, and the whole
script from speak() down to the scoring block. Replace VOCAB,
LESSONS, and the lesson divs.<details> for the deeper material.showLesson()'s stop list.s3://krueng.ai/video/.| Symptom | Cause |
|---|---|
| Animation keeps running behind another lesson | Missing stop call in showLesson(). Belt and braces: also self-halt on
!box.offsetParent. |
| Slider judders while dragging | No dead-band on the two-way sync. Only write sc.value when it differs by
more than ~0.002. |
| Page stutters during an animation | A caption is being rewritten every frame. Cache the phase key and write only on change. |
| Listen button does nothing, no error | closest() does not match a container holding the
.sum-en p. |
| Mic button does nothing, intermittently | An await crept into micStart() and broke the gesture
chain. |
| Mic will not stop, or bleeds into the next command | stop() instead of abort(), or a missing
recStopping guard. |
| A control toggles but nothing changes on screen | JS sets .on and no CSS renders it. |
| A control works but users say it does not | Something prominent nearby has the value hardcoded. Every number that depends on a control must be derived from it. |
| Video plays on desktop, silently fails on Android | Wrong H.264 profile. -profile:v main -level 4.0 -movflags +faststart. |
| Updated video still shows the old one | CloudFront cache. Invalidate the path. |
| Thai text collides with itself | Needs line-height:1.85; tone marks stack above and below the
baseline. |
Reference implementation: neuro_lab.html ยท Earlier relative: gene_lab.html (two-player, three languages) ยท Related page type: prehistory.html (article with per-section summaries).