← TutSynth results

TutSynth

Qwen 3.6 27b MTP · 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 36,486 bytes · SHA-256 eb1f4cd8e875
<!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: #e0e0e8;
  font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
  overflow-x: hidden;
}

#synth-app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 16px;
}

header {
  text-align: center;
  padding: 8px 0 4px;
}

header h1 {
  font-size: 1.5em;
  color: #fff;
  letter-spacing: -0.5px;
}

.subtitle {
  color: #6a6a8e;
  font-size: 0.82em;
  margin-top: 2px;
}

#progress-bar {
  width: 280px;
  height: 4px;
  background: #1a1a2e;
  border-radius: 2px;
  margin: 8px auto;
  overflow: hidden;
}

#progress-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, #6c5ce7, #a855f7);
  border-radius: 2px;
  transition: width 0.5s ease;
}

#next-btn {
  background: linear-gradient(135deg, #6c5ce7, #a855f7);
  color: white;
  border: none;
  padding: 7px 22px;
  border-radius: 20px;
  font-size: 0.82em;
  cursor: pointer;
  transition: all 0.3s;
  margin-top: 4px;
}

#next-btn:hover { transform: scale(1.05); box-shadow: 0 4px 20px rgba(108,92,231,0.4); }
#next-btn:disabled { opacity: 0.4; cursor: default; transform: none; box-shadow: none; }

#synth-body {
  display: grid;
  grid-template-columns: 250px 1fr;
  gap: 10px;
  margin-top: 6px;
}

@media (max-width: 800px) {
  #synth-body { grid-template-columns: 1fr; }
}

/* Lesson Panel */
#lesson-panel {
  background: linear-gradient(135deg, #1a1a2e, #16213e);
  border-radius: 10px;
  padding: 14px;
  border: 1px solid #2a2a4a;
  min-height: 180px;
}

#lesson-title {
  font-size: 1em;
  color: #c084fc;
  margin-bottom: 6px;
  line-height: 1.3;
}

#lesson-text {
  font-size: 0.78em;
  line-height: 1.55;
  color: #9a9ab8;
}

#lesson-text b, #lesson-text strong {
  color: #d4b5ff;
  font-weight: 600;
}

/* Controls Panel */
#controls-panel {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(185px, 1fr));
  gap: 8px;
}

.control-section {
  background: #12121f;
  border-radius: 8px;
  padding: 10px 12px;
  border: 1px solid #2a2a3e;
  position: relative;
  overflow: hidden;
  transition: all 0.5s ease;
}

.control-section.unlocked {
  border-color: rgba(108,92,231,0.4);
  box-shadow: 0 0 12px rgba(108,92,231,0.06);
}

.control-section.locked {
  opacity: 0.35;
  filter: grayscale(0.7);
}

.control-section h3 {
  font-size: 0.7em;
  text-transform: uppercase;
  letter-spacing: 1.2px;
  color: #6c5ce7;
  margin-bottom: 8px;
}

.lock-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(10,10,18,0.6);
  z-index: 2;
  pointer-events: none;
}

.lock-overlay span {
  font-size: 0.7em;
  color: #555;
}

.control-section.unlocked .lock-overlay { display: none; }

.section-inner { position: relative; z-index: 1; }

/* Controls */
.control-group {
  margin-bottom: 8px;
}

.control-group:last-child { margin-bottom: 0; }

.control-group label {
  font-size: 0.7em;
  color: #6a6a8e;
  display: block;
  margin-bottom: 3px;
}

input[type="range"] {
  -webkit-appearance: none;
  width: 100%;
  height: 5px;
  background: #1a1a2e;
  border-radius: 3px;
  outline: none;
}

input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 14px;
  height: 14px;
  background: linear-gradient(135deg, #6c5ce7, #a855f7);
  border-radius: 50%;
  cursor: pointer;
  box-shadow: 0 2px 6px rgba(108,92,231,0.4);
}

input[type="range"]::-moz-range-thumb {
  width: 14px;
  height: 14px;
  background: linear-gradient(135deg, #6c5ce7, #a855f7);
  border-radius: 50%;
  cursor: pointer;
  border: none;
}

.btn-group {
  display: flex;
  gap: 3px;
  flex-wrap: wrap;
}

.btn-group button {
  background: #1a1a2e;
  color: #6a6a8e;
  border: 1px solid #2a2a3e;
  padding: 3px 8px;
  border-radius: 5px;
  font-size: 0.7em;
  cursor: pointer;
  transition: all 0.2s;
}

.btn-group button.active {
  background: #6c5ce7;
  color: white;
  border-color: #6c5ce7;
}

.btn-group button:hover:not(.active) {
  border-color: #6c5ce7;
  color: #bbb;
}

/* Visualizer */
#visualizer-section {
  grid-column: 1 / -1;
  display: flex;
  gap: 8px;
}

