TutSynth
GLM 5.3 Flash · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 33,233 bytes · SHA-256 1b954cc6fd3a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Synth Lab — Learn Synthesis by Playing</title>
<style>
:root{
--bg:#0d0f14; --panel:#161a22; --panel2:#1d2230; --edge:#2a3142;
--ink:#e8ecf4; --dim:#8b93a7; --accent:#5eead4; --accent2:#f0abfc;
--amber:#fbbf24; --red:#fb7185;
font-size:16px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
background:radial-gradient(1200px 700px at 70% -10%, #1a2030 0%, var(--bg) 60%);
color:var(--ink); font-family:'Segoe UI',system-ui,sans-serif;
min-height:100vh; display:flex; flex-direction:column; align-items:center;
padding:18px 14px 40px;
}
h1{font-size:1.5rem; letter-spacing:.5px; margin-bottom:2px}
h1 .lab{color:var(--accent)}
.sub{color:var(--dim); font-size:.85rem; margin-bottom:14px}
#app{width:100%; max-width:1060px; display:flex; flex-direction:column; gap:14px}
/* ---------- lesson rail ---------- */
#rail{display:flex; gap:8px; flex-wrap:wrap; justify-content:center}
.step{
display:flex; align-items:center; gap:7px; padding:7px 12px; border-radius:10px;
background:var(--panel); border:1px solid var(--edge); cursor:pointer;
font-size:.8rem; color:var(--dim); transition:.2s; user-select:none;
}
.step .num{
width:20px;height:20px;border-radius:50%;background:#2a3142;display:flex;
align-items:center;justify-content:center;font-size:.7rem;color:var(--dim);flex-shrink:0;
}
.step.active{border-color:var(--accent); color:var(--ink); background:#182430}
.step.active .num{background:var(--accent); color:#06251f; font-weight:700}
.step.done .num{background:#1e3a34; color:var(--accent)}
.step.locked{opacity:.45; cursor:not-allowed}
/* ---------- lesson card ---------- */
#lesson{
background:linear-gradient(180deg,var(--panel2),var(--panel));
border:1px solid var(--edge); border-radius:14px; padding:18px 20px;
}
#lesson h2{font-size:1.15rem; color:var(--accent); margin-bottom:8px}
#lesson p{font-size:.9rem; line-height:1.55; color:#c6cdda; margin-bottom:8px}
#lesson .tip{
font-size:.82rem; color:var(--amber); background:#2a2415; border:1px solid #4a3d1a;
padding:8px 12px; border-radius:8px; margin:10px 0;
}
#lesson .tip b{color:#fde68a}
.lessonNav{display:flex; justify-content:space-between; margin-top:14px}
button{font-family:inherit}
.btn{
background:var(--accent); color:#06251f; border:none; padding:9px 18px;
border-radius:9px; font-weight:700; font-size:.85rem; cursor:pointer; transition:.15s;
}
.btn:hover{filter:brightness(1.12)}
.btn.ghost{background:transparent; color:var(--dim); border:1px solid var(--edge)}
.btn.ghost:hover{color:var(--ink)}
.btn:disabled{opacity:.4; cursor:not-allowed}
/* ---------- synth panel ---------- */
#synth{
background:var(--panel); border:1px solid var(--edge); border-radius:14px;
padding:16px; display:flex; flex-direction:column; gap:14px;
}
#modules{display:flex; gap:12px; flex-wrap:wrap}
.module{
background:var(--panel2); border:1px solid var(--edge); border-radius:12px;
padding:12px; flex:1 1 200px; min-width:190px;
}
.module h3{
font-size:.72rem; text-transform:uppercase; letter-spacing:1.5px;
color:var(--accent2); margin-bottom:10px; display:flex; align-items:center; gap:6px;
}
.module h3 .dot{width:7px;height:7px;border-radius:50%;background:var(--accent2)}
.module.locked{opacity:.35; pointer-events:none; filter:grayscale(.7)}
.ctl{display:flex; flex-direction:column; gap:3px; margin-bottom:9px}
.ctl label{font-size:.72rem; color:var(--dim); display:flex; justify-content:space-between}
.ctl label .val{color:var(--accent); font-variant-numeric:tabular-nums}
input[type=range]{-webkit-appearance:none; width:100%; height:4px; border-radius:2px;
background:#2a3142; outline:none; cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none; width:14px;height:14px;
border-radius:50%; background:var(--accent); border:2px solid #0d0f14; cursor:pointer}
input[type=range]::-moz-range-thumb{width:12px;height:12px;border-radius:50%;
background:var(--accent); border:2px solid #0d0f14; cursor:pointer}
.seg{display:flex; gap:4px; flex-wrap:wrap}
.seg button{
flex:1; padding:6px 4px; font-size:.68rem; border-radius:6px; cursor:pointer;
background:#232a3a; color:var(--dim); border:1px solid var(--edge); transition:.15s;
white-space:nowrap;
}
.seg button.on{background:var(--accent); color:#06251f; font-weight:700; border-color:var(--accent)}
.toggleRow{display:flex; gap:8px; margin-top:4px}
.tog{
flex:1; padding:7px; font-size:.7rem; border-radius:7px; cursor:pointer;
background:#232a3a; color:var(--dim); border:1px solid var(--edge);
}
.tog.on{background:#3b2a4a; color:var(--accent2); border-color:var(--accent2)}
/* ---------- keyboard ---------- */
#kbWrap{position:relative; user-select:none; -webkit-user-select:none}
#kb{display:flex; position:relative; height:150px; touch-action:none}
.wk{
flex:1; background:linear-gradient(180deg,#fdfdfd,#d8dce4); border:1px solid #99a;
border-radius:0 0 6px 6px; position:relative; cursor:pointer;
}
.wk.down{background:linear-gradient(180deg,var(--accent),#2dd4bf)}
.bk{
position:absolute; top:0; width:5.4%; height:60%; z-index:2;
background:linear-gradient(180deg,#2a2f3c,#12151c); border:1px solid #000;
border-radius:0 0 4px 4px; cursor:pointer;
}
.bk.down{background:linear-gradient(180deg,#2dd4bf,#0d9488)}
.klabel{position:absolute; bottom:5px; left:0; right:0; text-align:center;
font-size:.62rem; color:#556; pointer-events:none}
.bk .klabel{color:#8b93a7}
#scopeWrap{background:#0a0c10; border:1px solid var(--edge); border-radius:10px; padding:6px}
#scope{width:100%; height:70px; display:block}
#scopeWrap .cap{font-size:.62rem; color:var(--dim); text-align:center; margin-top:2px}
/* ---------- sequencer ---------- */
#seq{display:flex; flex-direction:column; gap:8px}
#seqGrid{display:grid; grid-template-columns:repeat(16,1fr); gap:4px}
.cell{
aspect-ratio:1; border-radius:5px; background:#232a3a; border:1px solid var(--edge);
cursor:pointer; transition:background .1s;
}
.cell.on{background:var(--accent); border-color:var(--accent)}
.cell.play{box-shadow:0 0 0 2px var(--amber)}
#seqBtns{display:flex; gap:8px; align-items:center; flex-wrap:wrap}
#bpmVal{color:var(--accent); font-variant-numeric:tabular-nums; font-size:.8rem}
.footNote{color:var(--dim); font-size:.72rem; text-align:center; margin-top:4px}
.presetRow{display:flex; gap:6px; flex-wrap:wrap; margin-top:2px}
.presetRow .btn{padding:6px 12px; font-size:.75rem}
</style>
</head>
<body>
<h1>🎛️ Synth <span class="lab">Lab</span></h1>
<div class="sub">Learn synthesis by building one — each level unlocks a new module. Play the keyboard any time (A W S E D F T G Y H U J K, or click/tap).</div>
<div id="app">
<div id="rail"></div>
<div id="lesson"></div>
<div id="synth">
<div id="modules"></div>
<div id="seq" style="display:none">
<div id="seqGrid"></div>
<div id="seqBtns">
<button class="btn" id="seqPlay">▶ Play</button>
<button class="btn ghost" id="seqClear">Clear</button>
<button class="btn ghost" id="seqRand">Randomize</button>
<span style="font-size:.75rem;color:var(--dim)">BPM <input type="range" id="bpm" min="60" max="200" value="120" style="width:110px"> <span id="bpmVal">120</span></span>
</div>
</div>
<div id="kbWrap">
<div id="kb"></div>
</div>
<div id="scopeWrap">
<canvas id="scope" width="1000" height="140"></canvas>
<div class="cap">oscilloscope — watch the waveform change as you tweak the synth</div>
</div>
<div class="footNote">Tip: hold several keys at once for chords. Every sound you hear is generated live by the Web Audio API in your browser.</div>
</div>
</div>
<script>
"use strict";
/* ============================================================
SYNTH LAB — progressive Web Audio synthesizer tutorial
============================================================ */
/* ---------- Audio engine ---------- */
let AC=null, master=null, analyser=null, comp=null;
let delayNode=null, delayFb=null, delayMix=null, delaySend=null;
let filterNode=null, lfo=null, lfoGain=null, lfoTarget='pitch';
let revNode=null, revGain=null, revSend=null;
function audioInit(){
if(AC) return;
AC = new (window.AudioContext||window.webkitAudioContext)();
master = AC.createGain(); master.gain.value = 0.5;
comp = AC.createDynamicsCompressor();
comp.threshold.value=-18; comp.ratio.value=6;
analyser = AC.createAnalyser(); analyser.fftSize=2048;
master.connect(comp); comp.connect(analyser); analyser.connect(AC.destination);
// filter (always present, in voice path)
filterNode = AC.createBiquadFilter(); filterNode.type='lowpass';
filterNode.frequency.value = state.cutoff; filterNode.Q.value = state.reso;
filterNode.connect(master);
// delay
delayNode = AC.createDelay(1.5); delayNode.delayTime.value = 0.28;
delayFb = AC.createGain(); delayFb.gain.value = 0.35;
delayMix = AC.createGain(); delayMix.gain.value = 0;
delaySend = AC.createGain(); delaySend.gain.value = 1;
delaySend.connect(delayNode); delayNode.connect(delayFb); delayFb.connect(delayNode);
delayNode.connect(delayMix); delayMix.connect(master);
// reverb (generated impulse response)
revNode = AC.createConvolver();
const len = AC.sampleRate*2.2, buf = AC.createBuffer(2,len,AC.sampleRate);
for(let c=0;c<2;c++){const d=buf.getChannelData(c);
for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*Math.pow(1-i/len,2.6);}
revNode.buffer = buf;
revGain = AC.createGain(); revGain.gain.value = 0;
revSend = AC.createGain(); revSend.gain.value = 1;
revSend.connect(revNode); revNode.connect(revGain); revGain.connect(master);
// LFO
lfo = AC.createOscillator(); lfo.type='sine'; lfo.frequency.value = state.lfoRate;
lfoGain = AC.createGain(); lfoGain.gain.value = 0;
lfo.connect(lfoGain); lfo.start();
ensureBuses();
applyLfoTarget();
buildKeyboard();
drawScope();
}
function applyLfoTarget(){
if(!AC) return;
try{ lfoGain.disconnect(); }catch(e){}
if(lfoTarget==='pitch'){ lfoGain.connect(oscPitchBus); }
else if(lfoTarget==='cutoff'){ lfoGain.connect(filterNode.frequency); }
else if(lfoTarget==='amp'){ lfoGain.connect(vcaGain.gain); }
}
// buses voices connect into
let oscPitchBus=null, vcaGain=null;
function ensureBuses(){
if(oscPitchBus) return;
oscPitchBus = AC.createGain(); oscPitchBus.gain.value=0; // sum of voice detune CVs
vcaGain = AC.createGain(); vcaGain.gain.value=1;
vcaGain.connect(filterNode);
filterNode.connect(delaySend); filterNode.connect(revSend);
}
/* ---------- State ---------- */
const state = {
level:0, maxUnlocked:0,
wave:'sawtooth', detune:8, voices:2, octave:0,
a:0.01, d:0.15, s:0.6, r:0.3,
cutoff:3500, reso:6, fEnv:2500, keytrack:true,
lfoRate:5, lfoAmt:0, lfoTarget:'pitch',
delayMix:0.25, delayFb:0.35, delayTime:0.28,
revMix:0.25,
glide:0.06,
seq:[1,0,0,0, 1,0,0,0, 1,0,0,0, 1,0,1,0],
bpm:120, seqPlaying:false
};
/* ---------- Lessons ---------- */
const LESSONS = [
{
title:"Level 1 — Oscillators: the raw sound",
html:`<p>Every synth starts with an <b>oscillator</b> — a circuit that repeats a simple shape hundreds of times per second. The shape (<b>waveform</b>) is the timbre's DNA:</p>
<p>• <b>Sine</b> — pure, flute-like, one frequency only.<br>
• <b>Square</b> — hollow, woody, like old game consoles.<br>
• <b>Sawtooth</b> — bright and brassy, the classic lead sound.<br>
• <b>Triangle</b> — soft and rounded, between sine and square.</p>
<p><b>Detune</b> stacks a second oscillator slightly off-pitch, creating that thick, chorused "supersaw" width. Try dragging it while holding a key.</p>
<div class="tip">🎛️ <b>Try this:</b> play a key, then cycle the waveform buttons and drag <b>Detune</b> from 0 to 25. Hear how the same note becomes four totally different instruments?</div>`,
modules:['osc'],
challenge(){ return {text:"Cycle through all 4 waveforms (Sine → Square → Saw → Tri) to unlock Level 2.", check:()=>wavesTried.size>=4}; }
},
{
title:"Level 2 — The Envelope: shaping volume over time",
html:`<p>An oscillator left alone is a boring, constant drone. The <b>ADSR envelope</b> is what makes notes feel alive — it shapes the volume (or anything else) over time:</p>
<p>• <b>Attack</b> — how fast the sound rises after you press a key. 1ms = punchy pluck; 800ms = slow swelling pad.<br>
• <b>Decay</b> — how quickly it falls from the peak to the sustain level.<br>
• <b>Sustain</b> — the level it holds while you keep holding the key.<br>
• <b>Release</b> — how long the tail rings after you let go.</p>
<div class="tip">🎹 <b>Try this:</b> set Attack to max for a slow pad, then to minimum for a pluck. Set Release high and tap keys — you get echoing piano-like tails.</div>`,
modules:['osc','env'],
challenge(){ return {text:"Make a slow pad (Attack ≥ 0.4s) and then a pluck (Attack ≤ 0.02s, Sustain ≤ 0.2) to unlock Level 3.", check:()=>padDone&&pluckDone}; }
},
{
title:"Level 3 — The Filter: sculpting the tone",
html:`<p>A <b>filter</b> removes frequencies above a cutoff point. This is the heart of the classic synth "wow" sound — think of the wobble in acid house.</p>
<p>• <b>Cutoff</b> — where the knife falls. Low cutoff = dark and muffled; high = bright and full.<br>
• <b>Resonance</b> — boosts the frequencies right at the cutoff, making it whistle and squelch.<br>
• <b>Filter Env</b> — the envelope from Level 2, but applied to the cutoff! Each note's filter opens then closes, giving that "zap" attack.<br>
• <b>Keytrack</b> — makes the cutoff follow the note pitch, keeping the brightness consistent.</p>
<div class="tip">🎚️ <b>Try this:</b> turn Cutoff way down (~200Hz), crank Resonance, then play notes and slowly raise Cutoff — that sweep is the most iconic synth move there is.</div>`,
modules:['osc','env','filter'],
challenge(){ return {text:"Sweep the cutoff while holding a note (try both extremes) to unlock Level 4.", check:()=>cutoffSwept}; }
},
{
title:"Level 4 — The LFO: motion & modulation",
html:`<p>An <b>LFO</b> (Low Frequency Oscillator) is a slow oscillator (usually under 20Hz) that you don't hear directly — instead it wiggles another control automatically. This is called <b>modulation</b>, and it's what separates static sounds from living ones.</p>
<p>• <b>Rate</b> — wiggle speed. Below ~8Hz sounds like wobble; near 20Hz it becomes a rough texture.<br>
• <b>Amount</b> — how deep the wiggle goes.<br>
• <b>Target</b> — what gets wiggled: <b>pitch</b> (vibrato, like a singer), <b>cutoff</b> (the classic dubstep/dub wobble), or <b>amp</b> (tremolo, like a guitar amp).</p>
<div class="tip">🌀 <b>Try this:</b> target <b>cutoff</b>, rate ~4Hz, amount high, with a saw wave and lowish cutoff — instant dub wobble. Then switch target to <b>pitch</b> for vibrato.</div>`,
modules:['osc','env','filter','lfo'],
challenge(){ return {text:"Hear the LFO on all three targets (pitch, cutoff, amp) to unlock Level 5.", check:()=>lfoTargetsTried.size>=3}; }
},
{
title:"Level 5 — Effects: space and depth",
html:`<p>Dry synth sounds sit flat in the mix. <b>Effects</b> place them in a space:</p>
<p>• <b>Delay</b> — echoes. <b>Time</b> sets echo spacing (short = slapback, long = dub), <b>Feedback</b> sets how many repeats. Set feedback high with a long time for trippy trails.<br>
• <b>Reverb</b> — the sound of a room. A little adds realism; a lot makes huge ambient washes. Convolution reverb here uses a real impulse-response simulation.</p>
<div class="tip">🌌 <b>Try this:</b> slow attack pad + high reverb + a little delay = instant ambient soundscape. Play a few single notes and let them bloom.</div>`,
modules:['osc','env','filter','lfo','fx'],
challenge(){ return {text:"Turn Delay and Reverb both above 15% to unlock the final level.", check:()=>state.delayMix>0.15&&state.revMix>0.15}; }
},
{
title:"Level 6 — The Sequencer: making music",
html:`<p>A <b>step sequencer</b> plays notes automatically — 16 steps, one per sixteenth-note. This is how drum machines and basslines work: program a pattern, tweak the synth while it plays.</p>
<p>Click cells to toggle notes. The pattern plays a minor scale, so almost anything you draw sounds musical. <b>Randomize</b> is a great starting point.</p>
<p>Now the real fun: <b>twist every knob while it plays</b>. Sweep the filter, change waveforms, add LFO wobble — you're doing live sound design, exactly like a performing synth player.</p>
<div class="tip">🏆 <b>Graduation:</b> run the sequencer and perform a full filter sweep with the LFO on cutoff. You now understand a synthesizer.</div>`,
modules:['osc','env','filter','lfo','fx','seq'],
challenge(){ return {text:"Play the sequencer for 8+ steps while tweaking the filter. 🎓 Synth Lab complete!", check:()=>seqGraduated}; }
}
];
/* ---------- Progress tracking ---------- */
const wavesTried=new Set(); let padDone=false, pluckDone=false, cutoffSwept=false;
const lfoTargetsTried=new Set(); let seqGraduated=false; let seqStepsHeard=0;
let prevCutoff=3500, prevLfoTarget='pitch';
/* ---------- UI: lesson rail ---------- */
const railEl=document.getElementById('rail');
function renderRail(){
railEl.innerHTML='';
LESSONS.forEach((L,i)=>{
const d=document.createElement('div');
d.className='step'+(i===state.level?' active':'')+(i<state.maxUnlocked?' done':'')+(i>state.maxUnlocked?' locked':'');
d.innerHTML=`<span class="num">${i<state.maxUnlocked?'✓':i+1}</span><span>${L.title.split('—')[1]||L.title}</span>`;
d.onclick=()=>{ if(i<=state.maxUnlocked){ state.level=i; renderAll(); } };
railEl.appendChild(d);
});
}
/* ---------- UI: lesson card ---------- */
const lessonEl=document.getElementById('lesson');
function renderLesson(){
const L=LESSONS[state.level];
const ch=L.challenge();
lessonEl.innerHTML=`<h2>${L.title}</h2>${L.html}
<div class="tip" id="challengeBox">🎯 <b>Challenge:</b> ${ch.text} <span id="chDone" style="color:#5eead4;font-weight:700"></span></div>
<div class="lessonNav">
<button class="btn ghost" id="prevBtn" ${state.level===0?'disabled':''}>← Previous</button>
<button class="btn" id="nextBtn" ${state.level>=state.maxUnlocked?'disabled':''}>Next →</button>
</div>`;
document.getElementById('prevBtn').onclick=()=>{ if(state.level>0){state.level--; renderAll();} };
document.getElementById('nextBtn').onclick=()=>{ if(state.level<state.maxUnlocked){state.level++; renderAll();} };
updateChallenge();
}
function updateChallenge(){
const box=document.getElementById('chDone'); if(!box) return;
const L=LESSONS[state.level]; const ch=L.challenge();
if(ch.check()){ box.textContent=' ✓ Complete!';
if(state.level===state.maxUnlocked && state.maxUnlocked<LESSONS.length-1){
state.maxUnlocked++; setTimeout(()=>{ state.level++; renderAll(); }, 900);
}
} else box.textContent='';
}
/* ---------- UI: modules ---------- */
const modulesEl=document.getElementById('modules');
function ctl(label, id, min, max, step, val, fmt){
return `<div class="ctl"><label>${label}<span class="val" id="${id}v">${fmt(val)}</span></label>
<input type="range" id="${id}" min="${min}" max="${max}" step="${step}" value="${val}"></div>`;
}
const fmtHz=v=>v>=1000?(v/1000).toFixed(1)+'k':Math.round(v)+'Hz';
const fmtS=v=>v<1?Math.round(v*1000)+'ms':v.toFixed(1)+'s';
function renderModules(){
const un=LESSONS[state.level].modules;
const has=m=>un.includes(m);
let h='';
if(has('osc')){
h+=`<div class="module"><h3><span class="dot"></span>OSCILLATOR</h3>
<div class="ctl"><label>Waveform</label><div class="seg" id="waveSeg">
${['sine','square','sawtooth','triangle'].map(w=>`<button data-w="${w}" class="${state.wave===w?'on':''}">${{sine:'Sine',square:'Square',sawtooth:'Saw',triangle:'Tri'}[w]}</button>`).join('')}
</div></div>
${ctl('Detune (2nd osc)','detune',0,30,1,state.detune,v=>'±'+v+' cents')}
${ctl('Unison voices','voices',1,4,1,state.voices,v=>v+' osc')}
${ctl('Octave','octave',-2,2,1,state.octave,v=>(v>0?'+':'')+v)}</div>`;
}
if(has('env')){
h+=`<div class="module"><h3><span class="dot"></span>ENVELOPE (ADSR)</h3>
${ctl('Attack','a',0.001,1.5,0.001,state.a,fmtS)}
${ctl('Decay','d',0.01,1.5,0.01,state.d,fmtS)}
${ctl('Sustain','s',0,1,0.01,state.s,v=>Math.round(v*100)+'%')}
${ctl('Release','r',0.01,3,0.01,state.r,fmtS)}</div>`;
}
if(has('filter')){
h+=`<div class="module"><h3><span class="dot"></span>FILTER</h3>
${ctl('Cutoff','cutoff',80,9000,10,state.cutoff,fmtHz)}
${ctl('Resonance','reso',0.5,20,0.1,state.reso,v=>'Q '+v.toFixed(1))}
${ctl('Filter Env amt','fEnv',0,6000,50,state.fEnv,fmtHz)}
<div class="toggleRow"><button class="tog ${state.keytrack?'on':''}" id="ktBtn">Keytrack</button></div></div>`;
}
if(has('lfo')){
h+=`<div class="module"><h3><span class="dot"></span>LFO</h3>
${ctl('Rate','lfoRate',0.1,20,0.1,state.lfoRate,v=>v.toFixed(1)+'Hz')}
${ctl('Amount','lfoAmt',0,1,0.01,state.lfoAmt,v=>Math.round(v*100)+'%')}
<div class="ctl"><label>Target</label><div class="seg" id="lfoSeg">
${['pitch','cutoff','amp'].map(t=>`<button data-t="${t}" class="${state.lfoTarget===t?'on':''}">${{pitch:'Pitch',cutoff:'Cutoff',amp:'Amp'}[t]}</button>`).join('')}
</div></div></div>`;
}
if(has('fx')){
h+=`<div class="module"><h3><span class="dot"></span>EFFECTS</h3>
${ctl('Delay mix','delayMix',0,1,0.01,state.delayMix,v=>Math.round(v*100)+'%')}
${ctl('Delay time','delayTime',0.05,0.9,0.01,state.delayTime,fmtS)}
${ctl('Feedback','delayFb',0,0.85,0.01,state.delayFb,v=>Math.round(v*100)+'%')}
${ctl('Reverb mix','revMix',0,1,0.01,state.revMix,v=>Math.round(v*100)+'%')}</div>`;
}
if(has('seq')){
h+=`<div class="module" style="flex:1 1 100%"><h3><span class="dot"></span>MASTER</h3>
${ctl('Glide (portamento)','glide',0,0.4,0.01,state.glide,fmtS)}
<div class="presetRow">
<button class="btn ghost" data-preset="pluck">Pluck</button>
<button class="btn ghost" data-preset="pad">Warm Pad</button>
<button class="btn ghost" data-preset="acid">Acid 303</button>
<button class="btn ghost" data-preset="bass">Sub Bass</button>
</div></div>`;
}
modulesEl.innerHTML=h;
wireModules();
document.getElementById('seq').style.display = has('seq')?'flex':'none';
}
function wireModules(){
const $=id=>document.getElementById(id);
const bind=(id,fn,fmt)=>{ const el=$(id); if(!el)return;
el.oninput=()=>{ fn(parseFloat(el.value)); const v=$(id+'v'); if(v&&fmt)v.textContent=fmt(parseFloat(el.value)); updateChallenge(); }; };
bind('detune',v=>state.detune=v,v=>'±'+v+' cents');
bind('voices',v=>state.voices=Math.round(v),v=>v+' osc');
bind('octave',v=>state.octave=Math.round(v),v=>(v>0?'+':'')+v);
bind('a',v=>{state.a=v; checkEnv();},fmtS);
bind('d',v=>state.d=v,fmtS);
bind('s',v=>{state.s=v; checkEnvCh();},v=>Math.round(v*100)+'%');
bind('r',v=>state.r=v,fmtS);
bind('cutoff',v=>{ if(Math.abs(v-prevCutoff)>2500) cutoffSwept=true; prevCutoff=v;
state.cutoff=v; if(AC)filterNode.frequency.setTargetAtTime(v,AC.currentTime,0.02);},fmtHz);
bind('reso',v=>{state.reso=v; if(AC)filterNode.Q.setTargetAtTime(v,AC.currentTime,0.02);},v=>'Q '+v.toFixed(1));
bind('fEnv',v=>state.fEnv=v,fmtHz);
bind('lfoRate',v=>{state.lfoRate=v; if(AC)lfo.frequency.setTargetAtTime(v,AC.currentTime,0.05);},v=>v.toFixed(1)+'Hz');
bind('lfoAmt',v=>{state.lfoAmt=v; if(AC)lfoGain.gain.setTargetAtTime(v*(lfoTarget==='pitch'?60:lfoTarget==='cutoff'?3000:0.6),AC.currentTime,0.05);},v=>Math.round(v*100)+'%');
bind('delayMix',v=>{state.delayMix=v; if(AC)delayMix.gain.setTargetAtTime(v*0.6,AC.currentTime,0.05);},v=>Math.round(v*100)+'%');
bind('delayTime',v=>{state.delayTime=v; if(AC)delayNode.delayTime.setTargetAtTime(v,AC.currentTime,0.05);},fmtS);
bind('delayFb',v=>{state.delayFb=v; if(AC)delayFb.gain.setTargetAtTime(v,AC.currentTime,0.05);},v=>Math.round(v*100)+'%');
bind('revMix',v=>{state.revMix=v; if(AC)revGain.gain.setTargetAtTime(v*0.8,AC.currentTime,0.05);},v=>Math.round(v*100)+'%');
bind('glide',v=>state.glide=v,fmtS);
const wseg=$('waveSeg');
if(wseg) wseg.querySelectorAll('button').forEach(b=>b.onclick=()=>{
state.wave=b.dataset.w; wavesTried.add(b.dataset.w);
wseg.querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b));
updateChallenge();
});
const lseg=$('lfoSeg');
if(lseg) lseg.querySelectorAll('button').forEach(b=>b.onclick=()=>{
state.lfoTarget=b.dataset.t; lfoTarget=b.dataset.t; lfoTargetsTried.add(b.dataset.t);
lseg.querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b));
applyLfoTarget(); updateChallenge();
});
const kt=$('ktBtn');
if(kt) kt.onclick=()=>{ state.keytrack=!state.keytrack; kt.classList.toggle('on',state.keytrack); };
document.querySelectorAll('[data-preset]').forEach(b=>b.onclick=()=>applyPreset(b.dataset.preset));
}
function checkEnvCh(){
if(state.a>=0.4) padDone=true;
if(state.a<=0.02&&state.s<=0.2) pluckDone=true;
updateChallenge();
}
const PRESETS={
pluck:{wave:'sawtooth',detune:10,voices:2,a:0.005,d:0.2,s:0.15,r:0.35,cutoff:2800,reso:4,fEnv:3000,glide:0},
pad:{wave:'triangle',detune:16,voices:3,a:0.7,d:0.4,s:0.8,r:1.8,cutoff:1800,reso:2,fEnv:800,glide:0.1},
acid:{wave:'sawtooth',detune:0,voices:1,a:0.003,d:0.18,s:0.05,r:0.12,cutoff:300,reso:18,fEnv:5500,glide:0.06},
bass:{wave:'square',detune:4,voices:1,a:0.004,d:0.25,s:0.4,r:0.15,cutoff:700,reso:6,fEnv:1200,glide:0}
};
function applyPreset(name){
Object.assign(state,PRESETS[name]);
if(AC){ filterNode.frequency.setTargetAtTime(state.cutoff,AC.currentTime,0.02);
filterNode.Q.setTargetAtTime(state.reso,AC.currentTime,0.02); }
renderAll();
}
/* ---------- Voices ---------- */
const active=new Map(); // note -> voice
function midiToFreq(m){ return 440*Math.pow(2,(m-69)/12); }
function noteOn(midi){
audioInit(); ensureBuses();
if(AC.state==='suspended') AC.resume();
if(active.has(midi)) return;
const now=AC.currentTime;
const freq=midiToFreq(midi+state.octave*12);
const amp=AC.createGain(); amp.gain.value=0;
const vca=AC.createGain(); vca.gain.value=1;
amp.connect(vca); vca.connect(vcaGain);
// glide: start pitch from last note
let startFreq=freq;
if(state.glide>0 && lastMidi!==null) startFreq=midiToFreq(lastMidi);
const n=state.voices;
const oscs=[];
for(let i=0;i<n;i++){
const o=AC.createOscillator(); o.type=state.wave;
const spread=(n>1)?(i-(n-1)/2)*(state.detune):0;
o.detune.value=spread;
o.frequency.setValueAtTime(startFreq,now);
if(startFreq!==freq) o.frequency.exponentialRampToValueAtTime(freq, now+Math.max(state.glide,0.001));
o.connect(amp); o.start(now); oscs.push(o);
}
// amp envelope
const peak=0.28/Math.sqrt(n);
amp.gain.setValueAtTime(0,now);
amp.gain.linearRampToValueAtTime(peak, now+state.a);
amp.gain.setTargetAtTime(peak*state.s, now+state.a, Math.max(state.d/3,0.01));
// filter envelope
const baseCut = state.keytrack ? Math.min(9000, state.cutoff*(freq/261.6)) : state.cutoff;
if(state.fEnv>0){
filterNode.frequency.cancelScheduledValues(now);
filterNode.frequency.setValueAtTime(Math.min(12000,baseCut+state.fEnv),now);
filterNode.frequency.setTargetAtTime(baseCut, now, Math.max(state.d/3,0.01));
}
active.set(midi,{oscs,amp,vca});
lastMidi=midi;
highlightKey(midi,true);
}
let lastMidi=null;
function noteOff(midi){
const v=active.get(midi); if(!v) return;
const now=AC.currentTime;
v.amp.gain.cancelScheduledValues(now);
v.amp.gain.setValueAtTime(v.amp.gain.value,now);
v.amp.gain.setTargetAtTime(0, now, Math.max(state.r/4,0.01));
v.oscs.forEach(o=>o.stop(now+state.r+0.3));
active.delete(midi);
highlightKey(midi,false);
}
/* ---------- Keyboard ---------- */
const KEYMAP={a:0,w:1,s:2,e:3,d:4,f:5,t:6,g:7,y:8,h:9,u:10,j:11,k:12,o:13,l:14,p:15,';':16};
const kbEl=document.getElementById('kb');
let kbKeys=[];
function buildKeyboard(){
kbEl.innerHTML=''; kbKeys=[];
const whiteNotes=[0,2,4,5,7,9,11,12,14,16,17,19,21,23];
const blackNotes={1:0,3:1,6:3,8:4,10:5,13:7,15:8,18:10,20:11,22:12};
const labels=['A','W','S','E','D','F','T','G','Y','H','U','J','K','O','L','P',';'];
const base=60; // C4
// whites
whiteNotes.forEach((off,i)=>{
const el=document.createElement('div'); el.className='wk';
const ki=labels.indexOf(labels.find(l=>KEYMAP[l]===off));
const klabel=Object.keys(KEYMAP).find(k=>KEYMAP[k]===off);
if(klabel) el.innerHTML=`<span class="klabel">${klabel.toUpperCase()}</span>`;
el.dataset.midi=base+off;
kbEl.appendChild(el);
});
// blacks positioned by white index
const wCount=whiteNotes.length;
Object.entries(blackNotes).forEach(([off,wi])=>{
if(wi>=wCount-1) return;
const el=document.createElement('div'); el.className='bk';
el.style.left=`calc(${(wi+1)/wCount*100}% - 2.7%)`;
const klabel=Object.keys(KEYMAP).find(k=>KEYMAP[k]===+off);
if(klabel) el.innerHTML=`<span class="klabel">${klabel.toUpperCase()}</span>`;
el.dataset.midi=base+ +off;
kbEl.appendChild(el);
});
kbEl.querySelectorAll('.wk,.bk').forEach(el=>{
const m=+el.dataset.midi;
el.addEventListener('pointerdown',e=>{e.preventDefault(); noteOn(m);});
el.addEventListener('pointerup',()=>noteOff(m));
el.addEventListener('pointerleave',()=>noteOff(m));
});
}
function highlightKey(midi,on){
const el=kbEl.querySelector(`[data-midi="${midi}"]`);
if(el) el.classList.toggle('down',on);
}
window.addEventListener('keydown',e=>{
if(e.repeat||e.target.tagName==='INPUT') return;
const off=KEYMAP[e.key.toLowerCase()];
if(off!==undefined){ noteOn(60+off); e.preventDefault(); }
});
window.addEventListener('keyup',e=>{
const off=KEYMAP[e.key.toLowerCase()];
if(off!==undefined) noteOff(60+off);
});
/* ---------- Sequencer ---------- */
const seqGrid=document.getElementById('seqGrid');
const SCALE=[0,3,5,7,10,12]; // minor pentatonic-ish
function buildSeq(){
seqGrid.innerHTML='';
for(let i=0;i<16;i++){
const c=document.createElement('div'); c.className='cell'+(state.seq[i]?' on':'');
c.onclick=()=>{ state.seq[i]=state.seq[i]?0:1; c.classList.toggle('on'); };
seqGrid.appendChild(c);
}
}
let seqStep=0, seqTimer=null;
function seqTick(){
seqStep=(seqStep+1)%16;
seqGrid.querySelectorAll('.cell').forEach((c,i)=>c.classList.toggle('play',i===seqStep));
if(state.seq[seqStep]){
const m=45+SCALE[seqStep%SCALE.length]+(seqStep>=8?12:0);
noteOn(m); seqStepsHeard++;
setTimeout(()=>noteOff(m), 180);
if(seqStepsHeard>10 && lfoTargetsTried.has('cutoff')) seqGraduated=true;
updateChallenge();
}
}
document.getElementById('seqPlay').onclick=function(){
audioInit(); ensureBuses();
state.seqPlaying=!state.seqPlaying;
this.textContent=state.seqPlaying?'⏸ Stop':'▶ Play';
if(state.seqPlaying){ seqStep=-1; const iv=60000/state.bpm/4;
seqTimer=setInterval(seqTick,iv); }
else { clearInterval(seqTimer); seqGrid.querySelectorAll('.cell').forEach(c=>c.classList.remove('play')); }
};
document.getElementById('seqClear').onclick=()=>{ state.seq=state.seq.map(()=>0); buildSeq(); };
document.getElementById('seqRand').onclick=()=>{
state.seq=state.seq.map((_,i)=>Math.random()<(i%4===0?0.8:0.35)?1:0); buildSeq();
};
document.getElementById('bpm').oninput=e=>{
state.bpm=+e.target.value; document.getElementById('bpmVal').textContent=state.bpm;
if(state.seqPlaying){ clearInterval(seqTimer); seqTimer=setInterval(seqTick,60000/state.bpm/4); }
};
/* ---------- Oscilloscope ---------- */
const scope=document.getElementById('scope'), sctx=scope.getContext('2d');
function drawScope(){
requestAnimationFrame(drawScope);
if(!analyser) return;
const data=new Uint8Array(analyser.fftSize);
analyser.getByteTimeDomainData(data);
sctx.clearRect(0,0,scope.width,scope.height);
sctx.strokeStyle='#5eead4'; sctx.lineWidth=2; sctx.beginPath();
const step=scope.width/data.length;
for(let i=0;i<data.length;i++){
const y=(data[i]/255)*scope.height;
i===0?sctx.moveTo(0,y):sctx.lineTo(i*step,y);
}
sctx.stroke();
// grid
sctx.strokeStyle='rgba(255,255,255,0.06)'; sctx.lineWidth=1;
sctx.beginPath(); sctx.moveTo(0,scope.height/2); sctx.lineTo(scope.width,scope.height/2); sctx.stroke();
}
/* ---------- Boot ---------- */
function renderAll(){ renderRail(); renderLesson(); renderModules(); }
renderAll();
buildKeyboard(); // visual keyboard at boot; audio inits on first interaction
drawScope();
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"69ca464e-f7b9-42de-b2fa-6edcb4588a57","tokensIn":377717,"tokensOut":12710,"tokensTotal":390427,"cost":0.014596535,"turns":16,"toolCalls":15,"failedToolCalls":0,"timestamp":"2026-08-28T04:13:33.543Z"} -->