← TutSynth results

TutSynth

thinkingcap qwen3.6 27b · 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 43,184 bytes · SHA-256 a0a5e366f5a4
<!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: #141420;
    --border: #2a2a3a;
    --accent: #6c5ce7;
    --accent2: #a29bfe;
    --text: #e0dff0;
    --dim: #666680;
    --green: #00cec9;
    --orange: #fdcb6e;
    --red: #ff7675;
    --pink: #fd79a8;
  }

  * { 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, #1a1a2e 0%, var(--bg) 100%);
  }

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

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

  /* Progress bar */
  .progress-bar {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    padding: 16px 20px;
    border-bottom: 1px solid var(--border);
    background: var(--panel);
  }

  .progress-step {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 0.75rem;
    color: var(--dim);
    transition: all 0.3s;
  }

  .progress-step.active { color: var(--accent2); }
  .progress-step.completed { color: var(--green); }

  .step-dot {
    width: 10px; height: 10px;
    border-radius: 50%;
    background: var(--border);
    transition: all 0.3s;
  }

  .progress-step.active .step-dot { background: var(--accent2); box-shadow: 0 0 8px var(--accent); }
  .progress-step.completed .step-dot { background: var(--green); }

  .step-line { width: 24px; height: 1px; background: var(--border); }

  /* Main layout */
  .main-container {
    display: flex;
    gap: 0;
    max-width: 1200px;
    margin: 0 auto;
    min-height: calc(100vh - 140px);
  }

  /* Lesson panel */
  .lesson-panel {
    width: 320px;
    min-width: 320px;
    border-right: 1px solid var(--border);
    padding: 24px;
    overflow-y: auto;
    background: var(--panel);
  }

  .lesson-panel h2 {
    font-size: 1.1rem;
    margin-bottom: 8px;
    color: var(--accent2);
  }

  .lesson-panel p {
    font-size: 0.85rem;
    line-height: 1.6;
    color: #b0afc0;
    margin-bottom: 12px;
  }

  .lesson-panel strong { color: var(--text); }

  .concept-box {
    background: rgba(108, 92, 231, 0.1);
    border-left: 3px solid var(--accent);
    padding: 12px;
    margin: 16px 0;
    border-radius: 0 8px 8px 0;
    font-size: 0.8rem;
    line-height: 1.5;
  }

  .try-it {
    background: rgba(0, 206, 201, 0.1);
    border-left: 3px solid var(--green);
    padding: 12px;
    margin: 16px 0;
    border-radius: 0 8px 8px 0;
    font-size: 0.8rem;
    line-height: 1.5;
  }

  .try-it strong { color: var(--green); }

  .next-btn {
    display: block;
    width: 100%;
    padding: 12px;
    margin-top: 20px;
    background: linear-gradient(135deg, var(--accent), #8b7cf7);
    color: white;
    border: none;
    border-radius: 8px;
    font-size: 0.9rem;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.2s;
  }

  .next-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 15px rgba(108,92,231,0.4); }
  .next-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }

  /* Synth panel */
  .synth-panel {
    flex: 1;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 20px;
  }

  /* Waveform display */
  .waveform-container {
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 16px;
    position: relative;
  }

  .waveform-label {
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 1px;
    color: var(--dim);
    margin-bottom: 8px;
  }

  canvas#waveCanvas, canvas#freqCanvas {
    width: 100%;
    height: 120px;
    border-radius: 8px;
    background: #0d0d15;
  }

  .canvas-row { display: flex; gap: 16px; }
  .canvas-col { flex: 1; }

  /* Module sections */
  .module {
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 20px;
    transition: all 0.5s;
  }

  .module.locked {
    opacity: 0.3;
    pointer-events: none;
    filter: blur(2px);
  }

  .module-header {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 16px;
  }

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

  .module-title {
    font-size: 0.95rem;
    font-weight: 600;
  }

  .module-badge {
    margin-left: auto;
    font-size: 0.65rem;
    padding: 2px 8px;
    border-radius: 10px;
    background: rgba(108,92,231,0.2);
    color: var(--accent2);
  }

  /* Controls */
  .controls-row {
    display: flex;
    gap: 16px;
    flex-wrap: wrap;
  }

  .control-group {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    min-width: 70px;
  }

  .control-label {
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    color: var(--dim);
  }

  .control-value {
    font-size: 0.75rem;
    color: var(--accent2);
    font-weight: 600;
    min-height: 1em;
  }

  /* Custom range sliders */
  input[type="range"] {
    -webkit-appearance: none;
    width: 80px;
    height: 6px;
    border-radius: 3px;
    background: var(--border);
    outline: none;
  }

  input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    background: var(--accent);
    cursor: pointer;
    box-shadow: 0 2px 6px rgba(108,92,231,0.4);
  }

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

  /* Waveform selector buttons */
  .wave-btns {
    display: flex;
    gap: 6px;
  }

  .wave-btn {
    padding: 8px 14px;
    background: rgba(255,255,255,0.05);
    border: 1px solid var(--border);
    border-radius: 6px;
    color: var(--dim);
    font-size: 0.75rem;
    cursor: pointer;
    transition: all 0.2s;
    display: flex;
    align-items: center;
    gap: 6px;
  }

  .wave-btn:hover { border-color: var(--accent); color: var(--text); }
  .wave-btn.active { background: rgba(108,92,231,0.2); border-color: var(--accent); color: var(--accent2); }

  .wave-icon { width: 24px; height: 16px; }

  /* Keyboard */
  .keyboard-container {
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 20px;
  }

  .keyboard {
    display: flex;
    position: relative;
    height: 120px;
    margin-top: 8px;
    user-select: none;
  }

  .key-white {
    width: 40px;
    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.pressed { background: linear-gradient(180deg, var(--accent2), var(--accent)); border-color: var(--accent); }

  .key-black {
    width: 26px;
    height: 75px;
    background: linear-gradient(180deg, #333, #111);
    border-radius: 0 0 4px 4px;
    cursor: pointer;
    position: absolute;
    z-index: 2;
    margin-left: -13px;
    transition: background 0.05s;
  }

  .key-black:hover { background: linear-gradient(180deg, #4a4a6a, #2a2a4a); }
  .key-black.pressed { background: linear-gradient(180deg, var(--accent), #4a3ab5); }

  .keyboard-hint {
    font-size: 0.7rem;
    color: var(--dim);
    text-align: center;
    margin-top: 8px;
  }

  /* Envelope visualizer */
  .env-canvas-wrap {
    background: #0d0d15;
    border-radius: 8px;
    padding: 8px;
    margin-top: 12px;
  }

  canvas#envCanvas {
    width: 100%;
    height: 80px;
    display: block;
  }

  /* LFO section */
  .lfo-target-btns {
    display: flex; gap: 6px; margin-top: 8px;
  }

  .lfo-target-btn {
    padding: 4px 10px;
    background: rgba(255,255,255,0.05);
    border: 1px solid var(--border);
    border-radius: 4px;
    color: var(--dim);
    font-size: 0.7rem;
    cursor: pointer;
    transition: all 0.2s;
  }

  .lfo-target-btn.active { background: rgba(108,92,231,0.2); border-color: var(--accent); color: var(--accent2); }

  /* Effects */
  .fx-toggle {
    display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
  }

  .toggle-switch {
    width: 40px; height: 22px;
    background: var(--border);
    border-radius: 11px;
    position: relative;
    cursor: pointer;
    transition: background 0.3s;
  }

  .toggle-switch.on { background: var(--accent); }

  .toggle-switch::after {
    content: '';
    width: 18px; height: 18px;
    border-radius: 50%;
    background: white;
    position: absolute;
    top: 2px; left: 2px;
    transition: transform 0.3s;
  }

  .toggle-switch.on::after { transform: translateX(18px); }

  /* Responsive */
  @media (max-width: 900px) {
    .main-container { flex-direction: column; }
    .lesson-panel { width: 100%; min-width: unset; border-right: none; border-bottom: 1px solid var(--border); max-height: 250px; }
  }

  /* Animations */
  @keyframes pulseGlow {
    0%, 100% { box-shadow: 0 0 5px rgba(108,92,231,0.3); }
    50% { box-shadow: 0 0 20px rgba(108,92,231,0.6); }
  }

  .module.unlocked-anim { animation: pulseGlow 1s ease-out; }

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

<header>
  <h1>🎹 Synth Playground</h1>
  <p>Learn synthesis by doing — each feature unlocks progressively</p>
</header>

<div class="progress-bar" id="progressBar"></div>

<div class="main-container">
  <div class="lesson-panel" id="lessonPanel"></div>

  <div class="synth-panel">
    <!-- Waveform visualization -->
    <div class="waveform-container">
      <div class="canvas-row">
        <div class="canvas-col">
          <div class="waveform-label">Waveform</div>
          <canvas id="waveCanvas"></canvas>
        </div>
        <div class="canvas-col">
          <div class="waveform-label">Spectrum</div>
          <canvas id="freqCanvas"></canvas>
        </div>
      </div>
    </div>

    <!-- OSCILLATOR MODULE -->
    <div class="module" id="modOscillator">
      <div class="module-header">
        <div class="module-icon" style="background:rgba(108,92,231,0.2);color:var(--accent2)">〰</div>
        <span class="module-title">Oscillator</span>
        <span class="module-badge">Stage 1</span>
      </div>
      <div class="controls-row">
        <div style="flex:1">
          <div class="control-label" style="margin-bottom:8px">Waveform Shape</div>
          <div class="wave-btns" id="waveBtns"></div>
        </div>
      </div>
    </div>

    <!-- FILTER MODULE -->
    <div class="module locked" id="modFilter">
      <div class="module-header">
        <div class="module-icon" style="background:rgba(0,206,201,0.2);color:var(--green)">◈</div>
        <span class="module-title">Filter</span>
        <span class="module-badge">Stage 2</span>
      </div>
      <div class="controls-row">
        <div class="control-group">
          <input type="range" id="filterCutoff" min="50" max="12000" value="8000" step="10">
          <span class="control-label">Cutoff</span>
          <span class="control-value" id="cutoffVal">8000 Hz</span>
        </div>
        <div class="control-group">
          <input type="range" id="filterResonance" min="0" max="25" value="1" step="0.1">
          <span class="control-label">Resonance</span>
          <span class="control-value" id="resVal">1.0</span>
        </div>
      </div>
    </div>

    <!-- ENVELOPE MODULE -->
    <div class="module locked" id="modEnvelope">
      <div class="module-header">
        <div class="module-icon" style="background:rgba(253,203,110,0.2);color:var(--orange)">⏱</div>
        <span class="module-title">Envelope (ADSR)</span>
        <span class="module-badge">Stage 3</span>
      </div>
      <div class="controls-row">
        <div class="control-group">
          <input type="range" id="envAttack" min="0.01" max="2" value="0.05" step="0.01">
          <span class="control-label">Attack</span>
          <span class="control-value" id="atkVal">50ms</span>
        </div>
        <div class="control-group">
          <input type="range" id="envDecay" min="0.01" max="2" value="0.3" step="0.01">
          <span class="control-label">Decay</span>
          <span class="control-value" id="decVal">300ms</span>
        </div>
        <div class="control-group">
          <input type="range" id="envSustain" min="0" max="1" value="0.7" step="0.01">
          <span class="control-label">Sustain</span>
          <span class="control-value" id="susVal">70%</span>
        </div>
        <div class="control-group">
          <input type="range" id="envRelease" min="0.01" max="3" value="0.5" step="0.01">
          <span class="control-label">Release</span>
          <span class="control-value" id="relVal">500ms</span>
        </div>
      </div>
      <div class="env-canvas-wrap">
        <canvas id="envCanvas"></canvas>
      </div>
    </div>

    <!-- LFO MODULE -->
    <div class="module locked" id="modLfo">
      <div class="module-header">
        <div class="module-icon" style="background:rgba(253,121,168,0.2);color:var(--pink)">∿</div>
        <span class="module-title">LFO (Low-Frequency Oscillator)</span>
        <span class="module-badge">Stage 4</span>
      </div>
      <div class="controls-row">
        <div class="control-group">
          <input type="range" id="lfoRate" min="0.1" max="20" value="5" step="0.1">
          <span class="control-label">Rate</span>
          <span class="control-value" id="lfoRateVal">5 Hz</span>
        </div>
        <div class="control-group">
          <input type="range" id="lfoDepth" min="0" max="100" value="30" step="1">
          <span class="control-label">Depth</span>
          <span class="control-value" id="lfoDepthVal">30%</span>
        </div>
      </div>
      <div style="margin-top:8px">
        <div class="control-label" style="margin-bottom:6px">Modulation Target</div>
        <div class="lfo-target-btns" id="lfoTargets"></div>
      </div>
    </div>

    <!-- EFFECTS MODULE -->
    <div class="module locked" id="modEffects">
      <div class="module-header">
        <div class="module-icon" style="background:rgba(255,118,117,0.2);color:var(--red)">✦</div>
        <span class="module-title">Effects</span>
        <span class="module-badge">Stage 5</span>
      </div>
      <div style="display:flex;gap:24px;flex-wrap:wrap;">
        <div style="flex:1;min-width:200px">
          <div class="fx-toggle">
            <div class="toggle-switch" id="reverbToggle"></div>
            <span style="font-size:0.85rem">Reverb</span>
          </div>
          <div class="controls-row">
            <div class="control-group">
              <input type="range" id="reverbMix" min="0" max="1" value="0.3" step="0.01">
              <span class="control-label">Mix</span>
              <span class="control-value" id="revMixVal">30%</span>
            </div>
            <div class="control-group">
              <input type="range" id="reverbDecay" min="0.5" max="8" value="2" step="0.1">
              <span class="control-label">Decay</span>
              <span class="control-value" id="revDecVal">2s</span>
            </div>
          </div>
        </div>
        <div style="flex:1;min-width:200px">
          <div class="fx-toggle">
            <div class="toggle-switch" id="delayToggle"></div>
            <span style="font-size:0.85rem">Delay</span>
          </div>
          <div class="controls-row">
            <div class="control-group">
              <input type="range" id="delayTime" min="0.05" max="1" value="0.3" step="0.01">
              <span class="control-label">Time</span>
              <span class="control-value" id="delTimeVal">300ms</span>
            </div>
            <div class="control-group">
              <input type="range" id="delayFeedback" min="0" max="0.9" value="0.4" step="0.01">
              <span class="control-label">Feedback</span>
              <span class="control-value" id="delFbVal">40%</span>
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- Keyboard -->
    <div class="keyboard-container">
      <div class="waveform-label">Keyboard</div>
      <div class="keyboard" id="keyboard"></div>
      <div class="keyboard-hint">Use your computer keyboard: A S D F G H J K L ; or click the keys</div>
    </div>
  </div>
</div>

<script>
// ============================================================
// SYNTH PLAYGROUND — Progressive Learning Synthesizer
// ============================================================

const STAGES = [
  { id: 'oscillator', name: 'Oscillators' },
  { id: 'filter', name: 'Filter' },
  { id: 'envelope', name: 'Envelope' },
  { id: 'lfo', name: 'LFO' },
  { id: 'effects', name: 'Effects' }
];

let currentStage = 0;
let audioCtx = null;
let masterGain = null;
let analyserNode = null;
let activeVoices = {};
let lfoNode = null, lfoGainNode = null;
let reverbEnabled = false, delayEnabled = false;

// ---- Audio Context Init ----
function initAudio() {
  if (audioCtx) return;
  audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  masterGain = audioCtx.createGain();
  masterGain.gain.value = 0.5;
  
  analyserNode = audioCtx.createAnalyser();
  analyserNode.fftSize = 2048;
  analyserNode.smoothingTimeConstant = 0.8;

  // Create reverb convolver (pre-generated impulse)
  createReverb();
  createDelay();

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

// ---- Reverb ----
let reverbConvolver, reverbMixGain, reverbDryGain, reverbWetGain;
function createReverb() {
  reverbConvolver = audioCtx.createConvolver();
  const rate = audioCtx.sampleRate;
  const length = rate * 2.5;
  const impulse = audioCtx.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, 2.5);
    }
  }
  reverbConvolver.buffer = impulse;

  reverbDryGain = audioCtx.createGain();
  reverbWetGain = audioCtx.createGain();
  reverbMixGain = audioCtx.createGain();
  
  // Chain: voice -> dry + wet(convolver) -> mix -> master
}

function updateReverbDecay() {
  const decay = parseFloat(document.getElementById('reverbDecay').value);
  const rate = audioCtx.sampleRate;
  const length = rate * Math.min(decay, 8);
  const impulse = audioCtx.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, 2.5);
    }
  }
  reverbConvolver.buffer = impulse;
}