canvas {
  background: #0d0d1a;
  border-radius: 6px;
  border: 1px solid #2a2a3e;
}

#waveform-canvas { flex: 2; height: 90px; }
#spectrum-canvas { flex: 1; height: 90px; }

/* Keyboard */
#keyboard-section {
  grid-column: 1 / -1;
  text-align: center;
}

.hint {
  font-size: 0.7em;
  color: #555;
  margin-top: 6px;
}

#piano-keyboard {
  display: inline-flex;
  position: relative;
  height: 110px;
}

.piano-key {
  width: 42px;
  height: 110px;
  background: linear-gradient(180deg, #f5f5fa, #e8e8f0);
  border: 1px solid #bbb;
  border-radius: 0 0 5px 5px;
  cursor: pointer;
  position: relative;
  z-index: 1;
  transition: background 0.05s, box-shadow 0.05s;
}

.piano-key.black {
  width: 30px;
  height: 68px;
  background: linear-gradient(180deg, #2a2a3e, #1a1a2e);
  border-color: #111;
  margin-left: -15px;
  margin-right: -15px;
  z-index: 2;
}

.piano-key.active {
  background: linear-gradient(180deg, #c084fc, #a855f7) !important;
  box-shadow: 0 0 15px rgba(168,85,247,0.5);
}

.piano-key.black.active {
  background: linear-gradient(180deg, #d8b4fe, #c084fc) !important;
}

.key-label {
  position: absolute;
  bottom: 6px;
  left: 50%;
  transform: translateX(-50%);
  font-size: 0.6em;
  color: #999;
  pointer-events: none;
  user-select: none;
}

.piano-key.black .key-label {
  color: #777;
  bottom: 4px;
}

/* Start Overlay */
#start-overlay {
  position: fixed;
  inset: 0;
  background: rgba(10,10,18,0.95);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
}

.overlay-content {
  text-align: center;
  max-width: 460px;
  padding: 20px;
}

.overlay-content h1 { font-size: 2em; margin-bottom: 12px; }
.overlay-content p { color: #8888aa; line-height: 1.6; margin-bottom: 24px; font-size: 0.95em; }

.big-btn {
  background: linear-gradient(135deg, #6c5ce7, #a855f7);
  color: white;
  border: none;
  padding: 14px 40px;
  border-radius: 30px;
  font-size: 1.1em;
  cursor: pointer;
  transition: all 0.3s;
}

.big-btn:hover { transform: scale(1.05); box-shadow: 0 6px 30px rgba(108,92,231,0.5); }

.hidden { display: none !important; }
</style>
</head>
<body>
<div id="synth-app">
  <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>
    <button id="next-btn" class="action-btn">Next Concept →</button>
  </header>

  <main id="synth-body">
    <section id="lesson-panel">
      <div id="lesson-content">
        <h2 id="lesson-title"></h2>
        <p id="lesson-text"></p>
        <div id="lesson-demo" class="hidden"></div>
      </div>
    </section>

    <section id="controls-panel">
      <div id="osc-section" class="control-section locked">
        <h3>Oscillators</h3>
        <div class="lock-overlay"><span>🔒 Unlock this concept first</span></div>
        <div class="section-inner">
          <div class="control-group">
            <label>Waveform</label>
            <div id="wave-select" class="btn-group">
              <button data-wave="sine" class="active">Sine</button>
              <button data-wave="square">Square</button>
              <button data-wave="sawtooth">Saw</button>
              <button data-wave="triangle">Triangle</button>
            </div>
          </div>
          <div class="control-group">
            <label for="osc-octave">Octave: <span id="octave-val">4</span></label>
            <input type="range" id="osc-octave" min="-2" max="5" value="4" step="1">
          </div>
        </div>
      </div>

      <div id="filter-section" class="control-section locked">
        <h3>Filter</h3>
        <div class="lock-overlay"><span>🔒 Unlock this concept first</span></div>
        <div class="section-inner">
          <div class="control-group">
            <label for="filter-cutoff">Cutoff: <span id="cutoff-val">2000</span> Hz</label>
            <input type="range" id="filter-cutoff" min="100" max="15000" value="2000" step="10">
          </div>
          <div class="control-group">
            <label for="filter-res">Resonance: <span id="res-val">1</span></label>
            <input type="range" id="filter-res" min="0.1" max="25" value="1" step="0.1">
          </div>
          <div class="control-group">
            <label>Type</label>
            <div id="filter-type-select" class="btn-group">
              <button data-ftype="lowpass" class="active">Low Pass</button>
              <button data-ftype="highpass">High Pass</button>
              <button data-ftype="bandpass">Band Pass</button>
            </div>
          </div>
        </div>
      </div>

      <div id="env-section" class="control-section locked">
        <h3>Envelope (ADSR)</h3>
        <div class="lock-overlay"><span>🔒 Unlock this concept first</span></div>
        <div class="section-inner">
          <div class="control-group">
            <label for="env-attack">Attack: <span id="atk-val">0.01</span>s</label>
            <input type="range" id="env-attack" min="0.001" max="2" value="0.01" step="0.001">
          </div>
          <div class="control-group">
            <label for="env-decay">Decay: <span id="dec-val">0.3</span>s</label>
            <input type="range" id="env-decay" min="0.01" max="2" value="0.3" step="0.01">
          </div>
          <div class="control-group">
            <label for="env-sustain">Sustain: <span id="sus-val">0.7</span></label>
            <input type="range" id="env-sustain" min="0" max="1" value="0.7" step="0.01">
          </div>
          <div class="control-group">
            <label for="env-release">Release: <span id="rel-val">0.5</span>s</label>
            <input type="range" id="env-release" min="0.01" max="3" value="0.5" step="0.01">
          </div>
        </div>
      </div>

      <div id="lfo-section" class="control-section locked">
        <h3>LFO (Vibrato)</h3>
        <div class="lock-overlay"><span>🔒 Unlock this concept first</span></div>
        <div class="section-inner">
          <div class="control-group">
            <label for="lfo-rate">Rate: <span id="lfo-rate-val">5</span> Hz</label>
            <input type="range" id="lfo-rate" min="0.1" max="20" value="5" step="0.1">
          </div>
          <div class="control-group">
            <label for="lfo-depth">Depth: <span id="lfo-depth-val">0</span></label>
            <input type="range" id="lfo-depth" min="0" max="50" value="0" step="1">
          </div>
        </div>
      </div>

      <div id="fx-section" class="control-section locked">
        <h3>Effects</h3>
        <div class="lock-overlay"><span>🔒 Unlock this concept first</span></div>
        <div class="section-inner">
          <div class="control-group">
            <label for="delay-time">Delay Time: <span id="delay-val">0.3</span>s</label>
            <input type="range" id="delay-time" min="0" max="1" value="0.3" step="0.01">
          </div>
          <div class="control-group">
            <label for="delay-feedback">Feedback: <span id="feedback-val">0.4</span></label>
            <input type="range" id="delay-feedback" min="0" max="0.9" value="0.4" step="0.01">
          </div>
          <div class="control-group">
            <label for="reverb-mix">Reverb Mix: <span id="reverb-val">0</span></label>
            <input type="range" id="reverb-mix" min="0" max="1" value="0" step="0.01">
          </div>
        </div>
      </div>

      <div id="master-section" class="control-section unlocked">
        <h3>Master</h3>
        <div class="section-inner">
          <div class="control-group">
            <label for="master-vol">Volume: <span id="vol-val">0.5</span></label>
            <input type="range" id="master-vol" min="0" max="1" value="0.5" step="0.01">
          </div>
        </div>
      </div>
    </section>

    <section id="visualizer-section">
      <canvas id="waveform-canvas"></canvas>
      <canvas id="spectrum-canvas"></canvas>
    </section>

    <section id="keyboard-section">
      <div id="piano-keyboard"></div>
      <p class="hint">Keyboard: A S D F G H J K L ; (white) | W E T Y U O P (black)</p>
    </section>
  </main>

  <div id="start-overlay">
    <div class="overlay-content">
      <h1>🎹 Synth Playground</h1>
      <p>An interactive guide to understanding synthesizers. Each concept unlocks progressively — learn by doing.</p>
      <button id="start-btn" class="big-btn">Start Learning →</button>
    </div>
  </div>
</div>
<script>
'use strict';

// ── const: NOTE_FREQS ──
const NOTE_FREQS = {
  'C3': 130.81, 'C#3': 138.59, 'D3': 146.83, 'D#3': 155.56, 'E3': 164.81,
  'F3': 174.61, 'F#3': 185.00, 'G3': 196.00, 'G#3': 207.65, 'A3': 220.00,
  'A#3': 233.08, 'B3': 246.94,
  'C4': 261.63, 'C#4': 277.18, 'D4': 293.66, 'D#4': 311.13, 'E4': 329.63,
  'F4': 349.23, 'F#4': 369.99, 'G4': 392.00, 'G#4': 415.30, 'A4': 440.00,
  'A#4': 466.16, 'B4': 493.88,
  'C5': 523.25, 'C#5': 554.37, 'D5': 587.33, 'D#5': 622.25, 'E5': 659.25,
};

// ── const: KEY_MAP ──
const KEY_MAP = {
  'a': 'C4', 'w': 'C#4', 's': 'D4', 'e': 'D#4', 'd': 'E4',
  'f': 'F4', 't': 'F#4', 'g': 'G4', 'y': 'G#4', 'h': 'A4',
  'u': 'A#4', 'j': 'B4', 'k': 'C5', 'o': 'C#5', 'l': 'D5',
  'p': 'D#5', ';': 'E5'
};

// ── const: WHITE_KEYS ──
const WHITE_KEYS = ['C4','D4','E4','F4','G4','A4','B4','C5','D5','E5'];

// ── const: BLACK_KEY_MAP ──
const BLACK_KEY_MAP = { 'C4':'C#4','D4':'D#4','F4':'F#4','G4':'G#4','A4':'A#4' };

// ── class: AudioEngine ──
class AudioEngine {
  constructor() {
    this.ctx = null;
    this.masterGain = null;
    this.filterNode = null;
    this.delayNode = null;
    this.feedbackGain = null;
    this.dryGain = null;
    this.wetGain = null;
    this.convolver = null;
    this.reverbDry = null;
    this.reverbWet = null;
    this.lfoOsc = null;
    this.lfoGain = null;
    this.analyserWave = null;
    this.analyserSpec = null;
    this.activeVoices = {};
    this.settings = {
      waveform: 'sine',
      octave: 4,
      filterCutoff: 2000,
      filterRes: 1,
      filterType: 'lowpass',
      attack: 0.01,
      decay: 0.3,
      sustain: 0.7,
      release: 0.5,
      lfoRate: 5,
      lfoDepth: 0,
      delayTime: 0.3,
      feedback: 0.4,
      reverbMix: 0,
      volume: 0.5,
    };
  }

  init() {
    this.ctx = new (window.AudioContext || window.webkitAudioContext)();
    
    // Master gain
    this.masterGain = this.ctx.createGain();
    this.masterGain.gain.value = this.settings.volume;
    
    // Analyser nodes for visualization
    this.analyserWave = this.ctx.createAnalyser();
    this.analyserWave.fftSize = 2048;
    this.analyserSpec = this.ctx.createAnalyser();
    this.analyserSpec.fftSize = 2048;
    
    // Filter
    this.filterNode = this.ctx.createBiquadFilter();
    this.filterNode.type = 'lowpass';
    this.filterNode.frequency.value = this.settings.filterCutoff;
    this.filterNode.Q.value = this.settings.filterRes;
    
    // Delay effect chain
    this.delayNode = this.ctx.createDelay(2.0);
    this.delayNode.delayTime.value = this.settings.delayTime;
    this.feedbackGain = this.ctx.createGain();
    this.feedbackGain.gain.value = this.settings.feedback;
    this.dryGain = this.ctx.createGain();
    this.dryGain.gain.value = 1;
    this.wetGain = this.ctx.createGain();
    this.wetGain.gain.value = 0.5;
    
    // Reverb (convolver with generated impulse)
    this.convolver = this.ctx.createConvolver();
    this.reverbDry = this.ctx.createGain();
    this.reverbDry.gain.value = 1;
    this.reverbWet = this.ctx.createGain();
    this.reverbWet.gain.value = this.settings.reverbMix;
    this._generateReverbImpulse(2.0, 3);
    
    // LFO for vibrato
    this.lfoOsc = this.ctx.createOscillator();
    this.lfoOsc.type = 'sine';
    this.lfoOsc.frequency.value = this.settings.lfoRate;
    this.lfoGain = this.ctx.createGain();
    this.lfoGain.gain.value = 0; // depth controlled per voice
    this.lfoOsc.start();
    
    // Routing: voices -> filter -> [delay chain] -> [reverb chain] -> master -> analysers -> destination
    this.filterNode.connect(this.dryGain);
    this.delayNode.connect(this.feedbackGain);
    this.feedbackGain.connect(this.delayNode);
    this.delayNode.connect(this.wetGain);
    
    // Dry + wet delay mix into reverb chain
    const delayMix = this.ctx.createGain();
    this.dryGain.connect(delayMix);
    this.wetGain.connect(delayMix);
    
    // Reverb routing
    delayMix.connect(this.reverbDry);
    delayMix.connect(this.convolver);
    this.convolver.connect(this.reverbWet);
    
    const reverbMix = this.ctx.createGain();
    this.reverbDry.connect(reverbMix);
    this.reverbWet.connect(reverbMix);
    
    // Final chain
    reverbMix.connect(this.masterGain);
    this.masterGain.connect(this.analyserWave);
    this.analyserWave.connect(this.analyserSpec);
    this.analyserSpec.connect(this.ctx.destination);
  }

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

  noteOn(noteName) {
    if (!this.ctx) return;
    if (this.activeVoices[noteName]) return; // already playing
    
    const freq = NOTE_FREQS[noteName];
    if (!freq) return;
    
    const now = this.ctx.currentTime;
    
    // Create voice: oscillator -> gain (envelope) -> filter
    const osc = this.ctx.createOscillator();
    osc.type = this.settings.waveform;
    osc.frequency.value = freq;
    
    // Apply octave shift
    const octShift = Math.pow(2, this.settings.octave - 4);
    osc.frequency.value *= octShift;
    
    // Envelope gain
    const envGain = this.ctx.createGain();
    envGain.gain.setValueAtTime(0, now);
    envGain.gain.linearRampToValueAtTime(1, now + this.settings.attack);
    envGain.gain.linearRampToValueAtTime(this.settings.sustain, now + this.settings.attack + this.settings.decay);
    
    // LFO modulation (vibrato)
    if (this.settings.lfoDepth > 0) {
      const lfoMod = this.ctx.createGain();
      lfoMod.gain.value = this.settings.lfoDepth;
      this.lfoOsc.connect(lfoMod);
      lfoMod.connect(osc.frequency);
    }
    
    osc.connect(envGain);
    envGain.connect(this.filterNode);
    osc.start(now);
    
    this.activeVoices[noteName] = { osc, envGain };
  }

  noteOff(noteName) {
    const voice = this.activeVoices[noteName];
    if (!voice) return;
    
    const now = this.ctx.currentTime;
    const { osc, envGain } = voice;
    
    // Release phase
    envGain.gain.cancelScheduledValues(now);
    envGain.gain.setValueAtTime(envGain.gain.value, now);
    envGain.gain.linearRampToValueAtTime(0, now + this.settings.release);
    
    osc.stop(now + this.settings.release + 0.1);
    
    // Disconnect LFO mod to avoid memory leak
    if (this.lfoOsc) {
      setTimeout(() => {
        try { osc.disconnect(); envGain.disconnect(); } catch(e) {}
      }, (this.settings.release + 0.2) * 1000);
    }
    
    delete this.activeVoices[noteName];
  }

  setSetting(key, value) {
    this.settings[key] = value;
    
    switch(key) {
      case 'volume':
        if (this.masterGain) this.masterGain.gain.value = value;
        break;
      case 'filterCutoff':
        if (this.filterNode) this.filterNode.frequency.value = value;
        break;
      case 'filterRes':
        if (this.filterNode) this.filterNode.Q.value = value;
        break;
      case 'filterType':
        if (this.filterNode) this.filterNode.type = value;
        break;
      case 'delayTime':
        if (this.delayNode) this.delayNode.delayTime.value = value;
        break;
      case 'feedback':
        if (this.feedbackGain) this.feedbackGain.gain.value = value;
        break;
      case 'reverbMix':
        if (this.reverbWet) this.reverbWet.gain.value = value;
        break;
      case 'lfoRate':
        if (this.lfoOsc) this.lfoOsc.frequency.value = value;
        break;
    }
  }

  getWaveformData() {
    if (!this.analyserWave) return null;
    const data = new Uint8Array(this.analyserWave.fftSize);
    this.analyserWave.getByteTimeDomainData(data);
    return data;
  }

  getSpectrumData() {
    if (!this.analyserSpec) return null;
    const data = new Uint8Array(this.analyserSpec.frequencyBinCount);
    this.analyserSpec.getByteFrequencyData(data);
    return data;
  }
}

// ── class: Visualizer ──
class Visualizer {
  constructor(waveCanvas, specCanvas) {
    this.waveCanvas = waveCanvas;
    this.specCanvas = specCanvas;
    this.waveCtx = waveCanvas.getContext('2d');
    this.specCtx = specCanvas.getContext('2d');
    this.resize();
  }

  resize() {
    const dpr = window.devicePixelRatio || 1;
    
    for (const canvas of [this.waveCanvas, this.specCanvas]) {
      const rect = canvas.getBoundingClientRect();
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      const ctx = canvas.getContext('2d');
      ctx.scale(dpr, dpr);
    }
    
    this.waveW = this.waveCanvas.getBoundingClientRect().width;
    this.waveH = this.waveCanvas.getBoundingClientRect().height;
    this.specW = this.specCanvas.getBoundingClientRect().width;
    this.specH = this.specCanvas.getBoundingClientRect().height;
  }

  drawWaveform(data) {
    const ctx = this.waveCtx;
    const w = this.waveW;
    const h = this.waveH;
    
    ctx.clearRect(0, 0, w, h);
    
    // Background grid
    ctx.strokeStyle = '#1a1a2e';
    ctx.lineWidth = 1;
    for (let y = 0; y < h; y += h/4) {
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(w, y);
      ctx.stroke();
    }
    
    // Center line
    ctx.strokeStyle = '#2a2a4e';
    ctx.beginPath();
    ctx.moveTo(0, h/2);
    ctx.lineTo(w, h/2);
    ctx.stroke();
    
    if (!data) return;
    
    // Waveform
    const gradient = ctx.createLinearGradient(0, 0, w, 0);
    gradient.addColorStop(0, '#6c5ce7');
    gradient.addColorStop(0.5, '#a855f7');
    gradient.addColorStop(1, '#ec4899');
    
    ctx.strokeStyle = gradient;
    ctx.lineWidth = 2;
    ctx.beginPath();
    
    const sliceWidth = w / data.length;
    let x = 0;
    
    for (let i = 0; i < data.length; i++) {
      const v = data[i] / 128.0;
      const y = (v * h) / 2;
      
      if (i === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
      x += sliceWidth;
    }
    
    ctx.stroke();
    
    // Glow effect
    ctx.shadowColor = '#a855f7';
    ctx.shadowBlur = 10;
    ctx.strokeStyle = 'rgba(168, 85, 247, 0.3)';
    ctx.lineWidth = 4;
    ctx.beginPath();
    x = 0;
    for (let i = 0; i < data.length; i++) {
      const v = data[i] / 128.0;
      const y = (v * h) / 2;
      if (i === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
      x += sliceWidth;
    }
    ctx.stroke();
    ctx.shadowBlur = 0;
  }

  drawSpectrum(data) {
    const ctx = this.specCtx;
    const w = this.specW;
    const h = this.specH;
    
    ctx.clearRect(0, 0, w, h);
    
    if (!data) return;
    
    const barCount = Math.min(64, data.length / 4);
    const barWidth = (w / barCount) - 1;
    
    for (let i = 0; i < barCount; i++) {
      const idx = Math.floor(i * data.length / barCount);
      const val = data[idx] / 255.0;
      const barHeight = val * h * 0.9;
      
      const hue = 260 + (i / barCount) * 80;
      ctx.fillStyle = `hsla(${hue}, 70%, ${40 + val * 30}%, ${0.5 + val * 0.5})`;
      
      const x = i * (barWidth + 1);
      ctx.fillRect(x, h - barHeight, barWidth, barHeight);
    }
  }

  animate(engine) {
    const waveData = engine.getWaveformData();
    const specData = engine.getSpectrumData();
    
    this.drawWaveform(waveData);
    this.drawSpectrum(specData);
    
    requestAnimationFrame(() => this.animate(engine));
  }
}

// ── const: LESSONS ──
const LESSONS = [
  {
    id: 'welcome',
    title: '🎵 Welcome to Synth Playground!',
    text: `A synthesizer creates sound electronically. At its heart is an <b>oscillator</b> — a circuit that generates a repeating waveform at a specific frequency (pitch).

Click "Next Concept" to start exploring, or play the keyboard below with your mouse or computer keys!`,
    unlocks: null,
  },
  {
    id: 'oscillators',
    title: '🎶 Oscillators — The Sound Source',
    text: `Every synth sound starts with a waveform. Different waveforms have different <b>harmonic content</b>:

• <b>Sine</b> — Pure, smooth tone (no harmonics)
• <b>Square</b> — Hollow, flute-like (odd harmonics)  
• <b>Sawtooth</b> — Bright, buzzy (all harmonics)
• <b>Triangle</b> — Soft, mellow (weak odd harmonics)

<b>Try it:</b> Switch between waveforms and play the same note. Notice how each sounds different even at the same pitch!`,
    unlocks: 'osc-section',
  },
  {
    id: 'filter',
    title: '🔊 Filter — Shaping the Tone',
    text: `A <b>filter</b> removes certain frequencies from your sound, like a sieve for audio.

• <b>Cutoff</b> — The frequency boundary. Frequencies above (lowpass) or below (highpass) this point are reduced.
• <b>Resonance</b> — Boosts the cutoff frequency, creating an emphasized "peak" at that point. High resonance = more character!

<b>Try it:</b> Use a sawtooth wave, then sweep the cutoff from low to high. You'll hear the sound go from dark/muffled to bright/full.`,
    unlocks: 'filter-section',
  },
  {
    id: 'envelope',
    title: '📈 Envelope (ADSR) — Shaping Over Time',
    text: `An <b>envelope</b> controls how the volume changes over time when you press and release a key. ADSR stands for:

• <b>A</b>ttack — How long to reach full volume after pressing
• <b>D</b>ecay — How long to drop from peak to sustain level  
• <b>S</b>ustain — The volume held while the key is pressed
• <b>R</b>elease — How long to fade out after releasing

<b>Try it:</b> Set attack high for a "swelling" sound, or set release high for a lingering tail. A piano has fast attack and medium release; a pad has slow attack and long release.`,
    unlocks: 'env-section',
  },
  {
    id: 'lfo',
    title: '〰️ LFO — Low-Frequency Oscillation',
    text: `An <b>LFO</b> is an oscillator that runs too slowly to hear as a pitch. Instead, it <b>modulates</b> (wiggles) other parameters.

Here we use the LFO to modulate pitch, creating <b>vibrato</b> — a subtle periodic pitch variation that makes sounds feel alive and expressive.

• <b>Rate</b> — How fast the vibrato oscillates
• <b>Depth</b> — How much the pitch wiggles

<b>Try it:</b> Hold a note and slowly increase the depth. You'll hear the pitch start to "wobble" like a singer's vibrato!`,
    unlocks: 'lfo-section',
  },
  {
    id: 'effects',
    title: '✨ Effects — Space and Depth',
    text: `Effects add dimension to your sound by simulating physical spaces or creating rhythmic echoes.

• <b>Delay</b> — Repeats the signal after a time interval. <b>Feedback</b> controls how many repeats you get.
• <b>Reverb</b> — Simulates the natural reflections of a room, hall, or space. Adds depth and "air."

<b>Try it:</b> Hold a note with a short envelope (fast attack/decay) and add delay. You'll hear rhythmic echoes! Add reverb for a sense of space.`,
    unlocks: 'fx-section',
  },
  {
    id: 'complete',
    title: '🎉 You\'ve Unlocked Everything!',
    text: `You now understand the core building blocks of synthesis:

1. <b>Oscillators</b> — Generate the raw sound
2. <b>Filters</b> — Shape which frequencies pass through  
3. <b>Envelopes</b> — Control how sound evolves over time
4. <b>LFOs</b> — Add movement and modulation
5. <b>Effects</b> — Create space and depth

<b>Now experiment!</b> Combine all these controls to create your own sounds. Try making a bass, a pad, a lead, or something entirely new!`,
    unlocks: null,
  },
];

// ── class: SynthUI ──
class SynthUI {
  constructor(engine) {
    this.engine = engine;
    this.currentLesson = 0;
    this.wireControls();
    this.showLesson(0);
  }

  wireControls() {
    // Waveform buttons
    document.querySelectorAll('#wave-select button').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('#wave-select button').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        this.engine.setSetting('waveform', btn.dataset.wave);
      });
    });

    // Filter type buttons
    document.querySelectorAll('#filter-type-select button').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('#filter-type-select button').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        this.engine.setSetting('filterType', btn.dataset.ftype);
      });
    });

    // Range sliders with value display
    const sliderMap = [
      ['osc-octave', 'octave-val', 'octave'],
      ['filter-cutoff', 'cutoff-val', 'filterCutoff'],
      ['filter-res', 'res-val', 'filterRes'],
      ['env-attack', 'atk-val', 'attack'],
      ['env-decay', 'dec-val', 'decay'],
      ['env-sustain', 'sus-val', 'sustain'],
      ['env-release', 'rel-val', 'release'],
      ['lfo-rate', 'lfo-rate-val', 'lfoRate'],
      ['lfo-depth', 'lfo-depth-val', 'lfoDepth'],
      ['delay-time', 'delay-val', 'delayTime'],
      ['delay-feedback', 'feedback-val', 'feedback'],
      ['reverb-mix', 'reverb-val', 'reverbMix'],
      ['master-vol', 'vol-val', 'volume'],
    ];

    sliderMap.forEach(([sliderId, displayId, settingKey]) => {
      const slider = document.getElementById(sliderId);
      const display = document.getElementById(displayId);
      
      if (slider && display) {
        slider.addEventListener('input', () => {
          const val = parseFloat(slider.value);
          display.textContent = Number.isInteger(val) ? val : val.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
          this.engine.setSetting(settingKey, val);
        });
      }
    });

    // Next button
    document.getElementById('next-btn').addEventListener('click', () => {
      if (this.currentLesson < LESSONS.length - 1) {
        this.currentLesson++;
        this.showLesson(this.currentLesson);
      }
    });
  }

  showLesson(index) {
    const lesson = LESSONS[index];
    
    document.getElementById('lesson-title').innerHTML = lesson.title;
    document.getElementById('lesson-text').innerHTML = lesson.text;
    
    // Update progress bar
    const pct = (index / (LESSONS.length - 1)) * 100;
    document.getElementById('progress-fill').style.width = pct + '%';
    
    // Unlock section if applicable
    if (lesson.unlocks) {
      const section = document.getElementById(lesson.unlocks);
      if (section) {
        section.classList.remove('locked');
        section.classList.add('unlocked');
        
        // Animate unlock
        section.style.transform = 'scale(1.03)';
        setTimeout(() => { section.style.transform = ''; }, 300);
      }
    }
    
    // Update next button state
    const nextBtn = document.getElementById('next-btn');
    if (index >= LESSONS.length - 1) {
      nextBtn.textContent = '🎉 All Concepts Unlocked!';
      nextBtn.disabled = true;
    } else {
      nextBtn.textContent = 'Next Concept →';
      nextBtn.disabled = false;
    }
  }

  advanceLesson() {
    if (this.currentLesson < LESSONS.length - 1) {
      this.currentLesson++;
      this.showLesson(this.currentLesson);
    }
  }
}

