← TutSynth results

TutSynth

inkling · basic

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 35,480 bytes · SHA-256 758e32dc487b
<!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 by Building</title>
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Inter:wght@300;400;600&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet" />
  <style>
    :root {
      --bg: #05070a;
      --surface: #0b0e14;
      --surface-2: #11141d;
      --surface-3: #181c28;
      --text: #e8edf2;
      --text-dim: #8b9cb3;
      --accent: #39d0ff;
      --accent-2: #9f3cff;
      --accent-3: #ff6b6b;
      --warn: #ffb84d;
      --success: #4ade80;
    }

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

    html { scroll-behavior: smooth; }

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

    /* Background pattern */
    body::before {
      content: "";
      position: fixed;
      inset: 0;
      background:
        radial-gradient(circle at 20% 30%, rgba(57,208,255,0.06) 0%, transparent 40%),
        radial-gradient(circle at 80% 70%, rgba(159,60,255,0.06) 0%, transparent 40%),
        radial-gradient(circle at 50% 50%, rgba(255,107,107,0.03) 0%, transparent 60%);
      pointer-events: none;
      z-index: 0;
    }

    /* Header */
    header {
      position: relative;
      z-index: 10;
      padding: 2rem 2rem 1.5rem;
      border-bottom: 1px solid rgba(255,255,255,0.05);
      background: rgba(11,14,20,0.7);
      backdrop-filter: blur(12px);
    }

    .header-inner {
      max-width: 1200px;
      margin: 0 auto;
      display: flex;
      align-items: flex-end;
      justify-content: space-between;
      gap: 2rem;
      flex-wrap: wrap;
    }

    .title-area h1 {
      font-family: 'Orbitron', sans-serif;
      font-size: clamp(1.8rem, 4vw, 2.6rem);
      letter-spacing: 0.08em;
      background: linear-gradient(135deg, var(--accent), var(--accent-2), var(--accent-3));
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
      background-clip: text;
      line-height: 1.1;
    }

    .title-area p {
      color: var(--text-dim);
      font-size: 0.95rem;
      margin-top: 0.25rem;
    }

    .progress-bar {
      width: 260px;
      height: 6px;
      background: var(--surface-3);
      border-radius: 3px;
      overflow: hidden;
      margin-top: 0.75rem;
      position: relative;
      border: 1px solid rgba(255,255,255,0.04);
    }

    .progress-fill {
      position: absolute;
      top: 0; left: 0; bottom: 0;
      width: 20%;
      background: linear-gradient(90deg, var(--accent), var(--accent-2));
      border-radius: 3px;
      transition: width 0.6s ease;
      box-shadow: 0 0 10px rgba(57,208,255,0.4);
    }

    /* Main layout */
    main {
      position: relative;
      z-index: 2;
      max-width: 1200px;
      margin: 0 auto;
      padding: 2rem 2rem 4rem;
      display: grid;
      grid-template-columns: 1fr 340px;
      gap: 2rem;
      align-items: start;
    }

    @media (max-width: 960px) {
      main { grid-template-columns: 1fr; }
      .header-inner { flex-direction: column; align-items: flex-start; }
    }

    /* Left: Synth & Keyboard */
    .synth-section {
      display: flex;
      flex-direction: column;
      gap: 1.5rem;
    }

    /* Lesson card */
    .lesson-card {
      background: var(--surface);
      border: 1px solid rgba(255,255,255,0.06);
      border-radius: 16px;
      padding: 1.5rem 1.75rem;
      box-shadow: 0 20px 50px rgba(0,0,0,0.4);
      position: relative;
      overflow: hidden;
    }

    .lesson-card::after {
      content: "";
      position: absolute;
      top: 0; left: 0; right: 0;
      height: 4px;
      background: linear-gradient(90deg, var(--accent), var(--accent-2), var(--accent-3));
    }

    .lesson-card h2 {
      font-family: 'Orbitron', sans-serif;
      font-size: 1.3rem;
      margin-bottom: 0.5rem;
    }

    .lesson-card .lesson-id {
      display: inline-block;
      font-family: 'JetBrains Mono', monospace;
      font-size: 0.75rem;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--accent);
      background: rgba(57,208,255,0.08);
      border: 1px solid rgba(57,208,255,0.15);
      padding: 0.15rem 0.5rem;
      border-radius: 4px;
      margin-bottom: 1rem;
    }

    .lesson-card p {
      color: var(--text-dim);
      line-height: 1.7;
      font-size: 0.95rem;
    }

    /* Synth panel */
    .synth-box {
      background: linear-gradient(160deg, #0f1118, #0a0c12);
      border: 1px solid rgba(255,255,255,0.07);
      border-radius: 20px;
      padding: 1.75rem;
      box-shadow: 0 30px 80px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.03);
      position: relative;
    }

    .synth-box::before {
      content: "";
      position: absolute;
      top: 12px; left: 12px; right: 12px;
      height: 1px;
      background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
    }

    /* Module sections */
    .module {
      margin-bottom: 1.25rem;
      padding-bottom: 1.25rem;
      border-bottom: 1px solid rgba(255,255,255,0.04);
    }
    .module:last-child { border-bottom: 0; margin-bottom: 0; padding-bottom: 0; }

    .module.locked {
      opacity: 0.35;
      pointer-events: none;
      filter: grayscale(0.8);
      position: relative;
    }
    .module.locked::after {
      content: "LOCKED — Complete previous stage";
      position: absolute;
      top: 50%; left: 50%;
      transform: translate(-50%, -50%);
      background: rgba(0,0,0,0.8);
      color: var(--warn);
      padding: 0.3rem 0.8rem;
      border-radius: 6px;
      font-family: 'JetBrains Mono', monospace;
      font-size: 0.75rem;
      white-space: nowrap;
      letter-spacing: 0.05em;
      pointer-events: none;
    }

    .module h3 {
      font-family: 'Orbitron', sans-serif;
      font-size: 1rem;
      letter-spacing: 0.06em;
      margin-bottom: 0.75rem;
      display: flex;
      align-items: center;
      gap: 0.5rem;
    }

    .badge {
      font-size: 0.65rem;
      background: var(--accent-2);
      color: #fff;
      padding: 0.1rem 0.35rem;
      border-radius: 3px;
      font-family: 'JetBrains Mono', monospace;
      letter-spacing: 0.04em;
    }

    /* Controls */
    .controls-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
      gap: 1rem;
    }

    .control-group label {
      display: block;
      font-size: 0.7rem;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--text-dim);
      margin-bottom: 0.3rem;
      font-weight: 600;
    }

    .slider-wrap {
      position: relative;
    }

    input[type="range"] {
      -webkit-appearance: none;
      width: 100%;
      height: 6px;
      background: var(--surface-3);
      border-radius: 3px;
      outline: none;
      border: none;
      transition: background 0.2s;
    }
    input[type="range"]:hover { background: #232738; }

    input[type="range"]::-webkit-slider-thumb {
      -webkit-appearance: none;
      width: 16px; height: 16px;
      border-radius: 50%;
      background: linear-gradient(135deg, var(--accent), var(--accent-2));
      cursor: pointer;
      box-shadow: 0 2px 8px rgba(57,208,255,0.35), 0 0 0 2px rgba(57,208,255,0.15);
      transition: transform 0.1s;
    }
    input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); }

    input[type="range"]::-moz-range-thumb {
      width: 16px; height: 16px;
      border-radius: 50%;
      background: linear-gradient(135deg, var(--accent), var(--accent-2));
      cursor: pointer;
      box-shadow: 0 2px 8px rgba(57,208,255,0.35);
      border: none;
    }

    .value-label {
      font-family: 'JetBrains Mono', monospace;
      font-size: 0.8rem;
      color: var(--accent);
      margin-top: 0.2rem;
    }

    /* Waveform buttons */
    .wave-btns {
      display: flex;
      gap: 0.35rem;
      flex-wrap: wrap;
    }

    .wave-btn {
      flex: 1 1 auto;
      padding: 0.35rem 0.5rem;
      border: 1px solid rgba(255,255,255,0.08);
      background: var(--surface-2);
      color: var(--text-dim);
      border-radius: 6px;
      font-family: 'JetBrains Mono', monospace;
      font-size: 0.75rem;
      cursor: pointer;
      transition: all 0.15s;
      text-align: center;
    }
    .wave-btn.active {
      background: linear-gradient(135deg, var(--accent), var(--accent-2));
      color: #fff;
      border-color: transparent;
      box-shadow: 0 0 10px rgba(57,208,255,0.25);
    }

    /* Visualizer */
    .viz-area {
      margin-top: 1rem;
      height: 80px;
      background: linear-gradient(180deg, rgba(57,208,255,0.04) 0%, transparent 100%);
      border: 1px solid rgba(255,255,255,0.05);
      border-radius: 10px;
      position: relative;
      overflow: hidden;
    }
    .viz-area canvas {
      width: 100%;
      height: 100%;
      display: block;
    }

    /* Keyboard */
    .keyboard-wrap {
      margin-top: 0.5rem;
    }
    .keyboard {
      display: flex;
      height: 140px;
      border-radius: 12px;
      overflow: hidden;
      border: 1px solid rgba(255,255,255,0.06);
      box-shadow: 0 10px 40px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.03);
    }

    .key {
      flex: 1;
      background: linear-gradient(180deg, #1a1d27, #0f1118);
      border-right: 1px solid rgba(255,255,255,0.05);
      position: relative;
      display: flex;
      align-items: flex-end;
      justify-content: center;
      padding-bottom: 0.8rem;
      cursor: pointer;
      user-select: none;
      transition: background 0.1s;
      font-family: 'JetBrains Mono', monospace;
      font-size: 0.75rem;
      color: var(--text-dim);
    }
    .key:last-child { border-right: 0; }
    .key:hover { background: linear-gradient(180deg, #252b3a, #181c28); }
    .key:active, .key.active {
      background: linear-gradient(180deg, var(--accent), #1a8fcf);
      color: #fff;
      box-shadow: inset 0 0 20px rgba(57,208,255,0.3);
    }

    /* Right: Stages sidebar */
    .stages-sidebar {
      position: sticky;
      top: 2rem;
    }

    .stage-card {
      background: var(--surface);
      border: 1px solid rgba(255,255,255,0.06);
      border-radius: 14px;
      padding: 1.25rem;
      margin-bottom: 1rem;
      box-shadow: 0 10px 30px rgba(0,0,0,0.3);
      transition: all 0.3s ease;
    }

    .stage-card.completed {
      border-color: rgba(74,222,128,0.35);
      box-shadow: 0 0 20px rgba(74,222,128,0.1);
    }
    .stage-card.completed .stage-num {
      background: var(--success);
      color: #fff;
    }

    .stage-num {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 28px; height: 28px;
      border-radius: 50%;
      background: var(--surface-3);
      color: var(--text-dim);
      font-family: 'Orbitron', sans-serif;
      font-size: 0.85rem;
      margin-bottom: 0.5rem;
    }

    .stage-card h3 {
      font-family: 'Orbitron', sans-serif;
      font-size: 1rem;
      margin-bottom: 0.25rem;
    }
    .stage-card p {
      font-size: 0.85rem;
      color: var(--text-dim);
      line-height: 1.5;
    }

    .stage-card.completed::before {
      content: "✓";
      position: absolute;
      top: -6px; right: -6px;
      background: var(--success);
      color: #fff;
      width: 24px; height: 24px;
      border-radius: 50%;
      display: grid;
      place-items: center;
      font-size: 0.7rem;
      box-shadow: 0 2px 8px rgba(74,222,128,0.4);
    }

    /* Unlock button */
    .unlock-btn {
      width: 100%;
      padding: 0.75rem;
      margin-top: 0.5rem;
      border: none;
      border-radius: 8px;
      background: linear-gradient(135deg, var(--accent), var(--accent-2));
      color: #fff;
      font-family: 'Orbitron', sans-serif;
      font-size: 0.9rem;
      letter-spacing: 0.08em;
      cursor: pointer;
      box-shadow: 0 4px 20px rgba(57,208,255,0.2);
      transition: all 0.2s;
    }
    .unlock-btn:hover {
      transform: translateY(-2px);
      box-shadow: 0 8px 30px rgba(57,208,255,0.35);
    }

    /* Footer */
    footer {
      position: relative;
      z-index: 2;
      text-align: center;
      padding: 2rem;
      color: var(--text-dim);
      font-size: 0.8rem;
      border-top: 1px solid rgba(255,255,255,0.04);
    }

    /* Responsive adjustments */
    @media (max-width: 700px) {
      .key { font-size: 0.6rem; }
      .controls-grid { grid-template-columns: 1fr; }
    }
  </style>
</head>
<body>

  <header>
    <div class="header-inner">
      <div class="title-area">
        <h1>SYNTH PLAYGROUND</h1>
        <p>A progressive guide to synthesis — learn by playing</p>
      </div>
      <div>
        <div style="font-family:'JetBrains Mono',monospace; font-size:0.8rem; color:var(--text-dim); text-align:right;">PROGRESS</div>
        <div class="progress-bar" aria-label="Course progress">
          <div id="progressFill" class="progress-fill" style="width:20%"></div>
        </div>
      </div>
    </div>
  </header>

  <main>
    <section class="synth-section" aria-label="Synth and lessons">

      <!-- Lesson 1 -->
      <article class="lesson-card" id="lesson-1">
        <span class="lesson-id">STAGE 01 — SOUND SOURCE</span>
        <h2>Oscillators: The Heart of Sound</h2>
        <p>Every synth starts with an oscillator — a repeating waveform. Sine sounds pure like a flute. Sawtooth is bright and buzzy like a string. Square is hollow and woodwind-like. Triangle is softer and flute-like.</p>
        <p><strong>Try it:</strong> Click the keyboard below. Switch waveforms and hear how the basic tone changes. This is the raw material before any shaping.</p>
      </article>

      <!-- Synth Box -->
      <div class="synth-box" id="synthBox">
        <!-- Module: Oscillator -->
        <div class="module" id="mod-osc">
          <h3>OSCILLATOR <span class="badge">STAGE 1</span></h3>
          <div class="controls-grid">
            <div class="control-group">
              <label for="waveSelect">Waveform</label>
              <div class="wave-btns">
                <button class="wave-btn active" onclick="setWave('sine')" aria-pressed="true" aria-label="Sine wave">SINE</button>
                <button class="wave-btn" onclick="setWave('sawtooth')" aria-pressed="false" aria-label="Sawtooth wave">SAW</button>
                <button class="wave-btn" onclick="setWave('square')" aria-pressed="false" aria-label="Square wave">SQUARE</button>
                <button class="wave-btn" onclick="setWave('triangle')" aria-pressed="false" aria-label="Triangle wave">TRIANGLE</button>
              </div>
            </div>
            <div class="control-group">
              <label for="freqRange">Pitch (semitones)</label>
              <div class="slider-wrap">
                <input type="range" id="freqRange" min="-24" max="24" value="0" step="1" oninput="updatePitch(this.value)">
                <div class="value-label" id="pitchVal">0</div>
              </div>
            </div>
          </div>
        </div>

        <!-- Module: Envelope -->
        <div class="module locked" id="mod-env">
          <h3>AMPLITUDE ENVELOPE <span class="badge">STAGE 2</span></h3>
          <p style="font-size:0.85rem; color:var(--text-dim); margin-bottom:0.75rem;">Attack = how quickly sound reaches full volume. Decay = how fast it falls. Sustain = holding level. Release = fade out time.</p>
          <div class="controls-grid">
            <div class="control-group">
              <label for="atkRange">Attack</label>
              <input type="range" id="atkRange" min="0.001" max="2" step="0.01" value="0.05" oninput="updateEnv()">
              <div class="value-label" id="atkVal">0.05 s</div>
            </div>
            <div class="control-group">
              <label for="decRange">Decay</label>
              <input type="range" id="decRange" min="0.01" max="2" step="0.01" value="0.3" oninput="updateEnv()">
              <div class="value-label" id="decVal">0.30 s</div>
            </div>
            <div class="control-group">
              <label for="susRange">Sustain</label>
              <input type="range" id="susRange" min="0" max="1" step="0.05" value="0.7" oninput="updateEnv()">
              <div class="value-label" id="susVal">0.70</div>
            </div>
            <div class="control-group">
              <label for="relRange">Release</label>
              <input type="range" id="relRange" min="0.01" max="3" step="0.01" value="0.5" oninput="updateEnv()">
              <div class="value-label" id="relVal">0.50 s</div>
            </div>
          </div>
        </div>

        <!-- Module: Filter -->
        <div class="module locked" id="mod-filt">
          <h3>FILTER <span class="badge">STAGE 3</span></h3>
          <p style="font-size:0.85rem; color:var(--text-dim); margin-bottom:0.75rem;">Filters shape tone by removing frequencies. A lowpass lets bass through and removes high sparkle. Resonance boosts the cutoff edge.</p>
          <div class="controls-grid">
            <div class="control-group">
              <label for="cutoffRange">Cutoff (Hz)</label>
              <input type="range" id="cutoffRange" min="100" max="8000" step="10" value="3000" oninput="updateFilter()">
              <div class="value-label" id="cutVal">3000 Hz</div>
            </div>
            <div class="control-group">
              <label for="resRange">Resonance</label>
              <input type="range" id="resRange" min="0" max="20" step="0.5" value="2" oninput="updateFilter()">
              <div class="value-label" id="resVal">2.0</div>
            </div>
          </div>
        </div>

        <!-- Module: LFO -->
        <div class="module locked" id="mod-lfo">
          <h3>LFO MODULATION <span class="badge">STAGE 4</span></h3>
          <p style="font-size:0.85rem; color:var(--text-dim); margin-bottom:0.75rem;">A Low-Frequency Oscillator slowly changes a parameter over time — like a tremolo (volume wobble) or vibrato (pitch wobble).</p>
          <div class="controls-grid">
            <div class="control-group">
              <label for="lfoRateRange">LFO Rate (Hz)</label>
              <input type="range" id="lfoRateRange" min="0.1" max="10" step="0.1" value="2" oninput="updateLFO()">
              <div class="value-label" id="lfoRateVal">2.0 Hz</div>
            </div>
            <div class="control-group">
              <label for="lfoDepthRange">Depth</label>
              <input type="range" id="lfoDepthRange" min="0" max="1" step="0.05" value="0.3" oninput="updateLFO()">
              <div class="value-label" id="lfoDepthVal">0.30</div>
            </div>
            <div class="control-group">
              <label for="lfoTarget">Target</label>
              <div class="wave-btns">
                <button class="wave-btn active" onclick="setLFO('pitch')" aria-pressed="true">PITCH</button>
                <button class="wave-btn" onclick="setLFO('filter')" aria-pressed="false">FILTER</button>
              </div>
            </div>
          </div>
        </div>

        <!-- Visualizer -->
        <div class="viz-area">
          <canvas id="scopeCanvas" aria-label="Oscilloscope visualization"></canvas>
        </div>
      </div>

      <!-- Keyboard -->
      <div class="keyboard-wrap">
        <h3 style="font-family:'Orbitron',sans-serif; font-size:0.85rem; letter-spacing:0.08em; margin-bottom:0.5rem;">KEYBOARD <span style="color:var(--text-dim); font-family:'Inter',sans-serif; font-size:0.8rem; font-weight:400;">(Mouse / Touch)</span></h3>
        <div class="keyboard" role="group" aria-label="Musical keyboard">
          <button class="key" onmousedown="playNote('C4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="C4">C</button>
          <button class="key" onmousedown="playNote('D4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="D4">D</button>
          <button class="key" onmousedown="playNote('E4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="E4">E</button>
          <button class="key" onmousedown="playNote('F4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="F4">F</button>
          <button class="key" onmousedown="playNote('G4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="G4">G</button>
          <button class="key" onmousedown="playNote('A4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="A4">A</button>
          <button class="key" onmousedown="playNote('B4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="B4">B</button>
          <button class="key" onmousedown="playNote('C5')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="C5">C+</button>
        </div>
      </div>

    </section>

    <!-- Right sidebar: stages -->
    <aside class="stages-sidebar" aria-label="Learning stages">
      <div class="stage-card" id="stage-card-1">
        <div class="stage-num">1</div>
        <h3>Oscillator</h3>
        <p>Choose waveform shape. Sine = pure, Saw = bright, Square = hollow, Triangle = soft.</p>
      </div>

      <div class="stage-card" id="stage-card-2">
        <div class="stage-num">2</div>
        <h3>Envelope (ADSR)</h3>
        <p>Shape the volume over time with Attack, Decay, Sustain, and Release.</p>
        <button class="unlock-btn" onclick="unlockStage(2)">Unlock Stage 2</button>
      </div>

      <div class="stage-card" id="stage-card-3">
        <div class="stage-num">3</div>
        <h3>Filter</h3>
        <p>Remove high frequencies with a lowpass filter. Resonance creates a sharp peak.</p>
        <button class="unlock-btn" onclick="unlockStage(3)">Unlock Stage 3</button>
      </div>

      <div class="stage-card" id="stage-card-4">
        <div class="stage-num">4</div>
        <h3>LFO Modulation</h3>
        <p>Use a slow oscillator to wobble pitch or filter over time.</p>
        <button class="unlock-btn" onclick="unlockStage(4)">Unlock Stage 4</button>
      </div>

      <div class="stage-card" id="stage-card-5">
        <div class="stage-num">5</div>
        <h3>Polyphony</h3>
        <p>Play multiple notes at once. Each voice is a complete synth chain.</p>
        <button class="unlock-btn" onclick="unlockStage(5)">Unlock Polyphony</button>
      </div>
    </aside>
  </main>

  <footer>
    <p>Built with the Web Audio API. No external synth libraries — pure browser synthesis.</p>
  </footer>

  <script>
    // ========== STATE ==========
    const audioCtx = new (window.AudioContext || window.webkitAudioContext)();

    let currentOsc = null;
    let currentGain = null;
    let currentFilter = null;
    let currentNote = null;

    // Wave selection
    let waveType = 'sine';

    // Envelope params
    let atk = 0.05, dec = 0.3, sus = 0.7, rel = 0.5;

    // Filter params
    let cutoff = 3000, resonance = 2;

    // LFO params
    let lfoRate = 2, lfoDepth = 0.3, lfoTarget = 'pitch';
    let lfoNode = null;
    let lfoGain = null;

    // Polyphony
    let polyEnabled = false;
    const activeVoices = new Map(); // key -> {osc, gain, filter, note}

    // Note mapping
    const noteFreq = {
      'C4': 261.63, 'D4': 293.66, 'E4': 329.63, 'F4': 349.23,
      'G4': 392.00, 'A4': 440.00, 'B4': 493.88, 'C5': 523.25
    };

    // ========== INIT ==========
    function initAudio() {
      if (audioCtx.state === 'suspended') audioCtx.resume();
    }

    // ========== WAVE ==========
    function setWave(type) {
      waveType = type;
      document.querySelectorAll('.wave-btn').forEach(b => {
        b.classList.remove('active');
        b.setAttribute('aria-pressed', 'false');
      });
      event.target.closest('.wave-btns').querySelectorAll('.wave-btn').forEach(b => {
        if (b.textContent.toLowerCase().includes(type === 'sine' ? 'sine' : type === 'triangle' ? 'triangle' : type === 'square' ? 'square' : 'saw')) {
          b.classList.add('active');
          b.setAttribute('aria-pressed', 'true');
        }
      });
    }

    // ========== PITCH ==========
    function updatePitch(val) {
      document.getElementById('pitchVal').textContent = val;
    }

    // ========== ENVELOPE ==========
    function updateEnv() {
      atk = parseFloat(document.getElementById('atkRange').value);
      dec = parseFloat(document.getElementById('decRange').value);
      sus = parseFloat(document.getElementById('susRange').value);
      rel = parseFloat(document.getElementById('relRange').value);
      document.getElementById('atkVal').textContent = atk.toFixed(2) + ' s';
      document.getElementById('decVal').textContent = dec.toFixed(2) + ' s';
      document.getElementById('susVal').textContent = sus.toFixed(2);
      document.getElementById('relVal').textContent = rel.toFixed(2) + ' s';
    }

    // ========== FILTER ==========
    function updateFilter() {
      cutoff = parseFloat(document.getElementById('cutoffRange').value);
      resonance = parseFloat(document.getElementById('resRange').value);
      document.getElementById('cutVal').textContent = Math.round(cutoff) + ' Hz';
      document.getElementById('resVal').textContent = resonance.toFixed(1);
    }

    // ========== LFO ==========
    function setLFO(target) {
      lfoTarget = target;
      document.querySelectorAll('#mod-lfo .wave-btn').forEach(b => {
        b.classList.remove('active');
        b.setAttribute('aria-pressed', 'false');
      });
      event.target.closest('.wave-btns').querySelectorAll('.wave-btn').forEach(b => {
        if ((b.textContent === 'PITCH' && target === 'pitch') || (b.textContent === 'FILTER' && target === 'filter')) {
          b.classList.add('active');
          b.setAttribute('aria-pressed', 'true');
        }
      });
    }

    function updateLFO() {
      lfoRate = parseFloat(document.getElementById('lfoRateRange').value);
      lfoDepth = parseFloat(document.getElementById('lfoDepthRange').value);
      document.getElementById('lfoRateVal').textContent = lfoRate.toFixed(1) + ' Hz';
      document.getElementById('lfoDepthVal').textContent = lfoDepth.toFixed(2);
    }

    // ========== PLAY ==========
    function noteToFreq(note) {
      const base = noteFreq[note] || 440;
      const semitones = parseInt(document.getElementById('freqRange').value) || 0;
      return base * Math.pow(2, semitones / 12);
    }

    function playNote(noteName) {
      initAudio();

      // Visual feedback
      document.querySelectorAll('.key').forEach(k => {
        if (k.textContent.includes('C') || k.textContent.includes('D') || k.textContent.includes('E') || k.textContent.includes('F') || k.textContent.includes('G') || k.textContent.includes('A') || k.textContent.includes('B')) {
          const n = k.getAttribute('aria-label');
          if (n === noteName) k.classList.add('active');
        }
      });

      if (polyEnabled) {
        playPolyVoice(noteName);
      } else {
        releaseNote();
        playMonoNote(noteName);
      }
    }

    function playMonoNote(noteName) {
      currentNote = noteName;
      const freq = noteToFreq(noteName);
      const now = audioCtx.currentTime;

      // Oscillator
      currentOsc = audioCtx.createOscillator();
      currentOsc.type = waveType;
      currentOsc.frequency.value = freq;

      // Filter
      currentFilter = audioCtx.createBiquadFilter();
      currentFilter.type = 'lowpass';
      currentFilter.frequency.value = cutoff;
      currentFilter.Q.value = resonance;

      // Gain (envelope)
      currentGain = audioCtx.createGain();
      currentGain.gain.setValueAtTime(0, now);
      currentGain.gain.linearRampToValueAtTime(1, now + atk);
      currentGain.gain.linearRampToValueAtTime(sus, now + atk + dec);

      // LFO
      lfoNode = audioCtx.createOscillator();
      lfoNode.type = 'sine';
      lfoNode.frequency.value = lfoRate;
      lfoGain = audioCtx.createGain();
      lfoGain.gain.value = lfoDepth * 20; // scale for filter or pitch

      if (lfoTarget === 'pitch') {
        lfoGain.connect(currentOsc.frequency);
        lfoNode.connect(lfoGain);
      } else {
        lfoGain.connect(currentFilter.frequency);
        lfoNode.connect(lfoGain);
      }

      // Routing
      currentOsc.connect(currentFilter);
      currentFilter.connect(currentGain);
      currentGain.connect(audioCtx.destination);

      // Start
      currentOsc.start();
      lfoNode.start();
    }

    function playPolyVoice(noteName) {
      const freq = noteToFreq(noteName);
      const now = audioCtx.currentTime;

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

      const flt = audioCtx.createBiquadFilter();
      flt.type = 'lowpass';
      flt.frequency.value = cutoff;
      flt.Q.value = resonance;

      const gain = audioCtx.createGain();
      gain.gain.setValueAtTime(0, now);
      gain.gain.linearRampToValueAtTime(1, now + atk);
      gain.gain.linearRampToValueAtTime(sus, now + atk + dec);

      // Connect LFO for poly too
      const lfoOsc = audioCtx.createOscillator();
      lfoOsc.type = 'sine';
      lfoOsc.frequency.value = lfoRate;
      const lfoG = audioCtx.createGain();
      lfoG.gain.value = lfoDepth * 20;
      if (lfoTarget === 'pitch') {
        lfoG.connect(osc.frequency);
        lfoOsc.connect(lfoG);
      } else {
        lfoG.connect(flt.frequency);
        lfoOsc.connect(lfoG);
      }

      osc.connect(flt);
      flt.connect(gain);
      gain.connect(audioCtx.destination);

      osc.start();
      lfoOsc.start();

      activeVoices.set(noteName, { osc, gain, filter: flt, lfoOsc, lfoG });
    }

    function releaseNote() {
      if (!polyEnabled && currentNote) {
        const now = audioCtx.currentTime;
        if (currentGain) {
          currentGain.gain.cancelScheduledValues(now);
          currentGain.gain.setValueAtTime(currentGain.gain.value, now);
          currentGain.gain.exponentialRampToValueAtTime(0.001, now + rel);
          if (currentOsc) currentOsc.stop(now + rel + 0.05);
        }
      }

      // Clear keyboard UI
      document.querySelectorAll('.key').forEach(k => k.classList.remove('active'));

      // For poly: release all if no keys held? Simplified: just reset mono.
      // In a full poly system we'd track held keys; for simplicity we'll clear voices after a delay.
      if (polyEnabled) {
        // Simplified poly release: immediately stop voices
        for (const [k, v] of activeVoices) {
          const now = audioCtx.currentTime;
          v.gain.gain.cancelScheduledValues(now);
          v.gain.gain.setValueAtTime(v.gain.gain.value, now);
          v.gain.gain.exponentialRampToValueAtTime(0.001, now + rel);
          v.osc.stop(now + rel + 0.05);
          v.lfoOsc.stop(now + rel + 0.05);
        }
        activeVoices.clear();
      }
    }

    // ========== STAGE PROGRESSION ==========
    let unlocked = 1;

    function unlockStage(n) {
      if (n !== unlocked) return;
      unlocked = n;

      // Unlock module
      const mod = document.getElementById('mod-' + (n === 2 ? 'env' : n === 3 ? 'filt' : n === 4 ? 'lfo' : 'poly'));
      if (mod) {
        mod.classList.remove('locked');
      }

      // Update card
      const card = document.getElementById('stage-card-' + n);
      if (card) card.classList.add('completed');

      // Update progress bar
      document.getElementById('progressFill').style.width = (n * 25) + '%';

      // Poly special handling
      if (n === 5) {
        polyEnabled = true;
        // Add a module for poly or just set state
        // We'll rely on the stage card to show poly enabled
      }
    }

    // ========== VISUALIZER ==========
    const canvas = document.getElementById('scopeCanvas');
    const ctx = canvas.getContext('2d');
    let analyser;
    let dataArray;

    function initVisualizer() {
      analyser = audioCtx.createAnalyser();
      analyser.fftSize = 512;
      dataArray = new Uint8Array(analyser.frequencyBinCount);
      // We'll connect the main output through analyser when playing
    }

    function resizeCanvas() {
      const rect = canvas.getBoundingClientRect();
      canvas.width = rect.width * window.devicePixelRatio;
      canvas.height = rect.height * window.devicePixelRatio;
      ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
    }

    function drawVisualizer() {
      requestAnimationFrame(drawVisualizer);
      if (!analyser) {
        resizeCanvas();
        ctx.clearRect(0, 0, canvas.width / window.devicePixelRatio, canvas.height / window.devicePixelRatio);
        return;
      }

      analyser.getByteTimeDomainData(dataArray);

      const w = canvas.width / window.devicePixelRatio;
      const h = canvas.height / window.devicePixelRatio;

      ctx.clearRect(0, 0, w, h);
      ctx.beginPath();

      const sliceWidth = w / dataArray.length;
      let x = 0;

      for (let i = 0; i < dataArray.length; i++) {
        const v = dataArray[i] / 128.0; // 0..2 range roughly
        const y = (v * h / 2) + h / 4;

        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);

        x += sliceWidth;
      }

      ctx.lineWidth = 2;
      ctx.strokeStyle = 'rgba(57,208,255,0.8)';
      ctx.shadowColor = 'rgba(57,208,255,0.5)';
      ctx.shadowBlur = 8;
      ctx.stroke();
      ctx.shadowBlur = 0;
    }

    // Connect analyser when playing
    const originalPlayMonoNote = playMonoNote;
    playMonoNote = function(noteName) {
      originalPlayMonoNote(noteName);
      if (!analyser) initVisualizer();
      if (currentGain && analyser) {
        // Disconnect and reconnect through analyser
        currentGain.disconnect();
        currentGain.connect(analyser);
        analyser.connect(audioCtx.destination);
      }
      resizeCanvas();
    };

    window.addEventListener('resize', resizeCanvas);
    drawVisualizer();
    resizeCanvas();

    // Keyboard events
    document.addEventListener('keydown', (e) => {
      const keyMap = { 'a':'C4','w':'D4','s':'E4','e':'F4','d':'G4','f':'A4','t':'B4','g':'C5' };
      const note = keyMap[e.key.toLowerCase()];
      if (note) {
        e.preventDefault();
        playNote(note);
        const btn = Array.from(document.querySelectorAll('.key')).find(b => b.getAttribute('aria-label') === note);
        if (btn) btn.classList.add('active');
      }
    });
    document.addEventListener('keyup', (e) => {
      const keyMap = { 'a':'C4','w':'D4','s':'E4','e':'F4','d':'G4','f':'A4','t':'B4','g':'C5' };
      if (keyMap[e.key.toLowerCase()]) {
        releaseNote();
      }
    });

    // Initialize first stage as active
    document.getElementById('mod-env').classList.add('locked');
    document.getElementById('mod-filt').classList.add('locked');
    document.getElementById('mod-lfo').classList.add('locked');

  </script>
</body>
</html>
<!-- agent-meta {"model":"thinkingmachines/inkling","provider":"openrouter","persona":"basic","sessionId":"4d7799da-0dbd-4c45-a415-6c73f37c9b6e","tokensIn":222,"tokensOut":10183,"tokensTotal":10405,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-07-18T20:56:18.750Z"} -->