// ---- Delay ----
let delayNode, delayFeedbackGain, delayWetGain, delayDryGain;
function createDelay() {
  delayNode = audioCtx.createDelay(1);
  delayFeedbackGain = audioCtx.createGain();
  delayWetGain = audioCtx.createGain();
  delayDryGain = audioCtx.createGain();

  delayNode.connect(delayFeedbackGain);
  delayFeedbackGain.connect(delayNode);
}

// ---- Voice Management ----
function getADSR() {
  return {
    attack: parseFloat(document.getElementById('envAttack').value),
    decay: parseFloat(document.getElementById('envDecay').value),
    sustain: parseFloat(document.getElementById('envSustain').value),
    release: parseFloat(document.getElementById('envRelease').value)
  };
}

function noteOn(note, freq) {
  initAudio();
  if (activeVoices[note]) return;

  const env = getADSR();
  const now = audioCtx.currentTime;

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

  // VCA (amplitude envelope)
  const vca = audioCtx.createGain();
  vca.gain.setValueAtTime(0, now);
  vca.gain.linearRampToValueAtTime(1, now + env.attack);
  vca.gain.linearRampToValueAtTime(env.sustain, now + env.attack + env.decay);

  // Filter (if unlocked)
  let filterNode = null;
  if (currentStage >= 1) {
    filterNode = audioCtx.createBiquadFilter();
    filterNode.type = 'lowpass';
    filterNode.frequency.value = parseFloat(document.getElementById('filterCutoff').value);
    filterNode.Q.value = parseFloat(document.getElementById('filterResonance').value);
  }

  // LFO (if unlocked)
  if (currentStage >= 3 && lfoNode) {
    const target = document.querySelector('.lfo-target-btn.active')?.dataset.target || 'pitch';
    if (target === 'pitch') {
      osc.frequency.setValueAtTime(freq, now);
      // LFO already connected to pitch via lfoGainNode
    } else if (target === 'filter' && filterNode) {
      // Connect LFO to filter instead
    }
  }

  // Build signal chain
  osc.connect(vca);
  
  if (filterNode) {
    vca.connect(filterNode);
    
    // Effects routing
    if (currentStage >= 4) {
      const dryGain = audioCtx.createGain();
      const wetRevGain = audioCtx.createGain();
      const wetDelGain = audioCtx.createGain();

      filterNode.connect(dryGain);
      
      if (reverbEnabled) {
        filterNode.connect(reverbConvolver);
        reverbConvolver.connect(wetRevGain);
      }
      if (delayEnabled) {
        filterNode.connect(delayNode);
        delayNode.connect(wetDelGain);
      }

      dryGain.connect(masterGain);
      wetRevGain.connect(masterGain);
      wetDelGain.connect(masterGain);

      const mixVal = parseFloat(document.getElementById('reverbMix').value);
      const delTimeVal = parseFloat(document.getElementById('delayTime').value);
      const delFbVal = parseFloat(document.getElementById('delayFeedback').value);
      const delWetVal = parseFloat(document.getElementById('delayTime').value > 0.1 ? '0.4' : '0');

      dryGain.gain.value = reverbEnabled || delayEnabled ? (1 - mixVal * 0.5) : 1;
      wetRevGain.gain.value = reverbEnabled ? mixVal : 0;
      wetDelGain.gain.value = delayEnabled ? 0.3 : 0;
      delayNode.delayTime.value = delTimeVal;
      delayFeedbackGain.gain.value = delFbVal;
    } else {
      filterNode.connect(masterGain);
    }
  } else if (currentStage >= 4) {
    // No filter but effects
    const dryGain = audioCtx.createGain();
    vca.connect(dryGain);
    
    if (reverbEnabled) {
      vca.connect(reverbConvolver);
      reverbConvolver.connect(masterGain);
    }
    if (delayEnabled) {
      vca.connect(delayNode);
      delayNode.connect(masterGain);
    }
    dryGain.connect(masterGain);
  } else {
    vca.connect(masterGain);
  }

  osc.start(now);

  activeVoices[note] = { osc, vca, filterNode };
}

