← TutSynth results

TutSynth

muse spark 1.2 · basic

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 52,371 bytes · SHA-256 2e1532f372f9
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SYNTH PLAYGROUND — Learn by Building Sound</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Space+Grotesk:wght@400;500;700&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
<style>
:root{
  --bg:#080a0f;
  --panel:#13161f;
  --panel2:#1a1e2b;
  --border:#262c3f;
  --accent:#00ffd1;
  --accent2:#ff3b82;
  --accent3:#7c5cff;
  --text:#e6e8ee;
  --muted:#8a90a8;
  --yellow:#ffd028;
  --radius:16px;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--text);font-family:'Space Grotesk',system-ui,sans-serif;overflow-x:hidden}
button{font:inherit}
a{color:inherit}
.topbar{
  height:56px;display:flex;align-items:center;justify-content:space-between;
  padding:0 18px;border-bottom:1px solid var(--border);background:rgba(8,10,15,0.9);backdrop-filter:blur(10px);
  position:sticky;top:0;z-index:50
}
.logo{display:flex;align-items:center;gap:12px;font-weight:700;letter-spacing:0.04em}
.logo-mark{width:32px;height:32px;background:var(--accent);color:#000;display:grid;place-items:center;font-family:'JetBrains Mono',monospace;font-weight:700;border-radius:6px}
.logo span{font-family:'JetBrains Mono',monospace;font-size:13px}
.topbar-right{display:flex;align-items:center;gap:14px}
.badge{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.08em;background:#1a1e2b;border:1px solid var(--border);padding:6px 10px;border-radius:999px;color:var(--muted)}
.power-btn{
  width:44px;height:26px;background:#1a1e2b;border:1px solid var(--border);border-radius:999px;position:relative;cursor:pointer;display:flex;align-items:center;padding:2px
}
.power-knob{width:20px;height:20px;background:var(--accent);border-radius:50%;transition:0.25s;box-shadow:0 0 10px rgba(0,255,209,0.5)}
.power-btn.on .power-knob{transform:translateX(18px);background:var(--accent)}
.power-btn.off .power-knob{transform:translateX(0);background:#3a4058}
.meter{width:90px;height:6px;background:#1a1e2b;border-radius:999px;overflow:hidden;border:1px solid var(--border)}
.meter-fill{height:100%;width:36%;background:linear-gradient(90deg,var(--accent),var(--yellow));}
.progress-wrap{
  display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--border);background:#0d0f16;overflow-x:auto
}
.step{
  display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:999px;border:1px solid var(--border);background:var(--panel);cursor:pointer;white-space:nowrap;transition:0.2s;opacity:0.5
}
.step.active{opacity:1;border-color:var(--accent);background:#11141e;box-shadow:0 0 0 1px rgba(0,255,209,0.2)}
.step.done{opacity:1;border-color:#2a3350}
.step.done .step-num{background:var(--accent);color:#000}
.step-num{width:26px;height:26px;border-radius:50%;background:#1e2335;color:var(--muted);display:grid;place-items:center;font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700}
.step.active .step-num{background:var(--accent);color:#000}
.step-label{font-size:13px;font-weight:600}
.step-sub{font-size:11px;color:var(--muted);font-family:'JetBrains Mono',monospace}
.arrow{color:var(--muted);opacity:0.4}
.layout{
  display:grid;grid-template-columns:420px 1fr;min-height:calc(100vh - 112px)
}
@media(max-width:980px){.layout{grid-template-columns:1fr}}
.lesson{
  border-right:1px solid var(--border);background:linear-gradient(180deg,#0f121b 0%, #0a0c12 100%);padding:18px;display:flex;flex-direction:column;gap:16px;
}
.lesson-card{
  background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;
}
.lesson-head{padding:18px 18px 0 18px}
.kicker{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.14em;color:var(--accent);font-weight:700}
.lesson-title{font-family:'Instrument Serif',serif;font-size:30px;line-height:0.95;margin:6px 0 8px}
.lesson-desc{color:var(--muted);font-size:13.5px;line-height:1.55}
.lesson-viz{margin:14px 18px;background:#0a0d14;border:1px solid var(--border);border-radius:12px;padding:12px;position:relative;overflow:hidden}
.lesson-viz canvas{width:100%;height:110px;display:block}
.viz-label{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.1em;color:var(--muted);display:flex;justify-content:space-between;margin-bottom:6px}
.tasks{background:#0a0d14;border:1px solid var(--border);border-radius:12px;padding:14px;margin:0 18px 14px}
.task{ display:flex;gap:10px;align-items:flex-start;font-size:13px;line-height:1.4;padding:8px 0;border-bottom:1px dashed #23293e}
.task:last-child{border:none}
.task input{accent-color:var(--accent);margin-top:3px}
.task b{color:var(--text)}
.btn-primary{
  background:var(--accent);color:#000;border:none;padding:12px 16px;border-radius:999px;font-weight:700;font-size:13px;cursor:pointer;width:100%;display:flex;justify-content:center;align-items:center;gap:8px;
}
.btn-ghost{background:transparent;border:1px solid var(--border);color:var(--text);padding:10px 14px;border-radius:999px;font-size:12px;font-family:'JetBrains Mono',monospace;cursor:pointer}
.lesson-foot{padding:0 18px 18px;display:flex;gap:10px}
.hint{font-size:11px;color:var(--muted);font-family:'JetBrains Mono',monospace;text-align:center;padding:0 18px 6px}
.synth{
  padding:18px;background:
    radial-gradient(600px 400px at 70% 0%, rgba(124,92,255,0.12), transparent 60%),
    radial-gradient(500px 300px at 0% 0%, rgba(0,255,209,0.08), transparent 60%),
    var(--bg);
}
.oscilloscope{
  background:#0e111a;border:1px solid var(--border);border-radius:16px;padding:12px;margin-bottom:14px;position:relative;overflow:hidden
}
.scope-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.scope-title{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.1em;color:var(--muted)}
.scope-actions{display:flex;gap:8px}
.pill{font-family:'JetBrains Mono',monospace;font-size:10px;padding:5px 8px;border-radius:999px;border:1px solid var(--border);background:#151a27;color:var(--muted)}
.pill.live{color:var(--accent);border-color:rgba(0,255,209,0.3);background:rgba(0,255,209,0.08)}
.scope-canvas-wrap{background:#070a11;border:1px solid #1c2132;border-radius:12px;position:relative;height:148px;overflow:hidden}
#scope{width:100%;height:100%;display:block}
.scope-grid{position:absolute;inset:0;background:repeating-linear-gradient(to right, transparent 0 39px, rgba(255,255,255,0.03) 40px), repeating-linear-gradient(to bottom, transparent 0 23px, rgba(255,255,255,0.03) 24px);pointer-events:none}
.scope-overlay{position:absolute;bottom:10px;left:10px;display:flex;gap:6px}
.tag{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.6);border:1px solid rgba(255,255,255,0.1);padding:4px 6px;border-radius:6px;color:#b9c0d9;backdrop-filter:blur(6px)}
.modules{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@media(max-width:700px){.modules{grid-template-columns:1fr}}
.module{
  background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:14px;position:relative;overflow:hidden;transition:0.2s
}
.module.locked{opacity:0.45;filter:grayscale(0.2)}
.module.locked::after{content:"LOCKED — complete previous lesson";position:absolute;inset:0;background:rgba(8,10,15,0.55);backdrop-filter:blur(1px);display:grid;place-items:center;font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.08em;color:#fff;text-align:center;padding:20px}
.module-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.module-title{font-weight:800;font-size:13px;letter-spacing:0.06em;display:flex;align-items:center;gap:8px}
.dot{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent)}
.module-num{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted);background:#0e111a;border:1px solid var(--border);padding:4px 7px;border-radius:999px}
.wave-choices{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:12px}
.wave-btn{
  background:#0e111a;border:1px solid var(--border);border-radius:10px;padding:8px 6px;cursor:pointer;text-align:center;transition:0.15s
}
.wave-btn.active{border-color:var(--accent);background:rgba(0,255,209,0.08);box-shadow:0 0 0 1px rgba(0,255,209,0.15) inset}
.wave-icon{height:28px;display:grid;place-items:center;margin-bottom:4px}
.wave-btn span{font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:700;color:var(--muted)}
.wave-btn.active span{color:var(--accent)}
.ctrl-row{display:flex;gap:10px;margin-bottom:10px}
.ctrl{flex:1;background:#0e111a;border:1px solid var(--border);border-radius:12px;padding:10px}
.ctrl label{font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:0.1em;color:var(--muted);display:flex;justify-content:space-between}
.ctrl input[type=range]{width:100%;margin:8px 0 2px;accent-color:var(--accent);height:3px}
.ctrl .val{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--yellow)}
.knob-row{display:flex;gap:10px}
.note{font-size:10px;color:var(--muted);line-height:1.4}
.filter-viz{height:70px;background:#070a11;border:1px solid #1c2132;border-radius:10px;margin-bottom:10px;position:relative;overflow:hidden}
.filter-viz canvas{width:100%;height:100%;display:block}
.toggle-row{display:flex;gap:8px;margin-bottom:10px}
.toggle{flex:1;padding:8px;border-radius:999px;border:1px solid var(--border);background:#0e111a;font-family:'JetBrains Mono',monospace;font-size:11px;cursor:pointer;text-align:center;color:var(--muted)}
.toggle.on{background:var(--accent);color:#000;border-color:var(--accent);font-weight:700}
.seq-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:6px;margin-bottom:10px}
.seq-step{aspect-ratio:1;background:#0e111a;border:1px solid var(--border);border-radius:8px;cursor:pointer;position:relative;overflow:hidden}
.seq-step.on{background:var(--accent);border-color:var(--accent);box-shadow:0 0 12px rgba(0,255,209,0.4)}
.seq-step.playing{outline:2px solid var(--yellow)}
.seq-step b{position:absolute;bottom:2px;left:50%;transform:translateX(-50%);font-family:'JetBrains Mono',monospace;font-size:8px;color:inherit}
.keyboard-wrap{
  margin-top:14px;background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:12px
}
.kb-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
.kb-title{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.08em;color:var(--muted)}
.oct-shift{display:flex;gap:6px;align-items:center}
.oct-btn{width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:#0e111a;color:var(--text);cursor:pointer;display:grid;place-items:center}
.piano{position:relative;display:flex;user-select:none;overflow:hidden;border-radius:10px;border:1px solid #1e2335;background:#0a0d14;height:108px}
.white{flex:1;background:linear-gradient(180deg,#f8f9ff 0%, #e6e8ee 100%);border-right:1px solid #c9cde0;position:relative;cursor:pointer;display:flex;align-items:flex-end;justify-content:center;padding-bottom:8px;font-family:'JetBrains Mono',monospace;font-size:10px;color:#5a638a}
.white.active{background:var(--accent)}
.white:last-child{border:none}
.black{position:absolute;top:0;width:5.5%;height:62%;background:linear-gradient(180deg,#1a1e2b,#0a0d14);border:1px solid #000;border-radius:0 0 6px 6px;z-index:2;cursor:pointer;display:grid;place-items:end center;padding-bottom:6px;color:#8a90a8;font-size:8px}
.black.active{background:var(--accent2)}
.preset-row{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
.preset{padding:7px 12px;border-radius:999px;border:1px solid var(--border);background:#151a27;font-family:'JetBrains Mono',monospace;font-size:11px;cursor:pointer;color:var(--muted)}
.preset.active{background:var(--yellow);color:#000;border-color:var(--yellow);font-weight:700}
.level-badge{position:absolute;top:10px;right:10px;font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:0.08em;background:var(--yellow);color:#000;padding:4px 8px;border-radius:999px;font-weight:700}
</style>
</head>
<body>
<div class="topbar">
  <div class="logo"><div class="logo-mark">◈</div><span>SYNTH PLAYGROUND</span><span style="color:var(--muted);font-weight:400;margin-left:6px">LEARN BY BUILDING SOUND</span></div>
  <div class="topbar-right">
    <div class="badge" id="powerLabel">AUDIO • CLICK TO START</div>
    <div class="power-btn off" id="powerBtn" title="Toggle audio"><div class="power-knob"></div></div>
    <div class="meter"><div class="meter-fill" id="meterFill"></div></div>
  </div>
</div>

<div class="progress-wrap" id="progress"></div>

<div class="layout">
  <aside class="lesson">
    <div class="lesson-card" id="lessonCard"></div>
    <div class="hint">TIP: Use your computer keyboard <b style="color:var(--yellow)">A S D F G H J</b> to play • <b>Z / X</b> octave</div>
    <div style="display:flex;gap:8px">
      <button class="btn-ghost" style="flex:1" id="prevBtn">← Previous</button>
      <button class="btn-ghost" style="flex:1" id="nextBtn">Next →</button>
    </div>
  </aside>

  <main class="synth">
    <div class="oscilloscope">
      <div class="scope-top">
        <div class="scope-title">● OSCILLOSCOPE & SPECTRUM — REAL TIME OUTPUT</div>
        <div class="scope-actions"><span class="pill live">● LIVE</span><span class="pill" id="noteDisplay">—</span></div>
      </div>
      <div class="scope-canvas-wrap">
        <div class="scope-grid"></div>
        <canvas id="scope" width="900" height="148"></canvas>
        <div class="scope-overlay"><span class="tag" id="waveTag">SAW • 440Hz</span><span class="tag" id="filterTag">FILTER 3.2kHz</span><span class="tag" id="adsrTag">ADSR 0.1 / 0.2 / 0.7 / 0.4</span></div>
      </div>
    </div>

    <div class="preset-row" id="presetRow"></div>

    <div class="modules" id="modules"></div>

    <div class="keyboard-wrap">
      <div class="kb-top">
        <div class="kb-title">KEYBOARD — CLICK, DRAG, OR TYPE</div>
        <div class="oct-shift">
          <span style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">OCTAVE <b id="octLabel" style="color:var(--yellow)">4</b></span>
          <button class="oct-btn" id="octDown">−</button>
          <button class="oct-btn" id="octUp">+</button>
          <span style="font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--muted);margin-left:6px">VEL <span id="velLabel" style="color:var(--text)">80%</span></span>
          <input type="range" id="velSlider" min="20" max="127" value="100" style="width:80px;accent-color:var(--accent)">
        </div>
      </div>
      <div class="piano" id="piano"></div>
      <div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap">
        <span class="pill">A S D F G H J K = white keys</span>
        <span class="pill">W E T Y U = black keys</span>
        <span class="pill">HOLD SHIFT = sustain</span>
      </div>
    </div>
  </main>
</div>

<script>
const lessons = [
 {
  kicker:"LEVEL 01 — THE SOURCE",
  title:"The Oscillator:<br>Where sound begins",
  desc:`Every synth sound starts with a <b style="color:var(--accent)">vibrating oscillator</b>. It just wiggles back and forth — the shape of that wiggle <i>is</i> the timbre. No oscillator, no sound.`,
  viz:"wave",
  tasks:[
    "Switch between <b>Sine</b> (pure, flute-like) and <b>Saw</b> (bright, buzzy — sum of all harmonics)",
    "Play a low note vs high note. Hear how <b>frequency = pitch</b>",
    "Try <b>Square</b> — hollow, video-gamey (only odd harmonics)"
  ],
  try:"Try this: Hold <b>A</b> (C) and flip waveforms while the note sustains — hear the harmonic content change in the scope."
 },
 {
  kicker:"LEVEL 02 — SHAPING TIME",
  title:"ADSR Envelope:<br>Sculpting volume",
  desc:`A raw oscillator drones forever. An <b style="color:var(--yellow)">ADSR envelope</b> shapes loudness over time: <b>Attack</b> (fade in), <b>Decay</b> (fall to Sustain), <b>Sustain</b> (held level), <b>Release</b> (fade out). Pluck vs pad is just ADSR.`,
  viz:"adsr",
  tasks:[
    "Set <b>Attack 0, Decay 0.2, Sustain 0, Release 0.2</b> → sharp pluck",
    "Set <b>Attack 0.8, Sustain 0.9</b> → slow swelling pad",
    "Notice the envelope drawing mirrors the volume you hear"
  ],
  try:"Challenge: Make a percussive <b>‘blip’</b> with almost no sustain and short release."
 },
 {
  kicker:"LEVEL 03 — COLOUR",
  title:"The Filter:<br>Remove to reveal",
  desc:`Subtractive synthesis = start bright, then <b style="color:var(--accent2)">carve away</b>. A <b>Low-Pass Filter</b> lets lows through, shaves off highs. <b>Cutoff</b> = where it starts cutting, <b>Resonance</b> = peak at the cutoff (that classic squelch).`,
  viz:"filter",
  tasks:[
    "Start with Saw wave, <b>Low-pass, Cutoff ~800Hz</b> → muffled & warm",
    "Crank <b>Resonance to 8+</b> and sweep cutoff → acid whistle",
    "Switch to <b>High-pass</b> → thin, telephone-like"
  ],
  try:"Try: Play a chord, slowly sweep cutoff from 200Hz → 8000Hz. You’re literally hearing harmonics being removed."
 },
 {
  kicker:"LEVEL 04 — MOVEMENT",
  title:"LFO & Modulation:<br>Sound that moves",
  desc:`An <b style="color:var(--accent3)">LFO (Low Frequency Oscillator)</b> wobbles too slow to hear, but perfect to <i>wiggle</i> other controls. Route it to <b>pitch → vibrato</b>, to <b>filter → wobble</b>, to <b>volume → tremolo</b>.`,
  viz:"lfo",
  tasks:[
    "Target = <b>Pitch</b>, Rate 5Hz, Depth 30% → classic vibrato",
    "Target = <b>Filter</b>, Rate 2Hz, Depth 70% → dub wobble",
    "Rate 0.5Hz + high depth → slow sweeping evolution"
  ],
  try:"Pro tip: Fast LFO on pitch (30Hz+) stops sounding like vibrato and starts sounding like FM metallics."
 },
 {
  kicker:"LEVEL 05 — SPACE & GRIT",
  title:"Effects:<br>Delay, Reverb & Drive",
  desc:`<b>Delay</b> = echo in time, <b>Reverb</b> = echo in space, <b>Distortion</b> = adds harmonics by clipping. Effects are post-synth, they turn a dry beep into a record.`,
  viz:"fx",
  tasks:[
    "Turn on <b>Delay 320ms, Feedback 40%</b> → rhythmic slapback",
    "Add <b>Reverb 50%</b> → put it in a hall",
    "Push <b>Drive 40%</b> on a Saw → angry and thick"
  ],
  try:"Stack them: Mild drive + short delay + long reverb = instant lo-fi pad."
 },
 {
  kicker:"LEVEL 06 — PATTERNS",
  title:"Sequencer:<br>Let it play itself",
  desc:`A <b>16-step sequencer</b> loops your notes. Each step = 1/16th note. Activate steps to create ostinatos, basslines, arpeggios. This is how electronic music composes itself.`,
  viz:"seq",
  tasks:[
    "Click steps <b>1, 5, 9, 13</b> → four-on-the-floor",
    "Press <b>PLAY</b>, tweak filter cutoff while it loops",
    "Change <b>Tempo</b> and hear the LFO drift against the sequence"
  ],
  try:"Final challenge: Make a 2-bar bassline, add filter LFO, and you’ve got a track."
 }
];

let currentLevel = 0;
let audioCtx, masterGain, analyser, delayNode, delayFb, delayMix, reverbNode, reverbGain, reverbMix, distortionNode, convolverReady=false;
let octave=4, velocity=100, isSustain=false;
let voices=new Map();
let settings={
  wave:'sawtooth',
  detune:0,
  attack:0.02, decay:0.18, sustain:0.65, release:0.35,
  filterType:'lowpass', cutoff:2800, resonance:1.2,
  lfoRate:4.5, lfoDepth:0, lfoTarget:'pitch',
  delayOn:false, delayTime:0.32, delayFb:0.35, delayMix:0.25,
  reverbOn:false, reverbMix:0.3,
  drive:0,
  tempo:120
};
const presets=[
  {name:'Init Saw', wave:'sawtooth', cutoff:8000, resonance:0.7, attack:0.01, decay:0.1, sustain:0.8, release:0.2, drive:0},
  {name:'Warm Pad', wave:'sawtooth', cutoff:1200, resonance:1.2, attack:0.6, decay:0.4, sustain:0.85, release:0.9, lfoDepth:0.25, lfoTarget:'filter', lfoRate:1.2},
  {name:'Pluck', wave:'sine', cutoff:2600, resonance:2.5, attack:0, decay:0.25, sustain:0, release:0.25, drive:0.15},
  {name:'Acid Bass', wave:'square', cutoff:700, resonance:8, attack:0, decay:0.15, sustain:0.4, release:0.15, drive:0.4, lfoDepth:0.35, lfoTarget:'filter', lfoRate:2.4},
  {name:'Wobble', wave:'sawtooth', cutoff:900, resonance:4, attack:0.05, decay:0.2, sustain:0.7, release:0.3, lfoDepth:0.75, lfoTarget:'filter', lfoRate:2.0},
  {name:'Crystal', wave:'triangle', cutoff:5000, resonance:0.9, attack:0.15, decay:0.6, sustain:0.5, release:0.8, delayOn:true, reverbOn:true, drive:0},
];

function initAudio(){
  if(audioCtx) return;
  audioCtx = new (window.AudioContext||window.webkitAudioContext)();
  masterGain = audioCtx.createGain(); masterGain.gain.value=0.7;
  analyser = audioCtx.createAnalyser(); analyser.fftSize=2048;
  // effects
  distortionNode = audioCtx.createWaveShaper(); distortionNode.curve=makeDistortion(0); distortionNode.oversample='4x';
  delayNode = audioCtx.createDelay(1); delayNode.delayTime.value=settings.delayTime;
  delayFb = audioCtx.createGain(); delayFb.gain.value=settings.delayFb;
  delayMix = audioCtx.createGain(); delayMix.gain.value=0;
  reverbNode = audioCtx.createConvolver();
  reverbGain = audioCtx.createGain(); reverbGain.gain.value=0;
  // routing: master -> analyser -> destination, with effect sends
  masterGain.connect(analyser);
  analyser.connect(audioCtx.destination);

  // delay loop
  delayNode.connect(delayFb); delayFb.connect(delayNode);
  // reverb impulse
  buildReverb(2.2, 0.85);
  document.getElementById('powerBtn').className='power-btn on';
  document.getElementById('powerLabel').textContent='AUDIO • ON';
  document.getElementById('powerLabel').style.color='var(--accent)';
}
function ensureAudio(){ if(!audioCtx) initAudio(); if(audioCtx && audioCtx.state==='suspended') audioCtx.resume(); }

function makeDistortion(amount){
  const k = amount*100;
  const n=44100, curve=new Float32Array(n);
  for(let i=0;i<n;i++){
    const x=i*2/n-1;
    curve[i]=(3+k)*x*20*Math.PI/180 / (Math.PI + k*Math.abs(x));
  }
  return curve;
}
function buildReverb(sec, decay){
  if(!audioCtx) return;
  const rate=audioCtx.sampleRate, len=rate*sec, impulse=audioCtx.createBuffer(2, len, rate);
  for(let c=0;c<2;c++){
    const ch=impulse.getChannelData(c);
    for(let i=0;i<len;i++) ch[i]=(Math.random()*2-1)*Math.pow(1-i/len, decay);
  }
  reverbNode.buffer=impulse;
}

function midiToFreq(m){ return 440*Math.pow(2,(m-69)/12); }

function noteOn(midi, vel=100){
  ensureAudio();
  const freq=midiToFreq(midi);
  const now=audioCtx.currentTime;
  const osc=audioCtx.createOscillator(); osc.type=settings.wave; osc.frequency.value=freq;
  osc.detune.value=settings.detune;
  const filt=audioCtx.createBiquadFilter(); filt.type=settings.filterType; filt.frequency.value=settings.cutoff; filt.Q.value=settings.resonance;
  const amp=audioCtx.createGain(); amp.gain.value=0;
  // envelope
  amp.gain.setValueAtTime(0, now);
  amp.gain.linearRampToValueAtTime(1, now+Math.max(0.001,settings.attack));
  amp.gain.linearRampToValueAtTime(settings.sustain, now+settings.attack+settings.decay);
  // chain
  let src=osc;
  // drive first
  if(settings.drive>0.01){
    distortionNode.curve=makeDistortion(settings.drive);
    src.connect(distortionNode); src=distortionNode;
    // need to reconnect: use intermediate gain to avoid sharing distortion node between voices causing glitch
    // Create per-voice shaper
    const ws=audioCtx.createWaveShaper(); ws.curve=makeDistortion(settings.drive); ws.oversample='4x';
    osc.disconnect(); osc.connect(ws); ws.connect(filt);
  } else {
    osc.connect(filt);
  }
  filt.connect(amp);
  // effects sends
  amp.connect(masterGain);
  if(settings.delayOn){
    const dSend=audioCtx.createGain(); dSend.gain.value=settings.delayMix;
    amp.connect(dSend); dSend.connect(delayNode);
    delayNode.connect(masterGain);
  }
  if(settings.reverbOn){
    const rSend=audioCtx.createGain(); rSend.gain.value=settings.reverbMix;
    amp.connect(rSend); rSend.connect(reverbNode); reverbNode.connect(masterGain);
  }
  // lfo
  let lfo, lfoGain;
  if(settings.lfoDepth>0.01){
    lfo=audioCtx.createOscillator(); lfo.frequency.value=settings.lfoRate;
    lfoGain=audioCtx.createGain();
    if(settings.lfoTarget==='pitch') lfoGain.gain.value=settings.lfoDepth*60; // cents-ish
    else if(settings.lfoTarget==='filter') lfoGain.gain.value=settings.lfoDepth*2500;
    else lfoGain.gain.value=settings.lfoDepth*0.6;
    lfo.connect(lfoGain);
    if(settings.lfoTarget==='pitch') lfoGain.connect(osc.frequency);
    else if(settings.lfoTarget==='filter') lfoGain.connect(filt.frequency);
    else lfoGain.connect(amp.gain);
    lfo.start(now);
  }
  osc.start(now);
  voices.set(midi, {osc,filt,amp,lfo, lfoGain, vel, midi});
  updateMeter();
  setNoteDisplay(midi);
}
function noteOff(midi){
  const v=voices.get(midi);
  if(!v||!audioCtx) return;
  const now=audioCtx.currentTime;
  const rel=Math.max(0.01, settings.release);
  v.amp.gain.cancelScheduledValues(now);
  v.amp.gain.setValueAtTime(v.amp.gain.value, now);
  v.amp.gain.linearRampToValueAtTime(0, now+rel);
  v.osc.stop(now+rel+0.05);
  if(v.lfo) v.lfo.stop(now+rel+0.05);
  setTimeout(()=>voices.delete(midi), (rel+0.1)*1000);
  setNoteDisplay(null);
  updateMeter();
}

function setNoteDisplay(midi){
  const el=document.getElementById('noteDisplay');
  if(midi==null){ el.textContent='—'; return;}
  const names=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
  el.textContent = names[midi%12]+Math.floor(midi/12-1) + ' • ' + Math.round(midiToFreq(midi))+'Hz';
}

// UI Builders
function renderProgress(){
  const c=document.getElementById('progress');
  c.innerHTML='';
  lessons.forEach((l,i)=>{
    const s=document.createElement('div');
    s.className='step '+(i===currentLevel?'active':'')+' '+(i<currentLevel?'done':'');
    s.innerHTML=`<div class="step-num">${i+1}</div><div><div class="step-label">${l.kicker.split('—')[1].trim()}</div><div class="step-sub">${['OSC','ENV','FLT','LFO','FX','SEQ'][i]}</div></div>`;
    s.onclick=()=>{ currentLevel=i; renderAll(); };
    c.appendChild(s);
    if(i<lessons.length-1){
      const a=document.createElement('div'); a.className='arrow'; a.textContent='→'; c.appendChild(a);
    }
  });
}
function renderLesson(){
  const l=lessons[currentLevel];
  const card=document.getElementById('lessonCard');
  card.innerHTML=`
    <div class="lesson-head">
      <div class="kicker">${l.kicker}</div>
      <div class="lesson-title">${l.title}</div>
      <div class="lesson-desc">${l.desc}</div>
    </div>
    <div class="lesson-viz" id="lessonVizBox">
      <div class="viz-label"><span>${l.viz==='wave'?'WAVE SHAPE':l.viz==='adsr'?'ENVELOPE SHAPE':l.viz==='filter'?'FILTER RESPONSE':l.viz==='lfo'?'LFO ROUTING':l.viz==='fx'?'EFFECTS CHAIN':'SEQUENCER'}</span><span style="color:var(--yellow)">LEVEL ${currentLevel+1}/6</span></div>
      <canvas id="lessonCanvas" width="380" height="110"></canvas>
    </div>
    <div class="tasks">
      ${l.tasks.map(t=>`<label class="task"><input type="checkbox"><span>${t}</span></label>`).join('')}
      <div style="margin-top:10px;background:#111526;border:1px solid #252c45;border-radius:10px;padding:10px;font-size:12px;line-height:1.5"><span style="color:var(--yellow);font-weight:700">TRY THIS →</span> ${l.try}</div>
    </div>
    <div class="lesson-foot">
      <button class="btn-primary" id="unlockBtn">${currentLevel<lessons.length-1?'Complete & Unlock Next →':'You’ve completed the playground! ↺ Restart'}</button>
    </div>
  `;
  document.getElementById('unlockBtn').onclick=()=>{
    if(currentLevel<lessons.length-1){ currentLevel++; renderAll(); }
    else { currentLevel=0; renderAll();}
  };
  requestAnimationFrame(drawLessonViz);
}
function renderModules(){
  const wrap=document.getElementById('modules');
  wrap.innerHTML='';
  // Module 1 OSC
  wrap.appendChild(moduleCard(0, `
    <div class="wave-choices" id="waveChoices">
      ${['sine','triangle','sawtooth','square'].map(w=>`
        <div class="wave-btn ${settings.wave===w?'active':''}" data-wave="${w}">
          <div class="wave-icon">${waveSVG(w)}</div><span>${w==='sawtooth'?'SAW':w.toUpperCase()}</span>
        </div>`).join('')}
    </div>
    <div class="ctrl-row">
      <div class="ctrl"><label>DETUNE <span class="val" id="detuneVal">${settings.detune}¢</span></label><input type="range" id="detune" min="-50" max="50" value="${settings.detune}"></div>
      <div class="ctrl"><label>OCTAVE <span class="val" id="octVal">${octave}</span></label><input type="range" id="octCtrl" min="2" max="6" value="${octave}"></div>
    </div>
    <div class="note">Sine = pure fundamental. Saw = all harmonics. Square = hollow. Triangle = soft. Detune thickens.</div>
  `, 'OSCILLATOR','01'));
  // Module 2 ADSR
  wrap.appendChild(moduleCard(1, `
    <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
      ${[
        ['attack','ATTACK',0,2,0.01,settings.attack],
        ['decay','DECAY',0,2,0.01,settings.decay],
        ['sustain','SUSTAIN',0,1,0.01,settings.sustain],
        ['release','RELEASE',0,3,0.01,settings.release],
      ].map(([id,label,min,max,step,val])=>`
        <div class="ctrl"><label>${label} <span class="val" id="${id}Val">${Number(val).toFixed(2)}</span></label><input type="range" id="${id}" min="${min}" max="${max}" step="${step}" value="${val}"></div>
      `).join('')}
    </div>
    <div style="margin-top:8px" class="note">A D S R controls loudness shape. Short A+D+S≈0 = pluck. Long A = pad.</div>
  `,'ENVELOPE','02'));
  // Module 3 Filter
  wrap.appendChild(moduleCard(2, `
    <div class="filter-viz"><canvas id="filterCanvas" width="300" height="70"></canvas></div>
    <div class="toggle-row">
      ${['lowpass','highpass','bandpass'].map(t=>`<div class="toggle ${settings.filterType===t?'on':''}" data-ftype="${t}">${t.toUpperCase()}</div>`).join('')}
    </div>
    <div class="knob-row">
      <div class="ctrl"><label>CUTOFF <span class="val" id="cutoffVal">${settings.cutoff} Hz</span></label><input type="range" id="cutoff" min="100" max="8000" value="${settings.cutoff}"></div>
      <div class="ctrl"><label>RESONANCE <span class="val" id="resVal">${settings.resonance}</span></label><input type="range" id="resonance" min="0.1" max="12" step="0.1" value="${settings.resonance}"></div>
    </div>
  `,'FILTER','03'));
  // Module 4 LFO
  wrap.appendChild(moduleCard(3, `
    <div class="toggle-row">
      ${[['pitch','PITCH'],['filter','FILTER'],['amp','VOLUME']].map(([v,l])=>`<div class="toggle ${settings.lfoTarget===v?'on':''}" data-lfo="${v}">${l}</div>`).join('')}
    </div>
    <div class="ctrl-row">
      <div class="ctrl"><label>RATE <span class="val" id="lfoRateVal">${settings.lfoRate} Hz</span></label><input type="range" id="lfoRate" min="0.1" max="18" step="0.1" value="${settings.lfoRate}"></div>
      <div class="ctrl"><label>DEPTH <span class="val" id="lfoDepthVal">${Math.round(settings.lfoDepth*100)}%</span></label><input type="range" id="lfoDepth" min="0" max="1" step="0.02" value="${settings.lfoDepth}"></div>
    </div>
    <div class="note">Rate = speed of wobble. Depth = how far it wobbles. Try Filter + 2Hz for dub.</div>
  `,'LFO / MOD','04'));
  // Module 5 FX
  wrap.appendChild(moduleCard(4, `
    <div style="display:flex;flex-direction:column;gap:8px">
      <div class="ctrl"><label style="display:flex;justify-content:space-between;align-items:center">DELAY <span class="val">${settings.delayOn?'ON':'OFF'}</span> <input type="checkbox" id="delayOn" ${settings.delayOn?'checked':''}></label>
        <label>TIME <span class="val">${settings.delayTime}s</span></label><input type="range" id="delayTime" min="0.05" max="0.6" step="0.01" value="${settings.delayTime}">
        <label>FEEDBACK <span class="val">${Math.round(settings.delayFb*100)}%</span></label><input type="range" id="delayFb" min="0" max="0.85" step="0.05" value="${settings.delayFb}">
      </div>
      <div class="ctrl"><label>REVERB <input type="checkbox" id="reverbOn" ${settings.reverbOn?'checked':''}> <span class="val">${settings.reverbOn?'ON':'OFF'}</span></label>
        <label>MIX <span class="val">${Math.round(settings.reverbMix*100)}%</span></label><input type="range" id="reverbMix" min="0" max="0.9" step="0.05" value="${settings.reverbMix}">
      </div>
      <div class="ctrl"><label>DRIVE <span class="val">${Math.round(settings.drive*100)}%</span></label><input type="range" id="drive" min="0" max="1" step="0.05" value="${settings.drive}"></div>
    </div>
  `,'SPACE & GRIT','05'));
  // Module 6 Sequencer
  wrap.appendChild(moduleCard(5, `
    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
      <button class="btn-ghost" id="seqPlay" style="padding:8px 14px;background:var(--yellow);color:#000;border-color:var(--yellow);font-weight:700">▶ PLAY</button>
      <div style="display:flex;gap:8px;align-items:center">
        <span style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">TEMPO <b id="tempoVal" style="color:var(--yellow)">${settings.tempo}</b></span>
        <input type="range" id="tempo" min="60" max="180" value="${settings.tempo}" style="width:90px;accent-color:var(--yellow)">
        <button class="btn-ghost" id="seqClear">CLEAR</button>
      </div>
    </div>
    <div class="seq-grid" id="seqGrid"></div>
    <div class="note">Click steps to toggle. Root note = C${octave}. Plays 16th notes. Tweak cutoff while it loops!</div>
  `,'SEQUENCER','06'));

  bindModuleEvents();
  drawFilterViz();
  buildSeq();
}
function moduleCard(level, inner, title,num){
  const d=document.createElement('div');
  d.className='module '+(level>currentLevel?'locked':'');
  d.innerHTML=`<div class="module-head"><div class="module-title"><span class="dot"></span>${title}</div><div class="module-num">${num}</div></div>${inner}`;
  return d;
}
function waveSVG(type){
  if(type==='sine') return `<svg width="44" height="18" viewBox="0 0 44 18"><path d="M0 9 Q6 1 12 9 T24 9 T36 9 T44 9" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>`;
  if(type==='triangle') return `<svg width="44" height="18" viewBox="0 0 44 18"><path d="M0 14 L11 2 L22 14 L33 2 L44 14" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>`;
  if(type==='sawtooth') return `<svg width="44" height="18" viewBox="0 0 44 18"><path d="M0 14 L12 2 L12 14 L24 2 L24 14 L36 2 L36 14 L44 2" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>`;
  if(type==='square') return `<svg width="44" height="18" viewBox="0 0 44 18"><path d="M0 14 H11 V2 H22 V14 H33 V2 H44 V14" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>`;
  return '';
}
function bindModuleEvents(){
  document.querySelectorAll('.wave-btn').forEach(b=>b.onclick=()=>{ settings.wave=b.dataset.wave; renderModules(); updateTags(); drawLessonViz(); });
  const det=document.getElementById('detune'); if(det) det.oninput=e=>{ settings.detune=+e.target.value; document.getElementById('detuneVal').textContent=settings.detune+'¢'; };
  const octC=document.getElementById('octCtrl'); if(octC) octC.oninput=e=>{ octave=+e.target.value; document.getElementById('octVal').textContent=octave; document.getElementById('octLabel').textContent=octave; buildPiano(); };
  ['attack','decay','sustain','release'].forEach(id=>{
    const el=document.getElementById(id); if(!el) return;
    el.oninput=e=>{ settings[id]=+e.target.value; document.getElementById(id+'Val').textContent=Number(settings[id]).toFixed(2); updateTags(); drawLessonViz(); };
  });
  document.querySelectorAll('[data-ftype]').forEach(t=>t.onclick=()=>{ settings.filterType=t.dataset.ftype; renderModules(); });
  const cutoff=document.getElementById('cutoff'); if(cutoff) cutoff.oninput=e=>{ settings.cutoff=+e.target.value; document.getElementById('cutoffVal').textContent=settings.cutoff+' Hz'; drawFilterViz(); updateTags(); };
  const res=document.getElementById('resonance'); if(res) res.oninput=e=>{ settings.resonance=+e.target.value; document.getElementById('resVal').textContent=settings.resonance; drawFilterViz(); };
  document.querySelectorAll('[data-lfo]').forEach(t=>t.onclick=()=>{ settings.lfoTarget=t.dataset.lfo; renderModules(); });
  const lr=document.getElementById('lfoRate'); if(lr) lr.oninput=e=>{ settings.lfoRate=+e.target.value; document.getElementById('lfoRateVal').textContent=settings.lfoRate+' Hz'; };
  const ld=document.getElementById('lfoDepth'); if(ld) ld.oninput=e=>{ settings.lfoDepth=+e.target.value; document.getElementById('lfoDepthVal').textContent=Math.round(settings.lfoDepth*100)+'%'; };
  const dOn=document.getElementById('delayOn'); if(dOn) dOn.onchange=e=>{ settings.delayOn=e.target.checked; if(audioCtx){ delayMix.gain.value=settings.delayOn?settings.delayMix:0; if(settings.delayOn) ensureAudio(); } renderModules(); };
  const dTime=document.getElementById('delayTime'); if(dTime) dTime.oninput=e=>{ settings.delayTime=+e.target.value; if(delayNode) delayNode.delayTime.value=settings.delayTime; renderModules(); };
  const dFb=document.getElementById('delayFb'); if(dFb) dFb.oninput=e=>{ settings.delayFb=+e.target.value; if(delayFb) delayFb.gain.value=settings.delayFb; renderModules(); };
  const rOn=document.getElementById('reverbOn'); if(rOn) rOn.onchange=e=>{ settings.reverbOn=e.target.checked; renderModules(); };
  const rMix=document.getElementById('reverbMix'); if(rMix) rMix.oninput=e=>{ settings.reverbMix=+e.target.value; renderModules(); };
  const drv=document.getElementById('drive'); if(drv) drv.oninput=e=>{ settings.drive=+e.target.value; renderModules(); };
  const tempo=document.getElementById('tempo'); if(tempo) tempo.oninput=e=>{ settings.tempo=+e.target.value; document.getElementById('tempoVal').textContent=settings.tempo; };
  const playBtn=document.getElementById('seqPlay'); if(playBtn) playBtn.onclick=toggleSeq;
  const clearBtn=document.getElementById('seqClear'); if(clearBtn) clearBtn.onclick=()=>{ seqSteps.fill(false); buildSeq(); };
}

function renderPresets(){
  const row=document.getElementById('presetRow');
  row.innerHTML=presets.map((p,i)=>`<button class="preset" data-pi="${i}">${p.name}</button>`).join('');
  row.querySelectorAll('.preset').forEach(b=>b.onclick=()=>{
    const p=presets[b.dataset.pi];
    Object.assign(settings, p);
    if(p.octave) octave=p.octave;
    row.querySelectorAll('.preset').forEach(x=>x.classList.remove('active')); b.classList.add('active');
    renderModules(); updateTags(); drawLessonViz();
  });
}
function buildPiano(){
  const piano=document.getElementById('piano');
  piano.innerHTML='';
  const whiteNotes=[0,2,4,5,7,9,11,12,14,16,17,19,21,23]; // two octaves
  const blackMap={1:0,3:1,6:2,8:3,10:4,13:5,15:6,18:7,20:8};
  // whites
  whiteNotes.forEach((semi, idx)=>{
    const midi=octave*12 + semi;
    const w=document.createElement('div'); w.className='white'; w.dataset.midi=midi;
    w.innerHTML=`<span>${['C','D','E','F','G','A','B'][idx%7]}<br><small style="opacity:0.6">${['A','S','D','F','G','H','J'][idx]||''}</small></span>`;
    w.onmousedown=e=>{ e.preventDefault(); noteOn(midi, velocity); w.classList.add('active'); };
    w.onmouseup=()=>{ if(!isSustain) noteOff(midi); w.classList.remove('active'); };
    w.onmouseleave=()=>w.classList.remove('active');
    piano.appendChild(w);
  });
  // blacks - positioned absolute
  const positions=[0.72,1.72,3.15,4.15,5.15,7.1,8.1,9.85,10.85]; // rough
  const blackSemis=[1,3,6,8,10,13,15,18,20];
  blackSemis.forEach((semi,i)=>{
    const midi=octave*12+semi;
    const b=document.createElement('div'); b.className='black'; b.dataset.midi=midi;
    const pct=(i+1)*6.55 + (i>4?2:0); // distribute
    // compute left as percentage
    const left = [7.1,14.2,28.5,35.7,42.8,64.2,71.3,85.6,92.7][i];
    b.style.left=`calc(${left}% - 14px)`;
    b.innerHTML=['W','E','','T','Y','U','','',''][i]||'';
    b.onmousedown=e=>{ e.preventDefault(); e.stopPropagation(); noteOn(midi, velocity); b.classList.add('active'); };
    b.onmouseup=e=>{ e.stopPropagation(); if(!isSustain) noteOff(midi); b.classList.remove('active'); };
    b.onmouseleave=()=>b.classList.remove('active');
    piano.appendChild(b);
  });
}
function updateTags(){
  document.getElementById('waveTag').textContent=`${settings.wave.toUpperCase()} • DET ${settings.detune}¢`;
  document.getElementById('filterTag').textContent=`${settings.filterType.toUpperCase()} ${settings.cutoff}Hz Q${settings.resonance}`;
  document.getElementById('adsrTag').textContent=`ADSR ${settings.attack.toFixed(2)} / ${settings.decay.toFixed(2)} / ${settings.sustain.toFixed(2)} / ${settings.release.toFixed(2)}`;
}
function drawLessonViz(){
  const c=document.getElementById('lessonCanvas'); if(!c) return; const ctx=c.getContext('2d');
  const w=c.width, h=c.height; ctx.clearRect(0,0,w,h);
  ctx.fillStyle='#070a11'; ctx.fillRect(0,0,w,h);
  ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1;
  for(let x=0;x<w;x+=28){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
  for(let y=0;y<h;y+=22){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
  const l=lessons[currentLevel];
  if(l.viz==='wave'){
    ctx.strokeStyle='#00ffd1'; ctx.lineWidth=2.5; ctx.beginPath();
    for(let x=0;x<w;x++){
      const t=x/w*4*Math.PI;
      let y;
      if(settings.wave==='sine') y=Math.sin(t*2);
      else if(settings.wave==='square') y=Math.sign(Math.sin(t*2));
      else if(settings.wave==='triangle') y=2*Math.abs(2*(t/(Math.PI*2)-Math.floor(t/(Math.PI*2)+0.5)))-1;
      else y=2*(t/(Math.PI*2)-Math.floor(t/(Math.PI*2)+0.5)); // saw
      const py=h/2 - y*36;
      if(x===0) ctx.moveTo(x,py); else ctx.lineTo(x,py);
    }
    ctx.stroke();
    ctx.fillStyle='rgba(0,255,209,0.12)'; ctx.lineTo(w,h); ctx.lineTo(0,h); ctx.closePath(); ctx.fill();
  } else if(l.viz==='adsr'){
    ctx.strokeStyle='#ffd028'; ctx.lineWidth=2.5; ctx.beginPath();
    const aW=settings.attack*60, dW=settings.decay*40, sH=h*0.5*settings.sustain, rW=settings.release*50;
    const sustainX=40+aW+dW;
    const pts=[[0,h-10],[40,h-10-aW*0.6],[40+aW,20],[sustainX, h-10 - sH],[sustainX+120, h-10 - sH],[sustainX+120+rW, h-10]];
    // simplified drawing
    ctx.moveTo(0,h-12);
    ctx.lineTo(40,h-12);
    ctx.lineTo(40+aW,12);
    ctx.lineTo(40+aW+dW, h-12 - (h*0.35*settings.sustain+12));
    ctx.lineTo(240, h-12 - (h*0.35*settings.sustain+12));
    ctx.lineTo(240+rW, h-12);
    ctx.stroke();
    // labels
    ctx.fillStyle='#8a90a8'; ctx.font='9px JetBrains Mono'; ctx.fillText('A',20,h-2); ctx.fillText('D', 40+aW/2,h-2); ctx.fillText('S',150,h-2); ctx.fillText('R',250,h-2);
  } else if(l.viz==='filter'){
    ctx.strokeStyle='#ff3b82'; ctx.lineWidth=2.2; ctx.beginPath();
    for(let x=0;x<w;x++){
      const f=100 + (x/w)*7900;
      const cutoff=settings.cutoff; const q=settings.resonance;
      let gain;
      if(settings.filterType==='lowpass') gain= 1/ Math.sqrt(1+ Math.pow(f/cutoff, 4)) + (f>cutoff*0.8&&f<cutoff*1.3?q*0.15*Math.exp(-Math.pow((f-cutoff)/200,2)):0);
      else if(settings.filterType==='highpass') gain= 1/ Math.sqrt(1+ Math.pow(cutoff/f, 4));
      else gain= Math.exp(-Math.pow((f-cutoff)/600,2))* (1+q*0.08);
      const y=h-14 - gain* (h*0.65);
      if(x===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
    }
    ctx.stroke();
  } else if(l.viz==='lfo'){
    ctx.strokeStyle='#7c5cff'; ctx.lineWidth=1.6; ctx.beginPath();
    for(let x=0;x<w;x++){ const y=h/2 + Math.sin(x*0.06* (settings.lfoRate/2))*28* (settings.lfoDepth||0.5); if(x===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);}
    ctx.stroke();
    ctx.strokeStyle='rgba(124,92,255,0.2)'; ctx.setLineDash([4,4]); ctx.beginPath(); ctx.moveTo(0,h/2); ctx.lineTo(w,h/2); ctx.stroke(); ctx.setLineDash([]);
    ctx.fillStyle='#8a90a8'; ctx.font='9px JetBrains Mono'; ctx.fillText(`LFO ${settings.lfoRate}Hz → ${settings.lfoTarget.toUpperCase()}`, 10,14);
  } else if(l.viz==='fx'){
    ctx.fillStyle='#1a1e2b'; ctx.strokeStyle='#262c3f';
    const boxes=[['OSC','0,255,209'],['FILTER','255,59,130'],['DRIVE','255,208,40'],['DELAY','124,92,255'],['REVERB','0,255,209']];
    let x=8;
    boxes.forEach(([label,col])=>{
      ctx.fillStyle='#0e111a'; ctx.strokeStyle='rgba(255,255,255,0.08)'; ctx.beginPath(); ctx.roundRect(x,28,62,50,8); ctx.fill(); ctx.stroke();
      ctx.fillStyle=`rgb(${col})`; ctx.font='700 9px JetBrains Mono'; ctx.fillText(label, x+10,52);
      if(x>8){ ctx.strokeStyle='rgba(255,255,255,0.25)'; ctx.beginPath(); ctx.moveTo(x-6,53); ctx.lineTo(x,53); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x-2,49); ctx.lineTo(x,53); ctx.lineTo(x-2,57); ctx.stroke();}
      x+=70;
    });
  } else if(l.viz==='seq'){
    ctx.fillStyle='#0e111a';
    for(let i=0;i<16;i++){
      const on=seqSteps[i];
      const xx=10 + i*22, yy=30;
      ctx.fillStyle=on?'#00ffd1':'#1a1e2b'; ctx.strokeStyle=on?'#00ffd1':'#262c3f';
      ctx.beginPath(); ctx.roundRect(xx,yy,18,48,6); ctx.fill(); ctx.stroke();
      if(on){ ctx.fillStyle='rgba(0,0,0,0.2)'; ctx.fillRect(xx,yy+36,18,12); }
      ctx.fillStyle=on?'#000':'#5a638a'; ctx.font='7px JetBrains Mono'; ctx.fillText(String(i+1), xx+5, yy+66);
    }
    ctx.fillStyle='#ffd028'; if(seqPlaying){ const px=10+seqPos*22; ctx.fillRect(px,22,18,2); }
  }
}
function drawFilterViz(){
  const c=document.getElementById('filterCanvas'); if(!c) return;
  const ctx=c.getContext('2d'); const w=c.width, h=c.height;
  ctx.clearRect(0,0,w,h);
  ctx.fillStyle='#070a11'; ctx.fillRect(0,0,w,h);
  ctx.strokeStyle='#ff3b82'; ctx.lineWidth=2;
  ctx.beginPath();
  for(let x=0;x<w;x++){
    const f=80*Math.pow(100, x/w); // log
    const cutoff=settings.cutoff;
    let g;
    if(settings.filterType==='lowpass') g= 1/(1+Math.pow(f/cutoff,3));
    else if(settings.filterType==='highpass') g= 1/(1+Math.pow(cutoff/f,3));
    else g= Math.exp(-Math.pow(Math.log(f/cutoff),2)*6);
    // resonance peak
    if(Math.abs(f-cutoff)<cutoff*0.18) g+= (settings.resonance/12)*Math.exp(-Math.pow((f-cutoff)/(cutoff*0.08),2));
    const y=h-8 - g*(h-18);
    if(x===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
  }
  ctx.stroke();
  // fill under
  ctx.lineTo(w,h); ctx.lineTo(0,h); ctx.closePath(); ctx.fillStyle='rgba(255,59,130,0.12)'; ctx.fill();
  // cutoff line
  const cx = Math.log(settings.cutoff/80)/Math.log(100) * w;
  ctx.strokeStyle='rgba(255,208,40,0.9)'; ctx.setLineDash([4,4]); ctx.beginPath(); ctx.moveTo(cx,0); ctx.lineTo(cx,h); ctx.stroke(); ctx.setLineDash([]);
}
let seqSteps=Array(16).fill(false);
let seqPos=0, seqTimer=null, seqPlaying=false;
function buildSeq(){
  const g=document.getElementById('seqGrid'); if(!g) return;
  g.innerHTML='';
  seqSteps.forEach((on,i)=>{
    const s=document.createElement('div'); s.className='seq-step '+(on?'on':'')+' '+(seqPlaying&&seqPos===i?'playing':'');
    s.innerHTML=`<b>${i+1}</b>`; s.onclick=()=>{ seqSteps[i]=!seqSteps[i]; buildSeq(); drawLessonViz(); };
    g.appendChild(s);
  });
}
function toggleSeq(){
  seqPlaying=!seqPlaying;
  const btn=document.getElementById('seqPlay');
  btn.textContent=seqPlaying?'■ STOP':'▶ PLAY';
  btn.style.background=seqPlaying?'var(--accent2)':'var(--yellow)';
  btn.style.color=seqPlaying?'#fff':'#000';
  if(seqPlaying){
    ensureAudio();
    const stepTime=()=> (60/settings.tempo)/4; // 16th
    const tick=()=>{
      if(!seqPlaying) return;
      const isOn=seqSteps[seqPos];
      if(isOn){
        const midi=octave*12 + [0,2,4,5,7,9,11,12][seqPos%8]; // simple pattern around C major
        // use pentatonic variation
        const base=octave*12; const scale=[0,2,4,7,9];
        const midi2=base + scale[seqPos%5] + 12*Math.floor(seqPos/5)%2;
        noteOn(midi2, 95); setTimeout(()=>noteOff(midi2), stepTime()*900);
      }
      seqPos=(seqPos+1)%16;
      buildSeq(); drawLessonViz();
      seqTimer=setTimeout(tick, stepTime()*1000);
    };
    seqPos=0;
    tick();
  } else {
    clearTimeout(seqTimer);
    buildSeq();
  }
}

// Scope analyser
function animateScope(){
  const canvas=document.getElementById('scope'); if(!canvas) return; const ctx=canvas.getContext('2d');
  const w=canvas.width, h=canvas.height;
  function frame(){
    requestAnimationFrame(frame);
    ctx.clearRect(0,0,w,h);
    // bg
    ctx.fillStyle='#070a11'; ctx.fillRect(0,0,w,h);
    // grid
    ctx.strokeStyle='rgba(255,255,255,0.04)'; ctx.lineWidth=1;
    for(let y=0;y<h;y+=24){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();}
    for(let x=0;x<w;x+=60){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke();}
    if(!analyser){ // idle wave
      ctx.strokeStyle='rgba(0,255,209,0.9)'; ctx.lineWidth=2; ctx.beginPath();
      for(let x=0;x<w;x++){ const y=h/2 + Math.sin(x*0.02 + Date.now()*0.004)*22; if(x===0)ctx.moveTo(x,y); else ctx.lineTo(x,y);}
      ctx.stroke(); return;
    }
    const buffer=new Uint8Array(analyser.fftSize);
    analyser.getByteTimeDomainData(buffer);
    ctx.strokeStyle='#00ffd1'; ctx.lineWidth=2; ctx.shadowColor='#00ffd1'; ctx.shadowBlur=8; ctx.beginPath();
    const slice=w/buffer.length;
    for(let i=0;i<buffer.length;i++){
      const v=buffer[i]/128 -1;
      const y=v*h*0.42 + h*0.5;
      const x=i*slice;
      if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
    }
    ctx.stroke(); ctx.shadowBlur=0;
    // spectrum faint
    analyser.getByteFrequencyData(buffer);
    ctx.fillStyle='rgba(124,92,255,0.18)';
    for(let i=0;i<120;i++){
      const val=buffer[i]/255;
      ctx.fillRect(i*7, h-2, 5, -val*40);
    }
  }
  frame();
}
function updateMeter(){
  const f=document.getElementById('meterFill');
  const active=voices.size>0? 85+Math.random()*15 : 12+Math.sin(Date.now()*0.005)*6;
  f.style.width=active+'%';
  f.style.background=voices.size>0? 'linear-gradient(90deg,var(--accent),var(--yellow))':'#2a3350';
  requestAnimationFrame(()=>setTimeout(updateMeter,100));
}

// Keyboard handling
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
};
let heldKeys=new Set();
window.addEventListener('keydown',e=>{
  if(e.repeat) return;
  if(e.key==='Shift') isSustain=true;
  if(e.key==='z'||e.key==='Z'){ octave=Math.max(2,octave-1); document.getElementById('octLabel').textContent=octave; buildPiano(); return;}
  if(e.key==='x'||e.key==='X'){ octave=Math.min(6,octave+1); document.getElementById('octLabel').textContent=octave; buildPiano(); return;}
  const k=e.key.toLowerCase();
  if(keyMap[k]!==undefined){
    const midi=octave*12 + keyMap[k];
    if(!heldKeys.has(midi)){ heldKeys.add(midi); noteOn(midi, velocity); highlightKey(midi,true); }
  }
});
window.addEventListener('keyup',e=>{
  if(e.key==='Shift'){ isSustain=false; heldKeys.forEach(m=>noteOff(m)); heldKeys.clear(); document.querySelectorAll('.white.active,.black.active').forEach(el=>el.classList.remove('active')); return;}
  const k=e.key.toLowerCase();
  if(keyMap[k]!==undefined){
    const midi=octave*12 + keyMap[k];
    heldKeys.delete(midi);
    if(!isSustain) noteOff(midi);
    highlightKey(midi,false);
  }
});
function highlightKey(midi,on){
  document.querySelectorAll(`[data-midi="${midi}"]`).forEach(el=>el.classList.toggle('active',on));
}

function renderAll(){
  renderProgress(); renderLesson(); renderModules(); renderPresets(); buildPiano(); updateTags();
}
document.getElementById('powerBtn').onclick=()=>{ ensureAudio(); };
document.getElementById('octUp').onclick=()=>{ octave=Math.min(6,octave+1); document.getElementById('octLabel').textContent=octave; buildPiano(); };
document.getElementById('octDown').onclick=()=>{ octave=Math.max(2,octave-1); document.getElementById('octLabel').textContent=octave; buildPiano(); };
document.getElementById('velSlider').oninput=e=>{ velocity=+e.target.value; document.getElementById('velLabel').textContent=Math.round(velocity/127*100)+'%'; };
document.getElementById('prevBtn').onclick=()=>{ currentLevel=Math.max(0,currentLevel-1); renderAll(); };
document.getElementById('nextBtn').onclick=()=>{ currentLevel=Math.min(lessons.length-1,currentLevel+1); renderAll(); };

// init
renderAll();
animateScope();
updateMeter();
updateTags();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"566ab9f6-1405-4c49-a457-e81b0c234f32","tokensIn":209,"tokensOut":17666,"tokensTotal":17875,"cost":0.07521745,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:28:28.476Z"} -->