← TutSynth results

TutSynth

Qwen 3.6 35b a3b · composer

Initial view of TutSynth

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

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

Generation prompt · exact text unavailable

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

Inspect original source 40,686 bytes · SHA-256 9057758eac7b
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Synth Playground — Learn Synthesis Interactively</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  background: #0a0a1a;
  color: #e0e0f0;
  font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
  min-height: 100vh;
  overflow-x: hidden;
}

#app {
  max-width: 900px;
  margin: 0 auto;
  padding: 20px;
}

#header {
  text-align: center;
  padding: 30px 0 20px;
  border-bottom: 1px solid #2a2a4a;
  margin-bottom: 20px;
}

#header h1 {
  font-size: 2.2rem;
  background: linear-gradient(135deg, #00ffaa, #00aaff, #aa44ff);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
  margin-bottom: 8px;
}

.subtitle {
  color: #8888aa;
  font-size: 0.95rem;
}

#main-panel {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding-bottom: 40px;
}

.synth-section {
  background: #12122a;
  border: 1px solid #2a2a4a;
  border-radius: 12px;
  overflow: hidden;
  transition: border-color 0.3s;
}

.synth-section:hover {
  border-color: #4a4a7a;
}

.section-header {
  padding: 16px 20px;
  background: linear-gradient(90deg, #1a1a3a, #12122a);
  border-bottom: 1px solid #2a2a4a;
}

.section-header h2 {
  font-size: 1.15rem;
  color: #00ffaa;
  margin-bottom: 4px;
}

.section-info {
  font-size: 0.82rem;
  color: #6a6a9a;
  font-style: italic;
}

.section-body {
  padding: 20px;
}

.control-group {
  margin-bottom: 18px;
}

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

.control-label {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.88rem;
  font-weight: 600;
  color: #c0c0e0;
  margin-bottom: 8px;
}

.value-display {
  color: #00ffaa;
  font-family: 'Consolas', 'Courier New', monospace;
  font-weight: 400;
}

.control-hint {
  font-size: 0.78rem;
  color: #5a5a8a;
  margin-top: 6px;
  line-height: 1.4;
  padding-left: 4px;
  border-left: 2px solid #2a2a4a;
}

/* Buttons */
.waveform-selector,
.filter-selector,
.env-target-selector,
.lfo-wave-selector,
.lfo-target-selector {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
  margin-bottom: 4px;
}

.wave-btn,
.filter-btn,
.env-target-btn,
.lfo-wave-btn,
.lfo-target-btn {
  padding: 6px 14px;
  border: 1px solid #3a3a5a;
  border-radius: 6px;
  background: #1a1a3a;
  color: #8888aa;
  font-size: 0.82rem;
  cursor: pointer;
  transition: all 0.2s;
}

.wave-btn:hover,
.filter-btn:hover,
.env-target-btn:hover,
.lfo-wave-btn:hover,
.lfo-target-btn:hover {
  border-color: #00ffaa;
  color: #00ffaa;
}

.wave-btn.active,
.filter-btn.active,
.env-target-btn.active,
.lfo-wave-btn.active,
.lfo-target-btn.active {
  border-color: #00ffaa;
  background: #0a2a2a;
  color: #00ffaa;
  box-shadow: 0 0 8px rgba(0, 255, 170, 0.2);
}

/* Sliders */
.slider {
  width: 100%;
  height: 6px;
  -webkit-appearance: none;
  appearance: none;
  background: #1a1a3a;
  border-radius: 3px;
  outline: none;
  cursor: pointer;
}

.slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: #00ffaa;
  cursor: pointer;
  box-shadow: 0 0 6px rgba(0, 255, 170, 0.4);
}

.slider::-moz-range-thumb {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: #00ffaa;
  cursor: pointer;
  border: none;
  box-shadow: 0 0 6px rgba(0, 255, 170, 0.4);
}

.slider:hover::-webkit-slider-thumb {
  box-shadow: 0 0 12px rgba(0, 255, 170, 0.6);
}

/* Envelope sliders */
.env-sliders {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 12px;
}

.env-param {
  margin-bottom: 0;
}

.env-param .control-label {
  font-size: 0.82rem;
}

.env-param .slider {
  height: 4px;
}

/* Envelope canvas */
.env-canvas-container {
  background: #0a0a1a;
  border-radius: 8px;
  padding: 8px;
  margin-bottom: 12px;
  display: flex;
  justify-content: center;
}

#env-canvas {
  border-radius: 4px;
}

/* Keyboard */
.keyboard {
  position: relative;
  display: flex;
  justify-content: center;
  margin-bottom: 12px;
  user-select: none;
}

.white-keys {
  display: flex;
  gap: 2px;
}