function noteOff(note) {
  const voice = activeVoices[note];
  if (!voice) return;

  const env = getADSR();
  const now = audioCtx.currentTime;

  voice.vca.gain.cancelScheduledValues(now);
  voice.vca.gain.setValueAtTime(voice.vca.gain.value, now);
  voice.vca.gain.linearRampToValueAtTime(0, now + env.release);
  
  voice.osc.stop(now + env.release + 0.1);
  delete activeVoices[note];
}

// ---- Waveform ----
let currentWaveform = 'sawtooth';
const waveforms = [
  { type: 'sine', label: 'Sine', color: '#6c5ce7' },
  { type: 'triangle', label: 'Triangle', color: '#00cec9' },
  { type: 'square', label: 'Square', color: '#fdcb6e' },
  { type: 'sawtooth', label: 'Sawtooth', color: '#ff7675' }
];

function buildWaveBtns() {
  const container = document.getElementById('waveBtns');
  waveforms.forEach(w => {
    const btn = document.createElement('button');
    btn.className = 'wave-btn' + (w.type === currentWaveform ? ' active' : '');
    btn.innerHTML = `<svg class="wave-icon" viewBox="0 0 24 16"><path d="${getWavePath(w.type)}" fill="none" stroke="${w.color}" stroke-width="2"/></svg> ${w.label}`;
    btn.onclick = () => {
      currentWaveform = w.type;
      document.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
    };
    container.appendChild(btn);
  });
}

