← TutSynth results

TutSynth

Deepseek v4.1 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 56,156 bytes · SHA-256 a266ff91eecb
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Synth Lab — Learn Synthesis by Building It</title>
<style>
  :root{
    --bg:#07080f;
    --panel:rgba(255,255,255,.035);
    --line:rgba(255,255,255,.09);
    --text:#e9edfb;
    --muted:#8e97b5;
    --cy:#6ee7ff;
    --pu:#b07cff;
    --pk:#ff7ab8;
    --gr:#6dfbc0;
    --am:#ffcc66;
    --acc:var(--cy);
  }
  *{box-sizing:border-box}
  html,body{height:100%}
  body{
    margin:0;color:var(--text);min-height:100%;
    font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Inter,sans-serif;
    -webkit-font-smoothing:antialiased;
    background:
      radial-gradient(1100px 700px at 12% -12%, rgba(110,231,255,.10), transparent 60%),
      radial-gradient(900px 620px at 96% -4%, rgba(176,124,255,.13), transparent 58%),
      radial-gradient(800px 700px at 60% 120%, rgba(255,122,184,.07), transparent 60%),
      #07080f;
    background-attachment:fixed;
  }
  .app{
    display:grid;gap:14px;padding:16px;max-width:1580px;margin:0 auto;
    grid-template-columns:minmax(0,1fr) 372px;
    align-items:start;
  }
  .card{
    background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.012));
    border:1px solid var(--line);border-radius:18px;
    box-shadow:0 22px 50px -30px #000, inset 0 1px 0 rgba(255,255,255,.04);
  }

  /* ---------- header ---------- */
  header{grid-column:1/-1;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
  .brand{display:flex;align-items:center;gap:12px;min-width:0}
  .logo{
    width:38px;height:38px;border-radius:12px;flex:none;position:relative;
    background:conic-gradient(from 200deg,var(--cy),var(--pu),var(--pk),var(--cy));
    box-shadow:0 0 26px rgba(110,231,255,.35);
  }
  .logo::after{content:"";position:absolute;inset:5px;border-radius:8px;background:#0a0c16}
  .logo::before{content:"";position:absolute;inset:0;margin:auto;width:14px;height:14px;border-radius:50%;
    border:2px solid var(--cy);box-shadow:0 0 12px var(--cy);z-index:2}
  h1{margin:0;font-size:19px;letter-spacing:.2px;font-weight:650}
  h1 span{background:linear-gradient(90deg,var(--cy),var(--pu) 55%,var(--pk));-webkit-background-clip:text;background-clip:text;color:transparent}
  .sub{margin:2px 0 0;font-size:12px;color:var(--muted)}
  .head-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
  .pill{
    display:flex;align-items:center;gap:8px;padding:7px 12px;border-radius:99px;
    border:1px solid var(--line);background:rgba(255,255,255,.03);font-size:12px;color:var(--muted)
  }
  .pill b{color:var(--text);font-variant-numeric:tabular-nums}
  .status-dot{width:7px;height:7px;border-radius:50%;background:#4a5170;box-shadow:none;transition:.3s}
  .status-dot.on{background:var(--gr);box-shadow:0 0 10px var(--gr)}
  #master{width:110px}

  /* ---------- stage ---------- */
  .stage{grid-column:1;position:relative;overflow:hidden;height:174px}
  .stage canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
  .stage-label{
    position:absolute;top:12px;left:14px;font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;
    color:var(--muted);pointer-events:none;z-index:2
  }
  .stage-legend{
    position:absolute;top:12px;right:14px;display:flex;gap:14px;font-size:10.5px;color:var(--muted);
    letter-spacing:.08em;pointer-events:none;z-index:2
  }
  .stage-legend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px;vertical-align:middle}

  /* ---------- sidebar ---------- */
  .sidebar{grid-column:2;grid-row:2/span 2;display:flex;flex-direction:column;gap:12px;position:sticky;top:16px}
  .progress{display:flex;gap:6px;padding:12px 14px 4px;align-items:center}
  .pdot{
    flex:1;height:6px;border-radius:99px;background:rgba(255,255,255,.08);position:relative;overflow:hidden;transition:.4s
  }
  .pdot.done{background:linear-gradient(90deg,var(--cy),var(--pu))}
  .pdot.cur{background:rgba(255,255,255,.14);box-shadow:0 0 0 1px rgba(110,231,255,.5)}
  .pcount{font-size:11px;color:var(--muted);white-space:nowrap;padding-left:6px}
  .lesson{padding:16px 18px 18px}
  .kicker{font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--acc);font-weight:700}
  .lesson h2{margin:8px 0 8px;font-size:19px;line-height:1.25;font-weight:650}
  .lesson p{margin:0 0 10px;font-size:13.5px;line-height:1.62;color:#c3cbe4}
  .lesson p em{color:var(--text);font-style:normal;border-bottom:1px dashed rgba(255,255,255,.35)}
  .task{
    margin-top:12px;border-radius:12px;padding:11px 13px;font-size:12.5px;line-height:1.5;
    border:1px solid rgba(110,231,255,.28);background:rgba(110,231,255,.07);display:flex;gap:10px
  }
  .task .box{
    width:16px;height:16px;flex:none;border-radius:5px;border:1.5px solid rgba(110,231,255,.6);margin-top:1px;
    position:relative;transition:.25s
  }
  .task.met{border-color:rgba(109,251,192,.45);background:rgba(109,251,192,.08)}
  .task.met .box{border-color:var(--gr);background:var(--gr);box-shadow:0 0 12px rgba(109,251,192,.5)}
  .task.met .box::after{content:"";position:absolute;left:4.5px;top:1px;width:4px;height:8px;border:solid #05231a;border-width:0 2px 2px 0;transform:rotate(42deg)}
  .lesson-actions{display:flex;gap:9px;margin-top:14px;align-items:center}
  button{font-family:inherit}
  .btn{
    border:1px solid var(--line);background:rgba(255,255,255,.05);color:var(--text);
    padding:9px 14px;border-radius:11px;font-size:12.5px;cursor:pointer;transition:.18s;font-weight:560
  }
  .btn:hover{background:rgba(255,255,255,.1);transform:translateY(-1px)}
  .btn:active{transform:translateY(0)}
  .btn.primary{
    border:none;color:#04121a;background:linear-gradient(100deg,var(--cy),#8ad4ff 60%,var(--pu));
    box-shadow:0 8px 26px -10px rgba(110,231,255,.7);font-weight:700
  }
  .btn.primary.ready{animation:pulse 1.6s infinite}
  @keyframes pulse{0%,100%{box-shadow:0 8px 26px -10px rgba(110,231,255,.7)}50%{box-shadow:0 8px 34px -6px rgba(176,124,255,.95)}}
  .btn.ghost{border:none;background:none;color:var(--muted);padding:9px 6px;font-size:11.5px;text-decoration:underline;text-underline-offset:3px}
  .btn.ghost:hover{color:var(--text);background:none;transform:none}
  .btn[disabled]{opacity:.35;pointer-events:none}
  .hintbox{font-size:12px;line-height:1.55;color:var(--muted);padding:12px 16px}
  .hintbox b{color:#cfd7ee;font-weight:600}

  /* ---------- keyboard ---------- */
  .keyboard-wrap{grid-column:1;padding:14px 14px 16px}
  .kb-top{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap}
  .kb-title{font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)}
  .kb-keys{margin-left:auto;font-size:11px;color:var(--muted)}
  .kb-keys kbd{
    display:inline-block;padding:1px 6px;border-radius:5px;border:1px solid var(--line);
    background:rgba(255,255,255,.05);font-family:ui-monospace,monospace;font-size:10.5px;margin:0 1px;color:#b9c2de
  }
  .keyboard{position:relative;height:126px;user-select:none;touch-action:none}
  .wkey{
    position:absolute;top:0;bottom:0;border-radius:0 0 8px 8px;
    background:linear-gradient(180deg,#f3f6ff,#c9d3ee);
    border:1px solid rgba(0,0,0,.35);border-top:none;
    box-shadow:inset 0 -7px 10px -6px rgba(0,0,0,.45);
    transition:background .06s, transform .06s;
  }
  .wkey.active{background:linear-gradient(180deg,#bff3ff,#6ee7ff);box-shadow:0 0 24px rgba(110,231,255,.55),inset 0 -4px 10px -4px rgba(0,0,0,.35)}
  .bkey{
    position:absolute;top:0;height:62%;border-radius:0 0 7px 7px;z-index:3;
    background:linear-gradient(180deg,#2a3048,#0e1120);
    border:1px solid #05070e;box-shadow:0 6px 10px -4px rgba(0,0,0,.8),inset 0 -6px 8px -6px rgba(255,255,255,.25);
    transition:background .06s;
  }
  .bkey.active{background:linear-gradient(180deg,#8a5cff,#b07cff);box-shadow:0 0 22px rgba(176,124,255,.6)}
  .key-lbl{
    position:absolute;bottom:6px;left:0;right:0;text-align:center;font-size:9.5px;color:#5b6685;
    pointer-events:none;letter-spacing:.04em
  }
  .wkey.active .key-lbl{color:#05293a}
  .key-tip{display:flex;gap:16px;margin-top:10px;font-size:11.5px;color:var(--muted);flex-wrap:wrap}

  /* ---------- modules ---------- */
  .modules{grid-column:1/-1;display:flex;gap:14px;overflow-x:auto;padding:4px 2px 14px;scroll-snap-type:x proximity}
  .modules::-webkit-scrollbar{height:9px}
  .modules::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:9px}
  .modules::-webkit-scrollbar-track{background:rgba(255,255,255,.04);border-radius:9px}
  .panel{
    flex:0 0 300px;scroll-snap-align:start;padding:14px 15px 16px;position:relative;
    transition:opacity .5s, filter .5s, transform .5s;
  }
  .panel.locked{opacity:.32;filter:grayscale(.85) blur(.4px);pointer-events:none;transform:scale(.98)}
  .panel.locked::after{
    content:"🔒 locked";position:absolute;top:13px;right:14px;font-size:10px;letter-spacing:.1em;
    text-transform:uppercase;color:var(--muted)
  }
  .panel.unlocking{animation:unlock .8s cubic-bezier(.2,.9,.2,1)}
  @keyframes unlock{0%{opacity:.3;filter:grayscale(1);transform:scale(.95) translateY(6px)}100%{opacity:1;filter:none;transform:none}}
  .panel-head{display:flex;align-items:center;gap:9px;margin-bottom:4px}
  .panel-head .dot{width:9px;height:9px;border-radius:50%;background:var(--acc);box-shadow:0 0 12px var(--acc)}
  .panel-head h3{margin:0;font-size:13.5px;font-weight:650;letter-spacing:.01em}
  .panel-step{margin-left:auto;font-size:10px;color:var(--muted);letter-spacing:.1em}
  .panel-note{font-size:11.5px;line-height:1.5;color:var(--muted);margin:2px 0 12px}
  .ctrl{display:block;margin-bottom:11px}
  .ctrl:last-child{margin-bottom:0}
  .ctrl-label{display:flex;justify-content:space-between;align-items:baseline;font-size:11.5px;color:#b7c0dc;margin-bottom:3px}
  .ctrl-label em{font-style:normal;color:var(--acc);font-variant-numeric:tabular-nums;font-size:11px;font-family:ui-monospace,monospace}
  .seg{display:flex;gap:5px;background:rgba(0,0,0,.28);padding:4px;border-radius:11px;border:1px solid var(--line)}
  .seg button{
    flex:1;border:none;background:transparent;color:var(--muted);font-size:11px;padding:7px 4px;border-radius:8px;
    cursor:pointer;transition:.16s;font-weight:560
  }
  .seg button:hover{color:var(--text);background:rgba(255,255,255,.05)}
  .seg button.on{background:linear-gradient(180deg,rgba(255,255,255,.16),rgba(255,255,255,.07));color:#fff;box-shadow:0 2px 8px -3px #000}
  .seg button svg{display:block;margin:0 auto}
  input[type=range]{
    -webkit-appearance:none;appearance:none;width:100%;height:20px;background:transparent;cursor:pointer;display:block;margin:0
  }
  input[type=range]::-webkit-slider-runnable-track{
    height:4px;border-radius:99px;
    background:linear-gradient(90deg,rgba(255,255,255,.42),rgba(255,255,255,.1))
  }
  input[type=range]::-webkit-slider-thumb{
    -webkit-appearance:none;width:14px;height:14px;margin-top:-5px;border-radius:50%;background:#eef6ff;
    box-shadow:0 0 0 3px rgba(110,231,255,.16),0 2px 6px rgba(0,0,0,.7);transition:transform .12s,box-shadow .12s
  }
  input[type=range]:hover::-webkit-slider-thumb{box-shadow:0 0 0 4px rgba(110,231,255,.28),0 2px 8px #000}
  input[type=range]:active::-webkit-slider-thumb{transform:scale(1.12)}
  input[type=range]::-moz-range-track{height:4px;border-radius:99px;background:rgba(255,255,255,.2)}
  input[type=range]::-moz-range-thumb{width:13px;height:13px;border:none;border-radius:50%;background:#eef6ff;box-shadow:0 0 0 3px rgba(110,231,255,.2)}
  .mini-canvas{width:100%;height:64px;display:block;border-radius:10px;background:rgba(0,0,0,.3);border:1px solid var(--line)}
  .grid-seq{display:grid;gap:3px;margin-top:2px}
  .cell{aspect-ratio:1/1;border-radius:5px;background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.05);cursor:pointer;transition:.1s}
  .cell:hover{background:rgba(255,255,255,.14)}
  .cell.on{background:linear-gradient(180deg,var(--pk),#ff4f9a);border-color:transparent;box-shadow:0 0 12px rgba(255,122,184,.5)}
  .cell.playing{outline:1.5px solid var(--cy);outline-offset:1px}
  .row-lab{font-size:9.5px;color:var(--muted);display:flex;align-items:center;justify-content:flex-end;padding-right:5px;font-family:ui-monospace,monospace}
  .seq-head{display:flex;gap:8px;align-items:center;margin-top:12px}
  .toast{
    position:fixed;left:50%;bottom:28px;transform:translate(-50%,140%);z-index:99;
    padding:12px 18px;border-radius:14px;font-size:13px;font-weight:600;color:#04121a;
    background:linear-gradient(100deg,var(--cy),var(--pu));box-shadow:0 18px 40px -14px rgba(110,231,255,.75);
    transition:transform .5s cubic-bezier(.2,.9,.2,1);display:flex;gap:10px;align-items:center;max-width:88vw
  }
  .toast.show{transform:translate(-50%,0)}
  .scrollx{overflow-x:auto}
  .hot{animation:hot 1.1s ease-out}
  @keyframes hot{0%{box-shadow:0 0 0 0 rgba(110,231,255,.6)}100%{box-shadow:0 0 0 14px rgba(110,231,255,0)}}
  @media (max-width:1000px){
    .app{grid-template-columns:minmax(0,1fr)}
    .sidebar{grid-column:1;grid-row:auto;position:static}
    .stage{grid-column:1}
    .keyboard-wrap{grid-column:1}
  }
</style>
</head>
<body>
<div class="app">
  <header>
    <div class="brand">
      <div class="logo"></div>
      <div>
        <h1>Synth <span>Lab</span></h1>
        <p class="sub">Build a synthesizer one block at a time — and hear what each one does.</p>
      </div>
    </div>
    <div class="head-right">
      <div class="pill"><span class="status-dot" id="statusDot"></span><span id="statusText">click to power on</span></div>
      <div class="pill">Master <input type="range" id="master" min="0" max="100" value="70"></div>
      <button class="btn" id="panic">All notes off</button>
    </div>
  </header>

  <section class="stage card">
    <div class="stage-label">Oscilloscope</div>
    <div class="stage-legend">
      <span><i style="background:rgba(110,231,255,.35)"></i>spectrum</span>
      <span><i style="background:#b07cff"></i>waveform</span>
    </div>
    <canvas id="scope"></canvas>
  </section>

  <aside class="sidebar">
    <div class="card">
      <div class="progress" id="progress">
        <span class="pcount" id="pcount">Step 1 / 6</span>
      </div>
      <div class="lesson">
        <div class="kicker" id="kicker">Module 1</div>
        <h2 id="lessonTitle">The Oscillator</h2>
        <div id="lessonBody"></div>
        <div class="task" id="task"><span class="box"></span><span id="taskText"></span></div>
        <div class="lesson-actions">
          <button class="btn primary" id="nextBtn" disabled>Continue ▸</button>
          <button class="btn ghost" id="skipBtn">skip ahead</button>
        </div>
      </div>
    </div>
    <div class="card hintbox" id="hintbox"></div>
  </aside>

  <section class="keyboard-wrap card">
    <div class="kb-top">
      <span class="kb-title">Keyboard · C4 – B5</span>
      <span class="kb-keys">play with <kbd>A</kbd><kbd>W</kbd><kbd>S</kbd><kbd>E</kbd><kbd>D</kbd><kbd>F</kbd><kbd>T</kbd><kbd>G</kbd><kbd>Y</kbd><kbd>H</kbd><kbd>U</kbd><kbd>J</kbd><kbd>K</kbd> … or click the keys</span>
    </div>
    <div class="keyboard" id="keyboard"></div>
    <div class="key-tip">
      <span>💡 Hold a key with the mouse and slide across the keyboard to glide between notes.</span>
    </div>
  </section>

  <section class="modules" id="modules"></section>
</div>
<div class="toast" id="toast"></div>
<script>
(function(){
"use strict";
const $ = s => document.querySelector(s);
const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
const mtof = m => 440*Math.pow(2,(m-69)/12);

/* ==========================================================================
   PARAMETERS  — the single source of truth for the whole synth
   ========================================================================== */
const P = {
  wave:'sawtooth', detune:0,
  attack:0.01, decay:0.30, sustain:0.65, release:0.35,
  filterType:'lowpass', cutoff:2600, resonance:2.5, envAmt:0,
  lfoTarget:'pitch', lfoRate:5.5, lfoDepth:0,
  drive:0, delayMix:0, delayTime:0.30, feedback:0.35,
  bpm:112, volume:0.72
};

/* ==========================================================================
   AUDIO ENGINE
   ========================================================================== */
let AC=null, mixBus, shaper, dryIn, delayNode, fbGain, wetGain, postGain, analyser, master;
let lfo, lfoPitchGain, lfoFilterGain, lfoAmpGain;
let waveData, freqData;
const voices = new Map();   // midi -> voice (held notes only)

function initAudio(){
  if(AC) return;
  AC = new (window.AudioContext||window.webkitAudioContext)();
  const t = AC.currentTime;

  mixBus   = AC.createGain();   mixBus.gain.value = 1;
  shaper   = AC.createWaveShaper();
  dryIn    = AC.createGain();   dryIn.gain.value = 1;
  delayNode= AC.createDelay(2.0); delayNode.delayTime.value = P.delayTime;
  fbGain   = AC.createGain();   fbGain.gain.value = P.feedback;
  wetGain  = AC.createGain();   wetGain.gain.value = P.delayMix;
  postGain = AC.createGain();   postGain.gain.value = 1;
  analyser = AC.createAnalyser();
  analyser.fftSize = 2048; analyser.smoothingTimeConstant = 0.78;
  master   = AC.createGain();   master.gain.value = P.volume * 0.9;

  mixBus.connect(shaper);
  shaper.connect(dryIn); dryIn.connect(postGain);
  shaper.connect(delayNode);
  delayNode.connect(fbGain); fbGain.connect(delayNode);
  delayNode.connect(wetGain); wetGain.connect(postGain);
  postGain.connect(analyser); analyser.connect(master); master.connect(AC.destination);

  waveData = new Uint8Array(analyser.fftSize);
  freqData = new Uint8Array(analyser.frequencyBinCount);
  makeDriveCurve(P.drive);

  /* ---- global LFO routed into buses that every voice hooks onto ---- */
  lfo = AC.createOscillator(); lfo.type='sine'; lfo.frequency.value = P.lfoRate;
  lfoPitchGain  = AC.createGain(); lfoPitchGain.gain.value = 0;   // cents
  lfoFilterGain = AC.createGain(); lfoFilterGain.gain.value = 0;  // Hz
  lfoAmpGain    = AC.createGain(); lfoAmpGain.gain.value = 0;     // gain units
  lfo.connect(lfoPitchGain); lfo.connect(lfoFilterGain); lfo.connect(lfoAmpGain);
  lfo.start();
  applyLFO();

  $("#statusDot").classList.add('on');
  $("#statusText").textContent = 'engine live';
}

function makeDriveCurve(amount){
  if(!shaper) return;
  const n = 1024, curve = new Float32Array(n), k = amount*18;
  for(let i=0;i<n;i++){
    const x = (i/(n-1))*2-1;
    curve[i] = k<0.001 ? x : (1+k) * x / (1 + k*Math.abs(x));
  }
  shaper.curve = curve;
  shaper.oversample = '2x';
}

function applyLFO(){
  if(!AC) return;
  const t = AC.currentTime;
  lfo.frequency.setTargetAtTime(P.lfoRate, t, 0.02);
  const d = P.lfoDepth;
  lfoPitchGain.gain.setTargetAtTime(P.lfoTarget==='pitch' ? d*600 : 0, t, 0.02);
  lfoFilterGain.gain.setTargetAtTime(P.lfoTarget==='filter'? d*4000 : 0, t, 0.02);
  lfoAmpGain.gain.setTargetAtTime(P.lfoTarget==='amp' ? d*0.5 : 0, t, 0.02);
  // tremolo needs a base offset so gain doesn't go negative
  voices.forEach(v => { if(v.trem) v.trem.gain.setTargetAtTime(1 - (P.lfoTarget==='amp'?d*0.5:0), t, 0.02); });
}

/* ---------------- voices ---------------- */
function makeVoice(freq, at){
  const osc  = AC.createOscillator();
  osc.type = P.wave;
  osc.frequency.setValueAtTime(freq, at);
  osc.detune.setValueAtTime(P.detune, at);

  const filt = AC.createBiquadFilter();
  filt.type = P.filterType;
  filt.frequency.setValueAtTime(P.cutoff, at);
  filt.Q.setValueAtTime(P.resonance, at);

  const amp  = AC.createGain(); amp.gain.setValueAtTime(0.0001, at);
  const trem = AC.createGain(); trem.gain.setValueAtTime(1 - (P.lfoTarget==='amp'?P.lfoDepth*0.5:0), at);

  osc.connect(filt); filt.connect(amp); amp.connect(trem); trem.connect(mixBus);
  lfoPitchGain.connect(osc.detune);
  lfoFilterGain.connect(filt.frequency);
  lfoAmpGain.connect(trem.gain);
  osc.start(at);

  return {osc, filt, amp, trem, freq};
}

const PEAK = 0.30;
function scheduleEnvelope(v, at, gate){
  const A=P.attack, D=P.decay, S=P.sustain, R=P.release;
  const g = v.amp.gain;
  g.cancelScheduledValues(at);
  g.setValueAtTime(0.0001, at);
  g.linearRampToValueAtTime(PEAK, at + Math.max(0.001,A));
  const holdStart = at + Math.max(0.001,A) + D;
  g.linearRampToValueAtTime(Math.max(0.0001, PEAK*S), holdStart);
  const off = gate==null ? null : at + gate;
  if(off != null){
    const rel = Math.max(off, holdStart + 0.005);
    g.setValueAtTime(Math.max(0.0001, PEAK*S), rel);
    g.linearRampToValueAtTime(0.0001, rel + Math.max(0.01,R));
    stopVoice(v, rel + Math.max(0.01,R) + 0.06);
  }
  // filter envelope
  if(P.envAmt > 0.001){
    const f = v.filt.frequency, base = P.cutoff;
    const peak = clamp(base * Math.pow(2, P.envAmt*5), 30, 19000);
    f.cancelScheduledValues(at);
    f.setValueAtTime(base, at);
    f.linearRampToValueAtTime(peak, at + Math.max(0.003,A));
    f.exponentialRampToValueAtTime(Math.max(30,base), at + Math.max(0.003,A) + D + 0.15);
  }
}
function stopVoice(v, when){
  try{ v.osc.stop(when); }catch(e){}
  v.osc.onended = () => {
    try{ v.osc.disconnect(); v.filt.disconnect(); v.amp.disconnect(); v.trem.disconnect(); }catch(e){}
  };
}
function releaseVoice(v, now){
  const R = Math.max(0.02, P.release);
  const g = v.amp.gain;
  if(g.cancelAndHoldAtTime) g.cancelAndHoldAtTime(now);
  else { g.cancelScheduledValues(now); g.setValueAtTime(Math.max(0.0001,g.value), now); }
  g.linearRampToValueAtTime(0.0001, now + R);
  stopVoice(v, now + R + 0.06);
}

/* ---------------- note events ---------------- */
const keyEls = new Map();
function noteOn(midi, src){
  initAudio();
  if(AC.state === 'suspended') AC.resume();
  if(voices.has(midi)) noteOff(midi);
  const now = AC.currentTime;
  const v = makeVoice(mtof(midi), now);
  v.midi = midi; voices.set(midi, v);
  scheduleEnvelope(v, now, null);            // gate unknown → held until noteOff
  flags.played = true;
  flags.waves.add(P.wave);
  const el = keyEls.get(midi); if(el) el.classList.add('active');
  setTimeout(()=>focusLesson(), 0);
}
function noteOff(midi){
  const v = voices.get(midi);
  if(!v || !AC) return;
  voices.delete(midi);
  releaseVoice(v, AC.currentTime);
  const el = keyEls.get(midi); if(el) el.classList.remove('active');
}
function allOff(){
  [...voices.keys()].forEach(noteOff);
}

/* ---------------- sequencer playback ---------------- */
let seqOn=false, seqStep=0, seqNext=0, seqTimer=null;
const flashQueue=[];
function scheduleStep(step, t){
  for(let row=0; row<SEQ_NOTES.length; row++){
    if(pattern[row][step]){
      const v = makeVoice(mtof(SEQ_NOTES[row]), t);
      scheduleEnvelope(v, t, 0.30);
      flashQueue.push({midi:SEQ_NOTES[row], t});
    }
  }
}
function seqTick(){
  if(!AC) return;
  const spb = 60/P.bpm/4;
  while(seqNext < AC.currentTime + 0.12){
    scheduleStep(seqStep, Math.max(seqNext, AC.currentTime + 0.01));
    seqNext += spb;
    uiStep = seqStep;
    seqStep = (seqStep+1) % 16;
  }
}

/* ==========================================================================
   STATE FLAGS + LESSON PLAN
   ========================================================================== */
const flags = { played:false, waves:new Set(), env:false, sweep:false, lfo:false, fx:false, seq:false };
let current = 0;

const seqFmt = v => v>=1000 ? (v/1000).toFixed(1)+'k' : Math.round(v)+'';
const LESSONS = [
  { key:'osc', acc:'var(--cy)', title:'The Oscillator', kicker:'Module 1 of 6',
    body:`<p>The oscillator is the <em>sound source</em> — it vibrates back and forth, and that vibration
      becomes sound. Two things matter: <em>how fast</em> it vibrates (that's pitch, and the keyboard controls it)
      and <em>the shape</em> of the vibration, which decides the tone.</p>
      <p>A <em>sine</em> is one pure frequency. <em>Sawtooth</em> and <em>square</em> add stacked harmonics
      (a bright, buzzy character); <em>triangle</em> sits in between. Detune nudges the tuning a few cents —
      it's how you make two notes "beat" against each other.</p>`,
    task:'Play a note, then listen to all four waveforms.',
    hint:'<b>Pitch comes from frequency, tone colour comes from the waveform.</b> Hold a note and switch shapes — notice how the oscilloscope changes. Detune is measured in cents: 100 cents = one semitone.',
    recipes:[] },

  { key:'env', acc:'var(--pu)', title:'The Amplitude Envelope', kicker:'Module 2 of 6',
    body:`<p>An envelope draws the <em>life story of a note</em>. The ADSR shape sets it: <em>Attack</em> is how long it
      fades in, <em>Decay</em> how long it falls to the <em>Sustain</em> level it holds while you press, and
      <em>Release</em> how long it fades out after you let go.</p>
      <p>This is the single biggest difference between a plucked string, an organ and a swelling pad.</p>`,
    task:'Shape a sound: long attack for a pad, or a snappy pluck. Play while you drag.',
    hint:'<b>Attack + Release define the feel.</b> Short attack &amp; short release = percussive (pluck, stab, kick). Long attack &amp; long release = pad. Instant attack + long release = bell or organ.',
    recipes:[['Pluck',{attack:0.005,decay:0.22,sustain:0.0,release:0.18}],
             ['Pad',{attack:1.2,decay:1.0,sustain:0.8,release:2.4}],
             ['Organ',{attack:0.02,decay:0.1,sustain:1.0,release:0.12}],
             ['Bell',{attack:0.002,decay:1.6,sustain:0.0,release:1.8}]] },

  { key:'filter', acc:'var(--am)', title:'The Filter', kicker:'Module 3 of 6',
    body:`<p>A filter is a <em>tone control on steroids</em>. A <em>low-pass</em> filter lets low frequencies through and
      removes everything above the <em>cutoff</em> — dark and muffled when low, bright and open when high.</p>
      <p><em>Resonance</em> rings the frequencies right at the cutoff, giving that whistling, "juicy" character.
      The <em>env amount</em> makes the filter open and close on its own with every note, which is where
      classic synth plucks and acid basslines come from.</p>`,
    task:'Hold a note and sweep the cutoff from dark to bright. Add resonance. Add some env amount.',
    hint:'<b>Cutoff is the most expressive knob on a synth.</b> Try high-pass to make things thin and airy, band-pass for a telephone effect. Give the envelope a big amount and a short decay for that "pew" bass sound.',
    recipes:[['Dark',{filterType:'lowpass',cutoff:420,resonance:2,envAmt:0}],
             ['Acid',{filterType:'lowpass',cutoff:300,resonance:16,envAmt:0.65,decay:0.3,sustain:0.1}],
             ['Airy',{filterType:'highpass',cutoff:1200,resonance:1,envAmt:0}],
             ['Phone',{filterType:'bandpass',cutoff:1600,resonance:6,envAmt:0}]] },

  { key:'lfo', acc:'var(--pk)', title:'The LFO', kicker:'Module 4 of 6',
    body:`<p>An LFO (low-frequency oscillator) is an oscillator running <em>slower than hearing range</em>, so instead of
      making a note it <em>moves another control for you</em>, over and over.</p>
      <p>Point it at the pitch and you get <em>vibrato</em>. Point it at the filter and you get a <em>wobble</em>.
      Point it at the volume and you get <em>tremolo</em>. Rate = how fast it moves, depth = how far.</p>`,
    task:'Turn up the depth, then try each of the three targets.',
    hint:'<b>Modulation is what makes a synth sound alive.</b> Pitch -&gt; vibrato (keep the depth small and the rate near 5 Hz). Filter -&gt; wobble (rate ~2–8 Hz, big depth). Amplitude -&gt; tremolo (slow rate, medium depth).',
    recipes:[['Vibrato',{lfoTarget:'pitch',lfoRate:5.5,lfoDepth:0.09}],
             ['Wobble',{lfoTarget:'filter',lfoRate:3,lfoDepth:0.7}],
             ['Tremolo',{lfoTarget:'amp',lfoRate:6.5,lfoDepth:0.8}],
             ['Siren',{lfoTarget:'pitch',lfoRate:0.6,lfoDepth:0.5}]] },

  { key:'fx', acc:'var(--gr)', title:'Drive &amp; Delay', kicker:'Module 5 of 6',
    body:`<p>Effects happen <em>after</em> the note. <em>Drive</em> pushes the signal past its limit, squashing the tops
      of the wave and adding gritty harmonics — subtle warmth to full destruction.</p>
      <p><em>Delay</em> records the sound and plays it back a moment later, over and over. <em>Time</em> sets the gap
      (short = slapback, long = dub echoes), <em>feedback</em> how many repeats, <em>mix</em> how loud they sit.</p>`,
    task:'Add some drive, then turn the delay mix up until you hear repeats.',
    hint:'<b>Time sets the groove, feedback sets the atmosphere.</b> 80–140 ms is slapback, 300–450 ms is rhythmic echo, and past 0.7 feedback it starts to build into a runaway wall of sound.',
    recipes:[['Warm',{drive:0.18,delayMix:0.10,delayTime:0.28,feedback:0.25}],
             ['Dub',{drive:0.1,delayMix:0.45,delayTime:0.42,feedback:0.68}],
             ['Slap',{drive:0.25,delayMix:0.28,delayTime:0.11,feedback:0.15}],
             ['Destroy',{drive:0.95,delayMix:0.35,delayTime:0.22,feedback:0.6}]] },

  { key:'seq', acc:'var(--cy)', title:'The Sequencer', kicker:'Module 6 of 6',
    body:`<p>Now put it together. A sequencer plays notes for you on a grid — this one is a mini piano roll where each
      row is a pitch and each column is a step in time. Hit <em>Play</em> and the whole patch you built starts looping.</p>
      <p>Change the tempo, add or remove steps, and hear how every control you've learned shapes the same pattern differently.</p>`,
    task:'Draw a pattern, press Play, then tweak the filter and LFO while it loops.',
    hint:'<b>You built a synthesizer.</b> The signal path, in order, was: oscillator → filter → amplifier (with its envelope) → effects → speakers. Almost every synth ever made is some version of exactly that chain.',
    recipes:[] }
];

/* ==========================================================================
   PANEL MARKUP
   ========================================================================== */
let SEQ_NOTES = [72,70,67,65,63,62,60,58];
SEQ_NOTES = [77,75,72,70,67,65,63,60];
let pattern = Array.from({length:8},()=>Array(16).fill(false));
let uiStep = -1, drawnStep = -1;

const WAVE_ICONS = {
  sine:'M2 10 C6 3 8 3 12 10 C16 17 18 17 22 10',
  square:'M2 15 L2 5 L12 5 L12 15 L22 15 L22 5',
  sawtooth:'M2 15 L12 5 L12 15 L22 5',
  triangle:'M2 15 L7 5 L12 15 L17 5 L22 15'
};
function waveBtn(type, extra){
  const path = WAVE_ICONS[type] || WAVE_ICONS.sine;
  return `<button data-wave="${type}" class="${P.wave===type?'on':''}">
    <svg width="26" height="18" viewBox="0 0 24 20"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>
    <span style="display:block;font-size:9.5px;margin-top:2px">${extra||type}</span></button>`;
}
function ctrl(id,label,min,max,step,val,fmt,unit){
  return `<label class="ctrl"><span class="ctrl-label"><span>${label}</span><em id="${id}v">${fmt(val)}</em></span>
    <input type="range" id="${id}" min="${min}" max="${max}" step="${step}" value="${val}"></label>`;
}
const fms = v => v<1 ? Math.round(v*1000)+' ms' : v.toFixed(2)+' s';
const fhz = v => v>=1000 ? (v/1000).toFixed(2)+' kHz' : Math.round(v)+' Hz';
const fpct= v => Math.round(v*100)+'%';
const fcent=v => (v>0?'+':'')+Math.round(v)+' cents';

function buildPanels(){
  $("#modules").innerHTML = `
    <section class="panel card locked" data-m="osc" style="--acc:var(--cy)">
      <div class="panel-head"><span class="dot"></span><h3>1 · Oscillator</h3><span class="panel-step">source</span></div>
      <p class="panel-note">The raw vibration. Shape = tone, frequency = pitch.</p>
      <div class="seg" id="waveSeg" style="margin-bottom:12px">
        ${['sine','triangle','square','sawtooth'].map(w=>waveBtn(w)).join('')}
      </div>
      ${ctrl('detune','Detune',-50,50,1,P.detune,fcent)}
    </section>

    <section class="panel card locked" data-m="env" style="--acc:var(--pu)">
      <div class="panel-head"><span class="dot"></span><h3>2 · Amplitude Envelope</h3><span class="panel-step">shape</span></div>
      <p class="panel-note">How the volume of a single note rises, holds and falls.</p>
      <canvas class="mini-canvas" id="envCanvas" style="height:74px;margin-bottom:12px"></canvas>
      ${ctrl('attack','Attack',0.001,3,0.001,P.attack,fms)}
      ${ctrl('decay','Decay',0.005,3,0.005,P.decay,fms)}
      ${ctrl('sustain','Sustain',0,1,0.01,P.sustain,fpct)}
      ${ctrl('release','Release',0.01,4,0.01,P.release,fms)}
    </section>

    <section class="panel card locked" data-m="filter" style="--acc:var(--am)">
      <div class="panel-head"><span class="dot"></span><h3>3 · Filter</h3><span class="panel-step">tone</span></div>
      <p class="panel-note">Removes frequencies above (or below) the cutoff point.</p>
      <div class="seg" id="filtSeg" style="margin-bottom:12px">
        <button data-ft="lowpass" class="${P.filterType==='lowpass'?'on':''}">Low-pass</button>
        <button data-ft="highpass" class="${P.filterType==='highpass'?'on':''}">High-pass</button>
        <button data-ft="bandpass" class="${P.filterType==='bandpass'?'on':''}">Band-pass</button>
      </div>
      <canvas class="mini-canvas" id="filtCanvas" style="height:70px;margin-bottom:12px"></canvas>
      <div id="cutWrap"></div>
      ${ctrl('reso','Resonance',0.1,24,0.1,P.resonance,f=>f.toFixed(1))}
      ${ctrl('envAmt','Filter envelope',0,1,0.01,P.envAmt,fpct)}
    </section>

    <section class="panel card locked" data-m="lfo" style="--acc:var(--pk)">
      <div class="panel-head"><span class="dot"></span><h3>4 · LFO</h3><span class="panel-step">motion</span></div>
      <p class="panel-note">A slow oscillator that automates another control.</p>
      <div class="seg" id="lfoSeg" style="margin-bottom:12px">
        <button data-lt="pitch" class="${P.lfoTarget==='pitch'?'on':''}">→ Pitch</button>
        <button data-lt="filter" class="${P.lfoTarget==='filter'?'on':''}">→ Filter</button>
        <button data-lt="amp" class="${P.lfoTarget==='amp'?'on':''}">→ Volume</button>
      </div>
      ${ctrl('lfoRate','Rate',0.1,20,0.1,P.lfoRate,v=>v.toFixed(1)+' Hz')}
      ${ctrl('lfoDepth','Depth',0,1,0.01,P.lfoDepth,fpct)}
    </section>

    <section class="panel card locked" data-m="fx" style="--acc:var(--gr)">
      <div class="panel-head"><span class="dot"></span><h3>5 · Drive &amp; Delay</h3><span class="panel-step">space</span></div>
      <p class="panel-note">After the note: distortion and echoes.</p>
      ${ctrl('drive','Drive',0,1,0.01,P.drive,fpct)}
      ${ctrl('delayTime','Delay time',0.02,0.9,0.01,P.delayTime,fms)}
      ${ctrl('feedback','Feedback',0,0.85,0.01,P.feedback,fpct)}
      ${ctrl('delayMix','Delay mix',0,1,0.01,P.delayMix,fpct)}
    </section>

    <section class="panel card locked" data-m="seq" style="--acc:var(--cy)">
      <div class="panel-head"><span class="dot"></span><h3>6 · Sequencer</h3><span class="panel-step">loop</span></div>
      <p class="panel-note">Click cells to build a 16-step pattern. Rows = pitches (C minor-ish pentatonic).</p>
      <div class="grid-seq" id="seqGrid"></div>
      <div class="seq-head">
        <button class="btn primary" id="seqPlay" style="padding:7px 14px">▶ Play</button>
        <button class="btn" id="seqRandom" style="padding:7px 12px;font-size:11.5px">Random</button>
        <button class="btn" id="seqClear" style="padding:7px 12px;font-size:11.5px">Clear</button>
      </div>
      <div style="margin-top:10px">${ctrl('bpm','Tempo',60,180,1,P.bpm,v=>Math.round(v)+' BPM')}</div>
    </section>`;

  // cutoff uses an exponential slider
  const cutX = Math.log(P.cutoff/60)/Math.log(19000/60)*100;
  $("#cutWrap").innerHTML = ctrl('cutoff','Cutoff',0,100,0.1,cutX.toFixed(1),
    ()=>fhz(P.cutoff));
  $("#cutoff").addEventListener('input', e => {
    const x = +e.target.value/100;
    P.cutoff = 60*Math.pow(19000/60, x);
    $("#cutoffv").textContent = fhz(P.cutoff);
    liveFilter(); drawFilterCurve(); flags.sweep = flags.sweep || voices.size>0; focusLesson();
  });


  // seq grid
  const g = $("#seqGrid");
  g.style.gridTemplateColumns = `24px repeat(16, minmax(0,1fr))`;
  for(let r=0;r<8;r++){
    const lab = document.createElement('div');
    lab.className='row-lab'; lab.textContent = seqFmt(SEQ_NOTES[r]);
    g.appendChild(lab);
    for(let c=0;c<16;c++){
      const cell=document.createElement('div');
      cell.className='cell'; cell.dataset.r=r; cell.dataset.c=c;
      cell.addEventListener('click',()=>{
        pattern[r][c]=!pattern[r][c];
        cell.classList.toggle('on',pattern[r][c]);
        flags.seq = flags.seq || pattern.flat().some(Boolean);
        focusLesson();
      });
      g.appendChild(cell);
    }
  }
  bindPanelControls();
}

/* ---------------- binding ---------------- */
function bindPanelControls(){
  // waveform
  $("#waveSeg").addEventListener('click', e=>{
    const b = e.target.closest('button[data-wave]'); if(!b) return;
    P.wave = b.dataset.wave;
    [...$("#waveSeg").children].forEach(x=>x.classList.toggle('on', x===b));
    liveOsc();
    if(voices.size===0) flags.waves.add(P.wave);  // count even when silent
    focusLesson();
  });
  // filter type
  $("#filtSeg").addEventListener('click', e=>{
    const b = e.target.closest('button[data-ft]'); if(!b) return;
    P.filterType = b.dataset.ft;
    [...$("#filtSeg").children].forEach(x=>x.classList.toggle('on', x===b));
    liveFilter(); drawFilterCurve(); focusLesson();
  });
  // lfo target
  $("#lfoSeg").addEventListener('click', e=>{
    const b = e.target.closest('button[data-lt]'); if(!b) return;
    P.lfoTarget = b.dataset.lt;
    [...$("#lfoSeg").children].forEach(x=>x.classList.toggle('on', x===b));
    applyLFO(); flags.lfo = flags.lfo || P.lfoDepth>0.02; refreshFlagUI(); focusLesson();
  });

  const bind = (id, key, fmt, after, flag) => {
    const el = $("#"+id); if(!el) return;
    el.addEventListener('input', ()=>{
      const v = +el.value; P[key] = v;
      $("#"+id+"v").textContent = fmt(v);
      if(after) after();
      if(flag) flags[flag] = true;
      focusLesson();
    });
  };
  bind('detune','detune', fcent, ()=>{ liveOsc(); flags.env=flags.env||false; });
  bind('attack','attack', fms, ()=>{ liveEnv(); drawEnv(); }, 'env');
  bind('decay','decay', fms, ()=>{ liveEnv(); drawEnv(); }, 'env');
  bind('sustain','sustain', fpct, ()=>{ liveEnv(); drawEnv(); }, 'env');
  bind('release','release', fms, ()=>drawEnv(), 'env');
  bind('reso','resonance', v=>v.toFixed(1), ()=>{ liveFilter(); drawFilterCurve(); });
  bind('envAmt','envAmt', fpct, null);
  bind('lfoRate','lfoRate', v=>v.toFixed(1)+' Hz', applyLFO);
  bind('lfoDepth','lfoDepth', fpct, ()=>{ applyLFO(); flags.lfo = P.lfoDepth>0.02; refreshFlagUI(); });
  bind('drive','drive', fpct, ()=>makeDriveCurve(P.drive), 'fx');
  bind('delayTime','delayTime', fms, ()=>{ if(delayNode) delayNode.delayTime.setTargetAtTime(P.delayTime, AC.currentTime, .05); }, 'fx');
  bind('feedback','feedback', fpct, ()=>{ if(fbGain) fbGain.gain.value = P.feedback; }, 'fx');
  bind('delayMix','delayMix', fpct, ()=>{ if(wetGain) wetGain.gain.value = P.delayMix; }, 'fx');
  bind('bpm','bpm', v=>Math.round(v)+' BPM');

  // sequencer transport
  $("#seqPlay").addEventListener('click', ()=>{
    initAudio();
    if(AC.state==='suspended') AC.resume();
    seqOn = !seqOn;
    $("#seqPlay").textContent = seqOn ? '❚❚ Stop' : '▶ Play';
    if(seqOn){ seqNext = AC.currentTime + 0.08; seqStep = 0; }
    else {
      flashQueue.length = 0;
      document.querySelectorAll('.grid-seq .cell.playing').forEach(c=>c.classList.remove('playing'));
      drawnStep = -1;
    }
    flags.seq = flags.seq || pattern.flat().some(Boolean);
    focusLesson();
  });
  $("#seqRandom").addEventListener('click', ()=>{
    pattern = pattern.map(()=>Array(16).fill(false));
    SEQ_NOTES.forEach((n,r)=>{ for(let c=0;c<16;c++) pattern[r][c] = Math.random() < (r<3?0.14:0.2); });
    pattern[7][0]=true; pattern[4][0]=true;
    paintSeq(); flags.seq=true; focusLesson();
  });
  $("#seqClear").addEventListener('click', ()=>{
    pattern = pattern.map(()=>Array(16).fill(false));
    paintSeq();
  });
}
function paintSeq(){
  document.querySelectorAll('.grid-seq .cell').forEach(c=>{
    c.classList.toggle('on', pattern[+c.dataset.r][+c.dataset.c]);
  });
}

/* live parameter updates on sounding voices */
function liveOsc(){ if(!AC) return; voices.forEach(v=>{ v.osc.type = P.wave; v.osc.detune.setTargetAtTime(P.detune, AC.currentTime, .01); }); }
function liveFilter(){ if(!AC) return; voices.forEach(v=>{ v.filt.type=P.filterType; v.filt.frequency.setTargetAtTime(P.cutoff, AC.currentTime, .02); v.filt.Q.setTargetAtTime(P.resonance, AC.currentTime, .02); }); }
function liveEnv(){ /* envelopes are per-note; new notes pick up changes */ }

function syncUI(){
  const set=(id,val,fmt)=>{ const el=$("#"+id); if(!el) return; el.value=val; const l=$("#"+id+"v"); if(l) l.textContent = fmt(+val); };
  set('detune',P.detune,fcent);
  set('attack',P.attack,fms); set('decay',P.decay,fms);
  set('sustain',P.sustain,fpct); set('release',P.release,fms);
  set('reso',P.resonance,v=>v.toFixed(1)); set('envAmt',P.envAmt,fpct);
  set('lfoRate',P.lfoRate,v=>v.toFixed(1)+' Hz'); set('lfoDepth',P.lfoDepth,fpct);
  set('drive',P.drive,fpct); set('delayTime',P.delayTime,fms);
  set('feedback',P.feedback,fpct); set('delayMix',P.delayMix,fpct); set('bpm',P.bpm,v=>Math.round(v)+' BPM');
  const cutX = Math.log(P.cutoff/60)/Math.log(19000/60)*100;
  const cEl=$('#cutoff'); if(cEl){ cEl.value=cutX; $('#cutoffv').textContent=fhz(P.cutoff); }
  document.querySelectorAll('#waveSeg button').forEach(b=>b.classList.toggle('on', b.dataset.wave===P.wave));
  document.querySelectorAll('#filtSeg button').forEach(b=>b.classList.toggle('on', b.dataset.ft===P.filterType));
  document.querySelectorAll('#lfoSeg button').forEach(b=>b.classList.toggle('on', b.dataset.lt===P.lfoTarget));
  drawEnv(); drawFilterCurve();
}
function applyPreset(obj){
  initAudio();
  Object.assign(P, obj);
  if(obj.delayTime!=null && delayNode) delayNode.delayTime.setTargetAtTime(P.delayTime, AC.currentTime, .05);
  if(obj.feedback!=null && fbGain) fbGain.gain.value = P.feedback;
  if(obj.delayMix!=null && wetGain) wetGain.gain.value = P.delayMix;
  if(obj.drive!=null) makeDriveCurve(P.drive);
  if(master) master.gain.setTargetAtTime(P.volume*0.9, AC.currentTime, .05);
  applyLFO(); liveOsc(); liveFilter(); syncUI();
}

/* ==========================================================================
   VISUALISERS
   ========================================================================== */
const scope = $("#scope");
function sizeCanvas(c){
  const dpr = window.devicePixelRatio||1;
  const r = c.getBoundingClientRect();
  if(!r.width) return null;
  if(c.width !== Math.round(r.width*dpr) || c.height !== Math.round(r.height*dpr)){
    c.width = Math.round(r.width*dpr); c.height = Math.round(r.height*dpr);
  }
  const x = c.getContext('2d');
  x.setTransform(dpr,0,0,dpr,0,0);
  return {x, w:r.width, h:r.height};
}
function drawScope(){
  const s = sizeCanvas(scope); if(!s) return;
  const {x,w,h} = s;
  x.clearRect(0,0,w,h);
  x.strokeStyle='rgba(255,255,255,.05)'; x.lineWidth=1;
  for(let i=1;i<6;i++){ x.beginPath(); x.moveTo(w*i/6,0); x.lineTo(w*i/6,h); x.stroke(); }
  for(let i=1;i<3;i++){ x.beginPath(); x.moveTo(0,h*i/3); x.lineTo(w,h*i/3); x.stroke(); }
  if(!analyser){
    x.strokeStyle='rgba(150,160,200,.35)'; x.lineWidth=1.5;
    x.beginPath(); x.moveTo(0,h/2); x.lineTo(w,h/2); x.stroke();
    x.fillStyle='rgba(142,151,181,.9)'; x.font='500 11px ui-sans-serif,sans-serif';
    x.fillText('play a note to wake the engine up', 14, h-14);
    return;
  }
  analyser.getByteFrequencyData(freqData);
  analyser.getByteTimeDomainData(waveData);
  const bars = 56, bw = w/bars;
  for(let i=0;i<bars;i++){
    const idx = Math.floor(Math.pow(i/bars, 1.8) * (freqData.length*0.6));
    const v = freqData[idx]/255;
    const bh = Math.pow(v,1.25) * h * 0.9;
    const g = x.createLinearGradient(0,h,0,h-bh);
    g.addColorStop(0,'rgba(110,231,255,.02)');
    g.addColorStop(1,'rgba(110,231,255,.34)');
    x.fillStyle = g;
    x.fillRect(i*bw + bw*0.18, h-bh, bw*0.64, bh);
  }
  const n = waveData.length;
  x.beginPath();
  for(let i=0;i<n;i++){
    const px = (i/(n-1))*w;
    const py = h/2 + (waveData[i]-128)/128 * h*0.40;
    i ? x.lineTo(px,py) : x.moveTo(px,py);
  }
  x.strokeStyle='#b98cff'; x.lineWidth=2; x.lineJoin='round';
  x.shadowColor='rgba(176,124,255,.9)'; x.shadowBlur=12;
  x.stroke(); x.shadowBlur=0;
}
function drawEnv(){
  const c = $("#envCanvas"); if(!c) return;
  const s = sizeCanvas(c); if(!s) return;
  const {x,w,h} = s;
  x.clearRect(0,0,w,h);
  const pad=9, A=P.attack, D=P.decay, S=P.sustain, R=P.release, HOLD=0.35;
  const total = Math.max(0.05, A+D+HOLD+R);
  const sx = t => pad + (t/total)*(w-pad*2);
  const y  = v => (h-pad) - v*(h-pad*2);
  x.strokeStyle='rgba(255,255,255,.06)'; x.lineWidth=1;
  for(let i=1;i<4;i++){ x.beginPath(); x.moveTo(0,h*i/4); x.lineTo(w,h*i/4); x.stroke(); }
  const pts=[[0,0.02],[A,1],[A+D,S],[A+D+HOLD,S],[A+D+HOLD+R,0.02]];
  x.beginPath(); x.moveTo(sx(0),y(0.02));
  const segs=[[A,1],[A+D,S],[A+D+HOLD,S],[A+D+HOLD+R,0.02]];
  for(const [t,v] of segs) x.lineTo(sx(t),y(v));
  x.lineTo(sx(A+D+HOLD+R),h-pad); x.lineTo(sx(0),h-pad); x.closePath();
  const g = x.createLinearGradient(0,0,0,h);
  g.addColorStop(0,'rgba(176,124,255,.34)'); g.addColorStop(1,'rgba(176,124,255,0)');
  x.fillStyle=g; x.fill();
  x.beginPath(); x.moveTo(sx(0),y(0.02));
  for(const [t,v] of segs) x.lineTo(sx(t),y(v));
  x.strokeStyle='#c39cff'; x.lineWidth=2; x.lineJoin='round'; x.stroke();
  x.fillStyle='rgba(200,210,240,.75)'; x.font='600 9px ui-monospace,monospace';
  x.fillText('A', sx(A/2)-3, h-2);
  x.fillText('D', sx(A+D/2)-3, h-2);
  x.fillText('S', sx(A+D+HOLD/2)-3, h-2);
  x.fillText('R', sx(A+D+HOLD+R/2)-3, h-2);
}
let curveFilter=null;
function drawFilterCurve(){
  const c = $("#filtCanvas"); if(!c) return;
  const s = sizeCanvas(c); if(!s) return;
  const {x,w,h} = s;
  x.clearRect(0,0,w,h);
  x.strokeStyle='rgba(255,255,255,.06)'; x.lineWidth=1;
  for(let i=1;i<4;i++){ x.beginPath(); x.moveTo(w*i/4,0); x.lineTo(w*i/4,h); x.stroke(); }
  if(!AC){ x.fillStyle='rgba(142,151,181,.8)'; x.font='500 10px ui-sans-serif'; x.fillText('power on to see response', 12, h/2+4); return; }
  if(!curveFilter) curveFilter = AC.createBiquadFilter();
  curveFilter.type = P.filterType; curveFilter.frequency.value = P.cutoff; curveFilter.Q.value = P.resonance;
  const N=220, freqs=new Float32Array(N), mag=new Float32Array(N), ph=new Float32Array(N);
  for(let i=0;i<N;i++) freqs[i] = 20*Math.pow(1000, i/(N-1));
  try{ curveFilter.getFrequencyResponse(freqs,mag,ph); }catch(e){ return; }
  const dbToY = db => h - ((clamp(db,-54,18)+54)/72)*h;
  x.beginPath();
  for(let i=0;i<N;i++){
    const px = (Math.log(freqs[i]/20)/Math.log(1000))*w;
    const db = 20*Math.log10(Math.max(1e-5,mag[i]));
    const py = dbToY(db);
    i?x.lineTo(px,py):x.moveTo(px,py);
  }
  x.lineTo(w,h); x.lineTo(0,h); x.closePath();
  const g = x.createLinearGradient(0,0,0,h);
  g.addColorStop(0,'rgba(255,204,102,.30)'); g.addColorStop(1,'rgba(255,204,102,0)');
  x.fillStyle=g; x.fill();
  x.beginPath();
  for(let i=0;i<N;i++){
    const px = (Math.log(freqs[i]/20)/Math.log(1000))*w;
    const db = 20*Math.log10(Math.max(1e-5,mag[i]));
    i?x.lineTo(px,dbToY(db)):x.moveTo(px,dbToY(db));
  }
  x.strokeStyle='#ffcc66'; x.lineWidth=2; x.stroke();
  const cx = (Math.log(P.cutoff/20)/Math.log(1000))*w;
  x.strokeStyle='rgba(255,204,102,.45)'; x.setLineDash([3,3]); x.lineWidth=1;
  x.beginPath(); x.moveTo(cx,0); x.lineTo(cx,h); x.stroke(); x.setLineDash([]);
  x.fillStyle='rgba(255,220,160,.85)'; x.font='600 9px ui-monospace,monospace';
  x.fillText('cutoff '+fhz(P.cutoff), Math.min(cx+4, w-70), 11);
}
function updateSeqUI(){
  if(!seqOn){
    if(drawnStep!==-1){
      document.querySelectorAll('.grid-seq .cell.playing').forEach(c=>c.classList.remove('playing'));
      drawnStep=-1;
    }
    return;
  }
  if(uiStep===drawnStep) return;
  drawnStep=uiStep;
  document.querySelectorAll('.grid-seq .cell').forEach(c=>{
    c.classList.toggle('playing', +c.dataset.c === uiStep);
  });
}
function loop(){
  requestAnimationFrame(loop);
  drawScope();
  if(seqOn && AC){
    seqTick();
    const now = AC.currentTime;
    while(flashQueue.length && flashQueue[0].t <= now){
      const f = flashQueue.shift();
      const el = keyEls.get(f.midi);
      if(el){ el.classList.add('active'); setTimeout(()=>{ if(!voices.has(f.midi)) el.classList.remove('active'); }, 170); }
    }
    if(flashQueue.length > 96) flashQueue.length = 0;
  }
  updateSeqUI();
}

/* ==========================================================================
   KEYBOARD
   ========================================================================== */
const KBLOW=60, NKEYS=24, WHITES=14, wIdx=[0,1,1,2,2,3,4,4,5,5,6,6];
const KEYMAP={};
"awsedftgyhujkolp;".split('').forEach((k,i)=>{ KEYMAP[k]=KBLOW+i; });
function buildKeyboard(){
  const kb = $("#keyboard"); kb.innerHTML='';
  const ww = 100/WHITES, bw = ww*0.62;
  for(let i=0;i<NKEYS;i++){
    const midi=KBLOW+i, s=i%12, oct=Math.floor(i/12);
    const black=[1,3,6,8,10].indexOf(s)>-1;
    const el=document.createElement('div');
    el.className = black?'bkey':'wkey';
    el.dataset.midi=midi;
    if(black){
      el.style.left = ((oct*7 + wIdx[s] + 1)*ww - bw/2) + '%';
      el.style.width = bw + '%';
    }else{
      el.style.left = ((oct*7 + wIdx[s])*ww) + '%';
      el.style.width = ww + '%';
    }
    if(midi%12===0){
      const l=document.createElement('div'); l.className='key-lbl';
      l.textContent='C'+(Math.floor(midi/12)-1);
      el.appendChild(l);
    }
    kb.appendChild(el); keyEls.set(midi, el);
  }
  const kbEl = kb;
  let pressed=false, cur=null;
  const at = e => {
    const t = document.elementFromPoint(e.clientX, e.clientY);
    const k = t && t.closest ? t.closest('.wkey,.bkey') : null;
    return k ? +k.dataset.midi : null;
  };
  kbEl.addEventListener('pointerdown', e=>{
    e.preventDefault();
    try{ kbEl.setPointerCapture(e.pointerId); }catch(err){}
    pressed=true;
    const m = at(e); if(m!=null){ cur=m; noteOn(m); }
  });
  kbEl.addEventListener('pointermove', e=>{
    if(!pressed) return;
    const m = at(e);
    if(m!==cur){
      if(cur!=null) noteOff(cur);
      cur=m;
      if(m!=null) noteOn(m);
    }
  });
  const up = ()=>{ if(!pressed) return; pressed=false; if(cur!=null) noteOff(cur); cur=null; };
  window.addEventListener('pointerup', up);
  window.addEventListener('pointercancel', up);
  kbEl.addEventListener('pointerleave', ()=>{ if(pressed && cur!=null){ noteOff(cur); cur=null; } });
}
window.addEventListener('keydown', e=>{
  if(e.repeat || e.metaKey || e.ctrlKey || e.altKey) return;
  const tag = (e.target.tagName||'').toLowerCase();
  if(tag==='input'||tag==='textarea') return;
  const m = KEYMAP[(e.key||'').toLowerCase()];
  if(m!=null){ e.preventDefault(); noteOn(m); }
});
window.addEventListener('keyup', e=>{
  const m = KEYMAP[(e.key||'').toLowerCase()];
  if(m!=null) noteOff(m);
});

/* ==========================================================================
   LESSON CONTROLLER
   ========================================================================== */
const CHECKS = {
  osc:    ()=> flags.played && flags.waves.size>=4,
  env:    ()=> flags.env,
  filter: ()=> flags.sweep,
  lfo:    ()=> flags.lfo,
  fx:     ()=> flags.fx,
  seq:    ()=> flags.seq
};
let toastTimer=null;
function toast(msg){
  const t=$("#toast"); t.textContent=msg; t.classList.add('show');
  clearTimeout(toastTimer);
  toastTimer=setTimeout(()=>t.classList.remove('show'), 3600);
}
function refreshFlagUI(){
  if(!LESSONS[current]) return;
  const done = CHECKS[LESSONS[current].key]();
  $("#task").classList.toggle('met', done);
  const next = $("#nextBtn");
  next.disabled = false;
  next.classList.toggle('ready', done && current < LESSONS.length-1);
  next.textContent = current >= LESSONS.length-1 ? 'Loop is running ✓' : 'Continue ▸';
}
function focusLesson(){ refreshFlagUI(); }

function renderLesson(){
  const L = LESSONS[current];
  $("#kicker").textContent = L.kicker;
  $("#lessonTitle").innerHTML = L.title;
  $("#lessonBody").innerHTML = L.body;
  $("#taskText").textContent = L.task;
  document.documentElement.style.setProperty('--acc', L.acc);
  // recipe buttons
  let html = `<b>${L.key==='seq'?'🎛️':'💡'} ${L.key==='seq'?'Where you ended up':'Try this'}</b><br>${L.hint}`;
  if(L.recipes.length){
    html += `<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">` +
      L.recipes.map((r,i)=>`<button class="btn" data-recipe="${L.key}:${i}" style="padding:6px 11px;font-size:11.5px">${r[0]}</button>`).join('') + `</div>`;
  }
  $("#hintbox").innerHTML = html;
  $("#hintbox").querySelectorAll('[data-recipe]').forEach(b=>{
    b.addEventListener('click', ()=>{
      const [k,i] = b.dataset.recipe.split(':');
      const L2 = LESSONS.find(l=>l.key===k);
      applyPreset(L2.recipes[+i][1]);
      b.classList.add('hot'); setTimeout(()=>b.classList.remove('hot'), 1200);
      toast('Applied "'+L2.recipes[+i][0]+'" — now play a note');
    });
  });
  // progress dots
  const bar = $("#progress");
  bar.innerHTML = LESSONS.map((l,i)=>{
    const cls = i<current ? 'pdot done' : (i===current ? 'pdot cur' : 'pdot');
    return `<span class="${cls}"></span>`;
  }).join('') + `<span class="pcount" id="pcount">${current+1} / ${LESSONS.length}</span>`;
  refreshFlagUI();
}
function unlockNext(){
  const nx = document.querySelector(`.panel[data-m="${LESSONS[current].key}"]`);
  if(nx){ nx.classList.remove('locked'); nx.classList.add('unlocking'); setTimeout(()=>nx.classList.remove('unlocking'),900); }
  if(current < LESSONS.length-1){
    const key = LESSONS[current+1].key;
    const p = document.querySelector(`.panel[data-m="${key}"]`);
    if(p){ p.classList.remove('locked'); p.classList.add('unlocking'); setTimeout(()=>p.classList.remove('unlocking'),900);
      p.scrollIntoView({behavior:'smooth', block:'nearest', inline:'center'}); }
  }
}

/* ==========================================================================
   INIT
   ========================================================================== */
function init(){
  buildKeyboard();
  buildPanels();
  renderLesson();
  drawEnv(); drawFilterCurve();

  // first panel is available from the start
  const first = document.querySelector('.panel[data-m="osc"]');
  if(first) first.classList.remove('locked');

  $("#nextBtn").addEventListener('click', ()=>{
    unlockNext();
    if(current < LESSONS.length-1){ current++; renderLesson(); }
    else toast('You finished the chain. Now go make some noise.');
  });
  $("#skipBtn").addEventListener('click', ()=>{
    unlockNext();
    if(current < LESSONS.length-1){ current++; renderLesson(); toast('Skipped ahead — the explanation is in the panel.'); }
  });
  $("#master").addEventListener('input', e=>{
    P.volume = (+e.target.value)/100;
    if(master) master.gain.setTargetAtTime(P.volume*0.9, AC.currentTime, .03);
  });
  $("#panic").addEventListener('click', ()=>{ allOff(); });

  const ro = ()=>{
    drawEnv(); drawFilterCurve(); sizeCanvas(scope);
  };
  window.addEventListener('resize', ro);
  if(window.ResizeObserver) new ResizeObserver(ro).observe(document.body);

  requestAnimationFrame(loop);
}
init();

/* small public handle (handy for tinkering in the console) */
window.SynthLab = {
  P, noteOn, noteOff, allOff, applyPreset,
  get ctx(){ return AC; },
  get voices(){ return voices; },
  get analyser(){ return analyser; },
  get nodes(){ return {mixBus, shaper, dryIn, postGain, master, delayNode, wetGain, lfo}; },
  get step(){ return uiStep; },
  flags
};
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"d70067e1-1bfd-41dc-8e19-690fcee0baf6","tokensIn":2617271,"tokensOut":53684,"tokensTotal":2670955,"cost":0.046147866,"turns":42,"toolCalls":41,"failedToolCalls":0,"timestamp":"2026-09-10T23:46:00.111Z"} -->