// ── const: REVERSE_KEY_MAP ──
const REVERSE_KEY_MAP = {
  'C4': 'a', 'C#4': 'w', 'D4': 's', 'D#4': 'e', 'E4': 'd',
  'F4': 'f', 'F#4': 't', 'G4': 'g', 'G#4': 'y', 'A4': 'h',
  'A#4': 'u', 'B4': 'j', 'C5': 'k', 'C#5': 'o', 'D5': 'l',
  'D#5': 'p', 'E5': ';'
};

// ── const: NOTE_ORDER ──
const NOTE_ORDER = [
  'C4', 'C#4', 'D4', 'D#4', 'E4', 
  'F4', 'F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4',
  'C5', 'C#5', 'D5', 'D#5', 'E5'
];

// ── class: PianoKeyboard ──
class PianoKeyboard {
  constructor(container) {
    this.container = container;
    this.keyElements = {};
    this.activeKeys = new Set();
    this.onNoteOn = null;
    this.onNoteOff = null;
    this.build();
  }

  build() {
    this.container.innerHTML = '';
    
    NOTE_ORDER.forEach(note => {
      const key = document.createElement('div');
      const isBlack = note.includes('#');
      key.className = `piano-key${isBlack ? ' black' : ''}`;
      key.dataset.note = note;
      
      // Add keyboard shortcut label
      const shortcutKey = REVERSE_KEY_MAP[note];
      if (shortcutKey) {
        const label = document.createElement('span');
        label.className = 'key-label';
        label.textContent = shortcutKey.toUpperCase();
        key.appendChild(label);
      }
      
      const handleDown = (e) => { e.preventDefault(); this.pressKey(note); };
      const handleUp = (e) => { e.preventDefault(); this.releaseKey(note); };
      
      key.addEventListener('mousedown', handleDown);
      key.addEventListener('mouseup', handleUp);
      key.addEventListener('mouseleave', handleUp);
      key.addEventListener('touchstart', handleDown, { passive: false });
      key.addEventListener('touchend', handleUp, { passive: false });
      
      this.container.appendChild(key);
      this.keyElements[note] = key;
    });
  }