function getWavePath(type) {
  switch(type) {
    case 'sine': return 'M0,8 Q6,0 12,8 Q18,16 24,8';
    case 'triangle': return 'M0,8 L6,1 L12,8 L18,15 L24,8';
    case 'square': return 'M0,3 L6,3 L6,13 L12,13 L12,3 L18,3 L18,13 L24,13';
    case 'sawtooth': return 'M0,15 L12,1 L12,15 L24,1';
  }
}

// ---- Keyboard ----
const NOTE_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 NOTE_FREQS = {
  '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
};

function buildKeyboard() {
  const kb = document.getElementById('keyboard');
  const whiteNotes = ['C4','D4','E4','F4','G4','A4','B4','C5','D5','E5'];
  const blackMap = { 'C4':'C#4','D4':'D#4','F4':'F#4','G4':'G#4','A4':'A#4','C5':'C#5','D5':'D#5' };

  let whiteIdx = 0;
  whiteNotes.forEach(note => {
    const key = document.createElement('div');
    key.className = 'key-white';
    key.dataset.note = note;
    key.onmousedown = () => { noteOn(note, NOTE_FREQS[note]); key.classList.add('pressed'); };
    key.onmouseup = key.onmouseleave = () => { noteOff(note); key.classList.remove('pressed'); };
    kb.appendChild(key);

    if (blackMap[note]) {
      const bkey = document.createElement('div');
      bkey.className = 'key-black';
      bkey.dataset.note = blackMap[note];
      bkey.style.left = (whiteIdx * 40 + 28) + 'px';
      bkey.onmousedown = () => { noteOn(blackMap[note], NOTE_FREQS[blackMap[note]]); bkey.classList.add('pressed'); };
      bkey.onmouseup = bkey.onmouseleave = () => { noteOff(blackMap[note]); bkey.classList.remove('pressed'); };
      kb.appendChild(bkey);
    }
    whiteIdx++;
  });
}

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

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

