← TutSynth results

TutSynth

muse spark 1.2 contributor · 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 53,099 bytes · SHA-256 60dd2ba7fa05
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SYNTH LAB — Learn by Playing</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
  --bg:#0a0a0f;
  --panel:#13131a;
  --panel2:#1a1a24;
  --border:#252538;
  --accent:#ff3b30;
  --accent2:#00e5ff;
  --accent3:#ffcc00;
  --accent4:#a855f7;
  --text:#f0f0f5;
  --muted:#8a8a9a;
  --knob:#2a2a3a;
}
body{background:var(--bg);color:var(--text);font-family:'Space Grotesk',sans-serif;overflow-x:hidden;min-height:100vh}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-thumb{background:#333;border-radius:99px}
/* HEADER */
.header{position:sticky;top:0;z-index:50;background:rgba(10,10,15,0.96);backdrop-filter:blur(16px);border-bottom:1px solid var(--border);padding:8px 12px;display:flex;align-items:center;gap:10px}
.logo{display:flex;align-items:center;gap:8px;flex-shrink:0;min-width:0}
.logo-mark{width:30px;height:30px;background:var(--accent);border-radius:7px;display:grid;place-items:center;font-weight:800;font-size:13px;letter-spacing:-1px;flex-shrink:0}
.logo h1{font-size:14px;letter-spacing:-0.5px;line-height:1;white-space:nowrap}
.logo p{font-size:8.5px;color:var(--muted);letter-spacing:0.1em;text-transform:uppercase;white-space:nowrap}
.progress{flex:1;min-width:0;max-width:880px;margin:0 auto;display:flex;gap:3px;overflow-x:auto;scrollbar-width:none;align-items:center;padding-bottom:2px}
.progress::-webkit-scrollbar{display:none}
.step{flex:1 1 0;min-width:0;display:flex;align-items:center;gap:5px;padding:5px 6px;border-radius:9px;cursor:pointer;transition:0.2s;border:1px solid transparent;opacity:0.45;white-space:nowrap;overflow:hidden}
.step.active{opacity:1;background:var(--panel);border-color:var(--border);box-shadow:0 2px 10px rgba(0,0,0,0.3)}
.step.done{opacity:0.78}
.step.done .step-num{background:var(--accent2);color:#000}
.step-num{width:22px;height:22px;border-radius:50%;background:var(--panel2);border:1px solid var(--border);display:grid;place-items:center;font-size:11px;font-weight:800;flex-shrink:0}
.step.active .step-num{background:var(--accent);color:white;border-color:var(--accent)}
.step label{font-size:10px;font-weight:700;line-height:1;cursor:pointer;letter-spacing:-0.02em}
.step span{font-size:8.5px;color:var(--muted);display:block;line-height:1.1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:86px}
.header-actions{display:flex;gap:8px;align-items:center;flex-shrink:0}
.btn{padding:8px 14px;border-radius:99px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--border);background:var(--panel2);color:var(--text);transition:0.15s;white-space:nowrap}
.btn:hover{transform:translateY(-1px);border-color:#3a3a4a}
.btn-primary{background:var(--accent);border-color:var(--accent);color:white}
.btn-ghost{background:transparent}
.power{width:42px;height:26px;background:#222;border-radius:99px;position:relative;cursor:pointer;border:1px solid var(--border);transition:0.2s}
.power.on{background:var(--accent2)}
.power::after{content:'';position:absolute;top:2px;left:2px;width:20px;height:20px;background:white;border-radius:50%;transition:0.2s;box-shadow:0 2px 6px rgba(0,0,0,0.3)}
.power.on::after{transform:translateX(16px)}
/* LAYOUT */
.main{max-width:1440px;margin:0 auto;padding:16px;display:grid;grid-template-columns:320px 1fr 380px;gap:16px;align-items:start}
@media(max-width:1180px){.main{grid-template-columns:300px 1fr} .lesson-col{grid-column:1 / -1}}
@media(max-width:700px){.main{grid-template-columns:1fr} .header{flex-wrap:wrap} .progress{order:3;max-width:100%}}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:16px;overflow:hidden;display:flex;flex-direction:column}
.panel-head{padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.panel-head h3{font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:var(--muted)}
.badge{font-size:10px;padding:4px 8px;border-radius:99px;background:var(--panel2);border:1px solid var(--border);color:var(--muted);font-weight:600;letter-spacing:0.06em;white-space:nowrap}
/* CONTROLS */
.controls{padding:14px;display:flex;flex-direction:column;gap:14px;max-height:calc(100vh - 120px);overflow:auto}
.group{border:1px solid var(--border);border-radius:14px;background:var(--panel2);padding:14px;position:relative;transition:0.3s}
.group.locked{opacity:0.38;pointer-events:none;filter:grayscale(0.25)}
.group.locked::after{content:'🔒 Complete previous lesson to unlock';position:absolute;inset:0;display:grid;place-items:center;background:rgba(19,19,26,0.68);backdrop-filter:blur(2px);border-radius:14px;font-size:11px;font-weight:700;letter-spacing:0.04em;color:var(--muted);text-align:center;padding:16px;line-height:1.3}
.group-head{display:flex;align-items:center;gap:8px;margin-bottom:12px}
.group-icon{width:28px;height:28px;border-radius:8px;display:grid;place-items:center;font-size:14px}
.group h4{font-size:13px;letter-spacing:0.04em}
.group p{font-size:11px;color:var(--muted);margin-top:2px;line-height:1.4}
.knobs{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px}
.knob-wrap{display:flex;flex-direction:column;align-items:center;gap:6px}
.knob{width:64px;height:64px;border-radius:50%;background:radial-gradient(circle at 30% 30%, #3a3a4e, #1e1e2a);border:2px solid #2e2e42;position:relative;cursor:grab;box-shadow:inset 0 2px 8px rgba(0,0,0,0.5), 0 4px 12px rgba(0,0,0,0.3);touch-action:none}
.knob:active{cursor:grabbing}
.knob-ind{position:absolute;top:6px;left:50%;width:3px;height:14px;background:var(--accent2);border-radius:99px;transform-origin:50% 26px;box-shadow:0 0 8px var(--accent2)}
.knob-label{font-size:10px;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);font-weight:600;text-align:center}
.knob-val{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--accent2);font-weight:600}
.slider-row{display:flex;flex-direction:column;gap:6px;margin-top:8px}
.slider-head{display:flex;justify-content:space-between;align-items:center}
.slider-head label{font-size:11px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--muted)}
.slider-head span{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text);background:#0f0f14;padding:2px 6px;border-radius:6px;border:1px solid var(--border)}
input[type=range]{-webkit-appearance:none;width:100%;height:4px;background:#252538;border-radius:99px;outline:none}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:white;border:2px solid var(--accent);cursor:pointer;box-shadow:0 2px 6px rgba(0,0,0,0.3)}
.wave-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}
.wave-btn{padding:10px;border-radius:10px;border:1px solid var(--border);background:#0f0f14;cursor:pointer;text-align:center;transition:0.15s}
.wave-btn.active{border-color:var(--accent);background:rgba(255,59,48,0.12);box-shadow:0 0 0 1px var(--accent) inset}
.wave-btn canvas{width:100%;height:28px}
.wave-btn label{font-size:11px;font-weight:700;letter-spacing:0.06em;display:block;margin-top:4px}
.wave-btn p{font-size:10px;color:var(--muted)}
.select{width:100%;padding:10px 12px;border-radius:10px;background:#0f0f14;border:1px solid var(--border);color:var(--text);font-size:13px;font-weight:600}
.chip-row{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.chip{padding:6px 10px;border-radius:99px;font-size:11px;font-weight:600;border:1px solid var(--border);background:#0f0f14;cursor:pointer}
.chip.active{background:var(--accent);color:white;border-color:var(--accent)}
/* CENTER */
.center{display:flex;flex-direction:column;gap:16px}
.viz{padding:12px;display:grid;grid-template-columns:1fr 1fr;gap:12px}
@media(max-width:600px){.viz{grid-template-columns:1fr}}
.viz-box{background:#07070a;border:1px solid var(--border);border-radius:12px;overflow:hidden}
.viz-head{padding:8px 12px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
.viz-head span{font-size:10px;letter-spacing:0.1em;text-transform:uppercase;color:var(--muted);font-weight:700}
.viz-head i{font-size:10px;color:var(--accent2);font-style:normal;font-family:'JetBrains Mono',monospace}
.viz canvas{width:100%;height:110px;display:block}
.keyboard-wrap{padding:12px}
.kb{position:relative;background:#0f0f14;border:1px solid var(--border);border-radius:12px;padding:10px;overflow:hidden}
.kb-keys{display:flex;gap:2px;user-select:none;overflow-x:auto;scrollbar-width:none}
.kb-keys::-webkit-scrollbar{display:none}
.key{flex:1;min-width:36px;height:110px;background:white;border-radius:0 0 8px 8px;position:relative;cursor:pointer;border:1px solid #ddd;display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;transition:0.06s}
.key.black{position:absolute;top:10px;width:24px;height:68px;background:linear-gradient(180deg,#1e1e1e,#000);border-radius:0 0 6px 6px;z-index:2;border:1px solid #000;transform:translateX(-50%)}
.key.active{background:var(--accent) !important;color:white}
.key.black.active{background:var(--accent) !important}
.key label{font-size:8px;font-weight:700;color:#333;pointer-events:none;line-height:1}
.key.black label{color:#e8e8e8;font-size:5.5px;letter-spacing:-0.04em;line-height:1;white-space:nowrap}
.kb-bar{display:flex;justify-content:space-between;align-items:center;margin-top:10px;gap:10px;flex-wrap:wrap}
.kb-hint{font-size:11px;color:var(--muted);line-height:1.4}
.kb-hint kbd{background:var(--panel2);border:1px solid var(--border);border-bottom-width:2px;padding:2px 6px;border-radius:6px;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text)}
.oct-ctrl{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.oct-ctrl button{width:28px;height:28px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--text);cursor:pointer;font-weight:700;flex-shrink:0}
.seq{padding:12px;border-top:1px solid var(--border)}
.seq-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
.seq-grid{display:grid;grid-template-columns:repeat(16,1fr);gap:4px}
.seq-step{aspect-ratio:1;background:#0f0f14;border:1px solid var(--border);border-radius:6px;cursor:pointer;position:relative;overflow:hidden;transition:0.12s}
.seq-step.active{background:var(--accent2);border-color:var(--accent2);box-shadow:0 0 10px rgba(0,229,255,0.4)}
.seq-step.playing{outline:2px solid var(--accent);outline-offset:1px}
.seq-step::after{content:'';position:absolute;bottom:0;left:0;right:0;height:var(--vel,30%);background:rgba(255,255,255,0.15)}
.seq-ctrl{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;align-items:center}
.tempo{flex:1;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}
/* LESSON */
.lesson{padding:18px;display:flex;flex-direction:column;gap:14px;overflow:visible}
.lesson-col .panel{max-height:calc(100vh - 88px);overflow:auto}
.lesson h2{font-family:'Instrument Serif',serif;font-size:28px;line-height:0.95;letter-spacing:-0.5px}
.lesson h2 em{font-style:italic;color:var(--accent)}
.lesson .lead{font-size:13px;line-height:1.65;color:#c8c8d0}
.try{background:linear-gradient(135deg, rgba(255,59,48,0.12), rgba(0,229,255,0.08));border:1px solid rgba(255,59,48,0.25);border-radius:12px;padding:12px}
.try h5{font-size:11px;letter-spacing:0.1em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
.try ol{padding-left:18px;display:flex;flex-direction:column;gap:6px;font-size:13px;line-height:1.5;color:#e8e8ee}
.try li::marker{color:var(--accent)}
.lesson-actions{display:flex;gap:8px;position:sticky;bottom:0;background:var(--panel);padding-top:8px;margin-top:2px}
.tip{background:var(--panel2);border:1px solid var(--border);border-radius:12px;padding:12px;display:flex;gap:10px}
.tip-icon{width:32px;height:32px;border-radius:8px;background:var(--accent3);display:grid;place-items:center;flex-shrink:0;color:#000;font-weight:800}
.tip p{font-size:12px;line-height:1.5;color:var(--muted)}
.tip strong{color:var(--text)}
.meter{height:6px;background:#0f0f14;border:1px solid var(--border);border-radius:99px;overflow:hidden}
.meter-fill{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent2));width:0%;transition:0.4s}
.preset-row{display:flex;gap:6px;flex-wrap:wrap}
.preset{padding:7px 12px;border-radius:99px;border:1px solid var(--border);background:#0f0f14;font-size:12px;font-weight:600;cursor:pointer;color:var(--text);transition:0.15s}
.preset:hover{border-color:var(--accent2);background:rgba(0,229,255,0.08)}
</style>
</head>
<body>
<header class="header">
  <div class="logo">
    <div class="logo-mark">◈</div>
    <div>
      <h1>SYNTH LAB</h1>
      <p>Learn by playing</p>
    </div>
  </div>
  <div class="progress" id="progress"></div>
  <div class="header-actions">
    <div style="display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted);font-weight:600;letter-spacing:0.06em">POWER <div class="power on" id="powerBtn"></div></div>
    <button class="btn btn-ghost" id="panicBtn">◼ Panic</button>
  </div>
</header>

<div class="main">
  <!-- LEFT CONTROLS -->
  <div class="panel">
    <div class="panel-head">
      <h3>◆ Patch Bay</h3>
      <span class="badge" id="patchName">INIT — SAW</span>
    </div>
    <div class="controls" id="controls"></div>
  </div>

  <!-- CENTER -->
  <div class="center">
    <div class="panel">
      <div class="viz">
        <div class="viz-box">
          <div class="viz-head"><span>◐ Oscilloscope</span><i id="freqRead">— Hz</i></div>
          <canvas id="scope" width="400" height="110"></canvas>
        </div>
        <div class="viz-box">
          <div class="viz-head"><span>▮ Spectrum</span><i id="noteRead">—</i></div>
          <canvas id="spectrum" width="400" height="110"></canvas>
        </div>
      </div>
      <div class="keyboard-wrap">
        <div class="kb">
          <div class="kb-keys" id="kb"></div>
          <div class="kb-bar">
            <div class="kb-hint">Play: <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd> <kbd>F</kbd> <kbd>G</kbd> <kbd>H</kbd> <kbd>J</kbd> <kbd>K</kbd> &nbsp;or click keys &nbsp;•&nbsp; Hold <kbd>Shift</kbd> for sustain</div>
            <div class="oct-ctrl">
              <button id="octDown">−</button>
              <span style="font-family:'JetBrains Mono',monospace;font-size:12px;min-width:56px;text-align:center" id="octLabel">OCT 4</span>
              <button id="octUp">+</button>
              <button class="btn" style="padding:6px 10px;font-size:11px" id="holdBtn">Hold: OFF</button>
            </div>
          </div>
        </div>
      </div>
      <div class="seq" id="seqPanel" style="display:none">
        <div class="seq-head">
          <h3 style="font-size:12px;letter-spacing:0.1em;text-transform:uppercase;color:var(--muted)">◆ 16-Step Sequencer</h3>
          <span class="badge" id="seqNote">C4 • 1/16</span>
        </div>
        <div class="seq-grid" id="seqGrid"></div>
        <div class="seq-ctrl">
          <button class="btn btn-primary" id="seqPlay">▶ Play</button>
          <button class="btn" id="seqClear">Clear</button>
          <button class="btn" id="seqRand">Randomize</button>
          <div class="tempo"><span>BPM</span><input type="range" id="bpm" min="60" max="180" value="110" style="width:120px"><span id="bpmVal" style="font-family:'JetBrains Mono',monospace;color:var(--text)">110</span></div>
          <div class="tempo"><span>Root</span><select id="seqRoot" class="select" style="width:auto;padding:6px 10px"><option>C4</option><option>D4</option><option>E4</option><option>G4</option><option>A4</option></select></div>
        </div>
      </div>
    </div>
    <div class="panel" style="padding:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
      <span style="font-size:11px;font-weight:700;letter-spacing:0.08em;color:var(--muted)">QUICK PRESETS</span>
      <div class="preset-row" id="presets"></div>
    </div>
  </div>

  <!-- RIGHT LESSON -->
  <div class="panel lesson-col">
    <div class="lesson" id="lesson"></div>
  </div>
</div>

<script>
const LESSONS = [
  {
    id:0, title:"The Oscillator", sub:"Where sound begins", color:"#ff3b30",
    lead:"Every synth sound starts with an <b>oscillator</b> — a circuit that vibrates. Its <b>waveform</b> is the shape of that vibration, and shape = timbre.",
    tryTitle:"Try this",
    steps:["Pick <b>Sine</b> — pure, flute-like. Play a note and watch the scope: smooth hills.", "Switch to <b>Saw</b> — bright & buzzy. See the jagged ramp? That's why it cuts through a mix.", "Try <b>Square</b> — hollow, retro game. <b>Triangle</b> is softer, like a muted flute.", "Hold a note and drag <b>Detune</b> — two oscillators beating against each other."],
    tip:"<strong>Saw</strong> is the workhorse of synths. Stack 2 detuned saws and you get that huge supersaw trance sound.",
    controls:["osc"]
  },
  {
    id:1, title:"Amplitude Envelope", sub:"Shaping time", color:"#00e5ff",
    lead:"A note isn't just on/off. An <b>ADSR envelope</b> sculpts loudness over time: <b>Attack</b> (fade in), <b>Decay</b> (fall to Sustain), <b>Sustain</b> (held level), <b>Release</b> (fade out).",
    tryTitle:"Sculpt it",
    steps:["Set <b>Attack ~0.4s</b> — now notes swell like strings.", "Short Attack + long <b>Release ~1s</b> — plucky but ringing.", "Lower <b>Sustain to 0.2</b> — percussive, like a piano.", "Make a pad: <b>A 0.8 / D 0.5 / S 0.7 / R 1.2</b> and hold a chord."],
    tip:"<strong>ADSR</strong> is everywhere: filters, pitch, even LFO depth can have envelopes. Learn it once, use it forever.",
    controls:["osc","env"]
  },
  {
    id:2, title:"The Filter", sub:"Subtractive magic", color:"#ffcc00",
    lead:"Subtractive synthesis = start bright, then <b>remove</b> frequencies with a <b>filter</b>. <b>Cutoff</b> sets where it cuts, <b>Resonance</b> adds a peak at that point.",
    tryTitle:"Filter sweep",
    steps:["Choose <b>Low-pass</b>, set <b>Cutoff ~400 Hz</b> — muffled, underwater.", "Slowly raise Cutoff to 8000 Hz — sound opens up. That's a classic sweep.", "Crank <b>Resonance to 0.7</b> — hear the whistle at the cutoff? Don't go to max, it screams.", "Try <b>High-pass</b> — thin & tinny. <b>Band-pass</b> — telephone voice."],
    tip:"Automate cutoff with an envelope for that <em>wow</em> pluck: fast attack on filter cutoff = instant modern pop.",
    controls:["osc","env","filter"]
  },
  {
    id:3, title:"Modulation — LFO", sub:"Movement & life", color:"#a855f7",
    lead:"An <b>LFO</b> (Low-Frequency Oscillator) wobbles something slowly. Route it to <b>pitch</b> = vibrato, to <b>filter</b> = wah, to <b>amplitude</b> = tremolo.",
    tryTitle:"Make it move",
    steps:["Route LFO → <b>Pitch</b>, <b>Rate 5 Hz, Depth 20</b> — classic vibrato.", "Switch to <b>Filter</b> target, Rate 2 Hz — rhythmic wah.", "Slow Rate to <b>0.5 Hz</b>, high Depth — evolving drone.", "Target <b>Amp</b> at 6 Hz — tremolo, like a vintage amp."],
    tip:"LFO rate in Hz = wobbles per second. < 1 Hz = slow evolution, 5–7 Hz = vibrato, >10 Hz = FM-like grit.",
    controls:["osc","env","filter","lfo"]
  },
  {
    id:4, title:"Space & Grit", sub:"Delay • Reverb • Drive", color:"#22c55e",
    lead:"<b>Delay</b> repeats, <b>Reverb</b> is space, <b>Drive</b> is saturation. Together they turn a dry beep into a record.",
    tryTitle:"Add dimension",
    steps:["Raise <b>Delay Mix to 0.35</b>, Time ~0.3s — slapback.", "Add <b>Reverb 0.4</b> — now it's in a hall. Play staccato vs legato.", "Nudge <b>Drive to 0.3</b> — warmth and harmonics. Too much = fuzz.", "Combine: short delay + medium reverb + touch of drive = instant polish."],
    tip:"Less is more. Pros often use < 25% wet. If you notice the effect, it's probably too much — unless that's the vibe.",
    controls:["osc","env","filter","lfo","fx"]
  },
  {
    id:5, title:"Playground", sub:"Make it yours", color:"#ff3b30",
    lead:"You now have the full synth. Build a patch, sequence it, and perform. The sequencer below lets you program 16 steps — click to toggle, velocity = height.",
    tryTitle:"Challenge",
    steps:["Design a <b>pluck</b>: Square, fast decay, low-pass with envelope.", "Design a <b>pad</b>: Saw + detune, slow attack, LFO on filter, reverb.", "Program a 16-step loop and hit <b>Play</b>. Tweak cutoff live while it runs.", "Try the presets, then break them — best way to learn."],
    tip:"Save your ears: keep volume moderate, take breaks. Synthesis is deep — you now know the 80% that matters.",
    controls:["osc","env","filter","lfo","fx","seq"]
  }
];

const PRESETS = [
  {name:"Init Saw", wave:"sawtooth", cutoff:8000, res:0.1, A:0.01,D:0.1,S:0.8,R:0.2, lfoRate:0, lfoDepth:0, delay:0, rev:0, drive:0},
  {name:"Warm Pad", wave:"sawtooth", cutoff:1200, res:0.25, A:0.6,D:0.4,S:0.7,R:1.0, lfoRate:1.2, lfoDepth:18, lfoTarget:"filter", delay:0.25, rev:0.45, drive:0.1, detune:8},
  {name:"Pluck", wave:"square", cutoff:1800, res:0.5, A:0.005,D:0.35,S:0.15,R:0.4, lfoRate:0, delay:0.18, rev:0.2, drive:0.15},
  {name:"Bass", wave:"square", cutoff:600, res:0.3, A:0.01,D:0.2,S:0.6,R:0.25, lfoRate:0, delay:0, rev:0.05, drive:0.35, detune:4},
  {name:"Lead", wave:"sawtooth", cutoff:3200, res:0.2, A:0.02,D:0.15,S:0.75,R:0.3, lfoRate:5.5, lfoDepth:12, lfoTarget:"pitch", delay:0.3, rev:0.25, drive:0.2, detune:6},
  {name:"Wobble", wave:"sawtooth", cutoff:900, res:0.65, A:0.02,D:0.1,S:0.9,R:0.3, lfoRate:2.1, lfoDepth:55, lfoTarget:"filter", delay:0.15, rev:0.15, drive:0.25},
  {name:"Bell", wave:"sine", cutoff:6000, res:0.1, A:0.005,D:1.2,S:0,R:1.0, lfoRate:0, delay:0.35, rev:0.5, drive:0},
  {name:"Drone", wave:"triangle", cutoff:1400, res:0.4, A:1.2,D:0.5,S:0.85,R:1.5, lfoRate:0.4, lfoDepth:30, lfoTarget:"filter", delay:0.4, rev:0.6, drive:0.12, detune:10},
];

let cur = 0;
let audioCtx, masterGain, analyser, analyser2, filterNode, delayNode, delayGain, convolver, convolverGain, waveShaper;
let oscType="sawtooth", detune=7, octave=4, hold=false, sustain=false;
let env={A:0.02,D:0.15,S:0.7,R:0.35};
let filt={type:"lowpass", freq:2200, Q:0.4};
let lfo={rate:4.5, depth:0, target:"pitch", wave:"sine"};
let fx={delay:0, delayTime:0.32, reverb:0, drive:0};
let activeVoices=new Map();
let seqSteps=Array(16).fill(0).map(()=>0), seqPos=-1, seqTimer=null, seqPlaying=false, bpm=110, seqRoot="C4";

// Audio init
function ensureAudio(){
  if(audioCtx) return;
  audioCtx = new (window.AudioContext||window.webkitAudioContext)();
  masterGain = audioCtx.createGain(); masterGain.gain.value=0.55;
  analyser = audioCtx.createAnalyser(); analyser.fftSize=1024;
  analyser2 = audioCtx.createAnalyser(); analyser2.fftSize=1024; analyser2.smoothingTimeConstant=0.85;
  filterNode = audioCtx.createBiquadFilter(); filterNode.type=filt.type; filterNode.frequency.value=filt.freq; filterNode.Q.value=filt.Q;
  delayNode = audioCtx.createDelay(1); delayNode.delayTime.value=fx.delayTime;
  delayGain = audioCtx.createGain(); delayGain.gain.value=0;
  convolver = audioCtx.createConvolver(); convolverGain = audioCtx.createGain(); convolverGain.gain.value=0;
  waveShaper = audioCtx.createWaveShaper(); waveShaper.curve=makeDistortionCurve(0); waveShaper.oversample='4x';
  // build reverb impulse
  convolver.buffer = makeReverb(2.2, 2.0);
  // chain: voices -> filter -> waveshaper -> delay -> convolver -> master -> analyser -> dest
  // We'll connect per-voice dynamically, but keep master chain
  filterNode.connect(waveShaper);
  waveShaper.connect(delayNode);
  delayNode.connect(delayGain); delayGain.connect(delayNode); // feedback
  // dry + wet mixing done per voice via gains
  // For simplicity: filter -> master, and parallel sends to delay/reverb
  // We'll create a post-filter mix node
  window._postFilter = audioCtx.createGain();
  filterNode.connect(window._postFilter);
  // delay send
  window._delaySend = audioCtx.createGain(); window._delaySend.gain.value=0;
  window._postFilter.connect(window._delaySend); window._delaySend.connect(delayNode);
  delayNode.connect(masterGain);
  // reverb send
  window._revSend = audioCtx.createGain(); window._revSend.gain.value=0;
  window._postFilter.connect(window._revSend); window._revSend.connect(convolver); convolver.connect(convolverGain); convolverGain.connect(masterGain);
  window._postFilter.connect(masterGain);
  masterGain.connect(analyser); masterGain.connect(analyser2); masterGain.connect(audioCtx.destination);
  updateFx();
}
function makeDistortionCurve(amount){
  const k = amount*100, n=44100, curve=new Float32Array(n);
  const deg=Math.PI/180;
  for(let i=0;i<n;i++){ const x=i*2/n-1; curve[i]=(3+k)*x*20*deg/(Math.PI + k*Math.abs(x)); }
  return curve;
}
function makeReverb(decay, seconds){
  if(!audioCtx) return null;
  const rate=audioCtx.sampleRate, len=rate*seconds, imp=audioCtx.createBuffer(2,len,rate);
  for(let c=0;c<2;c++){ const ch=imp.getChannelData(c); for(let i=0;i<len;i++) ch[i]=(Math.random()*2-1)*Math.pow(1-i/len, decay); }
  return imp;
}
function updateFx(){
  if(!audioCtx) return;
  window._delaySend.gain.value=fx.delay;
  delayGain.gain.value=fx.delay*0.38;
  delayNode.delayTime.value=fx.delayTime;
  window._revSend.gain.value=fx.reverb;
  convolverGain.gain.value=fx.reverb*0.9;
  waveShaper.curve=makeDistortionCurve(fx.drive);
  waveShaper.curve = fx.drive>0.01 ? makeDistortionCurve(fx.drive) : makeDistortionCurve(0);
}

// Voice
function noteToFreq(midi){ return 440*Math.pow(2,(midi-69)/12); }
function nameToMidi(name){
  const map={C:0,"C#":1,D:2,"D#":3,E:4,F:5,"F#":6,G:7,"G#":8,A:9,"A#":10,B:11};
  const m=name.match(/^([A-G]#?)(\d)$/); if(!m) return 60;
  return parseInt(m[2])*12 + map[m[1]] + 12;
}
function midiToName(m){ const n=["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]; return n[m%12]+Math.floor(m/12-1); }

function playNote(midi, vel=0.9){
  ensureAudio();
  if(audioCtx.state==='suspended') audioCtx.resume();
  const freq=noteToFreq(midi);
  const now=audioCtx.currentTime;
  // two oscillators for detune
  const osc1=audioCtx.createOscillator(), osc2=audioCtx.createOscillator();
  osc1.type=oscType; osc2.type=oscType;
  osc1.frequency.value=freq; osc2.frequency.value=freq;
  osc1.detune.value=-detune; osc2.detune.value=+detune;
  const gain=audioCtx.createGain(); gain.gain.value=0;
  // LFO per voice
  let lfoOsc=null, lfoGain=null;
  if(lfo.depth>0.5){
    lfoOsc=audioCtx.createOscillator(); lfoOsc.type=lfo.wave; lfoOsc.frequency.value=lfo.rate;
    lfoGain=audioCtx.createGain(); lfoGain.gain.value=lfo.depth;
    lfoOsc.connect(lfoGain);
    if(lfo.target==="pitch"){ lfoGain.connect(osc1.frequency); lfoGain.connect(osc2.frequency); }
    else if(lfo.target==="filter"){ lfoGain.connect(filterNode.frequency); }
    else if(lfo.target==="amp"){ lfoGain.connect(gain.gain); }
    lfoOsc.start(now);
  }
  osc1.connect(gain); osc2.connect(gain);
  gain.connect(filterNode);
  // envelope
  const peak=vel*0.85;
  gain.gain.cancelScheduledValues(now);
  gain.gain.setValueAtTime(0, now);
  gain.gain.linearRampToValueAtTime(peak, now+env.A);
  gain.gain.linearRampToValueAtTime(peak*env.S, now+env.A+env.D);
  osc1.start(now); osc2.start(now);
  activeVoices.set(midi, {osc1, osc2, gain, lfoOsc, lfoGain, start:now});
  // visual
  document.querySelectorAll('.key').forEach(k=>{ if(parseInt(k.dataset.midi)===midi) k.classList.add('active'); });
  document.getElementById('freqRead').textContent=Math.round(freq)+' Hz';
  document.getElementById('noteRead').textContent=midiToName(midi);
}
function stopNote(midi){
  const v=activeVoices.get(midi); if(!v) return;
  const now=audioCtx.currentTime;
  const rel=env.R;
  v.gain.gain.cancelScheduledValues(now);
  v.gain.gain.setValueAtTime(v.gain.gain.value, now);
  v.gain.gain.linearRampToValueAtTime(0, now+rel);
  setTimeout(()=>{
    try{ v.osc1.stop(); v.osc2.stop(); if(v.lfoOsc) v.lfoOsc.stop(); }catch(e){}
    v.osc1.disconnect(); v.osc2.disconnect(); v.gain.disconnect();
    if(v.lfoOsc) v.lfoOsc.disconnect();
    if(v.lfoGain) v.lfoGain.disconnect();
  }, (rel+0.1)*1000);
  activeVoices.delete(midi);
  document.querySelectorAll('.key').forEach(k=>{ if(parseInt(k.dataset.midi)===midi) k.classList.remove('active'); });
}
function panic(){ [...activeVoices.keys()].forEach(stopNote); }

// UI builders
function renderProgress(){
  const el=document.getElementById('progress'); el.innerHTML='';
  LESSONS.forEach((l,i)=>{
    const d=document.createElement('div'); d.className='step'+(i===cur?' active':'')+(i<cur?' done':'');
    d.title=l.title+' — '+l.sub;
    d.innerHTML=`<div class="step-num">${i<cur?'✓':i+1}</div><div style="min-width:0"><label>${l.title}</label><span>${l.sub}</span></div>`;
    d.onclick=()=>{ cur=i; renderAll(); };
    el.appendChild(d);
  });
}
function renderLesson(){
  const L=LESSONS[cur];
  const el=document.getElementById('lesson');
  el.innerHTML=`
    <div style="display:flex;align-items:center;gap:10px">
      <div style="width:44px;height:44px;border-radius:10px;background:${L.color};display:grid;place-items:center;font-weight:800;color:${L.id===2?'#000':'#fff'}">${cur+1}</div>
      <div><div style="font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);font-weight:700">${L.sub}</div><div style="font-size:12px;color:var(--muted)">${cur+1} / ${LESSONS.length}</div></div>
      <div style="margin-left:auto;display:flex;gap:6px"><span class="badge">${Math.round((cur+1)/LESSONS.length*100)}% complete</span></div>
    </div>
    <h2>${L.title.split(' ').map((w,i)=> i===1?`<em>${w}</em>`:w).join(' ')}</h2>
    <p class="lead">${L.lead}</p>
    <div class="try"><h5>✦ ${L.tryTitle}</h5><ol>${L.steps.map(s=>`<li>${s}</li>`).join('')}</ol></div>
    <div class="tip"><div class="tip-icon">💡</div><p>${L.tip}</p></div>
    <div class="meter"><div class="meter-fill" style="width:${(cur+1)/LESSONS.length*100}%"></div></div>
    <div class="lesson-actions">
      <button class="btn" id="prevBtn" ${cur===0?'disabled style="opacity:0.4"':''}>← Previous</button>
      <button class="btn btn-primary" id="nextBtn" style="flex:1">${cur===LESSONS.length-1?'✓ Back to start':'Next — '+LESSONS[cur+1].title+' →'}</button>
    </div>
    <div style="font-size:11px;color:var(--muted);text-align:center">Tip: tweak knobs while holding a note to hear changes live • Press <b>Space</b> to play C4</div>
  `;
  el.querySelector('#prevBtn')?.addEventListener('click',()=>{ cur=Math.max(0,cur-1); renderAll(); });
  el.querySelector('#nextBtn').addEventListener('click',()=>{ cur=(cur+1)%LESSONS.length; renderAll(); if(cur===0) window.scrollTo({top:0,behavior:'smooth'}); });
}

function knobHTML(id,label,val,min,max,step,unit){
  return `<div class="knob-wrap">
    <div class="knob" data-knob="${id}" data-min="${min}" data-max="${max}" data-step="${step}" data-val="${val}">
      <div class="knob-ind" style="transform:translateX(-50%) rotate(${((val-min)/(max-min)*270-135)}deg)"></div>
    </div>
    <div class="knob-label">${label}</div>
    <div class="knob-val" id="val-${id}">${typeof val==='number'?(unit?val.toFixed(val<10&&step<1?2:0)+unit:val):val}</div>
  </div>`;
}
function renderControls(){
  const L=LESSONS[cur];
  const unlocked=new Set(); LESSONS.slice(0,cur+1).forEach(l=>l.controls.forEach(c=>unlocked.add(c)));
  const show = c=> unlocked.has(c) ? '' : 'locked';
  const html = `
    <div class="group ${show('osc')}">
      <div class="group-head"><div class="group-icon" style="background:rgba(255,59,48,0.15);border:1px solid rgba(255,59,48,0.3)">◈</div><div><h4>Oscillator</h4><p>Waveform & pitch character</p></div></div>
      <div class="wave-grid">
        ${[['sine','Sine','Pure'],['sawtooth','Saw','Bright'],['square','Square','Hollow'],['triangle','Tri','Soft']].map(([v,l,d])=>`
          <div class="wave-btn ${oscType===v?'active':''}" data-wave="${v}"><canvas width="80" height="28" data-wavecanvas="${v}"></canvas><label>${l}</label><p>${d}</p></div>
        `).join('')}
      </div>
      <div class="knobs">
        ${knobHTML('detune','Detune',detune,0,30,1,'¢')}
        ${knobHTML('oct','Octave',octave,1,6,1,'')}
      </div>
    </div>

    <div class="group ${show('env')}">
      <div class="group-head"><div class="group-icon" style="background:rgba(0,229,255,0.15);border:1px solid rgba(0,229,255,0.3)">◐</div><div><h4>Envelope — ADSR</h4><p>Loudness over time</p></div></div>
      <div class="slider-row"><div class="slider-head"><label>Attack</label><span id="v-A">${env.A.toFixed(2)}s</span></div><input type="range" id="s-A" min="0.001" max="1.5" step="0.005" value="${env.A}"></div>
      <div class="slider-row"><div class="slider-head"><label>Decay</label><span id="v-D">${env.D.toFixed(2)}s</span></div><input type="range" id="s-D" min="0.01" max="1.5" step="0.01" value="${env.D}"></div>
      <div class="slider-row"><div class="slider-head"><label>Sustain</label><span id="v-S">${env.S.toFixed(2)}</span></div><input type="range" id="s-S" min="0" max="1" step="0.01" value="${env.S}"></div>
      <div class="slider-row"><div class="slider-head"><label>Release</label><span id="v-R">${env.R.toFixed(2)}s</span></div><input type="range" id="s-R" min="0.02" max="2" step="0.02" value="${env.R}"></div>
      <canvas id="envCanvas" width="280" height="70" style="width:100%;height:70px;background:#07070a;border:1px solid var(--border);border-radius:10px;margin-top:8px"></canvas>
    </div>

    <div class="group ${show('filter')}">
      <div class="group-head"><div class="group-icon" style="background:rgba(255,204,0,0.15);border:1px solid rgba(255,204,0,0.3)">▦</div><div><h4>Filter</h4><p>Remove frequencies</p></div></div>
      <div class="chip-row">
        ${['lowpass','highpass','bandpass'].map(t=>`<div class="chip ${filt.type===t?'active':''}" data-ftype="${t}">${t}</div>`).join('')}
      </div>
      <div class="knobs">
        ${knobHTML('cutoff','Cutoff',filt.freq,80,10000,1,' Hz')}
        ${knobHTML('res','Resonance',filt.Q,0.1,12,0.1,'')}
      </div>
    </div>

    <div class="group ${show('lfo')}">
      <div class="group-head"><div class="group-icon" style="background:rgba(168,85,247,0.15);border:1px solid rgba(168,85,247,0.3)">〜</div><div><h4>LFO — Modulation</h4><p>Wobble & movement</p></div></div>
      <div class="chip-row" style="margin-bottom:8px">
        ${['pitch','filter','amp'].map(t=>`<div class="chip ${lfo.target===t?'active':''}" data-lfot="${t}">${t}</div>`).join('')}
      </div>
      <div class="knobs">
        ${knobHTML('lfoRate','Rate',lfo.rate,0.1,12,0.1,' Hz')}
        ${knobHTML('lfoDepth','Depth',lfo.depth,0,80,1,'')}
      </div>
      <div style="font-size:11px;color:var(--muted);margin-top:8px;text-align:center">Depth 0 = off • Try 5 Hz on pitch for vibrato</div>
    </div>

    <div class="group ${show('fx')}">
      <div class="group-head"><div class="group-icon" style="background:rgba(34,197,94,0.15);border:1px solid rgba(34,197,94,0.3)">✦</div><div><h4>Space & Drive</h4><p>Delay • Reverb • Saturation</p></div></div>
      <div class="slider-row"><div class="slider-head"><label>Delay Mix</label><span id="v-delay">${Math.round(fx.delay*100)}%</span></div><input type="range" id="s-delay" min="0" max="0.6" step="0.01" value="${fx.delay}"></div>
      <div class="slider-row"><div class="slider-head"><label>Delay Time</label><span id="v-dtime">${fx.delayTime.toFixed(2)}s</span></div><input type="range" id="s-dtime" min="0.05" max="0.6" step="0.01" value="${fx.delayTime}"></div>
      <div class="slider-row"><div class="slider-head"><label>Reverb</label><span id="v-rev">${Math.round(fx.reverb*100)}%</span></div><input type="range" id="s-rev" min="0" max="0.7" step="0.01" value="${fx.reverb}"></div>
      <div class="slider-row"><div class="slider-head"><label>Drive</label><span id="v-drive">${Math.round(fx.drive*100)}%</span></div><input type="range" id="s-drive" min="0" max="0.8" step="0.01" value="${fx.drive}"></div>
    </div>
  `;
  document.getElementById('controls').innerHTML=html;
  // seq panel visibility
  document.getElementById('seqPanel').style.display = unlocked.has('seq') ? 'block' : 'none';
  document.getElementById('patchName').textContent = `${oscType.toUpperCase().slice(0,4)} • ${filt.type.toUpperCase()} ${Math.round(filt.freq)}Hz`;
  bindControls();
  drawWaveIcons(); drawEnv();
}

function bindControls(){
  document.querySelectorAll('[data-wave]').forEach(b=> b.onclick=()=>{ oscType=b.dataset.wave; renderControls(); });
  document.querySelectorAll('[data-ftype]').forEach(b=> b.onclick=()=>{ filt.type=b.dataset.ftype; if(audioCtx) filterNode.type=filt.type; renderControls(); });
  document.querySelectorAll('[data-lfot]').forEach(b=> b.onclick=()=>{ lfo.target=b.dataset.lfot; renderControls(); });

  // sliders
  const bind = (id, cb)=>{
    const el=document.getElementById(id); if(!el) return;
    el.addEventListener('input', e=> cb(parseFloat(e.target.value)));
  };
  bind('s-A', v=>{ env.A=v; document.getElementById('v-A').textContent=v.toFixed(2)+'s'; drawEnv(); });
  bind('s-D', v=>{ env.D=v; document.getElementById('v-D').textContent=v.toFixed(2)+'s'; drawEnv(); });
  bind('s-S', v=>{ env.S=v; document.getElementById('v-S').textContent=v.toFixed(2); drawEnv(); });
  bind('s-R', v=>{ env.R=v; document.getElementById('v-R').textContent=v.toFixed(2)+'s'; drawEnv(); });
  bind('s-delay', v=>{ fx.delay=v; document.getElementById('v-delay').textContent=Math.round(v*100)+'%'; updateFx(); });
  bind('s-dtime', v=>{ fx.delayTime=v; document.getElementById('v-dtime').textContent=v.toFixed(2)+'s'; updateFx(); });
  bind('s-rev', v=>{ fx.reverb=v; document.getElementById('v-rev').textContent=Math.round(v*100)+'%'; updateFx(); });
  bind('s-drive', v=>{ fx.drive=v; document.getElementById('v-drive').textContent=Math.round(v*100)+'%'; updateFx(); });

  // knobs drag
  document.querySelectorAll('.knob').forEach(knob=>{
    let dragging=false, startY=0, startVal=0;
    const min=parseFloat(knob.dataset.min), max=parseFloat(knob.dataset.max), step=parseFloat(knob.dataset.step);
    const id=knob.dataset.knob;
    const onMove = e=>{
      if(!dragging) return;
      const y = e.touches? e.touches[0].clientY : e.clientY;
      const delta = (startY - y) * (max-min)/180;
      let v = Math.min(max, Math.max(min, startVal + delta));
      v = Math.round(v/step)*step;
      knob.dataset.val=v;
      const ind=knob.querySelector('.knob-ind');
      ind.style.transform=`translateX(-50%) rotate(${((v-min)/(max-min)*270-135)}deg)`;
      const valEl=document.getElementById('val-'+id);
      if(valEl){
        if(id==='cutoff') valEl.textContent=Math.round(v)+' Hz';
        else if(id==='res') valEl.textContent=v.toFixed(1);
        else if(id==='detune') valEl.textContent=Math.round(v)+'¢';
        else if(id==='oct') valEl.textContent='OCT '+Math.round(v);
        else if(id==='lfoRate') valEl.textContent=v.toFixed(1)+' Hz';
        else if(id==='lfoDepth') valEl.textContent=Math.round(v);
      }
      applyKnob(id, v);
    };
    const onUp=()=>{ dragging=false; document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); document.removeEventListener('touchmove',onMove); document.removeEventListener('touchend',onUp); };
    knob.addEventListener('mousedown', e=>{ dragging=true; startY=e.clientY; startVal=parseFloat(knob.dataset.val); document.addEventListener('mousemove',onMove); document.addEventListener('mouseup',onUp); e.preventDefault(); });
    knob.addEventListener('touchstart', e=>{ dragging=true; startY=e.touches[0].clientY; startVal=parseFloat(knob.dataset.val); document.addEventListener('touchmove',onMove,{passive:false}); document.addEventListener('touchend',onUp); e.preventDefault(); }, {passive:false});
  });
}
function applyKnob(id,v){
  if(id==='detune') detune=v;
  else if(id==='oct') { octave=Math.round(v); document.getElementById('octLabel').textContent='OCT '+octave; buildKeyboard(); }
  else if(id==='cutoff'){ filt.freq=v; if(audioCtx) filterNode.frequency.value=v; document.getElementById('patchName').textContent=`${oscType.toUpperCase().slice(0,4)} • ${filt.type.toUpperCase()} ${Math.round(v)}Hz`; }
  else if(id==='res'){ filt.Q=v; if(audioCtx) filterNode.Q.value=v; }
  else if(id==='lfoRate') lfo.rate=v;
  else if(id==='lfoDepth') lfo.depth=v;
}

function drawWaveIcons(){
  document.querySelectorAll('[data-wavecanvas]').forEach(c=>{
    const ctx=c.getContext('2d'), w=c.width, h=c.height, type=c.dataset.wavecanvas;
    ctx.clearRect(0,0,w,h); ctx.strokeStyle='#ff3b30'; ctx.lineWidth=1.8; ctx.beginPath();
    for(let x=0;x<w;x++){
      const t=x/w;
      let y=0;
      if(type==='sine') y=Math.sin(t*Math.PI*2);
      else if(type==='sawtooth') y=1-2*t;
      else if(type==='square') y= t<0.5?1:-1;
      else if(type==='triangle') y= t<0.25? t*4 : t<0.75? 2- t*4 : -4 + t*4;
      const py=h/2 - y*(h/2-4);
      if(x===0) ctx.moveTo(x,py); else ctx.lineTo(x,py);
    }
    ctx.stroke();
  });
}
function drawEnv(){
  const c=document.getElementById('envCanvas'); if(!c) return;
  const ctx=c.getContext('2d'), w=c.width, h=c.height;
  ctx.clearRect(0,0,w,h);
  // grid
  ctx.strokeStyle='#1e1e2a'; ctx.lineWidth=1;
  for(let i=1;i<4;i++){ ctx.beginPath(); ctx.moveTo(0,h*i/4); ctx.lineTo(w,h*i/4); ctx.stroke(); }
  const total=env.A+env.D+0.6+env.R;
  const xA=w*env.A/total, xD=w*env.D/total, xS=w*0.6/total, xR=w*env.R/total;
  const yS=h*(1-env.S);
  ctx.fillStyle='rgba(0,229,255,0.12)'; ctx.strokeStyle='#00e5ff'; ctx.lineWidth=2; ctx.beginPath();
  ctx.moveTo(0,h); ctx.lineTo(xA, 6); ctx.lineTo(xA+xD, yS||6); ctx.lineTo(xA+xD+xS, yS||6); ctx.lineTo(xA+xD+xS+xR, h); ctx.stroke();
  ctx.lineTo(xA+xD+xS+xR, h); ctx.lineTo(0,h); ctx.fill();
  // labels
  ctx.fillStyle='#8a8a9a'; ctx.font='9px JetBrains Mono'; ctx.fillText('A', xA/2-4, h-6); ctx.fillText('D', xA+xD/2-4, h-6); ctx.fillText('S', xA+xD+xS/2-4, h-6); ctx.fillText('R', xA+xD+xS+xR/2-4, h-6);
}

// Keyboard
function buildKeyboard(){
  const kb=document.getElementById('kb'); kb.innerHTML='';
  const startMidi = octave*12+12; // C octave
  const count=14; // white keys count approx
  // Build white keys first
  const whiteNotes=[]; const blackOffsets=[1,3,6,8,10];
  for(let i=0;i<17;i++){
    const midi=startMidi+i;
    const isBlack=[1,3,6,8,10].includes(midi%12);
    if(!isBlack) whiteNotes.push(midi);
  }
  const container=document.createElement('div'); container.style.display='flex'; container.style.gap='2px'; container.style.position='relative'; container.style.width='100%';
  kb.appendChild(container);
  const keyEls=[];
  whiteNotes.slice(0,14).forEach(midi=>{
    const k=document.createElement('div'); k.className='key'; k.dataset.midi=midi;
    k.innerHTML=`<label>${midiToName(midi)}</label>`;
    k.addEventListener('mousedown',()=> playNote(midi));
    k.addEventListener('mouseup',()=> { if(!hold && !sustain) stopNote(midi); });
    k.addEventListener('mouseleave',()=> { if(!hold && !sustain) stopNote(midi); });
    k.addEventListener('touchstart',e=>{ e.preventDefault(); playNote(midi); });
    k.addEventListener('touchend',e=>{ e.preventDefault(); if(!hold) stopNote(midi); });
    container.appendChild(k); keyEls.push(k);
  });
  // black keys positioned absolute over container
  // compute positions: after each white key
  let whiteIdx=0;
  for(let i=0;i<17;i++){
    const midi=startMidi+i; if([1,3,6,8,10].includes(midi%12)){
      // find white index before this black
      // count whites up to midi
      let wBefore=0;
      for(let m=startMidi;m<midi;m++) if(![1,3,6,8,10].includes(m%12)) wBefore++;
      const k=document.createElement('div'); k.className='key black'; k.dataset.midi=midi;
      k.innerHTML=`<label>${midiToName(midi).replace('#','♯')}</label>`;
      // position: left = (wBefore * (100/14))% approx
      // use pixel calc after layout - simpler: use flex overlay with absolute left
      k.style.left = `calc(${(wBefore/14*100)}% + 2px)`;
      k.addEventListener('mousedown',e=>{ e.stopPropagation(); playNote(midi); });
      k.addEventListener('mouseup',e=>{ e.stopPropagation(); if(!hold && !sustain) stopNote(midi); });
      k.addEventListener('touchstart',e=>{ e.preventDefault(); e.stopPropagation(); playNote(midi); });
      k.addEventListener('touchend',e=>{ e.preventDefault(); if(!hold) stopNote(midi); });
      container.appendChild(k);
    }
  }
  // keyboard mapping
  kb._whiteNotes=whiteNotes;
}
const keyMap={a:0,s:2,d:4,f:5,g:7,h:9,j:11,k:12, w:1,e:3,t:6,y:8,u:10};
let heldKeys=new Set();
window.addEventListener('keydown',e=>{
  if(e.repeat) return;
  if(e.code==='Space'){ e.preventDefault(); playNote(nameToMidi('C'+octave)); return; }
  if(e.key==='Shift') sustain=true;
  const k=e.key.toLowerCase();
  if(keyMap[k]!==undefined){
    const midi = octave*12+12 + keyMap[k];
    if(!heldKeys.has(midi)){ heldKeys.add(midi); playNote(midi); }
  }
});
window.addEventListener('keyup',e=>{
  if(e.key==='Shift'){ sustain=false; if(!hold) [...activeVoices.keys()].forEach(m=> stopNote(m)); }
  const k=e.key.toLowerCase();
  if(k===' '){ stopNote(nameToMidi('C'+octave)); return; }
  if(keyMap[k]!==undefined){
    const midi=octave*12+12+keyMap[k];
    heldKeys.delete(midi);
    if(!hold && !sustain) stopNote(midi);
  }
});

// Visualizers
let _vizPhase=0;
function loopViz(){
  requestAnimationFrame(loopViz);
  _vizPhase+=0.04;
  const sc=document.getElementById('scope'), sp=document.getElementById('spectrum');
  if(!sc||!sp) return;
  const ctx=sc.getContext('2d'), ctx2=sp.getContext('2d');
  const w=sc.width, h=sc.height, w2=sp.width, h2=sp.height;
  // scope
  ctx.fillStyle='#07070a'; ctx.fillRect(0,0,w,h);
  ctx.strokeStyle='#1e1e2a'; ctx.lineWidth=1;
  ctx.beginPath(); ctx.moveTo(0,h/2); ctx.lineTo(w,h/2); ctx.stroke();
  // subtle grid
  ctx.strokeStyle='rgba(255,255,255,0.04)'; ctx.beginPath();
  for(let x=0;x<w;x+=40){ ctx.moveTo(x,0); ctx.lineTo(x,h); }
  for(let y=0;y<h;y+=22){ ctx.moveTo(0,y); ctx.lineTo(w,y); }
  ctx.stroke();
  const hasSignal = analyser && activeVoices.size>0;
  if(hasSignal){
    const data=new Uint8Array(analyser.fftSize); analyser.getByteTimeDomainData(data);
    ctx.strokeStyle='#00e5ff'; ctx.lineWidth=1.7; ctx.beginPath();
    for(let i=0;i<data.length;i++){
      const x=i/data.length*w, y=(data[i]/128-1)* (h*0.40) + h/2;
      if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
    }
    ctx.stroke();
    ctx.strokeStyle='rgba(0,229,255,0.18)'; ctx.lineWidth=5; ctx.stroke();
  } else {
    // idle animated preview based on current waveform
    ctx.strokeStyle='rgba(0,229,255,0.55)'; ctx.lineWidth=1.6; ctx.beginPath();
    for(let x=0;x<w;x++){
      const t=(x/w)*Math.PI*4 + _vizPhase;
      let y=0;
      if(oscType==='sine') y=Math.sin(t);
      else if(oscType==='sawtooth') y=1-2*((t%(Math.PI*2))/(Math.PI*2));
      else if(oscType==='square') y=Math.sin(t)>0?0.9:-0.9;
      else y=Math.asin(Math.sin(t))*2/Math.PI;
      const py=h/2 + y*(h*0.28);
      if(x===0) ctx.moveTo(x,py); else ctx.lineTo(x,py);
    }
    ctx.stroke();
    ctx.fillStyle='rgba(255,255,255,0.28)'; ctx.font='10px JetBrains Mono';
    ctx.fillText('♪ play a note — live waveform', 10, h-10);
  }
  // spectrum
  ctx2.fillStyle='#07070a'; ctx2.fillRect(0,0,w2,h2);
  if(analyser2 && activeVoices.size>0){
    const f=new Uint8Array(analyser2.frequencyBinCount); analyser2.getByteFrequencyData(f);
    const bars=48;
    const step=Math.floor(f.length/bars);
    for(let i=0;i<bars;i++){
      const v=f[i*step]/255;
      const bh=Math.max(2, v*h2*0.88);
      const x=i*(w2/bars)+2, bw=w2/bars-3;
      const grad=ctx2.createLinearGradient(0,h2-bh,0,h2);
      grad.addColorStop(0,'#ff3b30'); grad.addColorStop(1,'#ffcc00');
      ctx2.fillStyle=grad; ctx2.fillRect(x, h2-bh, bw, bh);
      ctx2.fillStyle='rgba(255,255,255,0.12)'; ctx2.fillRect(x, h2-bh-2, bw, 2);
    }
  } else {
    // idle spectrum bars
    for(let i=0;i<48;i++){
      const v=0.08 + Math.abs(Math.sin(_vizPhase*0.6 + i*0.35))*0.18 + Math.random()*0.04;
      const bh=v*h2*0.7;
      const x=i*(w2/48)+2, bw=w2/48-3;
      ctx2.fillStyle=i%8===0?'rgba(255,59,48,0.35)':'rgba(255,255,255,0.07)';
      ctx2.fillRect(x, h2-bh, bw, bh);
    }
    ctx2.fillStyle='rgba(255,255,255,0.28)'; ctx2.font='10px JetBrains Mono';
    ctx2.fillText('spectrum — play to animate', 10, h2-10);
  }
}

// Sequencer
function buildSeq(){
  const g=document.getElementById('seqGrid'); g.innerHTML='';
  seqSteps.forEach((v,i)=>{
    const s=document.createElement('div'); s.className='seq-step'+(v?' active':'');
    s.style.setProperty('--vel', (20+v*70)+'%');
    s.title=`Step ${i+1}`;
    s.onclick=()=>{
      seqSteps[i]= seqSteps[i]?0: (0.3+Math.random()*0.7);
      if(seqSteps[i]) s.classList.add('active'); else s.classList.remove('active');
      s.style.setProperty('--vel', (20+seqSteps[i]*70)+'%');
    };
    // velocity drag vertical?
    g.appendChild(s);
  });
}
function seqTick(){
  seqPos=(seqPos+1)%16;
  document.querySelectorAll('.seq-step').forEach((el,i)=> el.classList.toggle('playing', i===seqPos));
  const v=seqSteps[seqPos];
  if(v>0){
    const rootMidi=nameToMidi(document.getElementById('seqRoot').value);
    // map velocity to note offset: use pentatonic
    const scale=[0,2,4,7,9,12];
    const idx=Math.floor(v*scale.length) % scale.length;
    const midi=rootMidi+scale[idx];
    playNote(midi, 0.5+v*0.5);
    setTimeout(()=> stopNote(midi), 180);
  }
}
function startSeq(){
  if(seqPlaying) return;
  seqPlaying=true; document.getElementById('seqPlay').textContent='⏸ Pause';
  const interval=60000/bpm/4;
  seqTimer=setInterval(seqTick, interval);
}
function stopSeq(){
  seqPlaying=false; document.getElementById('seqPlay').textContent='▶ Play';
  clearInterval(seqTimer); seqPos=-1;
  document.querySelectorAll('.seq-step').forEach(el=>el.classList.remove('playing'));
}

// Presets
function renderPresets(){
  const el=document.getElementById('presets'); el.innerHTML='';
  PRESETS.forEach(p=>{
    const b=document.createElement('button'); b.className='preset'; b.textContent=p.name;
    b.onclick=()=> applyPreset(p);
    el.appendChild(b);
  });
}
function applyPreset(p){
  oscType=p.wave||oscType; detune=p.detune??detune; filt.type=p.filterType||filt.type; filt.freq=p.cutoff??filt.freq; filt.Q=p.res??filt.Q;
  env.A=p.A??env.A; env.D=p.D??env.D; env.S=p.S??env.S; env.R=p.R??env.R;
  lfo.rate=p.lfoRate??lfo.rate; lfo.depth=p.lfoDepth??lfo.depth; lfo.target=p.lfoTarget??lfo.target;
  fx.delay=p.delay??fx.delay; fx.reverb=p.rev??fx.reverb; fx.drive=p.drive??fx.drive;
  if(audioCtx){ filterNode.type=filt.type; filterNode.frequency.value=filt.freq; filterNode.Q.value=filt.Q; updateFx(); }
  renderControls();
}

// Global bindings
document.getElementById('powerBtn').onclick=function(){ this.classList.toggle('on'); const on=this.classList.contains('on'); if(audioCtx) masterGain.gain.value= on?0.55:0; };
document.getElementById('panicBtn').onclick=panic;
document.getElementById('octUp').onclick=()=>{ octave=Math.min(6,octave+1); document.getElementById('octLabel').textContent='OCT '+octave; buildKeyboard(); renderControls(); };
document.getElementById('octDown').onclick=()=>{ octave=Math.max(1,octave-1); document.getElementById('octLabel').textContent='OCT '+octave; buildKeyboard(); renderControls(); };
document.getElementById('holdBtn').onclick=function(){ hold=!hold; this.textContent='Hold: '+(hold?'ON':'OFF'); this.style.background=hold?'var(--accent)':'var(--panel2)'; this.style.color=hold?'white':''; if(!hold) panic(); };
document.getElementById('seqPlay').onclick=()=> seqPlaying?stopSeq():startSeq();
document.getElementById('seqClear').onclick=()=>{ seqSteps=seqSteps.map(()=>0); buildSeq(); };
document.getElementById('seqRand').onclick=()=>{ seqSteps=seqSteps.map(()=> Math.random()>0.5? Math.random():0); buildSeq(); };
document.getElementById('bpm').addEventListener('input',e=>{ bpm=parseInt(e.target.value); document.getElementById('bpmVal').textContent=bpm; if(seqPlaying){ stopSeq(); startSeq(); } });

function renderAll(){ renderProgress(); renderLesson(); renderControls(); }
renderAll(); buildKeyboard(); buildSeq(); renderPresets(); loopViz();

// initial hint: auto unlock audio on first interaction
window.addEventListener('pointerdown', ()=>{ ensureAudio(); if(audioCtx.state==='suspended') audioCtx.resume(); }, {once:true});
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"92329755-4a04-45c6-ab2f-775d02c0e19c","tokensIn":2782847,"tokensOut":33973,"tokensTotal":2816820,"cost":0.046534658000000007,"turns":38,"toolCalls":37,"failedToolCalls":1,"timestamp":"2026-08-23T03:46:27.224Z"} -->