  pressKey(note) {
    if (this.activeKeys.has(note)) return;
    this.activeKeys.add(note);
    
    const el = this.keyElements[note];
    if (el) el.classList.add('active');
    
    if (this.onNoteOn) this.onNoteOn(note);
  }

  releaseKey(note) {
    this.activeKeys.delete(note);
    
    const el = this.keyElements[note];
    if (el) el.classList.remove('active');
    
    if (this.onNoteOff) this.onNoteOff(note);
  }

  clearAll() {
    [...this.activeKeys].forEach(n => this.releaseKey(n));
  }
}

// ── main ──
(function() {
  const engine = new AudioEngine();
  let ui, keyboard, visualizer;
  let started = false;

  // Build piano keyboard
  const kbContainer = document.getElementById('piano-keyboard');
  keyboard = new PianoKeyboard(kbContainer);

  keyboard.onNoteOn = (note) => engine.noteOn(note);
  keyboard.onNoteOff = (note) => engine.noteOff(note);

  // Computer keyboard handling
  const pressedKeys = new Set();

  document.addEventListener('keydown', (e) => {
    if (!started) return;
    const key = e.key.toLowerCase();
    
    if (pressedKeys.has(key)) return;
    pressedKeys.add(key);
    
    const note = KEY_MAP[key];
    if (note) {
      e.preventDefault();
      engine.noteOn(note);
      keyboard.pressKey(note);
    }
  });

  document.addEventListener('keyup', (e) => {
    const key = e.key.toLowerCase();
    pressedKeys.delete(key);
    
    const note = KEY_MAP[key];
    if (note) {
      engine.noteOff(note);
      keyboard.releaseKey(note);
    }
  });

  // Start button
  document.getElementById('start-btn').addEventListener('click', () => {
    started = true;
    engine.init();
    
    ui = new SynthUI(engine);
    
    visualizer = new Visualizer(
      document.getElementById('waveform-canvas'),
      document.getElementById('spectrum-canvas')
    );
    visualizer.animate(engine);

    // Hide overlay with animation
    const overlay = document.getElementById('start-overlay');
    overlay.style.transition = 'opacity 0.5s';
    overlay.style.opacity = '0';
    setTimeout(() => { overlay.style.display = 'none'; }, 500);
    
    // Unlock first lesson section (welcome doesn't unlock anything, next does)
  });

  // Handle resize for visualizer
  window.addEventListener('resize', () => {
    if (visualizer) visualizer.resize();
  });
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"14fcee56-7ac2-49c6-9873-92fa8796a678","tokensIn":542535,"tokensOut":24288,"tokensTotal":566823,"turns":27,"toolCalls":26,"failedToolCalls":3,"timestamp":"2026-07-16T01:22:29.875Z"} -->