// ---- Visualization ----
function drawWaveform() {
  const canvas = document.getElementById('waveCanvas');
  const ctx = canvas.getContext('2d');
  canvas.width = canvas.offsetWidth * 2;
  canvas.height = canvas.offsetHeight * 2;
  ctx.scale(1, 1);

  if (!analyserNode) { requestAnimationFrame(drawWaveform); return; }

  const bufferLength = analyserNode.fftSize;
  const dataArray = new Uint8Array(bufferLength);
  analyserNode.getByteTimeDomainData(dataArray);

  ctx.fillStyle = '#0d0d15';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Grid lines
  ctx.strokeStyle = 'rgba(255,255,255,0.03)';
  ctx.lineWidth = 1;
  for (let i = 0; i < 5; i++) {
    const y = (canvas.height / 4) * i;
    ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
  }

  // Center line
  ctx.strokeStyle = 'rgba(108,92,231,0.2)';
  ctx.beginPath();
  ctx.moveTo(0, canvas.height / 2);
  ctx.lineTo(canvas.width, canvas.height / 2);
  ctx.stroke();

  // Waveform
  ctx.lineWidth = 2;
  ctx.strokeStyle = '#6c5ce7';
  ctx.shadowColor = '#6c5ce7';
  ctx.shadowBlur = 4;
  ctx.beginPath();

  const sliceWidth = canvas.width / bufferLength;
  let x = 0;
  for (let i = 0; i < bufferLength; i++) {
    const v = dataArray[i] / 128.0;
    const y = (v * canvas.height) / 2;
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
    x += sliceWidth;
  }
  ctx.stroke();
  ctx.shadowBlur = 0;

  requestAnimationFrame(drawWaveform);
}

