← TutSynth results

TutSynth

Qwen 3.7 max · composer

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 38,880 bytes · SHA-256 8335c75b7285
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Synth Playground — Learn Synthesis Interactively</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
  font-family: 'Segoe UI', system-ui, sans-serif;
  background: #0a0a0f;
  color: #e0e0e8;
  min-height: 100vh;
  overflow-x: hidden;
}
#app { max-width: 1100px; margin: 0 auto; padding: 20px; }
#header { text-align: center; margin-bottom: 24px; }
#header h1 { 
  font-size: 2rem; color: #a78bfa; letter-spacing: -1px;
  text-shadow: 0 0 20px rgba(167, 139, 250, 0.3);
}
#subtitle { color: #666; margin-top: 4px; font-size: 0.95rem; }
#main-layout { display: flex; flex-direction: column; gap: 16px; }
#stages-nav {
  display: flex; gap: 8px; flex-wrap: wrap; justify-content: center;
  padding: 12px; background: #12121a; border-radius: 12px; border: 1px solid #1e1e2e;
}
.stage-tab {
  padding: 8px 16px; border-radius: 8px; font-size: 0.85rem;
  cursor: pointer; border: 1px solid #2a2a3a; background: #16161f;
  color: #666; transition: all 0.3s;
}
.stage-tab.unlocked { color: #9ca3af; border-color: #3a3a4a; }
.stage-tab.unlocked:hover { background: #1e1e2e; color: #ccc; }
.stage-tab.active { background: #7c3aed; color: #fff; border-color: #7c3aed; box-shadow: 0 0 12px rgba(124, 58, 237, 0.3); }
.stage-tab.locked { opacity: 0.4; cursor: not-allowed; }
.stage-tab.locked::after { content: ' 🔒'; }

@media (max-width: 768px) {
  #app { padding: 12px; }
  #header h1 { font-size: 1.5rem; }
  .control-section { min-width: 100% !important; }
  #piano-keys { transform: scale(0.75); transform-origin: top center; }
}
/* ── css: workspace ── */
#workspace { display: flex; flex-direction: column; gap: 16px; }
#visualization-area {
  display: flex; flex-direction: column; gap: 2px;
  background: #12121a; border-radius: 12px; padding: 16px;
  border: 1px solid #1e1e2e;
}
#waveform-canvas {
  width: 100%; height: 100px; border-radius: 6px;
  background: #0d0d14;
}
#spectrum-canvas {
  width: 100%; height: 60px; border-radius: 6px;
  background: #0d0d14;
}
#lesson-panel {
  background: #12121a; border-radius: 12px; padding: 20px 24px;
  border: 1px solid #1e1e2e; min-height: 120px;
}
#lesson-content h2 { color: #a78bfa; font-size: 1.2rem; margin-bottom: 8px; }
#lesson-content p { line-height: 1.6; color: #b0b0c0; margin-bottom: 8px; font-size: 0.92rem; }
#lesson-content .highlight { color: #f0abfc; font-weight: 600; }
#lesson-content .try-it { 
  background: #1a1a2e; border-left: 3px solid #7c3aed;
  padding: 10px 14px; margin: 12px 0; border-radius: 0 8px 8px 0;
  color: #c4b5fd; font-size: 0.88rem; line-height: 1.6;
}
.unlock-btn {
  margin-top: 16px; padding: 10px 24px; background: linear-gradient(135deg, #7c3aed, #a855f7);
  color: white; border: none; border-radius: 8px; cursor: pointer;
  font-size: 0.95rem; font-weight: 600; transition: all 0.2s;
}
.unlock-btn:hover { background: linear-gradient(135deg, #6d28d9, #9333ea); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(124, 58, 237, 0.3); }
.unlock-btn.hidden { display: none; }
/* ── css: controls ── */
#controls-area {
  display: flex; flex-wrap: wrap; gap: 12px;
}
.control-section {
  background: #12121a; border-radius: 12px; padding: 16px;
  border: 1px solid #1e1e2e; flex: 1; min-width: 220px;
  transition: opacity 0.4s, transform 0.4s;
}
.control-section.locked { display: none; }
.control-section h3 {
  font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px;
  color: #7c3aed; margin-bottom: 12px; padding-bottom: 6px;
  border-bottom: 1px solid #1e1e2e;
}
.control-row {
  display: flex; align-items: center; justify-content: space-between;
  margin-bottom: 10px; gap: 10px;
}
.control-row label { font-size: 0.82rem; color: #9ca3af; white-space: nowrap; }
.control-row .value-display {
  font-size: 0.78rem; color: #a78bfa; min-width: 40px; text-align: right;
  font-family: 'Courier New', monospace;
}
input[type="range"] {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 6px; background: #1e1e2e; border-radius: 3px;
  outline: none; flex: 1;
}
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 16px; height: 16px; border-radius: 50%;
  background: #7c3aed; cursor: pointer; border: 2px solid #a78bfa;
}
input[type="range"]::-moz-range-thumb {
  width: 16px; height: 16px; border-radius: 50%;
  background: #7c3aed; cursor: pointer; border: 2px solid #a78bfa;
}
.wave-selector { display: flex; gap: 4px; }
.wave-btn {
  padding: 6px 12px; border: 1px solid #2a2a3a; background: #16161f;
  color: #888; border-radius: 6px; cursor: pointer; font-size: 0.78rem;
  transition: all 0.2s;
}
.wave-btn:hover { border-color: #7c3aed; color: #ccc; }
.wave-btn.active { background: #7c3aed; color: white; border-color: #7c3aed; }
/* ── css: keyboard ── */
#keyboard-area {
  background: #12121a; border-radius: 12px; padding: 16px;
  border: 1px solid #1e1e2e;
}
#keyboard-area.hidden { display: none; }
#piano-keys {
  position: relative;
  height: 140px; margin: 0 auto;
}
.key-white {
  height: 130px; background: linear-gradient(to bottom, #f8f8f8, #e8e8e8);
  border: 1px solid #bbb; border-radius: 0 0 6px 6px; cursor: pointer;
  position: absolute; z-index: 1; transition: background 0.1s;
  display: flex; align-items: flex-end; justify-content: center;
  padding-bottom: 8px; font-size: 0.65rem; color: #888;
  box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.key-white:hover { background: linear-gradient(to bottom, #fff, #f0f0f0); }
.key-white.active { background: linear-gradient(to bottom, #c4b5fd, #a78bfa) !important; }
.key-black {
  height: 85px; background: linear-gradient(to bottom, #333, #111);
  border: 1px solid #000; border-radius: 0 0 4px 4px; cursor: pointer;
  position: absolute; z-index: 2; top: 0;
  display: flex; align-items: flex-end; justify-content: center;
  padding-bottom: 6px; font-size: 0.55rem; color: #888;
  box-shadow: 0 2px 6px rgba(0,0,0,0.5);
}
.key-black:hover { background: linear-gradient(to bottom, #444, #222); }
.key-black.active { background: linear-gradient(to bottom, #6d28d9, #4c1d95) !important; }
.hint { text-align: center; margin-top: 10px; font-size: 0.78rem; color: #555; }
</style>
</head>
<body>
<div id="app">
  <header id="header">
    <h1>🎛️ Synth Playground</h1>
    <p id="subtitle">Learn synthesis by doing — each stage adds a new concept</p>
  </header>
  <div id="main-layout">
    <div id="stages-nav"></div>
    <div id="workspace">
      <div id="visualization-area">
        <canvas id="waveform-canvas" width="1024" height="200"></canvas>
        <canvas id="spectrum-canvas" width="1024" height="120"></canvas>
      </div>
      <div id="lesson-panel">
        <div id="lesson-content"></div>
        <button id="unlock-next-btn" class="unlock-btn">Unlock Next Stage →</button>
      </div>
      <div id="controls-area"></div>
      <div id="keyboard-area" class="hidden">
        <div id="piano-keys"></div>
        <p class="hint">Use keys A-L for white keys, W,E,T,Y,U for black keys</p>
      </div>
    </div>
  </div>
</div>
<script>
'use strict';

// ── const: STAGES ──
const STAGES = [
  {
    id: 'oscillator',
    title: '1. Oscillator',
    lesson: `<h2>Stage 1: The Oscillator</h2>
<p>Every synthesizer starts with a <span class="highlight">sound source</span> — the oscillator. It generates a raw waveform at a specific frequency (pitch).</p>
<p>Try the different <span class="highlight">waveforms</span> below. Each has a distinct character:</p>
<p>• <strong>Sine</strong> — pure, smooth, flute-like. Only one frequency, no harmonics.<br>
• <strong>Square</strong> — hollow, woody. Only odd harmonics.<br>
• <strong>Sawtooth</strong> — bright, buzzy. Contains ALL harmonics — great starting point for subtractive synthesis.<br>
• <strong>Triangle</strong> — soft, mellow. Odd harmonics only, but they drop off faster than square.</p>
<div class="try-it">🎮 Try: Click the Hold button, then switch between waveforms. Notice how the waveform visualization changes shape. Listen to the tonal difference.</div>
<p>The <span class="highlight">Frequency</span> control changes the pitch. The waveform repeats faster = higher pitch.</p>`,
  },
  {
    id: 'envelope',
    title: '2. Envelope',
    lesson: `<h2>Stage 2: The Amplitude Envelope (ADSR)</h2>
<p>An <span class="highlight">envelope</span> shapes how a sound changes over time. Without it, notes would just click on and off instantly. The ADSR envelope has four stages:</p>
<p>• <strong>Attack</strong> — How fast the sound reaches full volume. (0 = instant, high = slow fade in)<br>
• <strong>Decay</strong> — How fast it drops from peak to sustain level.<br>
• <strong>Sustain</strong> — The volume held while the note is pressed.<br>
• <strong>Release</strong> — How fast it fades after you let go.</p>
<div class="try-it">🎮 Try: Set a long attack (~1.0s) and play a note — hear it swell in. Then set a long release (~2.0s) and let go — hear it linger. This is how pads and strings work!</div>
<p>Short attack + short decay = percussion sounds. Long attack + long release = ambient pads.</p>`,
  },
  {
    id: 'filter',
    title: '3. Filter',
    lesson: `<h2>Stage 3: The Filter</h2>
<p>A <span class="highlight">filter</span> removes certain frequencies from the sound. The most common type is the <strong>low-pass filter</strong> — it lets low frequencies through and cuts high ones.</p>
<p>• <strong>Cutoff</strong> — The frequency where the filter starts cutting. Lower = darker/muffled. Higher = brighter.<br>
• <strong>Resonance</strong> — Boosts frequencies right at the cutoff point. Creates a "peaky" or "squelchy" sound. High resonance can self-oscillate!</p>
<div class="try-it">🎮 Try: Switch to the sawtooth wave (lots of harmonics to filter). Set cutoff low, then slowly raise it — hear the sound "open up." Add resonance for that classic synth "wow" sound.</div>
<p>The filter is the heart of <span class="highlight">subtractive synthesis</span>: start with a harmonically rich waveform, then sculpt it by removing frequencies.</p>`,
  },
  {
    id: 'lfo',
    title: '4. LFO',
    lesson: `<h2>Stage 4: Low-Frequency Oscillator (LFO)</h2>
<p>An <span class="highlight">LFO</span> is like a regular oscillator, but it runs so slowly (below human hearing) that you hear it as a <em>rhythmic change</em> rather than a pitch.</p>
<p>Use it to modulate (wiggle) other parameters automatically over time:</p>
<p>• <strong>Rate</strong> — How fast the LFO cycles. Slow = gentle movement. Fast = dramatic tremolo/vibrato.<br>
• <strong>Depth</strong> — How much the LFO affects its target.<br>
• <strong>Target</strong> — What the LFO controls. Pitch → vibrato. Filter → wah-wah. Volume → tremolo.</p>
<div class="try-it">🎮 Try: Set Rate to ~5Hz, Depth moderate, Target to Pitch — classic vibrato! Switch Target to Filter for a dubstep wobble. Slow the rate for gentle movement.</div>
<p>LFOs add <span class="highlight">movement and life</span> to otherwise static sounds.</p>`,
  },
  {
    id: 'effects',
    title: '5. Effects',
    lesson: `<h2>Stage 5: Effects</h2>
<p>Effects add <span class="highlight">space and texture</span> to your raw synth sound:</p>
<p>• <strong>Delay</strong> — Repeats the sound after a time interval. Dry/Wet controls the mix. Feedback controls how many repeats.<br>
• <strong>Distortion</strong> — Clips and saturates the signal. Adds grit and harmonics. Subtle = warmth. Extreme = destruction.</p>
<div class="try-it">🎮 Try: Add delay with moderate feedback, then play a short melody — the repeats create rhythmic texture. Add distortion gradually to hear how it adds edge.</div>
<p>In a signal chain, effects typically come <span class="highlight">last</span>, after the oscillator, filter, and envelope have shaped the core sound.</p>`,
  },
  {
    id: 'keyboard',
    title: '6. Play!',
    lesson: `<h2>Stage 6: The Keyboard — Make Music!</h2>
<p>Now you have all the building blocks of a synthesizer! The signal chain is:</p>
<p style="text-align:center; font-size:1.1rem; color:#a78bfa; margin: 16px 0;"><strong>Oscillator → Filter → Envelope → Effects → Output</strong></p>
<p>With the LFO modulating parameters along the way.</p>
<p>Use the keyboard below or your computer keys to play. Experiment with all the controls together to find sounds you love!</p>
<div class="try-it">🎮 Challenge: Try to make these sounds:<br>
• <strong>Bass</strong>: Saw wave, low cutoff, short attack, medium decay<br>
• <strong>Pad</strong>: Triangle, long attack, long release, filter halfway<br>
• <strong>Lead</strong>: Square, high cutoff, short attack, add delay<br>
• <strong>Wobble</strong>: Saw, LFO on filter, medium rate & depth</div>
<p>Remember: synthesis is about <span class="highlight">sculpting sound</span>. Start with a rich source, then shape it.</p>`,
  }
];

// ── const: NOTE_MAP ──
const NOTE_MAP = [
  { note: 'C4', freq: 261.63, type: 'white', key: 'a', label: 'A' },
  { note: 'C#4', freq: 277.18, type: 'black', key: 'w', label: 'W' },
  { note: 'D4', freq: 293.66, type: 'white', key: 's', label: 'S' },
  { note: 'D#4', freq: 311.13, type: 'black', key: 'e', label: 'E' },
  { note: 'E4', freq: 329.63, type: 'white', key: 'd', label: 'D' },
  { note: 'F4', freq: 349.23, type: 'white', key: 'f', label: 'F' },
  { note: 'F#4', freq: 369.99, type: 'black', key: 't', label: 'T' },
  { note: 'G4', freq: 392.00, type: 'white', key: 'g', label: 'G' },
  { note: 'G#4', freq: 415.30, type: 'black', key: 'y', label: 'Y' },
  { note: 'A4', freq: 440.00, type: 'white', key: 'h', label: 'H' },
  { note: 'A#4', freq: 466.16, type: 'black', key: 'u', label: 'U' },
  { note: 'B4', freq: 493.88, type: 'white', key: 'j', label: 'J' },
  { note: 'C5', freq: 523.25, type: 'white', key: 'k', label: 'K' },
  { note: 'C#5', freq: 554.37, type: 'black', key: 'o', label: 'O' },
  { note: 'D5', freq: 587.33, type: 'white', key: 'l', label: 'L' },
];

// ── class: AudioEngine ──
class AudioEngine {
  constructor() {
    this.ctx = null;
    this.analyserWave = null;
    this.analyserSpectrum = null;
    this.masterGain = null;
    this.currentVoice = null;
    this.activeVoices = new Map();
    
    // State
    this.waveform = 'sine';
    this.frequency = 220;
    this.holding = false;
    
    // Envelope
    this.attack = 0.05;
    this.decay = 0.2;
    this.sustain = 0.6;
    this.release = 0.3;
    
    // Filter
    this.filterCutoff = 2000;
    this.filterResonance = 1;
    this.filterEnabled = false;
    
    // LFO
    this.lfoRate = 3;
    this.lfoDepth = 0;
    this.lfoTarget = 'pitch';
    this.lfoEnabled = false;
    
    // Effects
    this.delayTime = 0.3;
    this.delayFeedback = 0.3;
    this.delayMix = 0;
    this.distortion = 0;
    this.effectsEnabled = false;
  }
  
  init() {
    if (this.ctx) return;
    this.ctx = new (window.AudioContext || window.webkitAudioContext)();
    
    // Master gain
    this.masterGain = this.ctx.createGain();
    this.masterGain.gain.value = 0.4;
    
    // Analysers
    this.analyserWave = this.ctx.createAnalyser();
    this.analyserWave.fftSize = 2048;
    this.analyserSpectrum = this.ctx.createAnalyser();
    this.analyserSpectrum.fftSize = 2048;
    
    this.masterGain.connect(this.analyserWave);
    this.analyserWave.connect(this.analyserSpectrum);
    this.analyserSpectrum.connect(this.ctx.destination);
  }
  
  _createSignalChain(freq) {
    const now = this.ctx.currentTime;
    const osc = this.ctx.createOscillator();
    osc.type = this.waveform;
    osc.frequency.value = freq;
    
    // Gain envelope
    const envGain = this.ctx.createGain();
    envGain.gain.setValueAtTime(0, now);
    envGain.gain.linearRampToValueAtTime(1, now + this.attack);
    envGain.gain.linearRampToValueAtTime(this.sustain, now + this.attack + this.decay);
    
    // Filter
    let filter = null;
    if (this.filterEnabled && this.filterCutoff < 20000) {
      filter = this.ctx.createBiquadFilter();
      filter.type = 'lowpass';
      filter.frequency.value = this.filterCutoff;
      filter.Q.value = this.filterResonance;
    }
    
    // LFO
    let lfo = null;
    let lfoGain = null;
    if (this.lfoEnabled && this.lfoDepth > 0) {
      lfo = this.ctx.createOscillator();
      lfo.type = 'sine';
      lfo.frequency.value = this.lfoRate;
      lfoGain = this.ctx.createGain();
      
      if (this.lfoTarget === 'pitch') {
        lfoGain.gain.value = this.lfoDepth * 100;
        lfo.connect(lfoGain);
        lfoGain.connect(osc.frequency);
      } else if (this.lfoTarget === 'filter' && filter) {
        lfoGain.gain.value = this.lfoDepth * this.filterCutoff;
        lfo.connect(lfoGain);
        lfoGain.connect(filter.frequency);
      } else if (this.lfoTarget === 'amplitude') {
        lfoGain.gain.value = this.lfoDepth * 0.5;
        lfo.connect(lfoGain);
        lfoGain.connect(envGain.gain);
      }
      lfo.start(now);
    }
    
    // Connect chain
    let lastNode = osc;
    if (filter) {
      osc.connect(filter);
      lastNode = filter;
    }
    lastNode.connect(envGain);
    
    // Effects
    let outputNode = envGain;
    if (this.effectsEnabled) {
      // Delay
      if (this.delayMix > 0) {
        const delay = this.ctx.createDelay(1.0);
        delay.delayTime.value = this.delayTime;
        const feedbackGain = this.ctx.createGain();
        feedbackGain.gain.value = this.delayFeedback;
        const delayMix = this.ctx.createGain();
        delayMix.gain.value = this.delayMix;
        const dryGain = this.ctx.createGain();
        dryGain.gain.value = 1 - this.delayMix * 0.5;
        
        const merger = this.ctx.createGain();
        envGain.connect(dryGain);
        dryGain.connect(merger);
        envGain.connect(delay);
        delay.connect(feedbackGain);
        feedbackGain.connect(delay);
        delay.connect(delayMix);
        delayMix.connect(merger);
        outputNode = merger;
      }
      
      // Distortion
      if (this.distortion > 0) {
        const waveshaper = this.ctx.createWaveShaper();
        const amount = this.distortion * 100;
        const samples = 256;
        const curve = new Float32Array(samples);
        for (let i = 0; i < samples; i++) {
          const x = (i * 2) / samples - 1;
          curve[i] = ((3 + amount) * x * 20 * (Math.PI / 180)) / (Math.PI + amount * Math.abs(x));
        }
        waveshaper.curve = curve;
        waveshaper.oversample = '4x';
        outputNode.connect(waveshaper);
        outputNode = waveshaper;
      }
    }
    
    outputNode.connect(this.masterGain);
    osc.start(now);
    
    return { osc, envGain, lfo, filter, startTime: now };
  }
  
  noteOn(freq, noteId) {
    this.init();
    if (this.ctx.state === 'suspended') this.ctx.resume();
    
    // Kill existing voice for this note
    if (this.activeVoices.has(noteId || freq)) {
      this.noteOff(noteId || freq);
    }
    
    const voice = this._createSignalChain(freq);
    const key = noteId || freq;
    this.activeVoices.set(key, voice);
    
    if (!noteId) {
      this.currentVoice = voice;
      this.holding = true;
    }
  }
  
  noteOff(noteId) {
    const key = noteId || this.frequency;
    const voice = this.activeVoices.get(key);
    if (!voice) return;
    
    const now = this.ctx.currentTime;
    voice.envGain.gain.cancelScheduledValues(now);
    voice.envGain.gain.setValueAtTime(voice.envGain.gain.value, now);
    voice.envGain.gain.linearRampToValueAtTime(0, now + this.release);
    
    const stopTime = now + this.release + 0.05;
    voice.osc.stop(stopTime);
    if (voice.lfo) voice.lfo.stop(stopTime);
    
    this.activeVoices.delete(key);
    
    if (!noteId) {
      this.holding = false;
      this.currentVoice = null;
    }
  }
  
  toggleHold() {
    if (this.holding) {
      this.noteOff();
    } else {
      this.noteOn(this.frequency);
    }
  }
  
  setWaveform(type) {
    this.waveform = type;
    // Update active oscillators
    this.activeVoices.forEach(v => { v.osc.type = type; });
  }
  
  setFrequency(freq) {
    this.frequency = freq;
    if (this.holding && this.currentVoice) {
      this.currentVoice.osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
    }
  }
  
  getWaveformData() {
    if (!this.analyserWave) return new Float32Array(0);
    const data = new Float32Array(this.analyserWave.fftSize);
    this.analyserWave.getFloatTimeDomainData(data);
    return data;
  }
  
  getSpectrumData() {
    if (!this.analyserSpectrum) return new Uint8Array(0);
    const data = new Uint8Array(this.analyserSpectrum.frequencyBinCount);
    this.analyserSpectrum.getByteFrequencyData(data);
    return data;
  }
}

// ── function: createSlider ──
function createSlider(parent, label, min, max, value, step, onChange) {
  const row = document.createElement('div');
  row.className = 'control-row';
  
  const lbl = document.createElement('label');
  lbl.textContent = label;
  
  const slider = document.createElement('input');
  slider.type = 'range';
  slider.min = min;
  slider.max = max;
  slider.value = value;
  slider.step = step;
  
  const display = document.createElement('span');
  display.className = 'value-display';
  display.textContent = parseFloat(value).toFixed(step < 1 ? 2 : 0);
  
  slider.addEventListener('input', () => {
    display.textContent = parseFloat(slider.value).toFixed(step < 1 ? 2 : 0);
    onChange(parseFloat(slider.value));
  });
  
  row.appendChild(lbl);
  row.appendChild(slider);
  row.appendChild(display);
  parent.appendChild(row);
  
  return { slider, display };
}

// ── function: createWaveSelector ──
function createWaveSelector(parent, activeType, onChange) {
  const row = document.createElement('div');
  row.className = 'control-row';
  const lbl = document.createElement('label');
  lbl.textContent = 'Waveform';
  row.appendChild(lbl);
  
  const btnGroup = document.createElement('div');
  btnGroup.className = 'wave-selector';
  
  const waves = [
    { type: 'sine', symbol: '∿' },
    { type: 'square', symbol: '⊓' },
    { type: 'sawtooth', symbol: '⩘' },
    { type: 'triangle', symbol: '△' },
  ];
  
  waves.forEach(w => {
    const btn = document.createElement('button');
    btn.className = 'wave-btn' + (w.type === activeType ? ' active' : '');
    btn.textContent = w.symbol + ' ' + w.type;
    btn.addEventListener('click', () => {
      btnGroup.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
      onChange(w.type);
    });
    btnGroup.appendChild(btn);
  });
  
  row.appendChild(btnGroup);
  parent.appendChild(row);
}

// ── function: createTargetSelector ──
function createTargetSelector(parent, activeTarget, onChange) {
  const row = document.createElement('div');
  row.className = 'control-row';
  const lbl = document.createElement('label');
  lbl.textContent = 'Target';
  row.appendChild(lbl);
  
  const btnGroup = document.createElement('div');
  btnGroup.className = 'wave-selector';
  
  ['pitch', 'filter', 'amplitude'].forEach(t => {
    const btn = document.createElement('button');
    btn.className = 'wave-btn' + (t === activeTarget ? ' active' : '');
    btn.textContent = t;
    btn.addEventListener('click', () => {
      btnGroup.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
      onChange(t);
    });
    btnGroup.appendChild(btn);
  });
  
  row.appendChild(btnGroup);
  parent.appendChild(row);
}

// ── function: buildKeyboard ──
function buildKeyboard(container, engine) {
  const keysDiv = document.getElementById('piano-keys');
  keysDiv.innerHTML = '';
  
  const whiteWidth = 48;
  const blackWidth = 30;
  const whiteNotes = NOTE_MAP.filter(n => n.type === 'white');
  const totalWidth = whiteNotes.length * whiteWidth;
  keysDiv.style.width = totalWidth + 'px';
  
  let whiteIndex = 0;
  
  NOTE_MAP.forEach((note, i) => {
    const key = document.createElement('div');
    
    if (note.type === 'white') {
      key.className = 'key-white';
      key.style.left = (whiteIndex * whiteWidth) + 'px';
      key.style.width = (whiteWidth - 2) + 'px';
      key.style.position = 'absolute';
      key.style.top = '0';
      key.textContent = note.label;
      whiteIndex++;
    } else {
      key.className = 'key-black';
      const prevWhiteCount = NOTE_MAP.slice(0, i).filter(n => n.type === 'white').length;
      const blackLeft = prevWhiteCount * whiteWidth - blackWidth / 2;
      key.style.left = blackLeft + 'px';
      key.style.width = blackWidth + 'px';
      key.textContent = note.label;
    }
    
    key.dataset.note = note.note;
    key.dataset.freq = note.freq;
    
    key.addEventListener('mousedown', (e) => {
      e.preventDefault();
      engine.noteOn(note.freq, note.note);
      key.classList.add('active');
    });
    key.addEventListener('mouseup', () => {
      engine.noteOff(note.note);
      key.classList.remove('active');
    });
    key.addEventListener('mouseleave', () => {
      engine.noteOff(note.note);
      key.classList.remove('active');
    });
    
    // Touch events for mobile
    key.addEventListener('touchstart', (e) => {
      e.preventDefault();
      engine.noteOn(note.freq, note.note);
      key.classList.add('active');
    }, { passive: false });
    key.addEventListener('touchend', (e) => {
      e.preventDefault();
      engine.noteOff(note.note);
      key.classList.remove('active');
    });
    
    keysDiv.appendChild(key);
  });
}

// ── function: drawVisualization ──
function drawVisualization(engine, waveCanvas, specCanvas) {
  const wCtx = waveCanvas.getContext('2d');
  const sCtx = specCanvas.getContext('2d');
  
  let prevActive = false;
  
  function draw() {
    requestAnimationFrame(draw);
    
    const wW = waveCanvas.width;
    const wH = waveCanvas.height;
    const sW = specCanvas.width;
    const sH = specCanvas.height;
    
    const hasAudio = engine.activeVoices && engine.activeVoices.size > 0;
    
    // Waveform
    wCtx.fillStyle = '#0d0d14';
    wCtx.fillRect(0, 0, wW, wH);
    
    // Grid
    wCtx.strokeStyle = '#181828';
    wCtx.lineWidth = 1;
    wCtx.setLineDash([4, 4]);
    for (let i = 1; i < 4; i++) {
      wCtx.beginPath();
      wCtx.moveTo(wW * i / 4, 0);
      wCtx.lineTo(wW * i / 4, wH);
      wCtx.stroke();
    }
    wCtx.beginPath();
    wCtx.moveTo(0, wH / 2);
    wCtx.lineTo(wW, wH / 2);
    wCtx.stroke();
    wCtx.setLineDash([]);
    
    // Center line
    wCtx.strokeStyle = '#252540';
    wCtx.lineWidth = 1;
    wCtx.beginPath();
    wCtx.moveTo(0, wH / 2);
    wCtx.lineTo(wW, wH / 2);
    wCtx.stroke();
    
    const waveData = engine.getWaveformData();
    if (waveData.length > 0 && hasAudio) {
      // Show clean cycles
      const sampleRate = engine.ctx ? engine.ctx.sampleRate : 44100;
      const freq = engine.frequency || 220;
      const samplesPerCycle = sampleRate / freq;
      const cyclesToShow = 4;
      const samplesToShow = Math.min(waveData.length, Math.floor(samplesPerCycle * cyclesToShow));
      const sliceWidth = wW / samplesToShow;
      
      // Glow background
      wCtx.strokeStyle = 'rgba(167, 139, 250, 0.3)';
      wCtx.lineWidth = 6;
      wCtx.shadowColor = '#7c3aed';
      wCtx.shadowBlur = 12;
      wCtx.beginPath();
      for (let i = 0; i < samplesToShow; i++) {
        const v = waveData[i];
        const y = (1 - v) * wH / 2;
        if (i === 0) wCtx.moveTo(0, y);
        else wCtx.lineTo(i * sliceWidth, y);
      }
      wCtx.stroke();
      
      // Main line
      wCtx.strokeStyle = '#a78bfa';
      wCtx.lineWidth = 2;
      wCtx.shadowColor = '#7c3aed';
      wCtx.shadowBlur = 4;
      wCtx.beginPath();
      for (let i = 0; i < samplesToShow; i++) {
        const v = waveData[i];
        const y = (1 - v) * wH / 2;
        if (i === 0) wCtx.moveTo(0, y);
        else wCtx.lineTo(i * sliceWidth, y);
      }
      wCtx.stroke();
      wCtx.shadowBlur = 0;
      prevActive = true;
    } else if (prevActive) {
      // Fade out effect
      wCtx.fillStyle = '#0d0d14';
      wCtx.fillRect(0, 0, wW, wH);
      prevActive = false;
    }
    
    // Labels
    wCtx.fillStyle = hasAudio ? '#666' : '#333';
    wCtx.font = '11px monospace';
    wCtx.fillText('WAVEFORM', 10, 16);
    wCtx.fillStyle = '#333';
    wCtx.fillText(engine.waveform || 'sine', wW - (engine.waveform || '').length * 7 - 10, 16);
    
    if (!hasAudio) {
      wCtx.fillStyle = '#333';
      wCtx.font = '12px sans-serif';
      wCtx.textAlign = 'center';
      wCtx.fillText('Click Hold or play a key to see the waveform', wW / 2, wH / 2 + 4);
      wCtx.textAlign = 'left';
    }
    
    // Spectrum
    sCtx.fillStyle = '#0d0d14';
    sCtx.fillRect(0, 0, sW, sH);
    
    const specData = engine.getSpectrumData();
    if (specData.length > 0) {
      const barCount = Math.min(specData.length, 200);
      const barWidth = sW / barCount;
      
      for (let i = 0; i < barCount; i++) {
        const val = specData[i] / 255;
        if (val < 0.005) continue;
        const barHeight = val * sH * 0.95;
        
        const hue = 260 + val * 50;
        sCtx.fillStyle = `hsla(${hue}, 75%, ${45 + val * 20}%, ${0.5 + val * 0.5})`;
        sCtx.fillRect(i * barWidth, sH - barHeight, Math.max(barWidth - 1, 1), barHeight);
      }
    }
    
    sCtx.fillStyle = hasAudio ? '#666' : '#333';
    sCtx.font = '11px monospace';
    sCtx.fillText('SPECTRUM', 10, 16);
  }
  
  draw();
}

// ── const: PRESETS ──
const PRESETS = [
  {
    name: '🔔 Bell',
    settings: { waveform: 'sine', frequency: 440, attack: 0.01, decay: 0.8, sustain: 0.0, release: 1.5, filterCutoff: 5000, filterResonance: 1 }
  },
  {
    name: '🎸 Bass',
    settings: { waveform: 'sawtooth', frequency: 80, attack: 0.01, decay: 0.3, sustain: 0.4, release: 0.2, filterCutoff: 400, filterResonance: 4 }
  },
  {
    name: '🌊 Pad',
    settings: { waveform: 'triangle', frequency: 220, attack: 1.2, decay: 0.5, sustain: 0.7, release: 2.0, filterCutoff: 2000, filterResonance: 2 }
  },
  {
    name: '🎹 Lead',
    settings: { waveform: 'square', frequency: 330, attack: 0.02, decay: 0.1, sustain: 0.8, release: 0.3, filterCutoff: 4000, filterResonance: 1 }
  },
  {
    name: '🤖 Wobble',
    settings: { waveform: 'sawtooth', frequency: 150, attack: 0.01, decay: 0.2, sustain: 0.6, release: 0.3, filterCutoff: 800, filterResonance: 8, lfoRate: 4, lfoDepth: 0.5, lfoTarget: 'filter' }
  },
];

// ── function: createPresetSection ──
function createPresetSection(parent, engine, rebuildControls) {
  const section = document.createElement('div');
  section.className = 'control-section';
  section.style.minWidth = '100%';
  section.innerHTML = '<h3>🎨 Presets — Quick Start Sounds</h3>';
  
  const btnRow = document.createElement('div');
  btnRow.className = 'wave-selector';
  btnRow.style.flexWrap = 'wrap';
  btnRow.style.gap = '6px';
  
  PRESETS.forEach(preset => {
    const btn = document.createElement('button');
    btn.className = 'wave-btn';
    btn.textContent = preset.name;
    btn.style.flex = '1';
    btn.style.minWidth = '100px';
    btn.addEventListener('click', () => {
      const s = preset.settings;
      engine.waveform = s.waveform;
      engine.frequency = s.frequency;
      engine.attack = s.attack;
      engine.decay = s.decay;
      engine.sustain = s.sustain;
      engine.release = s.release;
      engine.filterCutoff = s.filterCutoff;
      engine.filterResonance = s.filterResonance;
      engine.lfoRate = s.lfoRate || 3;
      engine.lfoDepth = s.lfoDepth || 0;
      engine.lfoTarget = s.lfoTarget || 'pitch';
      engine.lfoEnabled = (s.lfoDepth || 0) > 0;
      
      // Highlight active preset button
      btnRow.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
      
      // Rebuild controls with new values
      rebuildControls();
    });
    btnRow.appendChild(btn);
  });
  
  section.appendChild(btnRow);
  section.appendChild(document.createElement('br'));
  
  const hint = document.createElement('p');
  hint.style.cssText = 'color: #666; font-size: 0.8rem; margin-top: 8px;';
  hint.textContent = 'Click a preset to load it, then use Hold or play the keyboard to hear it.';
  section.appendChild(hint);
  
  parent.appendChild(section);
}

// ── main ──
const engine = new AudioEngine();

let currentStageIndex = 0;
let unlockedStages = 1;

const nav = document.getElementById('stages-nav');
const lessonContent = document.getElementById('lesson-content');
const unlockBtn = document.getElementById('unlock-next-btn');
const controlsArea = document.getElementById('controls-area');
const keyboardArea = document.getElementById('keyboard-area');
const waveCanvas = document.getElementById('waveform-canvas');
const specCanvas = document.getElementById('spectrum-canvas');

function buildNav() {
  nav.innerHTML = '';
  STAGES.forEach((stage, i) => {
    const tab = document.createElement('button');
    tab.className = 'stage-tab';
    tab.textContent = stage.title;
    
    if (i < unlockedStages) {
      tab.classList.add('unlocked');
      tab.addEventListener('click', () => switchToStage(i));
    } else {
      tab.classList.add('locked');
    }
    
    if (i === currentStageIndex) tab.classList.add('active');
    nav.appendChild(tab);
  });
}

function buildControls(stageIndex) {
  controlsArea.innerHTML = '';
  
  // Presets shown at the top when all unlocked
  if (unlockedStages >= 6) {
    createPresetSection(controlsArea, engine, () => buildControls(stageIndex));
  }
  
  // Oscillator always shown
  const oscSection = document.createElement('div');
  oscSection.className = 'control-section';
  oscSection.innerHTML = '<h3>🔊 Oscillator</h3>';
  
  createWaveSelector(oscSection, engine.waveform, type => {
    engine.setWaveform(type);
  });
  
  createSlider(oscSection, 'Frequency (Hz)', 50, 880, engine.frequency, 1, val => {
    engine.setFrequency(val);
  });
  
  createSlider(oscSection, 'Volume', 0, 0.8, engine.masterGain ? engine.masterGain.gain.value : 0.4, 0.01, val => {
    engine.init();
    engine.masterGain.gain.setTargetAtTime(val, engine.ctx.currentTime, 0.02);
  });
  
  const holdRow = document.createElement('div');
  holdRow.className = 'control-row';
  const holdBtn = document.createElement('button');
  holdBtn.className = 'wave-btn';
  holdBtn.id = 'hold-btn';
  holdBtn.textContent = '▶ Hold';
  holdBtn.style.width = '100%';
  holdBtn.addEventListener('click', () => {
    engine.toggleHold();
    holdBtn.classList.toggle('active');
    holdBtn.textContent = engine.holding ? '⏸ Stop' : '▶ Hold';
  });
  holdRow.appendChild(holdBtn);
  oscSection.appendChild(holdRow);
  controlsArea.appendChild(oscSection);
  
  if (unlockedStages >= 2) {
    const section = document.createElement('div');
    section.className = 'control-section';
    section.innerHTML = '<h3>📈 Envelope (ADSR)</h3>';
    createSlider(section, 'Attack (s)', 0.01, 2.0, engine.attack, 0.01, v => engine.attack = v);
    createSlider(section, 'Decay (s)', 0.01, 2.0, engine.decay, 0.01, v => engine.decay = v);
    createSlider(section, 'Sustain', 0.0, 1.0, engine.sustain, 0.01, v => engine.sustain = v);
    createSlider(section, 'Release (s)', 0.01, 3.0, engine.release, 0.01, v => engine.release = v);
    controlsArea.appendChild(section);
  }
  
  if (unlockedStages >= 3) {
    const section = document.createElement('div');
    section.className = 'control-section';
    section.innerHTML = '<h3>🔩 Filter (Low-pass)</h3>';
    engine.filterEnabled = true;
    createSlider(section, 'Cutoff (Hz)', 20, 8000, engine.filterCutoff, 10, v => engine.filterCutoff = v);
    createSlider(section, 'Resonance (Q)', 0.1, 20, engine.filterResonance, 0.1, v => engine.filterResonance = v);
    controlsArea.appendChild(section);
  }
  
  if (unlockedStages >= 4) {
    const section = document.createElement('div');
    section.className = 'control-section';
    section.innerHTML = '<h3>〰️ LFO</h3>';
    engine.lfoEnabled = true;
    createSlider(section, 'Rate (Hz)', 0.1, 20, engine.lfoRate, 0.1, v => engine.lfoRate = v);
    createSlider(section, 'Depth', 0, 1, engine.lfoDepth, 0.01, v => {
      engine.lfoDepth = v;
      engine.lfoEnabled = v > 0;
    });
    createTargetSelector(section, engine.lfoTarget, t => engine.lfoTarget = t);
    controlsArea.appendChild(section);
  }
  
  if (unlockedStages >= 5) {
    const section = document.createElement('div');
    section.className = 'control-section';
    section.innerHTML = '<h3>✨ Effects</h3>';
    engine.effectsEnabled = true;
    createSlider(section, 'Delay Time (s)', 0.05, 0.8, engine.delayTime, 0.01, v => engine.delayTime = v);
    createSlider(section, 'Feedback', 0, 0.7, engine.delayFeedback, 0.01, v => engine.delayFeedback = v);
    createSlider(section, 'Delay Mix', 0, 1, engine.delayMix, 0.01, v => engine.delayMix = v);
    createSlider(section, 'Distortion', 0, 1, engine.distortion, 0.01, v => engine.distortion = v);
    controlsArea.appendChild(section);
  }
  
  if (unlockedStages >= 6) {
    keyboardArea.classList.remove('hidden');
    buildKeyboard(keyboardArea, engine);
  } else {
    keyboardArea.classList.add('hidden');
  }
}

function switchToStage(index) {
  currentStageIndex = index;
  lessonContent.innerHTML = STAGES[index].lesson;
  const canUnlock = unlockedStages < STAGES.length;
  unlockBtn.classList.toggle('hidden', !canUnlock);
  buildNav();
  buildControls(index);
}

unlockBtn.addEventListener('click', () => {
  if (unlockedStages < STAGES.length) {
    unlockedStages++;
    switchToStage(unlockedStages - 1);
  }
});

const pressedKeys = new Set();
document.addEventListener('keydown', (e) => {
  if (unlockedStages < 6) return;
  const key = e.key.toLowerCase();
  if (pressedKeys.has(key)) return;
  const note = NOTE_MAP.find(n => n.key === key);
  if (note) {
    e.preventDefault();
    pressedKeys.add(key);
    engine.noteOn(note.freq, note.note);
    const el = document.querySelector(`[data-note="${note.note}"]`);
    if (el) el.classList.add('active');
  }
});

document.addEventListener('keyup', (e) => {
  const key = e.key.toLowerCase();
  pressedKeys.delete(key);
  const note = NOTE_MAP.find(n => n.key === key);
  if (note) {
    engine.noteOff(note.note);
    const el = document.querySelector(`[data-note="${note.note}"]`);
    if (el) el.classList.remove('active');
  }
});

buildNav();
switchToStage(0);
drawVisualization(engine, waveCanvas, specCanvas);
</script>
</body>
</html>