← TutSynth results

TutSynth

thinkingcap qwen3.6 27b · 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,817 bytes · SHA-256 b8715ff362dc
<!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 {
  background: #0a0a12;
  color: #e0e0f0;
  font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
  min-height: 100vh;
  overflow-x: hidden;
}

#synth-header {
  text-align: center;
  padding: 24px 16px 12px;
  background: linear-gradient(180deg, #15152a 0%, transparent 100%);
}
#synth-header h1 { font-size: 2em; margin-bottom: 4px; }
.subtitle { color: #8888aa; font-size: 0.95em; margin-bottom: 12px; }

#progress-bar {
  width: 300px; height: 6px; background: #222240; border-radius: 3px;
  margin: 0 auto; overflow: hidden;
}
#progress-fill {
  height: 100%; width: 20%; background: linear-gradient(90deg, #6c5ce7, #a855f7);
  border-radius: 3px; transition: width 0.6s ease;
}

#synth-body {
  max-width: 800px; margin: 0 auto; padding: 12px 16px 40px;
  display: flex; flex-direction: column; gap: 12px;
}

.synth-section {
  background: #13132a; border: 1px solid #252548; border-radius: 12px;
  padding: 16px; transition: all 0.4s ease; position: relative; overflow: hidden;
}
.synth-section.locked { opacity: 0.45; filter: grayscale(0.5); pointer-events: none; }
.synth-section.unlocked { border-color: #3a3a6e; }
.synth-section.active { border-color: #7c5ce7; box-shadow: 0 0 20px rgba(108,92,231,0.15); }

.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.section-header h2 { font-size: 1.1em; display: flex; align-items: center; gap: 8px; }
.icon { font-size: 1.3em; }

.learn-btn {
  background: #252548; border: 1px solid #3a3a6e; color: #aaaacc; padding: 6px 14px;
  border-radius: 6px; cursor: pointer; font-size: 0.85em; transition: all 0.2s;
}
.learn-btn:hover { background: #3a3a6e; color: #fff; }

.learn-panel {
  background: #1a1a35; border-radius: 8px; padding: 14px; margin-bottom: 14px;
  font-size: 0.9em; line-height: 1.6; color: #b0b0d0;
}
.learn-panel ul { margin: 8px 0 8px 20px; }
.learn-panel li { margin-bottom: 4px; }
.learn-panel strong { color: #c8b0ff; }

.controls { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; justify-content: center; }

.wave-buttons { display: flex; gap: 6px; }
.wave-btn {
  width: 48px; height: 40px; border-radius: 8px; border: 2px solid #3a3a6e;
  background: #1a1a35; color: #aaaacc; cursor: pointer; font-size: 1.2em;
  transition: all 0.2s; display: flex; align-items: center; justify-content: center;
}
.wave-btn:hover { border-color: #7c5ce7; }
.wave-btn.active { border-color: #a855f7; background: #2d1b69; color: #fff; box-shadow: 0 0 10px rgba(168,85,247,0.3); }

.knob-group {
  display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 80px;
}
.knob-group label { font-size: 0.75em; color: #8888aa; text-transform: uppercase; letter-spacing: 1px; }
.knob-group span { font-size: 0.8em; color: #a855f7; font-weight: bold; min-height: 1.2em; }

select {
  background: #1a1a35; border: 1px solid #3a3a6e; color: #ccccee; padding: 6px 10px;
  border-radius: 6px; font-size: 0.85em; cursor: pointer;
}

input[type="range"] {
  -webkit-appearance: none; appearance: none; width: 90px; height: 6px;
  background: #252548; border-radius: 3px; outline: none;
}
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none; width: 16px; height: 16px;
  background: #a855f7; border-radius: 50%; cursor: pointer; box-shadow: 0 0 6px rgba(168,85,247,0.5);
}
input[type="range"]::-moz-range-thumb {
  width: 16px; height: 16px; background: #a855f7; border-radius: 50%;
  cursor: pointer; border: none; box-shadow: 0 0 6px rgba(168,85,247,0.5);
}

#env-canvas { border-radius: 8px; background: #0d0d1a; margin-bottom: 8px; }

#waveform-display {
  width: 100%; height: 80px; border-radius: 8px; background: #0d0d1a;
  display: block; margin-bottom: 12px;
}

#keyboard {
  display: flex; justify-content: center; position: relative; height: 140px; user-select: none;
}
.key {
  width: 44px; border-radius: 0 0 8px 8px; cursor: pointer; transition: all 0.05s;
  display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px;
  font-size: 0.7em; color: #666; position: relative;
}
.key-white {
  background: linear-gradient(180deg, #e8e8f0, #d0d0dd); border: 1px solid #aaa;
  height: 140px; z-index: 1; margin: 0 1px;
}
.key-white:hover { background: linear-gradient(180deg, #f0f0ff, #e0e0ee); }
.key-white.pressed { background: linear-gradient(180deg, #c8b0ff, #a855f7); color: #fff; transform: scaleY(0.98); }

.key-black {
  background: linear-gradient(180deg, #333, #111); border: 1px solid #000;
  height: 90px; width: 28px; position: absolute; z-index: 2; margin-left: -14px;
}
.key-black:hover { background: linear-gradient(180deg, #444, #222); }
.key-black.pressed { background: linear-gradient(180deg, #7c5ce7, #6c3ce7); transform: scaleY(0.97); }

.hint { text-align: center; color: #555577; font-size: 0.8em; margin-top: 12px; }

.hidden { display: none !important; }

#unlock-toast {
  position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
  background: linear-gradient(135deg, #6c5ce7, #a855f7); color: #fff;
  padding: 14px 28px; border-radius: 12px; font-weight: bold; font-size: 1.05em;
  box-shadow: 0 8px 30px rgba(108,92,231,0.4); z-index: 100;
  animation: toastIn 0.4s ease, toastOut 0.4s ease 2.6s forwards;
}

@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(-20px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; pointer-events: none; } }

@media (max-width: 600px) {
  .controls { gap: 8px; }
  .knob-group { min-width: 65px; }
  input[type="range"] { width: 70px; }
}
/* ── css: locked-hints ── */
.synth-section.locked::after {
  content: attr(data-lock-hint);
  position: absolute; bottom: 8px; right: 12px;
  font-size: 0.7em; color: #555577; font-style: italic;
}

.synth-section.all-unlocked {
  border-color: #a855f7 !important;
  box-shadow: 0 0 30px rgba(168, 85, 247, 0.2);
}

#celebration {
  display: none; text-align: center; padding: 20px; margin-top: 12px;
  background: linear-gradient(135deg, #1a1a35, #2d1b69); border-radius: 12px;
  border: 1px solid #7c5ce7; animation: pulseGlow 2s infinite;
}
#celebration h3 { color: #a855f7; margin-bottom: 8px; }
#celebration p { color: #b0b0d0; font-size: 0.9em; }

@keyframes pulseGlow {
  0%, 100% { box-shadow: 0 0 20px rgba(168, 85, 247, 0.1); }
  50% { box-shadow: 0 0 30px rgba(168, 85, 247, 0.3); }
}

.preset-btns { display: flex; gap: 8px; justify-content: center; margin-top: 12px; flex-wrap: wrap; }
.preset-btn {
  background: #2d1b69; border: 1px solid #7c5ce7; color: #ccccee; padding: 8px 16px;
  border-radius: 8px; cursor: pointer; font-size: 0.85em; transition: all 0.2s;
}
.preset-btn:hover { background: #3d2b79; color: #fff; transform: scale(1.05); }
</style>
</head>
<body>
<div id="synth-app">
  <header id="synth-header">
    <h1>🎹 Synth Playground</h1>
    <p class="subtitle">Learn synthesis by building sounds — one concept at a time</p>
    <div id="progress-bar"><div id="progress-fill"></div></div>
  </header>

  <main id="synth-body">
    <!-- Section: Oscillator -->
    <section id="sec-osc" class="synth-section unlocked">
      <div class="section-header">
        <h2><span class="icon">〰️</span> Oscillators</h2>
        <button class="learn-btn" data-section="osc">Learn</button>
      </div>
      <div class="learn-panel hidden" id="learn-osc">
        <p>An <strong>oscillator</strong> generates the raw sound wave. Different waveforms have different harmonic content:</p>
        <ul><li><strong>Sine</strong> — pure tone, no harmonics (flute-like)</li><li><strong>Square</strong> — odd harmonics, hollow/retro (8-bit games)</li><li><strong>Sawtooth</strong> — all harmonics, bright/buzzy (strings, brass)</li><li><strong>Triangle</strong> — soft, between sine and square</li></ul>
        <p>The more harmonics a wave has, the "richer" it sounds.</p>
      </div>
      <div class="controls">
        <div class="wave-buttons" id="wave-select"></div>
        <div class="knob-group"><label>Octave</label><input type="range" id="octave" min="-2" max="2" value="0" step="1"><span id="octave-val">0</span></div>
      </div>
    </section>

    <!-- Section: Filter -->
    <section id="sec-filter" class="synth-section locked" data-lock-hint="Play 5 notes to unlock">
      <div class="section-header">
        <h2><span class="icon">🔽</span> Filter</h2>
        <button class="learn-btn" data-section="filter">Learn</button>
      </div>
      <div class="learn-panel hidden" id="learn-filter">
        <p>A <strong>filter</strong> shapes the tone by removing certain frequencies:</p>
        <ul><li><strong>Cutoff</strong> — frequency threshold. Lower = darker, higher = brighter</li><li><strong>Resonance (Q)</strong> — boosts near cutoff for a "squelchy" sound</li></ul>
        <p>Think of it like an equalizer that carves out the character of your sound.</p>
      </div>
      <div class="controls">
        <select id="filter-type"><option value="lowpass">Lowpass</option><option value="highpass">Highpass</option></select>
        <div class="knob-group"><label>Cutoff</label><input type="range" id="cutoff" min="20" max="18000" value="5000" step="1"><span id="cutoff-val">5000 Hz</span></div>
        <div class="knob-group"><label>Resonance</label><input type="range" id="resonance" min="0" max="25" value="1" step="0.1"><span id="resonance-val">1.0</span></div>
      </div>
    </section>

    <!-- Section: Envelope -->
    <section id="sec-envelope" class="synth-section locked" data-lock-hint="Learn filter + play 12 notes">
      <div class="section-header">
        <h2><span class="icon">📈</span> Envelope (ADSR)</h2>
        <button class="learn-btn" data-section="envelope">Learn</button>
      </div>
      <div class="learn-panel hidden" id="learn-envelope">
        <p>An <strong>envelope</strong> controls how volume changes over time:</p>
        <ul><li><strong>A</strong>tack — speed to full volume (fast = pluck, slow = swell)</li><li><strong>D</strong>ecay — time from peak down to sustain level</li><li><strong>S</strong>ustain — held volume while key is pressed</li><li><strong>R</strong>elease — fade time after releasing the key</li></ul>
        <p>Piano = fast attack, medium decay. Pad = slow attack, long release.</p>
      </div>
      <div class="controls">
        <canvas id="env-canvas" width="300" height="120"></canvas>
        <div class="knob-group"><label>Attack</label><input type="range" id="attack" min="0.01" max="2" value="0.05" step="0.01"><span id="attack-val">0.05s</span></div>
        <div class="knob-group"><label>Decay</label><input type="range" id="decay" min="0.01" max="2" value="0.3" step="0.01"><span id="decay-val">0.3s</span></div>
        <div class="knob-group"><label>Sustain</label><input type="range" id="sustain" min="0" max="1" value="0.7" step="0.01"><span id="sustain-val">0.70</span></div>
        <div class="knob-group"><label>Release</label><input type="range" id="release" min="0.01" max="3" value="0.5" step="0.01"><span id="release-val">0.5s</span></div>
      </div>
    </section>

    <!-- Section: LFO -->
    <section id="sec-lfo" class="synth-section locked" data-lock-hint="Learn envelope + play 20 notes">
      <div class="section-header">
        <h2><span class="icon">🔄</span> LFO (Modulation)</h2>
        <button class="learn-btn" data-section="lfo">Learn</button>
      </div>
      <div class="learn-panel hidden" id="learn-lfo">
        <p>A <strong>Low-Frequency Oscillator</strong> modulates other parameters over time:</p>
        <ul><li><strong>Rate</strong> — how fast modulation cycles (slow = subtle, fast = vibrato)</li><li><strong>Depth</strong> — how much the parameter changes</li><li><strong>Target</strong> — what gets modulated: pitch, volume, or filter</li></ul>
        <p>LFOs add movement and life to otherwise static sounds.</p>
      </div>
      <div class="controls">
        <select id="lfo-target"><option value="pitch">Pitch (Vibrato)</option><option value="volume">Volume (Tremolo)</option><option value="filter">Filter Cutoff</option></select>
        <div class="knob-group"><label>Rate</label><input type="range" id="lfo-rate" min="0.1" max="20" value="5" step="0.1"><span id="lfo-rate-val">5 Hz</span></div>
        <div class="knob-group"><label>Depth</label><input type="range" id="lfo-depth" min="0" max="1" value="0.3" step="0.01"><span id="lfo-depth-val">0.30</span></div>
      </div>
    </section>

    <!-- Section: Effects -->
    <section id="sec-effects" class="synth-section locked" data-lock-hint="Learn LFO + play 30 notes">
      <div class="section-header">
        <h2><span class="icon">✨</span> Effects</h2>
        <button class="learn-btn" data-section="effects">Learn</button>
      </div>
      <div class="learn-panel hidden" id="learn-effects">
        <p><strong>Effects</strong> add space and character to your sound:</p>
        <ul><li><strong>Reverb</strong> — simulates acoustic spaces. Makes sounds feel "big"</li><li><strong>Delay</strong> — echoes the signal. Time = spacing, feedback = repeats</li></ul>
        <p>Effects transform a thin sound into something that fills space.</p>
      </div>
      <div class="controls">
        <div class="knob-group"><label>Reverb Mix</label><input type="range" id="reverb-mix" min="0" max="1" value="0.2" step="0.01"><span id="reverb-val">0.20</span></div>
        <div class="knob-group"><label>Delay Time</label><input type="range" id="delay-time" min="0" max="1" value="0.3" step="0.01"><span id="delay-time-val">0.3s</span></div>
        <div class="knob-group"><label>Delay Feedback</label><input type="range" id="delay-feedback" min="0" max="0.9" value="0.4" step="0.01"><span id="delay-fb-val">0.40</span></div>
      </div>
    </section>

    <!-- Keyboard -->
    <div id="celebration"><h3>🎉 All Sections Unlocked!</h3><p>You've explored every concept in analog synthesis. Try the presets below or experiment freely!</p><div class="preset-btns" id="presets"></div></div><section id="keyboard-section">
      <canvas id="waveform-display" width="600" height="100"></canvas>
      <div id="keyboard"></div>
      <p class="hint">Use your computer keyboard: A S D F G H J K L ; (white keys) and W E T Y U O P (black keys)</p>
    </section>
  </main>

  <!-- Unlock notification -->
  <div id="unlock-toast" class="hidden"></div>
</div>
<script>
'use strict';

// ── class: SynthEngine ──
class SynthEngine {
  constructor() {
    this.ctx = null;
    this.masterGain = null;
    this.filterNode = null;
    this.reverbGain = null;
    this.dryGain = null;
    this.delayNode = null;
    this.delayFeedback = null;
    this.convolver = null;
    this.activeVoices = {};
    this.waveform = 'sine';
    this.octave = 0;
    this.cutoff = 5000;
    this.resonance = 1;
    this.filterType = 'lowpass';
    this.adsr = { attack: 0.05, decay: 0.3, sustain: 0.7, release: 0.5 };
    this.lfo = { rate: 5, depth: 0.3, target: 'pitch' };
    this.effects = { reverbMix: 0.2, delayTime: 0.3, delayFeedback: 0.4 };
    this.unlockedSections = new Set(['osc']);
    this.learnCount = {};
    this.totalLearned = 0;
  }

  init() {
    if (this.ctx) return;
    this.ctx = new (window.AudioContext || window.webkitAudioContext)();
    
    // Master gain
    this.masterGain = this.ctx.createGain();
    this.masterGain.gain.value = 0.35;
    
    // Filter
    this.filterNode = this.ctx.createBiquadFilter();
    this.filterNode.type = 'lowpass';
    this.filterNode.frequency.value = this.cutoff;
    this.filterNode.Q.value = this.resonance;
    
    // Effects chain: dry/wet for reverb
    this.dryGain = this.ctx.createGain();
    this.reverbGain = this.ctx.createGain();
    this.dryGain.gain.value = 1 - this.effects.reverbMix;
    this.reverbGain.gain.value = this.effects.reverbMix;
    
    // Convolver for reverb (simple impulse response)
    this.convolver = this.ctx.createConvolver();
    this.convolver.buffer = this.createReverbIR(2, 3);
    
    // Delay
    this.delayNode = this.ctx.createDelay(2);
    this.delayNode.delayTime.value = this.effects.delayTime;
    this.delayFeedback = this.ctx.createGain();
    this.delayFeedback.gain.value = this.effects.delayFeedback;
    
    // Routing: filter -> dry + reverb + delay -> master
    this.filterNode.connect(this.dryGain);
    this.filterNode.connect(this.convolver);
    this.convolver.connect(this.reverbGain);
    this.filterNode.connect(this.delayNode);
    this.delayNode.connect(this.delayFeedback);
    this.delayFeedback.connect(this.delayNode);
    this.delayNode.connect(this.masterGain);
    
    this.dryGain.connect(this.masterGain);
    this.reverbGain.connect(this.masterGain);
    this.masterGain.connect(this.ctx.destination);
  }

  createReverbIR(duration, decay) {
    const rate = this.ctx.sampleRate;
    const length = rate * duration;
    const buffer = this.ctx.createBuffer(2, length, rate);
    for (let ch = 0; ch < 2; ch++) {
      const data = buffer.getChannelData(ch);
      for (let i = 0; i < length; i++) {
        data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay);
      }
    }
    return buffer;
  }

  noteOn(note, freq) {
    this.init();
    if (this.ctx.state === 'suspended') this.ctx.resume();
    
    // Remove existing voice for this note
    if (this.activeVoices[note]) {
      this.noteOff(note);
    }
    
    const now = this.ctx.currentTime;
    const baseFreq = freq * Math.pow(2, this.octave);
    
    // Oscillator
    const osc = this.ctx.createOscillator();
    osc.type = this.waveform;
    osc.frequency.value = baseFreq;
    
    // Voice gain for envelope
    const voiceGain = this.ctx.createGain();
    voiceGain.gain.setValueAtTime(0, now);
    voiceGain.gain.linearRampToValueAtTime(1, now + this.adsr.attack);
    voiceGain.gain.linearRampToValueAtTime(this.adsr.sustain, now + this.adsr.attack + this.adsr.decay);
    
    // LFO
    let lfoNode = null;
    let lfoGain = null;
    if (this.unlockedSections.has('lfo') && this.lfo.depth > 0) {
      lfoNode = this.ctx.createOscillator();
      lfoGain = this.ctx.createGain();
      lfoNode.frequency.value = this.lfo.rate;
      
      const maxMod = this.lfo.target === 'pitch' ? baseFreq * 0.1 : 
                     this.lfo.target === 'filter' ? this.cutoff * 0.5 : 1;
      lfoGain.gain.value = this.lfo.depth * maxMod;
      
      if (this.lfo.target === 'pitch') {
        lfoNode.connect(lfoGain);
        lfoGain.connect(osc.frequency);
      } else if (this.lfo.target === 'volume') {
        lfoNode.connect(lfoGain);
        lfoGain.connect(voiceGain.gain);
      } else if (this.lfo.target === 'filter') {
        lfoNode.connect(lfoGain);
        lfoGain.connect(this.filterNode.frequency);
      }
      lfoNode.start(now);
    }
    
    // Connect: osc -> voiceGain -> filter
    osc.connect(voiceGain);
    voiceGain.connect(this.filterNode);
    osc.start(now);
    
    this.activeVoices[note] = { osc, voiceGain, lfoNode, lfoGain };
  }

  noteOff(note) {
    const voice = this.activeVoices[note];
    if (!voice) return;
    
    const now = this.ctx.currentTime;
    voice.voiceGain.gain.cancelScheduledValues(now);
    voice.voiceGain.gain.setValueAtTime(voice.voiceGain.gain.value, now);
    voice.voiceGain.gain.linearRampToValueAtTime(0, now + this.adsr.release);
    
    const stopTime = now + this.adsr.release + 0.1;
    voice.osc.stop(stopTime);
    if (voice.lfoNode) voice.lfoNode.stop(stopTime);
    
    delete this.activeVoices[note];
  }

  setWaveform(w) { this.waveform = w; }
  setOctave(o) { this.octave = parseInt(o); }
  setCutoff(c) { 
    this.cutoff = c; 
    if (this.filterNode) this.filterNode.frequency.setTargetAtTime(c, this.ctx.currentTime, 0.02);
  }
  setResonance(r) { 
    this.resonance = r; 
    if (this.filterNode) this.filterNode.Q.value = r;
  }
  setFilterType(t) { 
    this.filterType = t; 
    if (this.filterNode) this.filterNode.type = t;
  }
}

// ── const: NOTE_MAP ──
// Keyboard note mappings (middle C area)
const NOTE_MAP = {
  'a': { note: 'C4', freq: 261.63, type: 'white' },
  'w': { note: 'C#4', freq: 277.18, type: 'black' },
  's': { note: 'D4', freq: 293.66, type: 'white' },
  'e': { note: 'D#4', freq: 311.13, type: 'black' },
  'd': { note: 'E4', freq: 329.63, type: 'white' },
  'f': { note: 'F4', freq: 349.23, type: 'white' },
  't': { note: 'F#4', freq: 369.99, type: 'black' },
  'g': { note: 'G4', freq: 392.00, type: 'white' },
  'y': { note: 'G#4', freq: 415.30, type: 'black' },
  'h': { note: 'A4', freq: 440.00, type: 'white' },
  'u': { note: 'A#4', freq: 466.16, type: 'black' },
  'j': { note: 'B4', freq: 493.88, type: 'white' },
  'k': { note: 'C5', freq: 523.25, type: 'white' },
  'o': { note: 'C#5', freq: 554.37, type: 'black' },
  'l': { note: 'D5', freq: 587.33, type: 'white' },
  'p': { note: 'D#5', freq: 622.25, type: 'black' },
  ';': { note: 'E5', freq: 659.25, type: 'white' }
};

// ── const: WAVEFORMS ──
const WAVEFORMS = [
  { name: 'sine', icon: '∿', label: 'Sine' },
  { name: 'square', icon: '⊓', label: 'Square' },
  { name: 'sawtooth', icon: '⩘', label: 'Saw' },
  { name: 'triangle', icon: '△', label: 'Tri' }
];

// ── const: SECTION_ORDER ──
const SECTION_ORDER = ['osc', 'filter', 'envelope', 'lfo', 'effects'];

// ── function: buildKeyboard ──
function buildKeyboard() {
  const kb = document.getElementById('keyboard');
  const whiteKeys = Object.entries(NOTE_MAP).filter(([, v]) => v.type === 'white');
  
  // Build white keys in order
  let whiteIndex = 0;
  for (const [keyChar, info] of Object.entries(NOTE_MAP)) {
    if (info.type !== 'white') continue;
    
    const keyEl = document.createElement('div');
    keyEl.className = `key key-white`;
    keyEl.dataset.note = info.note;
    keyEl.dataset.key = keyChar;
    keyEl.textContent = keyChar.toUpperCase();
    kb.appendChild(keyEl);
    
    // Check if there's a black key after this white key
    const nextEntry = Object.entries(NOTE_MAP).find(([k]) => {
      const idx1 = Object.keys(NOTE_MAP).indexOf(keyChar);
      const idx2 = Object.keys(NOTE_MAP).indexOf(k);
      return idx2 === idx1 + 1;
    });
    
    if (nextEntry && nextEntry[1].type === 'black') {
      const blackEl = document.createElement('div');
      blackEl.className = `key key-black`;
      blackEl.dataset.note = nextEntry[1].note;
      blackEl.dataset.key = nextEntry[0];
      // Position: right edge of current white key
      const leftPos = (whiteIndex + 1) * 46 - 14;
      blackEl.style.left = leftPos + 'px';
      kb.appendChild(blackEl);
    }
    
    whiteIndex++;
  }
  
  // Click handlers for keys
  kb.querySelectorAll('.key').forEach(el => {
    const noteInfo = NOTE_MAP[el.dataset.key];
    if (!noteInfo) return;
    
    el.addEventListener('mousedown', (e) => {
      e.preventDefault();
      pressKey(noteInfo.note, noteInfo.freq);
      el.classList.add('pressed');
    });
    el.addEventListener('mouseup', () => releaseKey(noteInfo.note));
    el.addEventListener('mouseleave', () => releaseKey(noteInfo.note));
    
    // Touch support
    el.addEventListener('touchstart', (e) => {
      e.preventDefault();
      pressKey(noteInfo.note, noteInfo.freq);
      el.classList.add('pressed');
    });
    el.addEventListener('touchend', () => {
      releaseKey(noteInfo.note);
      el.classList.remove('pressed');
    });
  });
}

// ── function: buildWaveButtons ──
function buildWaveButtons() {
  const container = document.getElementById('wave-select');
  WAVEFORMS.forEach(wf => {
    const btn = document.createElement('button');
    btn.className = 'wave-btn' + (wf.name === 'sine' ? ' active' : '');
    btn.textContent = wf.icon;
    btn.title = wf.label;
    btn.dataset.waveform = wf.name;
    btn.addEventListener('click', () => {
      container.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
      synth.setWaveform(wf.name);
    });
    container.appendChild(btn);
  });
}

// ── function: drawEnvelope ──
function drawEnvelope() {
  const canvas = document.getElementById('env-canvas');
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  const w = canvas.width, h = canvas.height;
  
  ctx.clearRect(0, 0, w, h);
  
  const a = synth.adsr.attack;
  const d = synth.adsr.decay;
  const s = synth.adsr.sustain;
  const r = synth.adsr.release;
  const totalTime = Math.max(a + d + 1 + r, 0.5);
  
  const pad = 20;
  const drawW = w - pad * 2;
  const drawH = h - pad * 2;
  
  // Calculate points
  const attackEnd = pad + (a / totalTime) * drawW;
  const decayEnd = pad + ((a + d) / totalTime) * drawW;
  const sustainEnd = pad + ((a + d + 1) / totalTime) * drawW;
  const releaseEnd = pad + drawW;
  
  // Draw grid lines
  ctx.strokeStyle = '#1a1a35';
  ctx.lineWidth = 1;
  for (let i = 0; i <= 4; i++) {
    const y = pad + (drawH / 4) * i;
    ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(w - pad, y); ctx.stroke();
  }
  
  // Draw envelope curve
  ctx.beginPath();
  ctx.moveTo(pad, h - pad);
  ctx.lineTo(attackEnd, pad);
  ctx.lineTo(decayEnd, pad + drawH * (1 - s));
  ctx.lineTo(sustainEnd, pad + drawH * (1 - s));
  ctx.lineTo(releaseEnd, h - pad);
  
  // Gradient fill
  const gradient = ctx.createLinearGradient(0, pad, 0, h - pad);
  gradient.addColorStop(0, 'rgba(168, 85, 247, 0.3)');
  gradient.addColorStop(1, 'rgba(168, 85, 247, 0.02)');
  
  ctx.fillStyle = gradient;
  ctx.fill();
  
  // Stroke
  ctx.strokeStyle = '#a855f7';
  ctx.lineWidth = 2;
  ctx.stroke();
  
  // Labels
  ctx.fillStyle = '#6666aa';
  ctx.font = '10px sans-serif';
  ctx.fillText('A', (pad + attackEnd) / 2 - 3, h - pad + 14);
  ctx.fillText('D', (attackEnd + decayEnd) / 2 - 3, h - pad + 14);
  ctx.fillText('S', (decayEnd + sustainEnd) / 2 - 3, h - pad + 14);
  ctx.fillText('R', (sustainEnd + releaseEnd) / 2 - 3, h - pad + 14);
}

// ── function: drawWaveform ──
function drawWaveform() {
  const canvas = document.getElementById('waveform-display');
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  const w = canvas.width, h = canvas.height;
  
  ctx.clearRect(0, 0, w, h);
  
  // Draw center line
  ctx.strokeStyle = '#1a1a35';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(0, h / 2);
  ctx.lineTo(w, h / 2);
  ctx.stroke();
  
  // Draw waveform
  const waveforms = {
    sine: (t) => Math.sin(t * Math.PI * 2),
    square: (t) => t % 1 < 0.5 ? 0.8 : -0.8,
    sawtooth: (t) => 2 * (t % 1) - 1,
    triangle: (t) => { const f = t % 1; return f < 0.25 ? f*4 : f < 0.75 ? 2-f*4 : (f-1)*4; }
  };
  
  const fn = waveforms[synth.waveform] || waveforms.sine;
  
  // Glow effect
  ctx.shadowColor = '#a855f7';
  ctx.shadowBlur = 8;
  ctx.strokeStyle = '#a855f7';
  ctx.lineWidth = 2;
  ctx.beginPath();
  
  for (let x = 0; x < w; x++) {
    const t = x / w * 3; // 3 cycles
    const y = h / 2 - fn(t) * (h / 2 - 10);
    if (x === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.stroke();
  ctx.shadowBlur = 0;
}

// ── function: showToast ──
function showToast(message) {
  const toast = document.getElementById('unlock-toast');
  toast.textContent = message;
  toast.classList.remove('hidden');
  
  // Force reflow for animation restart
  void toast.offsetWidth;
  toast.style.animation = 'none';
  void toast.offsetWidth;
  toast.style.animation = '';
  
  setTimeout(() => { toast.classList.add('hidden'); }, 3200);
}

// ── function: updateProgress ──
function updateProgress() {
  const fill = document.getElementById('progress-fill');
  const pct = (synth.totalLearned / SECTION_ORDER.length) * 100;
  fill.style.width = pct + '%';
}

// ── function: pressKey ──
function pressKey(note, freq) {
  notesPlayed++;
  synth.noteOn(note, freq);
  checkAutoUnlock();
}

// ── function: releaseKey ──
function releaseKey(note) {
  synth.noteOff(note);
  const keyEl = document.querySelector(`.key[data-note="${note}"]`);
  if (keyEl) keyEl.classList.remove('pressed');
}

// ── let: notesPlayed ──
let notesPlayed = 0;

// ── const: PRESETS ──
const PRESETS = [
  { name: '🎹 Piano', wave: 'triangle', cutoff: 8000, resonance: 1, attack: 0.01, decay: 0.3, sustain: 0.4, release: 0.5, lfoRate: 0, lfoDepth: 0 },
  { name: '🎸 Bass', wave: 'sawtooth', cutoff: 600, resonance: 8, attack: 0.01, decay: 0.2, sustain: 0.6, release: 0.1, lfoRate: 5, lfoDepth: 0.05 },
  { name: '🌊 Pad', wave: 'sine', cutoff: 3000, resonance: 2, attack: 0.8, decay: 0.5, sustain: 0.7, release: 1.5, lfoRate: 0.5, lfoDepth: 0.1 },
  { name: '🚀 Lead', wave: 'sawtooth', cutoff: 4000, resonance: 5, attack: 0.01, decay: 0.1, sustain: 0.8, release: 0.2, lfoRate: 6, lfoDepth: 0.15 },
  { name: '🎵 Pluck', wave: 'square', cutoff: 2000, resonance: 3, attack: 0.005, decay: 0.4, sustain: 0.1, release: 0.3, lfoRate: 0, lfoDepth: 0 },
];

// ── function: applyPreset ──
function applyPreset(p) {
  synth.setWaveform(p.wave);
  synth.setCutoff(p.cutoff);
  synth.setResonance(p.resonance);
  
  // Update sliders to match preset values
  document.getElementById('cutoff').value = p.cutoff;
  document.getElementById('cutoff-val').textContent = Math.round(p.cutoff) + ' Hz';
  document.getElementById('resonance').value = p.resonance;
  document.getElementById('resonance-val').textContent = p.resonance.toFixed(1);
  
  synth.adsr.attack = p.attack;
  synth.adsr.decay = p.decay;
  synth.adsr.sustain = p.sustain;
  synth.adsr.release = p.release;
  
  document.getElementById('attack').value = p.attack;
  document.getElementById('attack-val').textContent = p.attack.toFixed(2) + 's';
  document.getElementById('decay').value = p.decay;
  document.getElementById('decay-val').textContent = p.decay.toFixed(2) + 's';
  document.getElementById('sustain').value = p.sustain;
  document.getElementById('sustain-val').textContent = p.sustain.toFixed(2);
  document.getElementById('release').value = p.release;
  document.getElementById('release-val').textContent = p.release.toFixed(2) + 's';
  
  synth.lfo.rate = p.lfoRate;
  synth.lfo.depth = p.lfoDepth;
  document.getElementById('lfo-rate').value = p.lfoRate;
  document.getElementById('lfo-rate-val').textContent = p.lfoRate.toFixed(1) + ' Hz';
  document.getElementById('lfo-depth').value = p.lfoDepth;
  document.getElementById('lfo-depth-val').textContent = p.lfoDepth.toFixed(2);
  
  // Update wave button active state
  document.querySelectorAll('.wave-btn').forEach(b => {
    b.classList.toggle('active', b.dataset.waveform === p.wave);
  });
  
  drawEnvelope();
}

// ── function: buildPresets ──
function buildPresets() {
  const container = document.getElementById('presets');
  PRESETS.forEach(p => {
    const btn = document.createElement('button');
    btn.className = 'preset-btn';
    btn.textContent = p.name;
    btn.addEventListener('click', () => applyPreset(p));
    container.appendChild(btn);
  });
}

// ── function: checkAllUnlocked ──
function checkAllUnlocked() {
  if (synth.unlockedSections.size === SECTION_ORDER.length) {
    const celeb = document.getElementById('celebration');
    celeb.style.display = 'block';
    
    // Mark all sections as fully unlocked visually
    document.querySelectorAll('.synth-section').forEach(s => s.classList.add('all-unlocked'));
  }
}

// ── function: unlockSection ──
function unlockSection(sectionId) {
  const section = document.getElementById('sec-' + sectionId);
  if (!section || synth.unlockedSections.has(sectionId)) return;
  
  synth.unlockedSections.add(sectionId);
  section.classList.remove('locked');
  section.classList.add('unlocked', 'active');
  
  setTimeout(() => section.classList.remove('active'), 1500);
  
  const names = { filter: '🔽 Filter', envelope: '📈 Envelope (ADSR)', lfo: '🔄 LFO Modulation', effects: '✨ Effects' };
  showToast(`Unlocked: ${names[sectionId] || sectionId}!`);
  
  updateProgress();
  checkAllUnlocked();
}

// ── function: checkAutoUnlock ──
function checkAutoUnlock() {
  // Unlock filter after playing 5 notes
  if (notesPlayed >= 5 && !synth.unlockedSections.has('filter')) {
    synth.learnCount['filter'] = 1;
    unlockSection('filter');
  }
  // Unlock envelope after playing 12 notes AND having learned/unlocked filter
  if (notesPlayed >= 12 && synth.unlockedSections.has('filter') && !synth.unlockedSections.has('envelope')) {
    synth.learnCount['envelope'] = 1;
    unlockSection('envelope');
  }
  // Unlock LFO after playing 20 notes AND having learned/unlocked envelope
  if (notesPlayed >= 20 && synth.unlockedSections.has('envelope') && !synth.unlockedSections.has('lfo')) {
    synth.learnCount['lfo'] = 1;
    unlockSection('lfo');
  }
  // Unlock effects after playing 30 notes AND having learned/unlocked LFO
  if (notesPlayed >= 30 && synth.unlockedSections.has('lfo') && !synth.unlockedSections.has('effects')) {
    synth.learnCount['effects'] = 1;
    unlockSection('effects');
  }
}

// ── main ──
const synth = new SynthEngine();

// Build UI
buildKeyboard();
buildWaveButtons();
drawEnvelope();
drawWaveform();
buildPresets();

// Waveform display update loop
let animFrame;
function renderLoop() {
  drawWaveform();
  animFrame = requestAnimationFrame(renderLoop);
}
renderLoop();

// Keyboard input
const pressedKeys = new Set();
document.addEventListener('keydown', (e) => {
  if (e.repeat) return;
  const key = e.key.toLowerCase();
  if (!NOTE_MAP[key] || pressedKeys.has(key)) return;
  
  pressedKeys.add(key);
  pressKey(NOTE_MAP[key].note, NOTE_MAP[key].freq);
  
  const el = document.querySelector(`.key[data-key="${key}"]`);
  if (el) el.classList.add('pressed');
});

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

// Learn buttons
document.querySelectorAll('.learn-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    const sectionId = btn.dataset.section;
    const panel = document.getElementById('learn-' + sectionId);
    
    if (panel.classList.contains('hidden')) {
      panel.classList.remove('hidden');
      btn.textContent = 'Close';
      
      // Track learning and unlock next section
      synth.learnCount[sectionId] = (synth.learnCount[sectionId] || 0) + 1;
      synth.totalLearned++;
      
      // Unlock the current section if locked
      if (!synth.unlockedSections.has(sectionId)) {
        unlockSection(sectionId);
      }
      
      // Check if we should unlock the next section
      const idx = SECTION_ORDER.indexOf(sectionId);
      if (idx < SECTION_ORDER.length - 1) {
        setTimeout(() => checkAutoUnlock(), 800);
      }
      
      updateProgress();
    } else {
      panel.classList.add('hidden');
      btn.textContent = 'Learn';
    }
  });
});

// Slider controls - Octave
document.getElementById('octave').addEventListener('input', (e) => {
  synth.setOctave(e.target.value);
  document.getElementById('octave-val').textContent = e.target.value;
});

// Filter controls
document.getElementById('cutoff').addEventListener('input', (e) => {
  synth.setCutoff(parseFloat(e.target.value));
  document.getElementById('cutoff-val').textContent = Math.round(e.target.value) + ' Hz';
});

document.getElementById('resonance').addEventListener('input', (e) => {
  synth.setResonance(parseFloat(e.target.value));
  document.getElementById('resonance-val').textContent = parseFloat(e.target.value).toFixed(1);
});

document.getElementById('filter-type').addEventListener('change', (e) => {
  synth.setFilterType(e.target.value);
});

// ADSR controls
['attack', 'decay', 'sustain', 'release'].forEach(param => {
  const el = document.getElementById(param);
  const valEl = document.getElementById(param + '-val');
  
  el.addEventListener('input', (e) => {
    synth.adsr[param] = parseFloat(e.target.value);
    
    if (param === 'attack' || param === 'decay' || param === 'release') {
      valEl.textContent = parseFloat(e.target.value).toFixed(2) + 's';
    } else {
      valEl.textContent = parseFloat(e.target.value).toFixed(2);
    }
    
    drawEnvelope();
  });
});

// LFO controls
document.getElementById('lfo-rate').addEventListener('input', (e) => {
  synth.lfo.rate = parseFloat(e.target.value);
  document.getElementById('lfo-rate-val').textContent = parseFloat(e.target.value).toFixed(1) + ' Hz';
});

document.getElementById('lfo-depth').addEventListener('input', (e) => {
  synth.lfo.depth = parseFloat(e.target.value);
  document.getElementById('lfo-depth-val').textContent = parseFloat(e.target.value).toFixed(2);
});

document.getElementById('lfo-target').addEventListener('change', (e) => {
  synth.lfo.target = e.target.value;
});

// Effects controls
document.getElementById('reverb-mix').addEventListener('input', (e) => {
  const val = parseFloat(e.target.value);
  synth.effects.reverbMix = val;
  document.getElementById('reverb-val').textContent = val.toFixed(2);
  
  if (synth.dryGain && synth.reverbGain) {
    synth.dryGain.gain.setTargetAtTime(1 - val, synth.ctx.currentTime, 0.05);
    synth.reverbGain.gain.setTargetAtTime(val, synth.ctx.currentTime, 0.05);
  }
});

document.getElementById('delay-time').addEventListener('input', (e) => {
  const val = parseFloat(e.target.value);
  synth.effects.delayTime = val;
  document.getElementById('delay-time-val').textContent = val.toFixed(1) + 's';
  
  if (synth.delayNode) {
    synth.delayNode.delayTime.setTargetAtTime(val, synth.ctx.currentTime, 0.05);
  }
});

document.getElementById('delay-feedback').addEventListener('input', (e) => {
  const val = parseFloat(e.target.value);
  synth.effects.delayFeedback = val;
  document.getElementById('delay-fb-val').textContent = val.toFixed(2);
  
  if (synth.delayFeedback) {
    synth.delayFeedback.gain.setTargetAtTime(val, synth.ctx.currentTime, 0.05);
  }
});

// Initial progress update
updateProgress();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"composer","sessionId":"08eda6a0-e3e5-4441-bae2-8f3d642cd69b","tokensIn":676599,"tokensOut":21510,"tokensTotal":698109,"turns":33,"toolCalls":32,"failedToolCalls":1,"timestamp":"2026-07-18T18:12:09.266Z"} -->