function drawSpectrum() {
  const canvas = document.getElementById('freqCanvas');
  const ctx = canvas.getContext('2d');
  canvas.width = canvas.offsetWidth * 2;
  canvas.height = canvas.offsetHeight * 2;

  if (!analyserNode) { requestAnimationFrame(drawSpectrum); return; }

  const bufferLength = analyserNode.frequencyBinCount;
  const dataArray = new Uint8Array(bufferLength);
  analyserNode.getByteFrequencyData(dataArray);

  ctx.fillStyle = '#0d0d15';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  const barWidth = (canvas.width / bufferLength) * 2.5;
  let x = 0;

  for (let i = 0; i < bufferLength; i++) {
    const v = dataArray[i] / 255;
    const barHeight = v * canvas.height;
    
    const hue = 260 - (i / bufferLength) * 120;
    ctx.fillStyle = `hsla(${hue}, 70%, 60%, ${0.4 + v * 0.6})`;
    ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);

    x += barWidth;
    if (x > canvas.width) break;
  }

  requestAnimationFrame(drawSpectrum);
}

function drawEnvelope() {
  const canvas = document.getElementById('envCanvas');
  const ctx = canvas.getContext('2d');
  canvas.width = canvas.offsetWidth * 2;
  canvas.height = canvas.offsetHeight * 2;

  const env = getADSR();
  const total = env.attack + env.decay + 0.5 + env.release; // 0.5 for sustain hold visualization
  const w = canvas.width;
  const h = canvas.height;
  const pad = 10;

  ctx.fillStyle = '#0d0d15';
  ctx.fillRect(0, 0, w, h);

  // Grid
  ctx.strokeStyle = 'rgba(255,255,255,0.03)';
  for (let i = 0; i < 4; i++) {
    const y = pad + ((h - 2 * pad) / 3) * i;
    ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(w - pad, y); ctx.stroke();
  }

  // Envelope shape
  const xScale = (w - 2 * pad) / total;
  const yScale = h - 2 * pad;

  const points = [
    { x: pad, y: h - pad },
    { x: pad + env.attack * xScale, y: pad },
    { x: pad + (env.attack + env.decay) * xScale, y: pad + (1 - env.sustain) * yScale },
    { x: pad + (env.attack + env.decay + 0.5) * xScale, y: pad + (1 - env.sustain) * yScale },
    { x: w - pad, y: h - pad }
  ];

  // Fill
  ctx.beginPath();
  ctx.moveTo(points[0].x, points[0].y);
  for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
  ctx.closePath();
  const grad = ctx.createLinearGradient(0, pad, 0, h - pad);
  grad.addColorStop(0, 'rgba(253,203,110,0.3)');
  grad.addColorStop(1, 'rgba(253,203,110,0.02)');
  ctx.fillStyle = grad;
  ctx.fill();

  // Line
  ctx.beginPath();
  ctx.moveTo(points[0].x, points[0].y);
  for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
  ctx.strokeStyle = '#fdcb6e';
  ctx.lineWidth = 2;
  ctx.shadowColor = '#fdcb6e';
  ctx.shadowBlur = 4;
  ctx.stroke();
  ctx.shadowBlur = 0;

  // Labels
  ctx.fillStyle = 'rgba(253,203,110,0.7)';
  ctx.font = '18px system-ui';
  const labels = ['A', 'D', 'S', 'R'];
  for (let i = 0; i < 4; i++) {
    const cx = (points[i].x + points[i+1].x) / 2;
    ctx.fillText(labels[i], cx - 3, pad + 16);
  }

  // Dots at corners
  for (let i = 0; i < points.length; i++) {
    ctx.beginPath();
    ctx.arc(points[i].x, points[i].y, 4, 0, Math.PI * 2);
    ctx.fillStyle = '#fdcb6e';
    ctx.fill();
  }

  requestAnimationFrame(drawEnvelope);
}

