← TutSynth results

TutSynth

GLM 5.3 Flash · full

Initial view of TutSynth

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"} -->