.white-key {
  width: 52px;
  height: 160px;
  border: 1px solid #3a3a5a;
  border-radius: 0 0 6px 6px;
  background: linear-gradient(180deg, #e8e8f0, #ffffff);
  color: #333;
  font-size: 0.8rem;
  font-weight: 600;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  padding-bottom: 10px;
  transition: background 0.1s;
}

.white-key:hover {
  background: linear-gradient(180deg, #d0d0e8, #f0f0ff);
}

.white-key.active {
  background: linear-gradient(180deg, #00ffaa, #00cc88);
  border-color: #00ffaa;
  box-shadow: 0 0 15px rgba(0, 255, 170, 0.4);
}

.black-keys {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  display: flex;
  pointer-events: none;
}

.black-key {
  width: 32px;
  height: 100px;
  border: 1px solid #1a1a2a;
  border-radius: 0 0 4px 4px;
  background: linear-gradient(180deg, #1a1a2a, #0a0a1a);
  color: #888;
  font-size: 0.7rem;
  font-weight: 600;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  padding-bottom: 6px;
  pointer-events: auto;
  transition: background 0.1s;
}

.black-key:hover {
  background: linear-gradient(180deg, #2a2a4a, #1a1a3a);
}

.black-key.active {
  background: linear-gradient(180deg, #00ffaa, #00cc88);
  color: #0a0a1a;
  box-shadow: 0 0 15px rgba(0, 255, 170, 0.4);
}

/* Positioning black keys */
.black-key:nth-child(1) { margin-left: 28px; }
.black-key:nth-child(2) { margin-left: 58px; }
.black-key:nth-child(3) { margin-left: 118px; }
.black-key:nth-child(4) { margin-left: 58px; }
.black-key:nth-child(5) { margin-left: 58px; }
.black-key:nth-child(6) { margin-left: 178px; }
.black-key:nth-child(7) { margin-left: 58px; }

/* Active notes */
.active-notes {
  text-align: center;
  font-size: 0.85rem;
  color: #00ffaa;
  font-family: 'Consolas', monospace;
  min-height: 24px;
}

/* Visualization */
.viz-container {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 8px;
}

.viz-container canvas {
  background: #0a0a1a;
  border-radius: 8px;
  border: 1px solid #2a2a4a;
  width: 100%;
}

/* Responsive */
@media (max-width: 600px) {
  #app { padding: 12px; }
  .white-key { width: 38px; height: 130px; }
  .black-key { width: 24px; height: 80px; }
  .env-sliders { grid-template-columns: 1fr 1fr; }
}
/* ── css: overlay ── */
.start-overlay {
  position: fixed;
  inset: 0;
  background: rgba(10, 10, 26, 0.95);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  backdrop-filter: blur(8px);
  transition: opacity 0.5s, visibility 0.5s;
}

.start-overlay.hidden {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

.start-content {
  text-align: center;
  max-width: 500px;
  padding: 40px;
}

.start-content h1 {
  font-size: 2.8rem;
  background: linear-gradient(135deg, #00ffaa, #00aaff, #aa44ff);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
  margin-bottom: 12px;
}

.start-subtitle {
  color: #8888aa;
  font-size: 1.1rem;
  margin-bottom: 24px;
}

.start-instructions {
  color: #6a6a9a;
  font-size: 0.95rem;
  line-height: 1.6;
  margin-bottom: 30px;
}

.start-btn {
  padding: 14px 40px;
  font-size: 1.1rem;
  font-weight: 600;
  border: 2px solid #00ffaa;
  border-radius: 8px;
  background: transparent;
  color: #00ffaa;
  cursor: pointer;
  transition: all 0.3s;
}

.start-btn:hover {
  background: #00ffaa;
  color: #0a0a1a;
  box-shadow: 0 0 30px rgba(0, 255, 170, 0.4);
}
</style>
</head>
<body>
<header id="header">
  <h1>🎛️ Synth Playground</h1>
  <p class="subtitle">Learn synthesis by doing — every control is explained</p>
</header>

<div id="main-panel">
  <section class="synth-section" id="oscillator-section">
    <div class="section-header">
      <h2>1. Oscillators — The Sound Source</h2>
      <span class="section-info">Oscillators generate the raw waveform. Different shapes = different harmonic content.</span>
    </div>
    <div class="section-body">
      <div class="control-group">
        <label class="control-label">Waveform</label>
        <div class="waveform-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>
        <p class="control-hint">Sine = pure tone. Square = hollow, hollow. Saw = bright, buzzy. Triangle = soft, flute-like.</p>
      </div>
      <div class="control-group">
        <label class="control-label">Detune <span class="value-display" id="detune-val">0</span> cents</label>
        <input type="range" class="slider" id="detune" min="-100" max="100" value="0" step="1">
        <p class="control-hint">Slight pitch offset. Detune two oscillators together → chorus / fat unison effect.</p>
      </div>
      <div class="control-group">
        <label class="control-label">Octave <span class="value-display" id="octave-val">0</span></label>
        <input type="range" class="slider" id="octave" min="-2" max="2" value="0" step="1">
        <p class="control-hint">Shift pitch ±2 octaves. Useful for bass or lead ranges.</p>
      </div>
    </div>
  </section>

  <section class="synth-section" id="filter-section">
    <div class="section-header">
      <h2>2. Filter — Shaping the Tone</h2>
      <span class="section-info">Filters remove or emphasize frequencies. Think of it as a "tone knob" you can automate.</span>
    </div>
    <div class="section-body">
      <div class="control-group">
        <label class="control-label">Filter Type</label>
        <div class="filter-selector">
          <button class="filter-btn active" data-filter="lowpass">Lowpass</button>
          <button class="filter-btn" data-filter="highpass">Highpass</button>
          <button class="filter-btn" data-filter="bandpass">Bandpass</button>
          <button class="filter-btn" data-filter="notch">Notch</button>
        </div>
        <p class="control-hint">Lowpass = keeps lows, removes highs (warm). Highpass = keeps highs, removes lows (thin). Bandpass = narrow band. Notch = removes narrow band.</p>
      </div>
      <div class="control-group">
        <label class="control-label">Cutoff <span class="value-display" id="cutoff-val">2000</span> Hz</label>
        <input type="range" class="slider" id="cutoff" min="20" max="20000" value="2000" step="1">
        <p class="control-hint">The frequency boundary. For lowpass, everything below passes through. For highpass, everything above.</p>
      </div>
      <div class="control-group">
        <label class="control-label">Resonance (Q) <span class="value-display" id="resonance-val">1</span></label>
        <input type="range" class="slider" id="resonance" min="0" max="30" value="1" step="0.1">
        <p class="control-hint">Boosts frequencies near cutoff. High Q = pronounced peak at cutoff → classic "squelchy" synth sound.</p>
      </div>
    </div>
  </section>

  <section class="synth-section" id="envelope-section">
    <div class="section-header">
      <h2>3. Envelope (ADSR) — How Sound Evolves Over Time</h2>
      <span class="section-info">An envelope controls how a parameter (like volume or filter cutoff) changes from the moment you press a key.</span>
    </div>
    <div class="section-body">
      <div class="control-group">
        <label class="control-label">Target</label>
        <div class="env-target-selector">
          <button class="env-target-btn active" data-target="amplitude">Volume</button>
          <button class="env-target-btn" data-target="filter">Filter</button>
        </div>
        <p class="control-hint">Apply the envelope to volume (classic synth) or filter cutoff (wah-wah sweeps).</p>
      </div>
      <div class="control-group">
        <div id="env-visual" class="env-canvas-container">
          <canvas id="env-canvas" width="300" height="120"></canvas>
        </div>
      </div>
      <div class="env-sliders">
        <div class="control-group env-param">
          <label class="control-label">Attack <span class="value-display" id="attack-val">0.01</span>s</label>
          <input type="range" class="slider" id="attack" min="0.001" max="2" value="0.01" step="0.001">
          <p class="control-hint">Time from silence to peak. 0 = instant (pluck). Long = pad swell.</p>
        </div>
        <div class="control-group env-param">
          <label class="control-label">Decay <span class="value-display" id="decay-val">0.3</span>s</label>
          <input type="range" class="slider" id="decay" min="0.001" max="2" value="0.3" step="0.001">
          <p class="control-hint">Time from peak down to sustain level. Short = percussive.</p>
        </div>
        <div class="control-group env-param">
          <label class="control-label">Sustain <span class="value-display" id="sustain-val">0.7</span></label>
          <input type="range" class="slider" id="sustain" min="0" max="1" value="0.7" step="0.01">
          <p class="control-hint">Level held while key is pressed. 1 = full volume. 0 = cuts to silence.</p>
        </div>
        <div class="control-group env-param">
          <label class="control-label">Release <span class="value-display" id="release-val">0.3</span>s</label>
          <input type="range" class="slider" id="release" min="0.001" max="3" value="0.3" step="0.001">
          <p class="control-hint">Time from sustain to silence after releasing key. Short = staccato. Long = fade out.</p>
        </div>
      </div>
    </div>
  </section>

  <section class="synth-section" id="lfo-section">
    <div class="section-header">
      <h2>4. LFO — Low-Frequency Oscillator (Modulation)</h2>
      <span class="section-info">An LFO is a slow oscillator that modulates (changes) other parameters over time, creating movement and expression.</span>
    </div>
    <div class="section-body">
      <div class="control-group">
        <label class="control-label">LFO Rate <span class="value-display" id="lfo-rate-val">4</span> Hz</label>
        <input type="range" class="slider" id="lfo-rate" min="0.1" max="20" value="4" step="0.1">
        <p class="control-hint">Speed of modulation. Slow = vibrato/tremolo. Fast = FM-style effects.</p>
      </div>
      <div class="control-group">
        <label class="control-label">LFO Depth <span class="value-display" id="lfo-depth-val">0</span></label>
        <input type="range" class="slider" id="lfo-depth" min="0" max="100" value="0" step="1">
        <p class="control-hint">How strongly the LFO affects the target. 0 = no modulation.</p>
      </div>
      <div class="control-group">
        <label class="control-label">LFO Waveform</label>
        <div class="lfo-wave-selector">
          <button class="lfo-wave-btn active" data-lfo-wave="sine">Sine</button>
          <button class="lfo-wave-btn" data-lfo-wave="triangle">Triangle</button>
          <button class="lfo-wave-btn" data-lfo-wave="square">Square</button>
          <button class="lfo-wave-btn" data-lfo-wave="sawtooth">Saw</button>
        </div>
      </div>
      <div class="control-group">
        <label class="control-label">LFO Target</label>
        <div class="lfo-target-selector">
          <button class="lfo-target-btn active" data-lfo-target="pitch">Pitch</button>
          <button class="lfo-target-btn" data-lfo-target="filter">Filter</button>
          <button class="lfo-target-btn" data-lfo-target="none">Off</button>
        </div>
        <p class="control-hint">Pitch → vibrato. Filter → wah-wah sweep. Off → no effect.</p>
      </div>
    </div>
  </section>

  <section class="synth-section" id="effects-section">
    <div class="section-header">
      <h2>5. Effects — Polish the Sound</h2>
      <span class="section-info">Effects add spatial depth and character. Try reverb for space, delay for echoes.</span>
    </div>
    <div class="section-body">
      <div class="control-group">
        <label class="control-label">Reverb Mix <span class="value-display" id="reverb-mix-val">0</span></label>
        <input type="range" class="slider" id="reverb-mix" min="0" max="1" value="0" step="0.01">
        <p class="control-hint">Amount of reverb (room ambience). 0 = dry. High = cathedral.</p>
      </div>
      <div class="control-group">
        <label class="control-label">Delay Mix <span class="value-display" id="delay-mix-val">0</span></label>
        <input type="range" class="slider" id="delay-mix" min="0" max="1" value="0" step="0.01">
        <p class="control-hint">Amount of delay (echo). 0 = no echo. High = dense repeats.</p>
      </div>
      <div class="control-group">
        <label class="control-label">Delay Time <span class="value-display" id="delay-time-val">0.3</span>s</label>
        <input type="range" class="slider" id="delay-time" min="0.01" max="1" value="0.3" step="0.01">
        <p class="control-hint">Time between repeats. Match to tempo for rhythmic echoes.</p>
      </div>
    </div>
  </section>

  <section class="synth-section" id="keyboard-section">
    <div class="section-header">
      <h2>6. Keyboard — Play!</h2>
      <span class="section-info">Use your mouse or keyboard. Keys: Z–M and Q–U map to white/black notes.</span>
    </div>
    <div class="section-body">
      <div id="keyboard" class="keyboard">
        <div class="white-keys">
          <button class="white-key" data-note="C4" data-key="z">Z</button>
          <button class="white-key" data-note="D4" data-key="x">X</button>
          <button class="white-key" data-note="E4" data-key="c">C</button>
          <button class="white-key" data-note="F4" data-key="v">V</button>
          <button class="white-key" data-note="G4" data-key="b">B</button>
          <button class="white-key" data-note="A4" data-key="n">N</button>
          <button class="white-key" data-note="B4" data-key="m">M</button>
          <button class="white-key" data-note="C5" data-key=",">,</button>
          <button class="white-key" data-note="D5" data-key=".">.</button>
          <button class="white-key" data-note="E5" data-key="/">/</button>
        </div>
        <div class="black-keys">
          <button class="black-key" data-note="C#4" data-key="s">S</button>
          <button class="black-key" data-note="D#4" data-key="d">D</button>
          <button class="black-key" data-note="F#4" data-key="g">G</button>
          <button class="black-key" data-note="G#4" data-key="h">H</button>
          <button class="black-key" data-note="A#4" data-key="j">J</button>
          <button class="black-key" data-note="C#5" data-key="l">L</button>
          <button class="black-key" data-note="D#5" data-key=";">;</button>
        </div>
      </div>
      <div id="active-notes" class="active-notes">No notes playing</div>
    </div>
  </section>

  <section class="synth-section" id="viz-section">
    <div class="section-header">
      <h2>📊 Live Visualization</h2>
    </div>
    <div class="section-body">
      <div class="viz-container">
        <canvas id="waveform-canvas" width="600" height="150"></canvas>
        <canvas id="frequency-canvas" width="600" height="150"></canvas>
      </div>
      <p class="control-hint">Top: waveform shape. Bottom: frequency spectrum. Play notes to see them!</p>
    </div>
  </section>
<div id="start-overlay" class="start-overlay">
  <div class="start-content">
    <h1>🎛️ Synth Playground</h1>
    <p class="start-subtitle">An interactive guide to synthesis</p>
    <p class="start-instructions">
      Every control on this page generates real sound.<br>
      Adjust knobs, change waveforms, play notes — and watch the visualizations react.
    </p>
    <button id="start-btn" class="start-btn">Start Playing</button>
  </div>
</div></div>
<script>
'use strict';

// ── const: NOTE_FREQUENCIES ──
const NOTE_FREQUENCIES = {
  'C4': 261.63, 'C#4': 277.18, 'D4': 293.66, 'D#4': 311.13,
  'E4': 329.63, 'F4': 349.23, 'F#4': 369.99, 'G4': 392.00,
  'G#4': 415.30, 'A4': 440.00, 'A#4': 466.16, 'B4': 493.88,
  'C5': 523.25, 'C#5': 554.37, 'D5': 587.33, 'D#5': 622.25,
  'E5': 659.25
};

// ── const: KEY_MAP ──
const KEY_MAP = {
  'z': 'C4', 's': 'C#4', 'x': 'D4', 'd': 'D#4', 'c': 'E4',
  'v': 'F4', 'g': 'F#4', 'b': 'G4', 'h': 'G#4', 'n': 'A4',
  'j': 'A#4', 'm': 'B4', ',': 'C5', 'l': 'C#5', '.': 'D5',
  ';': 'D#5', '/': 'E5'
};

// ── function: getActiveNoteCount ──
function getActiveNoteCount() {
  return synthState.activeVoices.size;
}

// ── function: isNoteActive ──
function isNoteActive(note) {
  return synthState.activeVoices.has(note);
}

// ── const: synthState ──
const synthState = {
  ctx: null,
  masterGain: null,
  analyserNode: null,
  analyserFreq: null,

  // Oscillator
  oscType: 'sawtooth',
  detune: 0,
  octave: 0,
  activeVoices: new Map(),

  // Filter
  filterType: 'lowpass',
  filterCutoff: 2000,
  filterResonance: 1,

  // Envelope
  envTarget: 'amplitude',
  attack: 0.01,
  decay: 0.3,
  sustain: 0.7,
  release: 0.3,

  // LFO
  lfoRate: 4,
  lfoDepth: 0,
  lfoWave: 'sine',
  lfoTarget: 'pitch',
  lfoNode: null,
  lfoGainNode: null,

  // Effects
  reverbMix: 0,
  delayMix: 0,
  delayTime: 0.3,
  reverbNode: null,
  reverbGain: null,
  dryGain: null,
  delayNode: null,
  delayGain: null,
  delayFeedback: null,
};

// ── function: createAudioContext ──
function createAudioContext() {
  synthState.ctx = new (window.AudioContext || window.webkitAudioContext)();
  setupSignalChain();
}

// ── function: setupSignalChain ──
function setupSignalChain() {
  const ctx = synthState.ctx;

  // Master gain
  synthState.masterGain = ctx.createGain();
  synthState.masterGain.gain.value = 0.5;

  // Analyser for waveform
  synthState.analyserNode = ctx.createAnalyser();
  synthState.analyserNode.fftSize = 2048;

  // Analyser for frequency
  synthState.analyserFreq = ctx.createAnalyser();
  synthState.analyserFreq.fftSize = 4096;
  synthState.analyserFreq.smoothingTimeConstant = 0.8;

  // Reverb (convolver with generated impulse)
  synthState.reverbNode = ctx.createConvolver();
  synthState.reverbGain = ctx.createGain();
  synthState.reverbGain.gain.value = 0;
  synthState.dryGain = ctx.createGain();
  synthState.dryGain.gain.value = 1;

  generateReverbImpulse(ctx, synthState.reverbNode, 2.5, 0.3);

  // Delay
  synthState.delayNode = ctx.createDelay(2);
  synthState.delayNode.delayTime.value = 0.3;
  synthState.delayGain = ctx.createGain();
  synthState.delayGain.gain.value = 0;
  synthState.delayFeedback = ctx.createGain();
  synthState.delayFeedback.gain.value = 0.35;

  // Build the chain: voices -> masterGain -> analyser -> destination
  // Also: masterGain -> reverb path -> destination
  // Also: masterGain -> delay path -> delayFeedback -> delayNode -> destination

  // Dry path
  synthState.masterGain.connect(synthState.dryGain);
  synthState.dryGain.connect(ctx.destination);

  // Reverb path
  synthState.masterGain.connect(synthState.reverbNode);
  synthState.reverbNode.connect(synthState.reverbGain);
  synthState.reverbGain.connect(ctx.destination);

  // Delay path
  synthState.masterGain.connect(synthState.delayNode);
  synthState.delayNode.connect(synthState.delayGain);
  synthState.delayGain.connect(ctx.destination);
  synthState.delayNode.connect(synthState.delayFeedback);
  synthState.delayFeedback.connect(synthState.delayNode);

  // Analyser for waveform display
  synthState.masterGain.connect(synthState.analyserNode);

  // Analyser for frequency display
  synthState.masterGain.connect(synthState.analyserFreq);
}

// ── function: generateReverbImpulse ──
function generateReverbImpulse(ctx, convolver, duration, decay) {
  const sampleRate = ctx.sampleRate;
  const length = sampleRate * duration;
  const impulse = ctx.createBuffer(2, length, sampleRate);

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

// ── function: noteOn ──
function noteOn(note) {
  ensureAudioContext();
  const ctx = synthState.ctx;
  if (!ctx) return;

  if (synthState.activeVoices.has(note)) return; // already playing

  const baseFreq = NOTE_FREQUENCIES[note];
  if (!baseFreq) return;

  const freq = baseFreq * Math.pow(2, synthState.octave);

  // Voice oscillator
  const osc = ctx.createOscillator();
  osc.type = synthState.oscType;
  osc.frequency.value = freq;
  osc.detune.value = synthState.detune;

  // Voice gain (amplitude envelope)
  const voiceGain = ctx.createGain();
  voiceGain.gain.value = 0;

  // Filter
  const filterNode = ctx.createBiquadFilter();
  filterNode.type = synthState.filterType;
  filterNode.frequency.value = synthState.filterCutoff;
  filterNode.Q.value = synthState.filterResonance;

  // Connect: osc -> filter -> voiceGain -> masterGain
  osc.connect(filterNode);
  filterNode.connect(voiceGain);
  voiceGain.connect(synthState.masterGain);

  osc.start();

  // Apply amplitude envelope
  const now = ctx.currentTime;
  voiceGain.gain.cancelScheduledValues(now);
  voiceGain.gain.setValueAtTime(0, now);
  voiceGain.gain.linearRampToValueAtTime(0.8, now + synthState.attack);
  voiceGain.gain.linearRampToValueAtTime(
    0.8 * synthState.sustain, now + synthState.attack + synthState.decay
  );

  // Apply filter envelope if target is filter
  if (synthState.envTarget === 'filter') {
    const now2 = ctx.currentTime;
    const peakCutoff = Math.min(synthState.filterCutoff * 3, 20000);
    filterNode.frequency.cancelScheduledValues(now2);
    filterNode.frequency.setValueAtTime(synthState.filterCutoff, now2);
    filterNode.frequency.linearRampToValueAtTime(peakCutoff, now2 + synthState.attack);
    filterNode.frequency.linearRampToValueAtTime(
      synthState.filterCutoff * (0.5 + synthState.sustain * 0.5),
      now2 + synthState.attack + synthState.decay
    );
  }

  synthState.activeVoices.set(note, { osc, voiceGain, filterNode });
}

// ── function: noteOff ──
function noteOff(note) {
  const voice = synthState.activeVoices.get(note);
  if (!voice) return;

  const { osc, voiceGain, filterNode } = voice;
  const ctx = synthState.ctx;
  const now = ctx.currentTime;

  // Release envelope
  voiceGain.gain.cancelScheduledValues(now);
  voiceGain.gain.setValueAtTime(voiceGain.gain.value, now);
  voiceGain.gain.linearRampToValueAtTime(0, now + synthState.release);

  osc.stop(now + synthState.release + 0.1);

  // Clean up after release
  setTimeout(() => {
    try { osc.disconnect(); } catch(e) {}
    try { voiceGain.disconnect(); } catch(e) {}
    try { filterNode.disconnect(); } catch(e) {}
  }, (synthState.release + 0.2) * 1000);

  synthState.activeVoices.delete(note);
}

// ── function: updateLFO ──
function updateLFO() {
  const ctx = synthState.ctx;
  if (!ctx) return;

  if (synthState.lfoTarget === 'none') {
    if (synthState.lfoNode) {
      synthState.lfoNode.stop();
      synthState.lfoNode.disconnect();
      synthState.lfoNode = null;
    }
    synthState.lfoGainNode?.disconnect();
    synthState.lfoGainNode = null;
    return;
  }

  if (!synthState.lfoNode) {
    synthState.lfoNode = ctx.createOscillator();
    synthState.lfoNode.type = synthState.lfoWave;
    synthState.lfoNode.frequency.value = synthState.lfoRate;
    synthState.lfoGainNode = ctx.createGain();
    synthState.lfoGainNode.gain.value = synthState.lfoDepth;
    synthState.lfoNode.connect(synthState.lfoGainNode);
    synthState.lfoNode.start();
  }

  synthState.lfoNode.frequency.value = synthState.lfoRate;
  synthState.lfoNode.type = synthState.lfoWave;
  synthState.lfoGainNode.gain.value = synthState.lfoDepth;

  // Connect LFO to target
  if (synthState.lfoTarget === 'pitch') {
    // Connect to all active voice oscillators
    for (const [, voice] of synthState.activeVoices) {
      try {
        synthState.lfoGainNode.disconnect();
        synthState.lfoGainNode.connect(voice.osc.detune);
      } catch(e) {}
    }
  } else if (synthState.lfoTarget === 'filter') {
    for (const [, voice] of synthState.activeVoices) {
      try {
        synthState.lfoGainNode.disconnect();
        synthState.lfoGainNode.connect(voice.filterNode.frequency);
      } catch(e) {}
    }
  }
}

// ── function: updateFilter ──
function updateFilter() {
  for (const [, voice] of synthState.activeVoices) {
    voice.filterNode.type = synthState.filterType;
    voice.filterNode.frequency.value = synthState.filterCutoff;
    voice.filterNode.Q.value = synthState.filterResonance;
  }
}

// ── function: updateReverbMix ──
function updateReverbMix() {
  if (!synthState.reverbGain) return;
  const now = synthState.ctx.currentTime;
  synthState.reverbGain.gain.setTargetAtTime(synthState.reverbMix, now, 0.05);
  synthState.dryGain.gain.setTargetAtTime(1 - synthState.reverbMix * 0.7, now, 0.05);
}

// ── function: updateDelayMix ──
function updateDelayMix() {
  if (!synthState.delayGain) return;
  const now = synthState.ctx.currentTime;
  synthState.delayGain.gain.setTargetAtTime(synthState.delayMix, now, 0.05);
  synthState.delayNode.delayTime.setTargetAtTime(synthState.delayTime, now, 0.05);
}

// ── function: drawWaveform ──
function drawWaveform() {
  const canvas = document.getElementById('waveform-canvas');
  if (!canvas || !synthState.analyserNode) return;
  const ctx = canvas.getContext('2d');
  const w = canvas.width;
  const h = canvas.height;

  const bufferLength = synthState.analyserNode.frequencyBinCount;
  const dataArray = new Float32Array(bufferLength);
  synthState.analyserNode.getFloatTimeDomainData(dataArray);

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

  // Grid lines
  ctx.strokeStyle = '#1a1a3a';
  ctx.lineWidth = 0.5;
  ctx.beginPath();
  ctx.moveTo(0, h / 2);
  ctx.lineTo(w, h / 2);
  ctx.stroke();

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

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

// ── function: drawFrequencySpectrum ──
function drawFrequencySpectrum() {
  const canvas = document.getElementById('frequency-canvas');
  if (!canvas || !synthState.analyserFreq) return;
  const ctx = canvas.getContext('2d');
  const w = canvas.width;
  const h = canvas.height;

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

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

  // Draw frequency bars
  const barCount = 128;
  const barWidth = w / barCount;
  const step = Math.floor(bufferLength / barCount);

  for (let i = 0; i < barCount; i++) {
    const value = dataArray[i * step];
    const barHeight = (value / 255) * h * 0.9;
    const hue = (i / barCount) * 180;
    ctx.fillStyle = `hsl(${hue}, 80%, 60%)`;
    ctx.fillRect(i * barWidth, h - barHeight, barWidth - 1, barHeight);
  }
}

// ── function: updateActiveNotesDisplay ──
function updateActiveNotesDisplay() {
  const el = document.getElementById('active-notes');
  if (!el) return;
  const notes = Array.from(synthState.activeVoices.keys());
  if (notes.length === 0) {
    el.textContent = 'No notes playing';
    el.style.color = '#00ffaa';
  } else {
    el.textContent = notes.join('  ');
    el.style.color = '#00aaff';
  }
}

// ── function: updateValueDisplays ──
function updateValueDisplays() {
  const map = {
    'detune-val': synthState.detune,
    'octave-val': synthState.octave,
    'cutoff-val': Math.round(synthState.filterCutoff),
    'resonance-val': synthState.filterResonance.toFixed(1),
    'attack-val': synthState.attack.toFixed(3),
    'decay-val': synthState.decay.toFixed(3),
    'sustain-val': synthState.sustain.toFixed(2),
    'release-val': synthState.release.toFixed(3),
    'lfo-rate-val': synthState.lfoRate.toFixed(1),
    'lfo-depth-val': synthState.lfoDepth,
    'reverb-mix-val': synthState.reverbMix.toFixed(2),
    'delay-mix-val': synthState.delayMix.toFixed(2),
    'delay-time-val': synthState.delayTime.toFixed(2),
  };
  for (const [id, val] of Object.entries(map)) {
    const el = document.getElementById(id);
    if (el) el.textContent = val;
  }
}

// ── function: drawEnvelopeGraph ──
function drawEnvelopeGraph() {
  const canvas = document.getElementById('env-canvas');
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  const w = canvas.width;
  const h = canvas.height;

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

  // Background grid
  ctx.strokeStyle = '#1a1a3a';
  ctx.lineWidth = 1;
  for (let y = 0; y < h; y += 20) {
    ctx.beginPath();
    ctx.moveTo(0, y);
    ctx.lineTo(w, y);
    ctx.stroke();
  }

  const totalDuration = synthState.attack + synthState.decay + 0.5 + synthState.release;
  const padding = 30;
  const graphW = w - padding * 2;
  const graphH = h - 20;

  // Draw envelope curve
  ctx.strokeStyle = '#00ffaa';
  ctx.lineWidth = 2;
  ctx.shadowColor = '#00ffaa';
  ctx.shadowBlur = 6;
  ctx.beginPath();

  const ax = padding;
  const ay = h - 10;

  // Attack
  const attackX = padding + (synthState.attack / totalDuration) * graphW;
  ctx.moveTo(ax, ay);
  ctx.lineTo(attackX, 10);

  // Decay
  const decayEnd = attackX + (synthState.decay / totalDuration) * graphW;
  const sustainY = 10 + (1 - synthState.sustain) * graphH;
  ctx.lineTo(decayEnd, sustainY);

  // Sustain (hold for 0.5s)
  const sustainEnd = decayEnd + (0.5 / totalDuration) * graphW;
  ctx.lineTo(sustainEnd, sustainY);

  // Release
  const releaseEnd = sustainEnd + (synthState.release / totalDuration) * graphW;
  ctx.lineTo(releaseEnd, ay);

  ctx.stroke();
  ctx.shadowBlur = 0;

  // Labels
  ctx.fillStyle = '#6a6a9a';
  ctx.font = '11px Consolas, monospace';
  ctx.textAlign = 'center';

  const labels = [
    { x: (ax + attackX) / 2, label: 'A' },
    { x: (attackX + decayEnd) / 2, label: 'D' },
    { x: (decayEnd + sustainEnd) / 2, label: 'S' },
    { x: (sustainEnd + releaseEnd) / 2, label: 'R' },
  ];

  labels.forEach(l => {
    ctx.fillText(l.label, l.x, ay + 16);
  });

  // Y-axis label
  ctx.save();
  ctx.translate(8, h / 2);
  ctx.rotate(-Math.PI / 2);
  ctx.fillText('Amplitude', 0, 0);
  ctx.restore();

  // X-axis label
  ctx.fillText('Time', w / 2, h - 2);
}

// ── function: ensureAudioContext ──
function ensureAudioContext() {
  if (!synthState.ctx) {
    synthState.ctx = new (window.AudioContext || window.webkitAudioContext)();
    setupSignalChain();
  }
  if (synthState.ctx.state === 'suspended') {
    synthState.ctx.resume();
  }
}

// ── main ──
// Make synthState accessible globally for probes
window.synthState = synthState;

// ---- Start overlay ----
const overlay = document.getElementById('start-overlay');
const startBtn = document.getElementById('start-btn');
if (startBtn && overlay) {
  startBtn.addEventListener('click', () => {
    overlay.classList.add('hidden');
    ensureAudioContext();
  });
  // Also allow any key/click to dismiss
  document.addEventListener('keydown', function dismiss(e) {
    if (e.key === 'Escape' || e.key === ' ') {
      overlay.classList.add('hidden');
      ensureAudioContext();
      document.removeEventListener('keydown', dismiss);
    }
  });
}

// ---- Slider bindings ----
const sliderMap = {
  'detune': (v) => { synthState.detune = +v; updateLFO(); },
  'octave': (v) => { synthState.octave = +v; },
  'cutoff': (v) => { synthState.filterCutoff = +v; updateFilter(); },
  'resonance': (v) => { synthState.filterResonance = +v; updateFilter(); },
  'attack': (v) => { synthState.attack = +v; },
  'decay': (v) => { synthState.decay = +v; },
  'sustain': (v) => { synthState.sustain = +v; },
  'release': (v) => { synthState.release = +v; },
  'lfo-rate': (v) => { synthState.lfoRate = +v; updateLFO(); },
  'lfo-depth': (v) => { synthState.lfoDepth = +v; updateLFO(); },
  'reverb-mix': (v) => { synthState.reverbMix = +v; updateReverbMix(); },
  'delay-mix': (v) => { synthState.delayMix = +v; updateDelayMix(); },
  'delay-time': (v) => { synthState.delayTime = +v; updateDelayMix(); },
};

for (const [id, handler] of Object.entries(sliderMap)) {
  const el = document.getElementById(id);
  if (el) {
    el.addEventListener('input', (e) => {
      handler(e.target.value);
      updateValueDisplays();
      drawEnvelopeGraph();
    });
  }
}

// ---- Waveform selector ----
document.querySelectorAll('.wave-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    document.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    synthState.oscType = btn.dataset.wave;
  });
});

// ---- Filter selector ----
document.querySelectorAll('.filter-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    synthState.filterType = btn.dataset.filter;
    updateFilter();
  });
});

// ---- Envelope target selector ----
document.querySelectorAll('.env-target-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    document.querySelectorAll('.env-target-btn').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    synthState.envTarget = btn.dataset.target;
  });
});

// ---- LFO waveform selector ----
document.querySelectorAll('.lfo-wave-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    document.querySelectorAll('.lfo-wave-btn').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    synthState.lfoWave = btn.dataset.lfoWave;
    updateLFO();
  });
});