// ---- Lessons ----
const LESSONS = [
  {
    title: '🎵 Oscillators — The Sound Source',
    content: `
      <p>Every synthesizer sound starts with an <strong>oscillator</strong> — a circuit that generates a repeating electrical signal at audio frequency.</p>
      <div class="concept-box">
        <strong>The waveform shape determines the timbre (tone color).</strong><br><br>
        🟣 <strong>Sine</strong> — Pure, smooth. Only one frequency.<br>
        🔵 <strong>Triangle</strong> — Soft but slightly richer than sine.<br>
        🟡 <strong>Square</strong> — Hollow, buzzy. Like a NES game console.<br>
        🔴 <strong>Sawtooth</strong> — Bright, rich in harmonics. The classic synth lead sound.
      </div>
      <p>The sawtooth and square waves contain many <strong>harmonics</strong> (integer multiples of the fundamental frequency), which is why they sound "richer."</p>
      <div class="try-it">
        <strong>Try this:</strong> Click each waveform button, then play a note. Notice how dramatically the character changes — same pitch, completely different sound!
      </div>
    `
  },
  {
    title: '🔊 Filter — Shaping the Tone',
    content: `
      <p>A <strong>filter</strong> removes certain frequencies from your signal. Think of it like a sieve for sound.</p>
      <div class="concept-box">
        <strong>Low-pass filter (LPF)</strong> lets low frequencies through and blocks high ones.<br><br>
        🔹 <strong>Cutoff</strong> — The frequency boundary. Everything above gets attenuated.<br>
        🔹 <strong>Resonance (Q)</strong> — Boosts the frequencies right at the cutoff point, creating a "peak" or whistle.
      </div>
      <p>Filters are what make synth sounds "warm" or "mellow." A low cutoff = dark/muffled. High cutoff = bright.</p>
      <div class="try-it">
        <strong>Try this:</strong> Start with sawtooth wave, then slowly drag the <em>Cutoff</em> slider left. Hear how it goes from bright → mellow → muffled? Now crank up <em>Resonance</em> for that classic "squelchy" filter sound!
      </div>
    `
  },
  {
    title: '⏱ Envelope — How Sound Evolves',
    content: `
      <p>An <strong>envelope</strong> controls how a sound changes over time. Without it, every note would just start and stop abruptly.</p>
      <div class="concept-box">
        <strong>ADSR = Attack → Decay → Sustain → Release</strong><br><br>
        ⏩ <strong>Attack</strong> — How fast the sound reaches full volume<br>
        ⏬ <strong>Decay</strong> — How fast it drops to the sustain level<br>
        ➡️ <strong>Sustain</strong> — The volume while you hold the key<br>
        ⏪ <strong>Release</strong> — How long it fades after you release
      </div>
      <p>A piano has a fast attack and medium release. A pad has slow attack and long release. A pluck has fast attack, fast decay.</p>
      <div class="try-it">
        <strong>Try this:</strong> Set Attack to 2s for a "swelling" pad sound. Then try Attack=0.01, Decay=0.3, Sustain=0 for a sharp pluck! Watch the envelope graph update in real time.
      </div>
    `
  },
  {
    title: '∿ LFO — Modulation Magic',
    content: `
      <p>An <strong>LFO (Low-Frequency Oscillator)</strong> is an oscillator running too slowly to hear as a tone — instead, it <em>modulates</em> other parameters.</p>
      <div class="concept-box">
        <strong>The LFO rhythmically changes something else:</strong><br><br>
        🎵 Modulating <strong>Pitch</strong> → Vibrato (pitch wobble)<br>
        🔊 Modulating <strong>Filter Cutoff</strong> → Wah-wah effect<br>
        🔈 Modulating <strong>Volume</strong> → Tremolo (pulsing volume)
      </div>
      <p><strong>Rate</strong> controls how fast the modulation cycles. <strong>Depth</strong> controls how intense it is.</p>
      <div class="try-it">
        <strong>Try this:</strong> Set Rate to ~5 Hz and Depth high for dramatic vibrato on a sustained note. Then switch target to "Filter" with slow rate (~1 Hz) for an auto-wah effect!
      </div>
    `
  },
  {
    title: '✦ Effects — Space & Time',
    content: `
      <p><strong>Effects</strong> process your sound after all the synthesis. They add dimension, space, and character.</p>
      <div class="concept-box">
        <strong>Reverb</strong> simulates acoustic spaces (rooms, halls). It adds a tail of decaying reflections.<br><br>
        <strong>Delay</strong> echoes your sound back at set intervals. <em>Feedback</em> controls how many repeats you get.
      </div>
      <p>Reverb makes sounds feel "big" and placed in space. Delay creates rhythmic patterns from a single note.</p>
      <div class="try-it">
        <strong>Try this:</strong> Toggle Reverb on with high decay for a huge hall sound. Then try Delay at 0.25s with high feedback — play short notes and watch them cascade into echoes! Combine both for epic textures.
      </div>
    `
  }
];

function updateLesson() {
  const panel = document.getElementById('lessonPanel');
  const lesson = LESSONS[currentStage];
  
  let html = `<h2>${lesson.title}</h2>`;
  html += lesson.content;
  
  if (currentStage < STAGES.length - 1) {
    html += `<button class="next-btn" id="nextBtn">Unlock Next: ${STAGES[currentStage + 1].name} →</button>`;
  } else {
    html += `<div style="text-align:center;margin-top:20px;padding:16px;background:rgba(0,206,201,0.1);border-radius:8px;font-size:0.85rem;color:var(--green)">🎉 You've unlocked all modules! Experiment freely with every control to design your own sounds.</div>`;
  }

  panel.innerHTML = html;

  if (currentStage < STAGES.length - 1) {
    document.getElementById('nextBtn').onclick = unlockNext;
  }
}

function updateProgressBar() {
  const bar = document.getElementById('progressBar');
  let html = '';
  STAGES.forEach((s, i) => {
    if (i > 0) html += '<div class="step-line"></div>';
    const cls = i < currentStage ? 'completed' : (i === currentStage ? 'active' : '');
    html += `<div class="progress-step ${cls}"><div class="step-dot"></div><span>${s.name}</span></div>`;
  });
  bar.innerHTML = html;
}

function unlockNext() {
  if (currentStage >= STAGES.length - 1) return;
  
  currentStage++;
  
  // Unlock module
  const modMap = ['modOscillator', 'modFilter', 'modEnvelope', 'modLfo', 'modEffects'];
  const mod = document.getElementById(modMap[currentStage]);
  if (mod) {
    mod.classList.remove('locked');
    mod.classList.add('unlocked-anim');
    setTimeout(() => mod.classList.remove('unlocked-anim'), 1000);
  }

  // Setup LFO when unlocked
  if (currentStage === 3) setupLFO();

  updateLesson();
  updateProgressBar();
}

