← TutSynth results

TutSynth

Qwen 3.6 27b MTP · full

Initial view of TutSynth

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 42,194 bytes · SHA-256 784c043e1894
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Synth Playground — Learn Synthesis</title>
<style>
  :root {
    --bg: #0a0a0f;
    --panel: #12121a;
    --border: #1e1e2e;
    --text: #c8c8d4;
    --dim: #5a5a6e;
    --accent: #7b5ea7;
    --accent-bright: #a78bfa;
    --green: #34d399;
    --orange: #fb923c;
    --red: #f87171;
    --cyan: #22d3ee;
    --yellow: #fbbf24;
  }

  * { margin: 0; padding: 0; box-sizing: border-box; }

  body {
    background: var(--bg);
    color: var(--text);
    font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
    min-height: 100vh;
    overflow-x: hidden;
  }

  header {
    text-align: center;
    padding: 24px 20px 16px;
    border-bottom: 1px solid var(--border);
    background: linear-gradient(180deg, #151520 0%, var(--bg) 100%);
  }

  header h1 {
    font-size: 1.6rem;
    font-weight: 700;
    letter-spacing: -0.5px;
    background: linear-gradient(135deg, var(--accent-bright), var(--cyan));
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
  }

  header p {
    color: var(--dim);
    font-size: 0.85rem;
    margin-top: 4px;
  }

  #progress-bar-container {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 12px;
    padding: 12px 20px;
    border-bottom: 1px solid var(--border);
    background: var(--panel);
  }

  #progress-bar-container span {
    font-size: 0.75rem;
    color: var(--dim);
    white-space: nowrap;
  }

  #progress-track {
    flex: 1;
    max-width: 400px;
    height: 6px;
    background: var(--border);
    border-radius: 3px;
    overflow: hidden;
  }

  #progress-fill {
    height: 100%;
    width: 0%;
    background: linear-gradient(90deg, var(--accent-bright), var(--cyan));
    border-radius: 3px;
    transition: width 0.6s ease;
  }

  #main {
    max-width: 1200px;
    margin: 0 auto;
    padding: 20px;
    display: flex;
    flex-direction: column;
    gap: 16px;
  }

  /* Visualizer */
  #visualizer-section {
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 12px;
    overflow: hidden;
    position: relative;
  }

  #visualizer-section canvas {
    width: 100%;
    height: 160px;
    display: block;
  }

  .viz-label {
    position: absolute;
    top: 8px;
    left: 12px;
    font-size: 0.7rem;
    color: var(--dim);
    text-transform: uppercase;
    letter-spacing: 1px;
  }

  /* Keyboard */
  #keyboard-section {
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 16px;
    overflow-x: auto;
  }

  #keyboard-hint {
    text-align: center;
    font-size: 0.75rem;
    color: var(--dim);
    margin-bottom: 10px;
  }

  #keyboard {
    display: flex;
    justify-content: center;
    position: relative;
    height: 120px;
    user-select: none;
  }

  .key-white {
    width: 44px;
    height: 120px;
    background: linear-gradient(180deg, #f0f0f0, #fff);
    border: 1px solid #ccc;
    border-radius: 0 0 6px 6px;
    cursor: pointer;
    position: relative;
    z-index: 1;
    transition: background 0.05s;
  }

  .key-white:hover { background: linear-gradient(180deg, #e0e0ff, #f0f0ff); }
  .key-white.active { background: linear-gradient(180deg, var(--accent-bright), #c4b5fd) !important; }

  .key-black {
    width: 28px;
    height: 76px;
    background: linear-gradient(180deg, #222, #3a3a3a);
    border-radius: 0 0 4px 4px;
    cursor: pointer;
    position: absolute;
    z-index: 2;
    margin-left: -14px;
    transition: background 0.05s;
  }

  .key-black:hover { background: linear-gradient(180deg, #333, #4a4a6a); }
  .key-black.active { background: linear-gradient(180deg, var(--accent), #7c3aed) !important; }

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

  .key-black .key-label { color: #777; bottom: 4px; font-size: 0.55rem; }

  /* Module Grid */
  #modules-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
    gap: 16px;
  }

  .module {
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 12px;
    overflow: hidden;
    transition: border-color 0.3s, box-shadow 0.3s;
  }

  .module.locked { opacity: 0.45; pointer-events: none; filter: grayscale(0.6); }
  .module.unlocked { border-color: var(--accent); box-shadow: 0 0 20px rgba(123,94,167,0.15); }

  .module-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 14px 18px;
    border-bottom: 1px solid var(--border);
    background: rgba(255,255,255,0.02);
  }

  .module-header h3 {
    font-size: 0.9rem;
    font-weight: 600;
    display: flex;
    align-items: center;
    gap: 8px;
  }

  .module-icon {
    width: 24px;
    height: 24px;
    border-radius: 6px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.75rem;
  }

  .module-step {
    font-size: 0.65rem;
    color: var(--dim);
    background: rgba(255,255,255,0.05);
    padding: 3px 8px;
    border-radius: 10px;
  }

  .module-body { padding: 16px 18px; }

  /* Lesson */
  .lesson {
    background: rgba(123,94,167,0.08);
    border-left: 3px solid var(--accent-bright);
    padding: 12px 14px;
    border-radius: 0 8px 8px 0;
    margin-bottom: 14px;
    font-size: 0.8rem;
    line-height: 1.6;
    color: var(--text);
  }

  .lesson strong { color: var(--accent-bright); }
  .lesson em { color: var(--cyan); font-style: normal; font-weight: 500; }

  /* Controls */
  .control-row {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-bottom: 10px;
  }

  .control-label {
    width: 80px;
    font-size: 0.75rem;
    color: var(--dim);
    text-transform: uppercase;
    letter-spacing: 0.5px;
    flex-shrink: 0;
  }

  .control-value {
    width: 48px;
    text-align: right;
    font-size: 0.75rem;
    color: var(--accent-bright);
    font-family: 'Courier New', monospace;
    flex-shrink: 0;
  }

  input[type="range"] {
    -webkit-appearance: none;
    appearance: none;
    flex: 1;
    height: 6px;
    background: var(--border);
    border-radius: 3px;
    outline: none;
    cursor: pointer;
  }

  input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 16px;
    height: 16px;
    background: var(--accent-bright);
    border-radius: 50%;
    cursor: pointer;
    box-shadow: 0 0 8px rgba(167,139,250,0.4);
  }

  input[type="range"]::-moz-range-thumb {
    width: 16px;
    height: 16px;
    background: var(--accent-bright);
    border-radius: 50%;
    cursor: pointer;
    border: none;
  }

  /* Waveform selector */
  .wave-selector {
    display: flex;
    gap: 8px;
    margin-bottom: 14px;
  }

  .wave-btn {
    flex: 1;
    padding: 10px 6px;
    background: rgba(255,255,255,0.03);
    border: 1px solid var(--border);
    border-radius: 8px;
    color: var(--dim);
    cursor: pointer;
    text-align: center;
    transition: all 0.2s;
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.5px;
  }

  .wave-btn:hover { border-color: var(--accent); color: var(--text); }
  .wave-btn.active {
    border-color: var(--accent-bright);
    background: rgba(123,94,167,0.15);
    color: var(--accent-bright);
  }

  .wave-canvas {
    width: 100%;
    height: 40px;
    margin-bottom: 8px;
  }

  /* Learn button */
  .learn-btn {
    padding: 6px 14px;
    background: linear-gradient(135deg, var(--accent), #6d28d9);
    border: none;
    border-radius: 6px;
    color: white;
    font-size: 0.7rem;
    cursor: pointer;
    text-transform: uppercase;
    letter-spacing: 1px;
    transition: all 0.2s;
  }

  .learn-btn:hover { transform: scale(1.05); box-shadow: 0 0 16px rgba(123,94,167,0.4); }

  /* Unlock button */
  .unlock-btn {
    padding: 8px 18px;
    background: linear-gradient(135deg, var(--green), #059669);
    border: none;
    border-radius: 8px;
    color: white;
    font-size: 0.75rem;
    cursor: pointer;
    text-transform: uppercase;
    letter-spacing: 1px;
    transition: all 0.2s;
    margin-top: 10px;
  }

  .unlock-btn:hover { transform: scale(1.05); box-shadow: 0 0 16px rgba(52,211,153,0.4); }

  /* Preset buttons */
  .preset-row {
    display: flex;
    gap: 6px;
    margin-top: 10px;
    flex-wrap: wrap;
  }

  .preset-btn {
    padding: 5px 12px;
    background: rgba(255,255,255,0.04);
    border: 1px solid var(--border);
    border-radius: 6px;
    color: var(--dim);
    cursor: pointer;
    font-size: 0.7rem;
    transition: all 0.2s;
  }

  .preset-btn:hover { border-color: var(--cyan); color: var(--cyan); }

  /* Notification */
  #notification {
    position: fixed;
    top: 20px;
    right: 20px;
    background: linear-gradient(135deg, var(--green), #059669);
    color: white;
    padding: 14px 20px;
    border-radius: 10px;
    font-size: 0.85rem;
    box-shadow: 0 8px 30px rgba(0,0,0,0.4);
    transform: translateX(120%);
    transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
    z-index: 100;
    max-width: 300px;
  }

  #notification.show { transform: translateX(0); }

  /* Responsive */
  @media (max-width: 700px) {
    #modules-grid { grid-template-columns: 1fr; }
    .key-white { width: 32px; }
    .key-black { width: 22px; margin-left: -11px; height: 64px; }
    #keyboard { height: 90px; }
  }

  /* Scrollbar */
  ::-webkit-scrollbar { width: 6px; height: 6px; }
  ::-webkit-scrollbar-track { background: var(--bg); }
  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>

<header>
  <h1>⚡ Synth Playground</h1>
  <p>Learn synthesis by doing — each module unlocks as you explore</p>
</header>

<div id="progress-bar-container">
  <span>Your Progress</span>
  <div id="progress-track"><div id="progress-fill"></div></div>
  <span id="progress-text">0 / 5 modules</span>
</div>

<div id="main">
  <!-- Visualizer -->
  <div id="visualizer-section">
    <canvas id="viz-canvas"></canvas>
    <span class="viz-label">Waveform & Spectrum</span>
  </div>

  <!-- Keyboard -->
  <div id="keyboard-section">
    <div id="keyboard-hint">Click keys or use your keyboard (A–L for white keys, W/E/T/Y/U/O/P for black keys)</div>
    <div id="keyboard"></div>
  </div>

  <!-- Modules -->
  <div id="modules-grid">

    <!-- Module 1: Oscillator -->
    <div class="module unlocked" data-module="oscillator">
      <div class="module-header">
        <h3><span class="module-icon" style="background:rgba(167,139,250,0.2);color:var(--accent-bright)">〰</span> Oscillator</h3>
        <span class="module-step">Step 1 of 5</span>
      </div>
      <div class="module-body">
        <div class="lesson" id="osc-lesson">
          The <strong>oscillator</strong> is the heart of a synth — it generates the raw sound. Different waveforms have different harmonic content, which shapes the timbre (tone color). Try each one and listen to how they differ!
        </div>
        <canvas class="wave-canvas" id="wave-preview"></canvas>
        <div class="wave-selector">
          <button class="wave-btn active" data-wave="sine">Sine</button>
          <button class="wave-btn" data-wave="square">Square</button>
          <button class="wave-btn" data-wave="sawtooth">Saw</button>
          <button class="wave-btn" data-wave="triangle">Triangle</button>
        </div>
        <div class="control-row">
          <span class="control-label">Octave</span>
          <input type="range" id="octave-slider" min="-2" max="2" value="0" step="1">
          <span class="control-value" id="octave-val">0</span>
        </div>
        <button class="unlock-btn" data-unlock="envelope">▶ Unlock Envelope →</button>
      </div>
    </div>

    <!-- Module 2: ADSR Envelope -->
    <div class="module locked" data-module="envelope">
      <div class="module-header">
        <h3><span class="module-icon" style="background:rgba(52,211,153,0.2);color:var(--green)">◈</span> ADSR Envelope</h3>
        <span class="module-step">Step 2 of 5</span>
      </div>
      <div class="module-body">
        <div class="lesson" id="env-lesson">
          An <strong>ADSR envelope</strong> shapes how volume changes over time. <em>A</em>ttack = how fast sound rises, <em>D</em>ecay = how long to reach sustain level, <em>S</em>ustain = the held volume, <em>R</em>elease = how long it fades after you let go. Hold a key and tweak these!
        </div>
        <canvas class="wave-canvas" id="env-preview"></canvas>
        <div class="control-row">
          <span class="control-label">Attack</span>
          <input type="range" id="attack-slider" min="0.01" max="2" value="0.05" step="0.01">
          <span class="control-value" id="attack-val">0.05s</span>
        </div>
        <div class="control-row">
          <span class="control-label">Decay</span>
          <input type="range" id="decay-slider" min="0.01" max="2" value="0.3" step="0.01">
          <span class="control-value" id="decay-val">0.30s</span>
        </div>
        <div class="control-row">
          <span class="control-label">Sustain</span>
          <input type="range" id="sustain-slider" min="0" max="1" value="0.7" step="0.01">
          <span class="control-value" id="sustain-val">0.70</span>
        </div>
        <div class="control-row">
          <span class="control-label">Release</span>
          <input type="range" id="release-slider" min="0.01" max="3" value="0.5" step="0.01">
          <span class="control-value" id="release-val">0.50s</span>
        </div>
        <button class="unlock-btn" data-unlock="filter">▶ Unlock Filter →</button>
      </div>
    </div>

    <!-- Module 3: Filter -->
    <div class="module locked" data-module="filter">
      <div class="module-header">
        <h3><span class="module-icon" style="background:rgba(251,146,60,0.2);color:var(--orange)">◉</span> Filter</h3>
        <span class="module-step">Step 3 of 5</span>
      </div>
      <div class="module-body">
        <div class="lesson" id="filter-lesson">
          A <strong>filter</strong> carves away frequencies from the sound. A <em>low-pass filter (LPF)</em> lets low frequencies through and cuts highs — like turning down the treble. <strong>Cutoff</strong> sets where it starts cutting, <strong>resonance (Q)</strong> boosts the cutoff frequency for a "squelchy" sound. Try with a sawtooth wave!
        </div>
        <div class="control-row">
          <span class="control-label">Filter</span>
          <input type="range" id="filter-slider" min="20" max="15000" value="8000" step="1">
          <span class="control-value" id="filter-val">8.0k</span>
        </div>
        <div class="control-row">
          <span class="control-label">Resonance</span>
          <input type="range" id="resonance-slider" min="0" max="25" value="1" step="0.1">
          <span class="control-value" id="resonance-val">1.0</span>
        </div>
        <div class="wave-selector" style="margin-top:8px">
          <button class="wave-btn active" data-filter="lowpass">Low-Pass</button>
          <button class="wave-btn" data-filter="highpass">High-Pass</button>
          <button class="wave-btn" data-filter="bandpass">Band-Pass</button>
        </div>
        <button class="unlock-btn" data-unlock="lfo">▶ Unlock LFO →</button>
      </div>
    </div>

    <!-- Module 4: LFO -->
    <div class="module locked" data-module="lfo">
      <div class="module-header">
        <h3><span class="module-icon" style="background:rgba(34,211,238,0.2);color:var(--cyan)">∿</span> LFO (Vibrato)</h3>
        <span class="module-step">Step 4 of 5</span>
      </div>
      <div class="module-body">
        <div class="lesson" id="lfo-lesson">
          An <strong>LFO</strong> (Low Frequency Oscillator) modulates another parameter at a slow rate. Here it modulates pitch, creating <em>vibrato</em> — the subtle pitch wobble singers use. <strong>Rate</strong> controls speed of oscillation, <strong>Depth</strong> controls how much pitch bends. Hold a note and sweep the depth!
        </div>
        <div class="control-row">
          <span class="control-label">Rate</span>
          <input type="range" id="lfo-rate-slider" min="0.1" max="20" value="5" step="0.1">
          <span class="control-value" id="lfo-rate-val">5.0 Hz</span>
        </div>
        <div class="control-row">
          <span class="control-label">Depth</span>
          <input type="range" id="lfo-depth-slider" min="0" max="100" value="20" step="1">
          <span class="control-value" id="lfo-depth-val">20 ct</span>
        </div>
        <button class="unlock-btn" data-unlock="effects">▶ Unlock Effects →</button>
      </div>
    </div>

    <!-- Module 5: Effects -->
    <div class="module locked" data-module="effects">
      <div class="module-header">
        <h3><span class="module-icon" style="background:rgba(251,191,36,0.2);color:var(--yellow)">✦</span> Effects (Delay)</h3>
        <span class="module-step">Step 5 of 5</span>
      </div>
      <div class="module-body">
        <div class="lesson" id="fx-lesson">
          <strong>Effects</strong> process the sound after it leaves the synth. A <em>delay</em> echoes your signal back at you. <strong>Time</strong> sets how long between repeats, <strong>Feedback</strong> controls how many repeats (higher = more echoes), and <strong>Mix</strong> blends dry vs wet signal. Try a plucky envelope with delay for ambient textures!
        </div>
        <div class="control-row">
          <span class="control-label">Delay Time</span>
          <input type="range" id="delay-time-slider" min="0.01" max="1" value="0.3" step="0.01">
          <span class="control-value" id="delay-time-val">0.30s</span>
        </div>
        <div class="control-row">
          <span class="control-label">Feedback</span>
          <input type="range" id="delay-fb-slider" min="0" max="0.95" value="0.4" step="0.01">
          <span class="control-value" id="delay-fb-val">0.40</span>
        </div>
        <div class="control-row">
          <span class="control-label">Mix</span>
          <input type="range" id="delay-mix-slider" min="0" max="1" value="0.3" step="0.01">
          <span class="control-value" id="delay-mix-val">0.30</span>
        </div>
      </div>
    </div>

  </div>

  <!-- Presets -->
  <div style="text-align:center; padding: 8px;">
    <span style="font-size:0.75rem;color:var(--dim);margin-right:12px">Presets:</span>
    <button class="preset-btn" data-preset="pad">Warm Pad</button>
    <button class="preset-btn" data-preset="pluck">Pluck</button>
    <button class="preset-btn" data-preset="bass">Bass</button>
    <button class="preset-btn" data-preset="lead">Lead</button>
    <button class="preset-btn" data-preset="organ">Organ</button>
  </div>

</div>

<div id="notification"></div>

<script>
(function() {
  'use strict';

  // ─── Audio Context & State ──────────────────────────────
  let audioCtx = null;
  let masterGain, analyserNode, delayNode, feedbackGain, dryGain, wetGain;
  let filterNode;
  const activeVoices = {};
  let currentWaveform = 'sine';
  let octaveOffset = 0;
  let unlockedModules = new Set(['oscillator']);

  // ADSR defaults
  const adsr = { attack: 0.05, decay: 0.3, sustain: 0.7, release: 0.5 };
  // Filter defaults
  const filterState = { type: 'lowpass', cutoff: 8000, resonance: 1 };
  // LFO defaults
  const lfoState = { rate: 5, depth: 20 };
  // Delay defaults
  const delayState = { time: 0.3, feedback: 0.4, mix: 0.3 };

  function initAudio() {
    if (audioCtx) return;
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();

    // Master chain: voices → filter → delay → analyser → masterGain → destination
    masterGain = audioCtx.createGain();
    masterGain.gain.value = 0.5;

    analyserNode = audioCtx.createAnalyser();
    analyserNode.fftSize = 2048;

    // Delay effect chain
    delayNode = audioCtx.createDelay(2);
    feedbackGain = audioCtx.createGain();
    dryGain = audioCtx.createGain();
    wetGain = audioCtx.createGain();

    delayNode.delayTime.value = delayState.time;
    feedbackGain.gain.value = delayState.feedback;
    dryGain.gain.value = 1 - delayState.mix * 0.5;
    wetGain.gain.value = delayState.mix;

    // Routing: source → filter → [dry + delay] → analyser → master → out
    filterNode = audioCtx.createBiquadFilter();
    filterNode.type = filterState.type;
    filterNode.frequency.value = filterState.cutoff;
    filterNode.Q.value = filterState.resonance;

    // Dry path: filter → dryGain → analyser
    filterNode.connect(dryGain);
    dryGain.connect(analyserNode);

    // Wet path: filter → delayNode → feedbackGain → delayNode (loop)
    filterNode.connect(delayNode);
    delayNode.connect(feedbackGain);
    feedbackGain.connect(delayNode);
    delayNode.connect(wetGain);
    wetGain.connect(analyserNode);

    analyserNode.connect(masterGain);
    masterGain.connect(audioCtx.destination);
  }

  // ─── Note Frequencies ──────────────────────────────────
  const noteNames = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
  function noteFreq(note, octave) {
    const midi = (octave + 1) * 12 + note;
    return 440 * Math.pow(2, (midi - 69) / 12);
  }

  // ─── Keyboard Layout ───────────────────────────────────
  const keyboardMap = [
    { key: 'a', note: 0, name: 'C', type: 'white' },
    { key: 'w', note: 1, name: 'C#', type: 'black' },
    { key: 's', note: 2, name: 'D', type: 'white' },
    { key: 'e', note: 3, name: 'D#', type: 'black' },
    { key: 'd', note: 4, name: 'E', type: 'white' },
    { key: 'f', note: 5, name: 'F', type: 'white' },
    { key: 't', note: 6, name: 'F#', type: 'black' },
    { key: 'g', note: 7, name: 'G', type: 'white' },
    { key: 'y', note: 8, name: 'G#', type: 'black' },
    { key: 'h', note: 9, name: 'A', type: 'white' },
    { key: 'u', note: 10, name: 'A#', type: 'black' },
    { key: 'j', note: 11, name: 'B', type: 'white' },
    { key: 'k', note: 12, name: 'C', type: 'white' },
    { key: 'o', note: 13, name: 'C#', type: 'black' },
    { key: 'l', note: 14, name: 'D', type: 'white' },
    { key: 'p', note: 15, name: 'D#', type: 'black' },
  ];

  function buildKeyboard() {
    const kb = document.getElementById('keyboard');
    kb.innerHTML = '';
    let whiteIndex = 0;
    keyboardMap.forEach((k) => {
      const el = document.createElement('div');
      el.className = `key-${k.type}`;
      el.dataset.note = k.note + 4; // base octave 4
      el.dataset.key = k.key;

      if (k.type === 'white') {
        const label = document.createElement('span');
        label.className = 'key-label';
        label.textContent = `${k.name}${4}`;
        el.appendChild(label);
        whiteIndex++;
      } else {
        // Position black keys between whites
        const leftPos = (whiteIndex * 44) - 14;
        el.style.left = leftPos + 'px';
        const label = document.createElement('span');
        label.className = 'key-label';
        label.textContent = k.key.toUpperCase();
        el.appendChild(label);
      }

      el.addEventListener('mousedown', (e) => { e.preventDefault(); noteOn(k.note + 4, el); });
      el.addEventListener('mouseup', () => noteOff(k.note + 4));
      el.addEventListener('mouseleave', () => noteOff(k.note + 4));
      el.addEventListener('touchstart', (e) => { e.preventDefault(); noteOn(k.note + 4, el); });
      el.addEventListener('touchend', () => noteOff(k.note + 4));

      kb.appendChild(el);
    });
  }

  // ─── Voice Management ──────────────────────────────────
  function noteOn(noteMidi, keyEl) {
    initAudio();
    if (audioCtx.state === 'suspended') audioCtx.resume();

    const id = `voice-${noteMidi}`;
    if (activeVoices[id]) return; // already playing

    const freq = noteFreq(noteMidi % 12, Math.floor(noteMidi / 12) + octaveOffset);

    const osc = audioCtx.createOscillator();
    osc.type = currentWaveform;
    osc.frequency.value = freq;

    const gainNode = audioCtx.createGain();
    gainNode.gain.value = 0;

    // LFO for vibrato
    let lfoNode = null, lfoGainNode = null;
    if (unlockedModules.has('lfo') && lfoState.depth > 0) {
      lfoNode = audioCtx.createOscillator();
      lfoGainNode = audioCtx.createGain();
      lfoNode.frequency.value = lfoState.rate;
      lfoGainNode.gain.value = lfoState.depth;
      lfoNode.connect(lfoGainNode);
      lfoGainNode.connect(osc.frequency);
      lfoNode.start();
    }

    // ADSR envelope
    const now = audioCtx.currentTime;
    gainNode.gain.setValueAtTime(0, now);
    gainNode.gain.linearRampToValueAtTime(0.6, now + adsr.attack);
    gainNode.gain.linearRampToValueAtTime(adsr.sustain * 0.6, now + adsr.attack + adsr.decay);

    osc.connect(gainNode);
    gainNode.connect(filterNode);
    osc.start();

    activeVoices[id] = { osc, gain: gainNode, lfo: lfoNode, lfoGain: lfoGainNode };

    if (keyEl) keyEl.classList.add('active');
  }

  function noteOff(noteMidi) {
    const id = `voice-${noteMidi}`;
    const voice = activeVoices[id];
    if (!voice) return;

    const now = audioCtx.currentTime;
    voice.gain.gain.cancelScheduledValues(now);
    voice.gain.gain.setValueAtTime(voice.gain.gain.value, now);
    voice.gain.gain.linearRampToValueAtTime(0, now + adsr.release);

    const stopTime = now + adsr.release + 0.05;
    voice.osc.stop(stopTime);
    if (voice.lfo) voice.lfo.stop(stopTime);

    // Disconnect after release
    setTimeout(() => {
      try {
        voice.gain.disconnect();
        voice.osc.disconnect();
        if (voice.lfoGain) voice.lfoGain.disconnect();
        if (voice.lfo) voice.lfo.disconnect();
      } catch(e) {}
    }, (adsr.release + 0.1) * 1000);

    delete activeVoices[id];

    // Remove visual active state
    const keyEl = document.querySelector(`[data-note="${noteMidi}"]`);
    if (keyEl) keyEl.classList.remove('active');
  }

  // ─── Keyboard Input ────────────────────────────────────
  const pressedKeys = {};
  document.addEventListener('keydown', (e) => {
    if (e.repeat || e.target.tagName === 'INPUT') return;
    const k = keyboardMap.find(m => m.key === e.key.toLowerCase());
    if (!k) return;
    pressedKeys[e.key] = true;
    noteOn(k.note + 4, document.querySelector(`[data-key="${e.key}"]`));
  });

  document.addEventListener('keyup', (e) => {
    const k = keyboardMap.find(m => m.key === e.key.toLowerCase());
    if (!k) return;
    pressedKeys[e.key] = false;
    noteOff(k.note + 4);
  });

  // ─── Waveform Preview Canvas ───────────────────────────
  function drawWavePreview() {
    const canvas = document.getElementById('wave-preview');
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    const w = canvas.width = canvas.offsetWidth * 2;
    const h = canvas.height = canvas.offsetHeight * 2;

    ctx.clearRect(0, 0, w, h);
    ctx.strokeStyle = '#a78bfa';
    ctx.lineWidth = 2;
    ctx.beginPath();

    for (let x = 0; x < w; x++) {
      const t = (x / w) * Math.PI * 4;
      let y = 0;
      switch(currentWaveform) {
        case 'sine': y = Math.sin(t); break;
        case 'square': y = Math.sin(t) >= 0 ? 1 : -1; break;
        case 'sawtooth': y = 2 * ((t / (2*Math.PI)) % 1) - 1; break;
        case 'triangle': y = 2 * Math.abs(2 * ((t / (2*Math.PI)) % 1) - 1) - 1; break;
      }
      const py = h/2 - y * (h/2 - 4);
      x === 0 ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
    }
    ctx.stroke();
  }

  // ─── ADSR Preview Canvas ───────────────────────────────
  function drawEnvPreview() {
    const canvas = document.getElementById('env-preview');
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    const w = canvas.width = canvas.offsetWidth * 2;
    const h = canvas.height = canvas.offsetHeight * 2;

    ctx.clearRect(0, 0, w, h);
    ctx.strokeStyle = '#34d399';
    ctx.lineWidth = 2;
    ctx.beginPath();

    const totalDur = adsr.attack + adsr.decay + 1.5 + adsr.release;
    let x = 0;
    // Attack
    const ax = (adsr.attack / totalDur) * w;
    ctx.moveTo(0, h - 4);
    ctx.lineTo(ax, 4);
    // Decay
    const dx = ax + (adsr.decay / totalDur) * w;
    ctx.lineTo(dx, h - 4 - adsr.sustain * (h - 8));
    // Sustain hold
    const sx = dx + (1.5 / totalDur) * w;
    ctx.lineTo(sx, h - 4 - adsr.sustain * (h - 8));
    // Release
    ctx.lineTo(w, h - 4);

    ctx.stroke();

    // Labels
    ctx.fillStyle = '#34d399';
    ctx.font = '16px sans-serif';
    ctx.fillText('A', ax/2 - 4, h - 8);
    ctx.fillText('D', (ax+dx)/2 - 4, h - 8);
    ctx.fillText('S', (sx+dx)/2 - 4, h - 8);
    ctx.fillText('R', sx + ((w-sx)/2) - 4, h - 8);
  }

  // ─── Main Visualizer ───────────────────────────────────
  const vizCanvas = document.getElementById('viz-canvas');
  const vizCtx = vizCanvas.getContext('2d');

  function resizeViz() {
    vizCanvas.width = vizCanvas.offsetWidth * (window.devicePixelRatio || 1);
    vizCanvas.height = vizCanvas.offsetHeight * (window.devicePixelRatio || 1);
  }
  window.addEventListener('resize', () => { resizeViz(); drawWavePreview(); });

  function drawVisualizer() {
    requestAnimationFrame(drawVisualizer);
    const w = vizCanvas.width;
    const h = vizCanvas.height;

    vizCtx.fillStyle = 'rgba(10,10,15,0.3)';
    vizCtx.fillRect(0, 0, w, h);

    if (!analyserNode) return;

    // Waveform
    const bufLen = analyserNode.fftSize;
    const timeData = new Uint8Array(bufLen);
    analyserNode.getByteTimeDomainData(timeData);

    vizCtx.strokeStyle = '#a78bfa';
    vizCtx.lineWidth = 2;
    vizCtx.beginPath();
    const sliceW = w / bufLen;
    for (let i = 0; i < bufLen; i++) {
      const v = timeData[i] / 128.0;
      const y = (v * h) / 2;
      i === 0 ? vizCtx.moveTo(0, y) : vizCtx.lineTo(i * sliceW, y);
    }
    vizCtx.stroke();

    // Spectrum bars
    const freqData = new Uint8Array(bufLen);
    analyserNode.getByteFrequencyData(freqData);
    const barCount = 64;
    const barW = w / barCount;
    for (let i = 0; i < barCount; i++) {
      const idx = Math.floor(i * bufLen / barCount / 2);
      const val = freqData[idx] / 255;
      const barH = val * h * 0.4;
      const hue = 260 + (i / barCount) * 80;
      vizCtx.fillStyle = `hsla(${hue}, 70%, 60%, ${0.15 + val * 0.3})`;
      vizCtx.fillRect(i * barW, h - barH, barW - 1, barH);
    }
  }

  // ─── UI Wiring ─────────────────────────────────────────

  // Waveform buttons
  document.querySelectorAll('.wave-btn[data-wave]').forEach(btn => {
    btn.addEventListener('click', () => {
      currentWaveform = btn.dataset.wave;
      document.querySelectorAll('.wave-btn[data-wave]').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
      drawWavePreview();
    });
  });

  // Filter type buttons
  document.querySelectorAll('.wave-btn[data-filter]').forEach(btn => {
    btn.addEventListener('click', () => {
      filterState.type = btn.dataset.filter;
      if (filterNode) filterNode.type = filterState.type;
      document.querySelectorAll('.wave-btn[data-filter]').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
    });
  });

  // Octave slider
  const octaveSlider = document.getElementById('octave-slider');
  octaveSlider.addEventListener('input', () => {
    octaveOffset = parseInt(octaveSlider.value);
    document.getElementById('octave-val').textContent = (octaveOffset >= 0 ? '+' : '') + octaveOffset;
  });

  // ADSR sliders
  ['attack','decay','sustain','release'].forEach(param => {
    const slider = document.getElementById(`${param}-slider`);
    const valEl = document.getElementById(`${param}-val`);
    slider.addEventListener('input', () => {
      adsr[param] = parseFloat(slider.value);
      if (['attack','decay','release'].includes(param)) {
        valEl.textContent = adsr[param].toFixed(2) + 's';
      } else {
        valEl.textContent = adsr[param].toFixed(2);
      }
      drawEnvPreview();
    });
  });

  // Filter sliders
  const filterSlider = document.getElementById('filter-slider');
  filterSlider.addEventListener('input', () => {
    filterState.cutoff = parseFloat(filterSlider.value);
    if (filterNode) filterNode.frequency.value = filterState.cutoff;
    const v = filterState.cutoff;
    document.getElementById('filter-val').textContent = v >= 1000 ? (v/1000).toFixed(1)+'k' : Math.round(v);
  });

  const resonanceSlider = document.getElementById('resonance-slider');
  resonanceSlider.addEventListener('input', () => {
    filterState.resonance = parseFloat(resonanceSlider.value);
    if (filterNode) filterNode.Q.value = filterState.resonance;
    document.getElementById('resonance-val').textContent = filterState.resonance.toFixed(1);
  });

  // LFO sliders
  const lfoRateSlider = document.getElementById('lfo-rate-slider');
  lfoRateSlider.addEventListener('input', () => {
    lfoState.rate = parseFloat(lfoRateSlider.value);
    document.getElementById('lfo-rate-val').textContent = lfoState.rate.toFixed(1) + ' Hz';
  });

  const lfoDepthSlider = document.getElementById('lfo-depth-slider');
  lfoDepthSlider.addEventListener('input', () => {
    lfoState.depth = parseFloat(lfoDepthSlider.value);
    document.getElementById('lfo-depth-val').textContent = Math.round(lfoState.depth) + ' ct';
  });

  // Delay sliders
  const delayTimeSlider = document.getElementById('delay-time-slider');
  delayTimeSlider.addEventListener('input', () => {
    delayState.time = parseFloat(delayTimeSlider.value);
    if (delayNode) delayNode.delayTime.value = delayState.time;
    document.getElementById('delay-time-val').textContent = delayState.time.toFixed(2) + 's';
  });

  const delayFbSlider = document.getElementById('delay-fb-slider');
  delayFbSlider.addEventListener('input', () => {
    delayState.feedback = parseFloat(delayFbSlider.value);
    if (feedbackGain) feedbackGain.gain.value = delayState.feedback;
    document.getElementById('delay-fb-val').textContent = delayState.feedback.toFixed(2);
  });

  const delayMixSlider = document.getElementById('delay-mix-slider');
  delayMixSlider.addEventListener('input', () => {
    delayState.mix = parseFloat(delayMixSlider.value);
    if (wetGain) wetGain.gain.value = delayState.mix;
    if (dryGain) dryGain.gain.value = 1 - delayState.mix * 0.5;
    document.getElementById('delay-mix-val').textContent = delayState.mix.toFixed(2);
  });

  // ─── Module Unlocking ──────────────────────────────────
  const moduleOrder = ['oscillator','envelope','filter','lfo','effects'];

  function unlockModule(name) {
    if (unlockedModules.has(name)) return;
    unlockedModules.add(name);

    const el = document.querySelector(`[data-module="${name}"]`);
    if (el) {
      el.classList.remove('locked');
      el.classList.add('unlocked');
    }

    updateProgress();
    showNotification(`🎵 ${name.charAt(0).toUpperCase() + name.slice(1)} module unlocked!`);
  }

  document.querySelectorAll('.unlock-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      const target = btn.dataset.unlock;
      unlockModule(target);
      // Scroll to the new module
      setTimeout(() => {
        const modEl = document.querySelector(`[data-module="${target}"]`);
        if (modEl) modEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
      }, 300);
    });
  });

  function updateProgress() {
    const count = unlockedModules.size;
    document.getElementById('progress-fill').style.width = ((count / moduleOrder.length) * 100) + '%';
    document.getElementById('progress-text').textContent = `${count} / ${moduleOrder.length} modules`;
  }

  function showNotification(msg) {
    const el = document.getElementById('notification');
    el.textContent = msg;
    el.classList.add('show');
    setTimeout(() => el.classList.remove('show'), 3000);
  }

  // ─── Presets ───────────────────────────────────────────
  const presets = {
    pad: {
      wave: 'sawtooth', octave: 0,
      attack: 0.8, decay: 1.5, sustain: 0.6, release: 2,
      filterType: 'lowpass', cutoff: 3000, resonance: 2,
      lfoRate: 4, lfoDepth: 8,
      delayTime: 0.4, delayFb: 0.5, delayMix: 0.4
    },
    pluck: {
      wave: 'triangle', octave: 1,
      attack: 0.01, decay: 0.3, sustain: 0.1, release: 0.2,
      filterType: 'lowpass', cutoff: 5000, resonance: 4,
      lfoRate: 6, lfoDepth: 5,
      delayTime: 0.25, delayFb: 0.3, delayMix: 0.2
    },
    bass: {
      wave: 'sawtooth', octave: -1,
      attack: 0.02, decay: 0.2, sustain: 0.8, release: 0.1,
      filterType: 'lowpass', cutoff: 600, resonance: 8,
      lfoRate: 5, lfoDepth: 3,
      delayTime: 0.1, delayFb: 0.2, delayMix: 0.1
    },
    lead: {
      wave: 'square', octave: 0,
      attack: 0.01, decay: 0.5, sustain: 0.7, release: 0.3,
      filterType: 'lowpass', cutoff: 6000, resonance: 3,
      lfoRate: 5.5, lfoDepth: 25,
      delayTime: 0.35, delayFb: 0.45, delayMix: 0.35
    },
    organ: {
      wave: 'sine', octave: 0,
      attack: 0.01, decay: 0.01, sustain: 1.0, release: 0.05,
      filterType: 'lowpass', cutoff: 12000, resonance: 0.5,
      lfoRate: 3, lfoDepth: 0,
      delayTime: 0.2, delayFb: 0.15, delayMix: 0.15
    }
  };

  document.querySelectorAll('.preset-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      const p = presets[btn.dataset.preset];
      if (!p) return;

      // Apply waveform
      currentWaveform = p.wave;
      document.querySelectorAll('.wave-btn[data-wave]').forEach(b => {
        b.classList.toggle('active', b.dataset.wave === p.wave);
      });

      // Octave
      octaveOffset = p.octave;
      octaveSlider.value = p.octave;
      document.getElementById('octave-val').textContent = (p.octave >= 0 ? '+' : '') + p.octave;

      // ADSR
      adsr.attack = p.attack; adsr.decay = p.decay; adsr.sustain = p.sustain; adsr.release = p.release;
      document.getElementById('attack-slider').value = p.attack;
      document.getElementById('decay-slider').value = p.decay;
      document.getElementById('sustain-slider').value = p.sustain;
      document.getElementById('release-slider').value = p.release;
      document.getElementById('attack-val').textContent = p.attack.toFixed(2) + 's';
      document.getElementById('decay-val').textContent = p.decay.toFixed(2) + 's';
      document.getElementById('sustain-val').textContent = p.sustain.toFixed(2);
      document.getElementById('release-val').textContent = p.release.toFixed(2) + 's';

      // Filter
      filterState.type = p.filterType;
      filterState.cutoff = p.cutoff;
      filterState.resonance = p.resonance;
      if (filterNode) { filterNode.type = p.filterType; filterNode.frequency.value = p.cutoff; filterNode.Q.value = p.resonance; }
      document.getElementById('filter-slider').value = p.cutoff;
      document.getElementById('resonance-slider').value = p.resonance;
      const cv = p.cutoff >= 1000 ? (p.cutoff/1000).toFixed(1)+'k' : Math.round(p.cutoff);
      document.getElementById('filter-val').textContent = cv;
      document.getElementById('resonance-val').textContent = p.resonance.toFixed(1);
      document.querySelectorAll('.wave-btn[data-filter]').forEach(b => {
        b.classList.toggle('active', b.dataset.filter === p.filterType);
      });

      // LFO
      lfoState.rate = p.lfoRate; lfoState.depth = p.lfoDepth;
      document.getElementById('lfo-rate-slider').value = p.lfoRate;
      document.getElementById('lfo-depth-slider').value = p.lfoDepth;
      document.getElementById('lfo-rate-val').textContent = p.lfoRate.toFixed(1) + ' Hz';
      document.getElementById('lfo-depth-val').textContent = Math.round(p.lfoDepth) + ' ct';

      // Delay
      delayState.time = p.delayTime; delayState.feedback = p.delayFb; delayState.mix = p.delayMix;
      if (delayNode) delayNode.delayTime.value = p.delayTime;
      if (feedbackGain) feedbackGain.gain.value = p.delayFb;
      if (wetGain) wetGain.gain.value = p.delayMix;
      if (dryGain) dryGain.gain.value = 1 - p.delayMix * 0.5;
      document.getElementById('delay-time-slider').value = p.delayTime;
      document.getElementById('delay-fb-slider').value = p.delayFb;
      document.getElementById('delay-mix-slider').value = p.delayMix;
      document.getElementById('delay-time-val').textContent = p.delayTime.toFixed(2) + 's';
      document.getElementById('delay-fb-val').textContent = p.delayFb.toFixed(2);
      document.getElementById('delay-mix-val').textContent = p.delayMix.toFixed(2);

      drawWavePreview();
      drawEnvPreview();
      showNotification(`🎹 "${btn.dataset.preset.charAt(0).toUpperCase() + btn.dataset.preset.slice(1)}" preset loaded`);
    });
  });

  // ─── Init ──────────────────────────────────────────────
  buildKeyboard();
  resizeViz();
  drawWavePreview();
  drawEnvPreview();
  updateProgress();
  requestAnimationFrame(drawVisualizer);

})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"43bfcdac-fc89-435d-9efc-c031805bfcbc","tokensIn":77760,"tokensOut":14658,"tokensTotal":92418,"turns":5,"toolCalls":4,"failedToolCalls":0,"timestamp":"2026-07-17T22:35:32.012Z"} -->