// ---- LFO target selector ----
document.querySelectorAll('.lfo-target-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    document.querySelectorAll('.lfo-target-btn').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    synthState.lfoTarget = btn.dataset.lfoTarget;
    updateLFO();
  });
});

// ---- Keyboard (mouse) ----
const keyButtons = document.querySelectorAll('.white-key, .black-key');
keyButtons.forEach(btn => {
  btn.addEventListener('mousedown', (e) => {
    e.preventDefault();
    const note = btn.dataset.note;
    btn.classList.add('active');
    noteOn(note);
    updateActiveNotesDisplay();
  });
  btn.addEventListener('mouseup', () => {
    const note = btn.dataset.note;
    btn.classList.remove('active');
    noteOff(note);
    updateActiveNotesDisplay();
  });
  btn.addEventListener('mouseleave', () => {
    const note = btn.dataset.note;
    btn.classList.remove('active');
    noteOff(note);
    updateActiveNotesDisplay();
  });
});

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

document.addEventListener('keydown', (e) => {
  if (e.repeat) return;
  const key = e.key.toLowerCase();
  if (KEY_MAP[key] && !pressedKeys.has(key)) {
    pressedKeys.add(key);
    const note = KEY_MAP[key];
    noteOn(note);
    const btn = document.querySelector(`[data-note="${note}"]`);
    if (btn) btn.classList.add('active');
    updateActiveNotesDisplay();
  }
});

document.addEventListener('keyup', (e) => {
  const key = e.key.toLowerCase();
  if (KEY_MAP[key]) {
    pressedKeys.delete(key);
    const note = KEY_MAP[key];
    noteOff(note);
    const btn = document.querySelector(`[data-note="${note}"]`);
    if (btn) btn.classList.remove('active');
    updateActiveNotesDisplay();
  }
});

// ---- Main render loop ----
function renderLoop() {
  drawWaveform();
  drawFrequencySpectrum();
  requestAnimationFrame(renderLoop);
}
renderLoop();

// Initial draws
updateValueDisplays();
drawEnvelopeGraph();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.6-35b-a3b","provider":"lmstudio","persona":"composer","sessionId":"2b54f18f-f036-4bee-b1d5-08f5a2665993","tokensIn":978357,"tokensOut":28355,"tokensTotal":1006712,"turns":33,"toolCalls":40,"failedToolCalls":4,"timestamp":"2026-07-20T23:21:56.257Z"} -->