// ---- LFO Setup ----
function setupLFO() {
  lfoNode = audioCtx.createOscillator();
  lfoGainNode = audioCtx.createGain();
  
  lfoNode.type = 'sine';
  lfoNode.frequency.value = parseFloat(document.getElementById('lfoRate').value);
  lfoGainNode.gain.value = parseFloat(document.getElementById('lfoDepth').value) * 20; // Hz range for pitch
  
  lfoNode.connect(lfoGainNode);
  lfoGainNode.connect(masterGain.gain); // Default: modulate volume (tremolo)
  
  lfoNode.start();
}

// ---- LFO Target switching ----
function buildLfoTargets() {
  const container = document.getElementById('lfoTargets');
  const targets = [
    { id: 'pitch', label: 'Pitch' },
    { id: 'filter', label: 'Filter' },
    { id: 'volume', label: 'Volume' }
  ];
  
  targets.forEach(t => {
    const btn = document.createElement('button');
    btn.className = 'lfo-target-btn' + (t.id === 'pitch' ? ' active' : '');
    btn.dataset.target = t.id;
    btn.textContent = t.label;
    btn.onclick = () => {
      document.querySelectorAll('.lfo-target-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
      switchLfoTarget(t.id);
    };
    container.appendChild(btn);
  });
}

function switchLfoTarget(target) {
  if (!lfoNode || !lfoGainNode) return;
  
  // Disconnect all
  lfoGainNode.disconnect();
  
  if (target === 'pitch') {
    // Modulate master gain as a proxy — in real synth would mod each voice's osc frequency
    lfoGainNode.connect(masterGain.gain);
    lfoGainNode.gain.value = parseFloat(document.getElementById('lfoDepth').value) * 2;
  } else if (target === 'volume') {
    lfoGainNode.connect(masterGain.gain);
    lfoGainNode.gain.value = parseFloat(document.getElementById('lfoDepth').value) / 100;
  } else if (target === 'filter' && currentStage >= 1) {
    // Can't easily retroactively connect to existing voice filters, so we modulate master gain as visual feedback
    lfoGainNode.connect(masterGain.gain);
    lfoGainNode.gain.value = parseFloat(document.getElementById('lfoDepth').value) / 200;
  }
}

// ---- Slider value displays ----
function setupSliderDisplays() {
  const sliders = [
    { id: 'filterCutoff', display: 'cutoffVal', fmt: v => `${Math.round(v)} Hz` },
    { id: 'filterResonance', display: 'resVal', fmt: v => parseFloat(v).toFixed(1) },
    { id: 'envAttack', display: 'atkVal', fmt: v => `${Math.round(v*1000)}ms` },
    { id: 'envDecay', display: 'decVal', fmt: v => `${Math.round(v*1000)}ms` },
    { id: 'envSustain', display: 'susVal', fmt: v => `${Math.round(v*100)}%` },
    { id: 'envRelease', display: 'relVal', fmt: v => `${Math.round(v*1000)}ms` },
    { id: 'lfoRate', display: 'lfoRateVal', fmt: v => `${parseFloat(v).toFixed(1)} Hz` },
    { id: 'lfoDepth', display: 'lfoDepthVal', fmt: v => `${v}%` },
    { id: 'reverbMix', display: 'revMixVal', fmt: v => `${Math.round(v*100)}%` },
    { id: 'reverbDecay', display: 'revDecVal', fmt: v => `${parseFloat(v).toFixed(1)}s` },
    { id: 'delayTime', display: 'delTimeVal', fmt: v => `${Math.round(v*1000)}ms` },
    { id: 'delayFeedback', display: 'delFbVal', fmt: v => `${Math.round(v*100)}%` }
  ];

  sliders.forEach(s => {
    const el = document.getElementById(s.id);
    if (!el) return;
    el.oninput = () => {
      document.getElementById(s.display).textContent = s.fmt(el.value);
      if (s.id === 'lfoRate' && lfoNode) lfoNode.frequency.value = parseFloat(el.value);
      if (s.id === 'reverbDecay') updateReverbDecay();
    };
  });

  // Toggles
  document.getElementById('reverbToggle').onclick = function() {
    this.classList.toggle('on');
    reverbEnabled = this.classList.contains('on');
  };
  
  document.getElementById('delayToggle').onclick = function() {
    this.classList.toggle('on');
    delayEnabled = this.classList.contains('on');
  };
}

// ---- Init ----
function init() {
  buildWaveBtns();
  buildKeyboard();
  buildLfoTargets();
  setupSliderDisplays();
  updateLesson();
  updateProgressBar();
  
  requestAnimationFrame(drawWaveform);
  requestAnimationFrame(drawSpectrum);
  requestAnimationFrame(drawEnvelope);
}

init();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"full","sessionId":"7f4700d0-d9b2-4a67-8234-3c3cf532edac","tokensIn":188325,"tokensOut":16334,"tokensTotal":204659,"turns":10,"toolCalls":9,"failedToolCalls":2,"timestamp":"2026-07-18T21:34:44.154Z"} -->