← TutSynth results

TutSynth

thinkingcap qwen3.6 27b · middle

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,664 bytes · SHA-256 6772fe78968c
<!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: #0a0a12;
    --panel: #14142a;
    --panel-border: #2a2a5a;
    --accent: #7b61ff;
    --accent-glow: #9d8aff;
    --text: #e0dff0;
    --text-dim: #8888aa;
    --success: #4ade80;
    --warn: #fbbf24;
  }

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

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

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

  header h1 {
    font-size: 28px;
    font-weight: 700;
    letter-spacing: -0.5px;
    background: linear-gradient(135deg, #a78bfa, #7c3aed, #6d28d9);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
  }

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

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

  .progress-track {
    flex: 1;
    height: 6px;
    background: #2a2a4a;
    border-radius: 3px;
    overflow: hidden;
  }

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

  .progress-label { font-size: 12px; color: var(--text-dim); white-space: nowrap; }

  .main-layout {
    display: grid;
    grid-template-columns: 340px 1fr;
    min-height: calc(100vh - 120px);
  }

  /* Tutorial Panel */
  .tutorial-panel {
    padding: 24px;
    border-right: 1px solid var(--panel-border);
    overflow-y: auto;
    max-height: calc(100vh - 120px);
  }

  .step-card {
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 12px;
    padding: 20px;
    margin-bottom: 16px;
    transition: all 0.3s ease;
  }

  .step-card.active {
    border-color: var(--accent);
    box-shadow: 0 0 20px rgba(123, 97, 255, 0.15);
  }

  .step-card.completed { opacity: 0.6; }
  .step-card.locked { opacity: 0.3; pointer-events: none; filter: grayscale(1); }

  .step-number {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px; height: 28px;
    border-radius: 50%;
    background: var(--accent);
    color: white;
    font-size: 13px;
    font-weight: 700;
    margin-bottom: 10px;
  }

  .step-card.completed .step-number { background: var(--success); }
  .step-card.locked .step-number { background: #444; }

  .step-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
  .step-desc { font-size: 13px; line-height: 1.6; color: var(--text-dim); }
  .step-desc strong { color: var(--accent-glow); }

  .step-hint {
    margin-top: 12px;
    padding: 10px 14px;
    background: rgba(123, 97, 255, 0.1);
    border-left: 3px solid var(--accent);
    border-radius: 0 8px 8px 0;
    font-size: 12px;
    line-height: 1.5;
    color: #c4b5fd;
  }

  .complete-btn {
    margin-top: 14px;
    width: 100%;
    padding: 10px;
    border: none;
    border-radius: 8px;
    background: linear-gradient(135deg, var(--accent), #6d28d9);
    color: white;
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.2s;
  }

  .complete-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 15px rgba(123,97,255,0.4); }
  .complete-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }

  /* Synth Panel */
  .synth-panel {
    padding: 24px;
    overflow-y: auto;
    max-height: calc(100vh - 120px);
  }

  .module-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 16px;
    margin-bottom: 24px;
  }

  .synth-module {
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 12px;
    padding: 20px;
    transition: all 0.3s ease;
  }

  .synth-module.locked {
    opacity: 0.25;
    pointer-events: none;
    filter: grayscale(1);
  }

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

  .module-icon {
    width: 32px; height: 32px;
    border-radius: 8px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 16px;
  }

  .module-name { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; }

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

  .control-label {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    color: var(--text-dim);
    min-width: 60px;
  }

  .control-value {
    font-size: 12px;
    color: var(--accent-glow);
    min-width: 40px;
    text-align: right;
    font-variant-numeric: tabular-nums;
  }

  input[type="range"] {
    -webkit-appearance: none;
    appearance: none;
    flex: 1;
    height: 6px;
    background: #2a2a4a;
    border-radius: 3px;
    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 0 8px rgba(123,97,255,0.5);
  }

  input[type="range"]::-moz-range-thumb {
    width: 18px; height: 18px;
    border-radius: 50%;
    background: var(--accent);
    cursor: pointer;
    border: none;
    box-shadow: 0 0 8px rgba(123,97,255,0.5);
  }

  select {
    flex: 1;
    padding: 6px 10px;
    background: #1e1e3a;
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    color: var(--text);
    font-size: 13px;
    outline: none;
    cursor: pointer;
  }

  select:focus { border-color: var(--accent); }

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

  .keyboard-label { font-size: 13px; color: var(--text-dim); margin-bottom: 12px; text-align: center; }

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

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

  .key-white:hover { background: linear-gradient(180deg, #e0e0ff, #f0f0ff); }
  .key-white.active { background: linear-gradient(180deg, var(--accent), var(--accent-glow)); border-color: var(--accent); }

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

  .key-black:hover { background: linear-gradient(180deg, #333, #555); }
  .key-black.active { background: linear-gradient(180deg, var(--accent), var(--accent-glow)); }

  /* Waveform Display */
  .waveform-container {
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 12px;
    padding: 20px;
    margin-bottom: 24px;
  }

  canvas#waveCanvas {
    width: 100%;
    height: 160px;
    border-radius: 8px;
    background: #0a0a18;
  }

  /* Signal Flow */
  .signal-flow {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 4px;
    padding: 16px;
    margin-bottom: 24px;
    background: var(--panel);
    border-radius: 12px;
    border: 1px solid var(--panel-border);
    flex-wrap: wrap;
  }

  .flow-node {
    padding: 8px 14px;
    border-radius: 8px;
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    background: #2a2a4a;
    color: var(--text-dim);
    transition: all 0.3s;
  }

  .flow-node.active {
    background: rgba(123,97,255,0.2);
    color: var(--accent-glow);
    border: 1px solid var(--accent);
  }

  .flow-arrow { color: #444; font-size: 16px; transition: color 0.3s; }
  .flow-arrow.active { color: var(--accent); }

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

  .preset-btn {
    padding: 6px 14px;
    border-radius: 20px;
    border: 1px solid var(--panel-border);
    background: transparent;
    color: var(--text-dim);
    font-size: 12px;
    cursor: pointer;
    transition: all 0.2s;
  }

  .preset-btn:hover { border-color: var(--accent); color: var(--accent-glow); background: rgba(123,97,255,0.1); }

  /* Responsive */
  @media (max-width: 800px) {
    .main-layout { grid-template-columns: 1fr; }
    .tutorial-panel { max-height: none; border-right: none; border-bottom: 1px solid var(--panel-border); }
    .synth-panel { max-height: none; }
  }

  /* Animations */
  @keyframes pulse-glow {
    0%, 100% { box-shadow: 0 0 5px rgba(123,97,255,0.2); }
    50% { box-shadow: 0 0 20px rgba(123,97,255,0.4); }
  }

  .synth-module.unlocked-anim { animation: pulse-glow 1s ease; }

  /* Tooltip */
  .info-tip {
    display: inline-block;
    width: 16px; height: 16px;
    border-radius: 50%;
    background: #3a3a5a;
    color: var(--text-dim);
    font-size: 10px;
    text-align: center;
    line-height: 16px;
    cursor: help;
    margin-left: 4px;
    position: relative;
  }

  .info-tip:hover::after {
    content: attr(data-tip);
    position: absolute;
    bottom: 24px;
    left: 50%;
    transform: translateX(-50%);
    background: #1a1a3a;
    border: 1px solid var(--panel-border);
    padding: 8px 12px;
    border-radius: 8px;
    font-size: 11px;
    white-space: nowrap;
    z-index: 100;
    color: var(--text);
    text-transform: none;
    letter-spacing: normal;
    font-weight: 400;
  }

  .key-hint {
    display: inline-block;
    background: #2a2a4a;
    padding: 1px 6px;
    border-radius: 3px;
    font-size: 10px;
    color: var(--text-dim);
    margin-left: 4px;
  }

  .master-vol {
    display: flex; align-items: center; gap: 12px; padding: 12px 16px;
    background: rgba(123,97,255,0.08); border-radius: 8px; margin-bottom: 16px;
  }

  .master-vol label { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
</style>
</head>
<body>

<header>
  <h1>🎹 Synth Playground</h1>
  <p>Learn synthesis by building sounds — one module at a time</p>
</header>

<div class="progress-bar-container">
  <span class="progress-label" id="progressLabel">Step 1 of 7</span>
  <div class="progress-track"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
</div>

<div class="main-layout">
  <!-- Tutorial Panel -->
  <div class="tutorial-panel" id="tutorialPanel"></div>

  <!-- Synth Panel -->
  <div class="synth-panel">
    <!-- Signal Flow Diagram -->
    <div class="signal-flow" id="signalFlow"></div>

    <!-- Master Volume -->
    <div class="master-vol">
      <label>🔊 Master</label>
      <input type="range" min="0" max="100" value="70" id="masterVol">
      <span class="control-value" id="masterVolVal">70%</span>
    </div>

    <!-- Waveform Display -->
    <div class="waveform-container">
      <canvas id="waveCanvas"></canvas>
    </div>

    <!-- Keyboard -->
    <div class="keyboard-container">
      <div class="keyboard-label">Click keys or use your keyboard (A–L row)</div>
      <div class="keyboard" id="keyboard"></div>
    </div>

    <!-- Modules Grid -->
    <div class="module-grid" id="moduleGrid"></div>
  </div>
</div>

<script>
// ==================== AUDIO ENGINE ====================
const AudioCtx = window.AudioContext || window.webkitAudioContext;
let audioCtx = null;
let masterGain = null;
let analyser = null;
let activeNotes = {};

function initAudio() {
  if (audioCtx) return;
  audioCtx = new AudioCtx();
  masterGain = audioCtx.createGain();
  masterGain.gain.value = 0.7;
  analyser = audioCtx.createAnalyser();
  analyser.fftSize = 2048;
  masterGain.connect(analyser);
  analyser.connect(audioCtx.destination);
}

// Synth parameters
const params = {
  // Oscillator
  waveform: 'sawtooth',
  detune: 0,
  octave: 0,
  // ADSR
  attack: 0.05,
  decay: 0.2,
  sustain: 0.6,
  release: 0.3,
  // Filter
  filterType: 'lowpass',
  cutoffFreq: 8000,
  resonance: 1,
  envAmount: 0,
  // LFO
  lfoWaveform: 'sine',
  lfoRate: 5,
  lfoDepth: 0,
  lfoTarget: 'pitch',
  // Delay
  delayTime: 0.3,
  delayFeedback: 0.3,
  delayMix: 0,
  // Reverb
  reverbMix: 0,
};

// Note frequencies
const noteFreqs = {
  '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
};

const keyMap = {
  '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'
};

const keyLabels = {
  'a':'A','w':'W','s':'S','e':'E','d':'D',
  'f':'F','t':'T','g':'G','y':'Y','h':'H',
  'u':'U','j':'J','k':'K'
};

function noteOn(note) {
  initAudio();
  if (activeNotes[note]) return;

  const freq = noteFreqs[note] * Math.pow(2, params.octave);
  const now = audioCtx.currentTime;

  // Oscillator
  const osc = audioCtx.createOscillator();
  osc.type = params.waveform;
  osc.frequency.value = freq;
  osc.detune.value = params.detune;

  // LFO on pitch
  if (params.lfoDepth > 0 && params.lfoTarget === 'pitch') {
    const lfo = audioCtx.createOscillator();
    const lfoGain = audioCtx.createGain();
    lfo.type = params.lfoWaveform;
    lfo.frequency.value = params.lfoRate;
    lfoGain.gain.value = freq * params.lfoDepth * 0.01;
    lfo.connect(lfoGain);
    lfoGain.connect(osc.detune);
    lfo.start(now);
    activeNotes[note] = { osc, lfo };
  } else {
    activeNotes[note] = { osc };
  }

  // Filter
  const filter = audioCtx.createBiquadFilter();
  filter.type = params.filterType;
  filter.frequency.value = params.cutoffFreq;
  filter.Q.value = params.resonance;

  // ADSR Envelope
  const envGain = audioCtx.createGain();
  envGain.gain.setValueAtTime(0, now);
  envGain.gain.linearRampToValueAtTime(1, now + Math.max(params.attack, 0.005));
  envGain.gain.linearRampToValueAtTime(params.sustain, now + params.attack + Math.max(params.decay, 0.005));

  // Filter envelope
  if (params.envAmount > 0) {
    const filterEnvTarget = Math.min(18000, params.cutoffFreq * (1 + params.envAmount));
    filter.frequency.setValueAtTime(params.cutoffFreq, now);
    filter.frequency.linearRampToValueAtTime(filterEnvTarget, now + Math.max(params.attack, 0.005));
    filter.frequency.linearRampToValueAtTime(params.cutoffFreq, now + params.attack + Math.max(params.decay, 0.005));
  }

  // LFO on filter
  if (params.lfoDepth > 0 && params.lfoTarget === 'filter') {
    const lfo = audioCtx.createOscillator();
    const lfoGain = audioCtx.createGain();
    lfo.type = params.lfoWaveform;
    lfo.frequency.value = params.lfoRate;
    lfoGain.gain.value = params.cutoffFreq * params.lfoDepth * 0.3;
    lfo.connect(lfoGain);
    lfoGain.connect(filter.frequency);
    lfo.start(now);
    activeNotes[note].lfoFilter = { lfo, lfoGain };
  }

  // LFO on amplitude (tremolo)
  let tremoloLfo = null;
  if (params.lfoDepth > 0 && params.lfoTarget === 'amplitude') {
    const lfo = audioCtx.createOscillator();
    const lfoGain = audioCtx.createGain();
    lfo.type = params.lfoWaveform;
    lfo.frequency.value = params.lfoRate;
    lfoGain.gain.value = 0.5 * params.lfoDepth;
    lfo.connect(lfoGain);
    lfoGain.connect(envGain.gain);
    lfo.start(now);
    tremoloLfo = { lfo, lfoGain };
  }

  // Delay effect
  let delayNode = null, delayFeedbackNode = null, delayDry = null, delayWet = null;
  if (params.delayMix > 0) {
    delayNode = audioCtx.createDelay(1);
    delayNode.delayTime.value = params.delayTime;
    delayFeedbackNode = audioCtx.createGain();
    delayFeedbackNode.gain.value = params.delayFeedback;
    delayDry = audioCtx.createGain();
    delayWet = audioCtx.createGain();
    delayDry.gain.value = 1 - params.delayMix * 0.5;
    delayWet.gain.value = params.delayMix;

    osc.connect(filter);
    filter.connect(envGain);
    envGain.connect(delayDry);
    envGain.connect(delayNode);
    delayNode.connect(delayFeedbackNode);
    delayFeedbackNode.connect(delayNode);
    delayNode.connect(delayWet);
    delayDry.connect(masterGain);
    delayWet.connect(masterGain);
  } else {
    osc.connect(filter);
    filter.connect(envGain);
    envGain.connect(masterGain);
  }

  activeNotes[note].filter = filter;
  activeNotes[note].envGain = envGain;
  if (delayNode) {
    activeNotes[note].delayDry = delayDry;
    activeNotes[note].delayWet = delayWet;
    activeNotes[note].tremoloLfo = tremoloLfo;
  }

  osc.start(now);
}

function noteOff(note) {
  const data = activeNotes[note];
  if (!data) return;

  const now = audioCtx.currentTime;
  const envGain = data.envGain;

  // Release phase
  envGain.gain.cancelScheduledValues(now);
  envGain.gain.setValueAtTime(envGain.gain.value, now);
  envGain.gain.linearRampToValueAtTime(0, now + Math.max(params.release, 0.01));

  const stopTime = now + params.release + 0.1;

  data.osc.stop(stopTime);
  if (data.lfo) data.lfo.stop(stopTime);
  if (data.tremoloLfo && data.tremoloLfo.lfo) data.tremoloLfo.lfo.stop(stopTime);
  if (data.lfoFilter && data.lfoFilter.lfo) data.lfoFilter.lfo.stop(stopTime);

  setTimeout(() => {
    try {
      data.osc.disconnect();
      if (data.filter) data.filter.disconnect();
      envGain.disconnect();
      if (data.delayDry) data.delayDry.disconnect();
      if (data.delayWet) data.delayWet.disconnect();
    } catch(e) {}
  }, (params.release + 0.2) * 1000);

  delete activeNotes[note];
}

// ==================== TUTORIAL STEPS ====================
const steps = [
  {
    id: 'oscillator',
    title: 'The Oscillator — Your Sound Source',
    desc: `Every synthesizer starts with an <strong>oscillator</strong> — it generates the raw sound wave. Different waveforms produce different timbres (colors of sound). A <strong>sine</strong> is pure and smooth, a <strong>square</strong> is hollow and buzzy, a <strong>sawtooth</strong> is rich and bright, and a <strong>triangle</strong> is soft and mellow.`,
    hint: '🎵 Try each waveform by clicking the dropdown. Play notes on the keyboard to hear the difference!',
    check: () => true,
  },
  {
    id: 'adsr',
    title: 'ADSR Envelope — Shaping Time',
    desc: `Raw oscillator tones are lifeless. An <strong>envelope</strong> shapes how a note evolves over time. <strong>A</strong>ttack is how fast the sound rises, <strong>D</strong>ecay is how quickly it drops to sustain level, <strong>S</strong>ustain is the held volume, and <strong>R</strong>elease is how long it fades after you let go.`,
    hint: '🎵 Try setting Attack=0.5s for a soft pad sound, or Release=2s for a long fade-out!',
    check: () => true,
  },
  {
    id: 'filter',
    title: 'The Filter — Sculpting Tone',
    desc: `A <strong>filter</strong> removes frequencies from the signal. A <strong>lowpass filter</strong> cuts high frequencies (making sounds darker), while a <strong>highpass</strong> cuts lows. The <strong>cutoff frequency</strong> sets where the cut begins, and <strong>resonance</strong> emphasizes frequencies near the cutoff for a "squelchy" sound.`,
    hint: '🎵 Lower the cutoff to ~500Hz for a muffled bass tone, then add resonance=8 for that classic acid squelch!',
    check: () => true,
  },
  {
    id: 'filterEnv',
    title: 'Filter Envelope — Dynamic Filtering',
    desc: `The <strong>filter envelope amount</strong> makes the filter cutoff open and close with each note. This is what gives keys their "pluck" or "wah" character. More amount = more movement from bright to dark as the note plays.`,
    hint: '🎵 Set Env Amount high (0.8+) with a short attack for a plucky FM-like sound!',
    check: () => true,
  },
  {
    id: 'lfo',
    title: 'LFO — Modulation Magic',
    desc: `A <strong>Low Frequency Oscillator</strong> (LFO) is an oscillator too slow to hear as a pitch. Instead, it modulates other parameters. Set the target to <strong>Pitch</strong> for vibrato (pitch wobble), <strong>Filter</strong> for auto-wah, or <strong>Amplitude</strong> for tremolo (volume pulsing).`,
    hint: '🎵 Try LFO→Pitch at Rate=6Hz, Depth=0.3 for classic vibrato! Or LFO→Filter for a throbbing bass!',
    check: () => true,
  },
  {
    id: 'delay',
    title: 'Delay — Space and Echo',
    desc: `<strong>Delay</strong> repeats your signal after a time interval. The <strong>time</strong> sets the echo spacing, <strong>feedback</strong> controls how many repeats you get, and <strong>mix</strong> blends wet/dry signals. Small amounts add depth; large amounts create rhythmic echoes.`,
    hint: '🎵 Try Time=0.4s, Feedback=0.5, Mix=0.3 for a spacious echo that adds dimension!',
    check: () => true,
  },
  {
    id: 'master',
    title: 'You\'re a Synth Wizard! 🧙',
    desc: `You've explored all the core modules of a subtractive synthesizer. Real synths chain these together in <strong>signal paths</strong>: Oscillator → Filter → Envelope → Effects → Output. Now experiment freely — try combining everything to design your own sounds!`,
    hint: '🎵 Try the presets below for inspiration, then tweak each parameter to understand how they interact.',
    check: () => true,
  },
];

let currentStep = 0;
const unlockedModules = new Set();

// ==================== UI RENDERING ====================
function renderTutorial() {
  const panel = document.getElementById('tutorialPanel');
  panel.innerHTML = steps.map((step, i) => {
    const isActive = i === currentStep;
    const isCompleted = i < currentStep;
    const isLocked = i > currentStep;
    return `
      <div class="step-card ${isActive ? 'active' : ''} ${isCompleted ? 'completed' : ''} ${isLocked ? 'locked' : ''}" id="step-${i}">
        <div class="step-number">${isCompleted ? '✓' : i + 1}</div>
        <div class="step-title">${step.title}</div>
        <div class="step-desc">${step.desc}</div>
        ${isActive ? `<div class="step-hint">${step.hint}</div>` : ''}
        ${isActive ? `<button class="complete-btn" onclick="advanceStep()" id="completeBtn">Got it — Next →</button>` : ''}
      </div>
    `;
  }).join('');

  // Update progress
  const pct = (currentStep / (steps.length - 1)) * 100;
  document.getElementById('progressFill').style.width = pct + '%';
  document.getElementById('progressLabel').textContent = `Step ${currentStep + 1} of ${steps.length}`;
}

function renderSignalFlow() {
  const flow = document.getElementById('signalFlow');
  const nodes = [
    { label: 'Oscillator', active: unlockedModules.has('oscillator') },
    { label: 'Filter', active: unlockedModules.has('filter') || unlockedModules.has('filterEnv') },
    { label: 'Envelope', active: unlockedModules.has('adsr') },
    { label: 'LFO', active: unlockedModules.has('lfo') },
    { label: 'Delay', active: unlockedModules.has('delay') },
    { label: 'Output', active: true },
  ];

  flow.innerHTML = nodes.map((n, i) => `
    <div class="flow-node ${n.active ? 'active' : ''}">${n.label}</div>
    ${i < nodes.length - 1 ? `<span class="flow-arrow ${n.active ? 'active' : ''}">→</span>` : ''}
  `).join('');
}

function renderModules() {
  const grid = document.getElementById('moduleGrid');
  const modules = [
    // Oscillator Module
    {
      id: 'oscillator',
      name: 'Oscillator',
      icon: '🎵',
      color: '#7c3aed',
      controls: `
        <div class="control-row">
          <span class="control-label">Waveform</span>
          <select id="ctrl-waveform" onchange="setParam('waveform', this.value)">
            <option value="sine">Sine — Pure & Smooth</option>
            <option value="square">Square — Hollow & Buzzy</option>
            <option value="sawtooth" selected>Sawtooth — Rich & Bright</option>
            <option value="triangle">Triangle — Soft & Mellow</option>
          </select>
        </div>
        <div class="control-row">
          <span class="control-label">Detune<span class="info-tip" data-tip="Shift pitch slightly for a thicker sound">?</span></span>
          <input type="range" min="-100" max="100" value="${params.detune}" oninput="setParam('detune', +this.value); this.nextElementSibling.textContent=this.value+'¢'">
          <span class="control-value">${params.detune}¢</span>
        </div>
        <div class="control-row">
          <span class="control-label">Octave<span class="info-tip" data-tip="Shift the pitch up or down by octaves">?</span></span>
          <input type="range" min="-2" max="2" value="${params.octave}" step="1" oninput="setParam('octave', +this.value); this.nextElementSibling.textContent=this.value">
          <span class="control-value">${params.octave}</span>
        </div>
      `,
    },
    // ADSR Module
    {
      id: 'adsr',
      name: 'ADSR Envelope',
      icon: '📈',
      color: '#2563eb',
      controls: `
        <div class="control-row">
          <span class="control-label">Attack<span class="info-tip" data-tip="Time from note-on to full volume (0=instant)">?</span></span>
          <input type="range" min="1" max="2000" value="${params.attack*1000}" oninput="setParam('attack', this.value/1000); this.nextElementSibling.textContent=(this.value/1000).toFixed(2)+'s'">
          <span class="control-value">${params.attack.toFixed(2)}s</span>
        </div>
        <div class="control-row">
          <span class="control-label">Decay<span class="info-tip" data-tip="Time to drop from peak to sustain level">?</span></span>
          <input type="range" min="1" max="2000" value="${params.decay*1000}" oninput="setParam('decay', this.value/1000); this.nextElementSibling.textContent=(this.value/1000).toFixed(2)+'s'">
          <span class="control-value">${params.decay.toFixed(2)}s</span>
        </div>
        <div class="control-row">
          <span class="control-label">Sustain<span class="info-tip" data-tip="Volume level while holding the note (0=silent, 1=full)">?</span></span>
          <input type="range" min="0" max="100" value="${params.sustain*100}" oninput="setParam('sustain', this.value/100); this.nextElementSibling.textContent=Math.round(this.value)+'%'">
          <span class="control-value">${Math.round(params.sustain*100)}%</span>
        </div>
        <div class="control-row">
          <span class="control-label">Release<span class="info-tip" data-tip="Time for sound to fade after releasing the key">?</span></span>
          <input type="range" min="1" max="3000" value="${params.release*1000}" oninput="setParam('release', this.value/1000); this.nextElementSibling.textContent=(this.value/1000).toFixed(2)+'s'">
          <span class="control-value">${params.release.toFixed(2)}s</span>
        </div>
      `,
    },
    // Filter Module
    {
      id: 'filter',
      name: 'Filter',
      icon: '🔬',
      color: '#059669',
      controls: `
        <div class="control-row">
          <span class="control-label">Type</span>
          <select id="ctrl-filterType" onchange="setParam('filterType', this.value)">
            <option value="lowpass" selected>Lowpass — Cuts highs</option>
            <option value="highpass">Highpass — Cuts lows</option>
            <option value="bandpass">Bandpass — Keeps middle</option>
          </select>
        </div>
        <div class="control-row">
          <span class="control-label">Cutoff<span class="info-tip" data-tip="Frequency where filtering begins (Hz)">?</span></span>
          <input type="range" min="20" max="18000" value="${params.cutoffFreq}" oninput="setParam('cutoffFreq', +this.value); this.nextElementSibling.textContent=this.value+'Hz'">
          <span class="control-value">${params.cutoffFreq}Hz</span>
        </div>
        <div class="control-row">
          <span class="control-label">Resonance<span class="info-tip" data-tip="Emphasizes frequencies near cutoff (0=flat, 15=squelchy)">?</span></span>
          <input type="range" min="0.1" max="20" step="0.1" value="${params.resonance}" oninput="setParam('resonance', +this.value); this.nextElementSibling.textContent=parseFloat(this.value).toFixed(1)">
          <span class="control-value">${params.resonance.toFixed(1)}</span>
        </div>
      `,
    },
    // Filter Envelope Module
    {
      id: 'filterEnv',
      name: 'Filter Envelope',
      icon: '🌊',
      color: '#d97706',
      controls: `
        <div class="control-row">
          <span class="control-label">Amount<span class="info-tip" data-tip="How much the filter opens on each note (0=none, 1=max)">?</span></span>
          <input type="range" min="0" max="100" value="${params.envAmount*100}" oninput="setParam('envAmount', this.value/100); this.nextElementSibling.textContent=Math.round(this.value)+'%'">
          <span class="control-value">${Math.round(params.envAmount*100)}%</span>
        </div>
      `,
    },
    // LFO Module
    {
      id: 'lfo',
      name: 'LFO Modulation',
      icon: '🔄',
      color: '#dc2626',
      controls: `
        <div class="control-row">
          <span class="control-label">Waveform</span>
          <select id="ctrl-lfoWave" onchange="setParam('lfoWaveform', this.value)">
            <option value="sine" selected>Sine — Smooth</option>
            <option value="square">Square — Stepped</option>
            <option value="sawtooth">Sawtooth — Ramping</option>
          </select>
        </div>
        <div class="control-row">
          <span class="control-label">Rate<span class="info-tip" data-tip="Speed of modulation in Hz (0.1=slow, 20=fast)">?</span></span>
          <input type="range" min="1" max="200" value="${params.lfoRate*10}" oninput="setParam('lfoRate', this.value/10); this.nextElementSibling.textContent=(this.value/10).toFixed(1)+'Hz'">
          <span class="control-value">${params.lfoRate.toFixed(1)}Hz</span>
        </div>
        <div class="control-row">
          <span class="control-label">Depth<span class="info-tip" data-tip="Intensity of modulation (0=off, 1=max)">?</span></span>
          <input type="range" min="0" max="100" value="${params.lfoDepth*100}" oninput="setParam('lfoDepth', this.value/100); this.nextElementSibling.textContent=Math.round(this.value)+'%'">
          <span class="control-value">${Math.round(params.lfoDepth*100)}%</span>
        </div>
        <div class="control-row">
          <span class="control-label">Target</span>
          <select id="ctrl-lfoTarget" onchange="setParam('lfoTarget', this.value)">
            <option value="pitch" selected>Pitch (Vibrato)</option>
            <option value="filter">Filter (Auto-wah)</option>
            <option value="amplitude">Amplitude (Tremolo)</option>
          </select>
        </div>
      `,
    },
    // Delay Module
    {
      id: 'delay',
      name: 'Delay Effect',
      icon: '🔁',
      color: '#7c3aed',
      controls: `
        <div class="control-row">
          <span class="control-label">Time<span class="info-tip" data-tip="Echo spacing in seconds">?</span></span>
          <input type="range" min="10" max="1000" value="${params.delayTime*1000}" oninput="setParam('delayTime', this.value/1000); this.nextElementSibling.textContent=(this.value/1000).toFixed(2)+'s'">
          <span class="control-value">${params.delayTime.toFixed(2)}s</span>
        </div>
        <div class="control-row">
          <span class="control-label">Feedback<span class="info-tip" data-tip="How many echoes (0=one repeat, 0.9=infinite)">?</span></span>
          <input type="range" min="0" max="95" value="${params.delayFeedback*100}" oninput="setParam('delayFeedback', this.value/100); this.nextElementSibling.textContent=Math.round(this.value)+'%'">
          <span class="control-value">${Math.round(params.delayFeedback*100)}%</span>
        </div>
        <div class="control-row">
          <span class="control-label">Mix<span class="info-tip" data-tip="Blend of echoed signal (0=dry, 1=all echo)">?</span></span>
          <input type="range" min="0" max="100" value="${params.delayMix*100}" oninput="setParam('delayMix', this.value/100); this.nextElementSibling.textContent=Math.round(this.value)+'%'">
          <span class="control-value">${Math.round(params.delayMix*100)}%</span>
        </div>
      `,
    },
  ];

  grid.innerHTML = modules.map(m => `
    <div class="synth-module ${unlockedModules.has(m.id) ? '' : 'locked'}" id="module-${m.id}">
      <div class="module-header">
        <div class="module-icon" style="background:${m.color}22;color:${m.color}">${m.icon}</div>
        <div class="module-name">${m.name}</div>
      </div>
      ${m.controls}
    </div>
  `).join('');
}

function unlockModule(id) {
  if (unlockedModules.has(id)) return;
  unlockedModules.add(id);
  const el = document.getElementById(`module-${id}`);
  if (el) {
    el.classList.remove('locked');
    el.classList.add('unlocked-anim');
    setTimeout(() => el.classList.remove('unlocked-anim'), 1000);
  }
}

function setParam(key, value) {
  params[key] = value;
}

// ==================== KEYBOARD ====================
const noteNames = ['C4','C#4','D4','D#4','E4','F4','F#4','G4','G#4','A4','A#4','B4','C5'];
const isBlack = [false,true,false,true,false,false,true,false,true,false,true,false,false];

function buildKeyboard() {
  const kb = document.getElementById('keyboard');
  let whiteIdx = 0;
  noteNames.forEach((note, i) => {
    if (!isBlack[i]) {
      const key = document.createElement('div');
      key.className = 'key-white';
      key.dataset.note = note;
      key.addEventListener('mousedown', () => { initAudio(); noteOn(note); key.classList.add('active'); });
      key.addEventListener('mouseup', () => { noteOff(note); key.classList.remove('active'); });
      key.addEventListener('mouseleave', () => { if (key.classList.contains('active')) { noteOff(note); key.classList.remove('active'); } });
      // Touch support
      key.addEventListener('touchstart', e => { e.preventDefault(); initAudio(); noteOn(note); key.classList.add('active'); });
      key.addEventListener('touchend', e => { e.preventDefault(); noteOff(note); key.classList.remove('active'); });
      kb.appendChild(key);
      whiteIdx++;
    } else {
      const key = document.createElement('div');
      key.className = 'key-black';
      key.dataset.note = note;
      // Position: after the previous white key
      const leftPos = whiteIdx * 48 - 16;
      key.style.left = leftPos + 'px';
      key.addEventListener('mousedown', e => { e.stopPropagation(); initAudio(); noteOn(note); key.classList.add('active'); });
      key.addEventListener('mouseup', () => { noteOff(note); key.classList.remove('active'); });
      key.addEventListener('mouseleave', () => { if (key.classList.contains('active')) { noteOff(note); key.classList.remove('active'); } });
      key.addEventListener('touchstart', e => { e.preventDefault(); e.stopPropagation(); initAudio(); noteOn(note); key.classList.add('active'); });
      key.addEventListener('touchend', e => { e.preventDefault(); noteOff(note); key.classList.remove('active'); });
      kb.appendChild(key);
    }
  });
}

// Computer keyboard input
const pressedKeys = new Set();
document.addEventListener('keydown', e => {
  if (e.repeat) return;
  const k = e.key.toLowerCase();
  if (keyMap[k] && !pressedKeys.has(k)) {
    pressedKeys.add(k);
    initAudio();
    noteOn(keyMap[k]);
    // Highlight visual key
    const visKey = document.querySelector(`.keyboard [data-note="${keyMap[k]}"]`);
    if (visKey) visKey.classList.add('active');
  }
});

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

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

  function draw() {
    requestAnimationFrame(draw);
    const w = canvas.offsetWidth;
    const h = canvas.offsetHeight;

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

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

    if (!analyser) return;

    const bufferLength = analyser.frequencyBinCount;
    const dataArray = new Uint8Array(bufferLength);
    analyser.getByteTimeDomainData(dataArray);

    // Glow effect
    ctx.shadowColor = '#7c3aed';
    ctx.shadowBlur = 10;
    ctx.strokeStyle = '#a78bfa';
    ctx.lineWidth = 2;
    ctx.beginPath();

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

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

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

  draw();
}

// ==================== MASTER VOLUME ====================
document.getElementById('masterVol').addEventListener('input', function() {
  const v = this.value / 100;
  document.getElementById('masterVolVal').textContent = Math.round(this.value) + '%';
  if (masterGain) masterGain.gain.value = v;
});

// ==================== STEP MANAGEMENT ====================
function advanceStep() {
  if (currentStep >= steps.length - 1) return;

  const stepIds = ['oscillator', 'adsr', 'filter', 'filterEnv', 'lfo', 'delay', 'master'];
  currentStep++;

  // Unlock corresponding module
  if (stepIds[currentStep]) {
    unlockModule(stepIds[currentStep]);
  }

  renderTutorial();
  renderSignalFlow();
}

// ==================== PRESETS ====================
const presets = [
  { name: '🎹 Warm Pad', params: { waveform:'sawtooth', attack:0.8, decay:0.5, sustain:0.7, release:1.5, cutoffFreq:2000, resonance:2, envAmount:0.3, lfoDepth:0.15, lfoRate:4, lfoTarget:'pitch', delayMix:0.2 } },
  { name: '🎸 Pluck Bass', params: { waveform:'square', attack:0.01, decay:0.3, sustain:0.1, release:0.2, cutoffFreq:800, resonance:6, envAmount:0.9, lfoDepth:0, delayMix:0 } },
  { name: '🌊 Wobble', params: { waveform:'sawtooth', attack:0.05, decay:0.2, sustain:0.8, release:0.3, cutoffFreq:1500, resonance:8, envAmount:0.4, lfoDepth:0.7, lfoRate:6, lfoTarget:'filter', delayMix:0 } },
  { name: '✨ Sparkle', params: { waveform:'sine', attack:0.01, decay:0.5, sustain:0.3, release:2, cutoffFreq:6000, resonance:1, envAmount:0, lfoDepth:0.1, lfoRate:3, lfoTarget:'amplitude', delayMix:0.4 } },
];

function applyPreset(idx) {
  const p = presets[idx].params;
  Object.assign(params, p);
  renderModules();
}

// ==================== INIT ====================
function init() {
  // Unlock first module immediately
  unlockModule('oscillator');

  buildKeyboard();
  renderTutorial();
  renderSignalFlow();
  renderModules();
  drawWaveform();

  // Add presets to last step area after rendering
  setTimeout(() => {
    const panel = document.getElementById('tutorialPanel');
    const presetDiv = document.createElement('div');
    presetDiv.style.cssText = 'padding:16px;';
    presetDiv.innerHTML = `
      <div style="font-size:13px;color:var(--text-dim);margin-bottom:8px;">🎛️ Quick Presets</div>
      <div class="preset-row">
        ${presets.map((p, i) => `<button class="preset-btn" onclick="applyPreset(${i})">${p.name}</button>`).join('')}
      </div>
    `;
    panel.appendChild(presetDiv);
  }, 100);
}

init();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"middle","sessionId":"d08b84cf-9cb7-4ca1-827b-f8dcb25a5d65","tokensIn":15704,"tokensOut":13686,"tokensTotal":29390,"turns":2,"toolCalls":1,"failedToolCalls":0,"timestamp":"2026-07-18T23:39:24.215Z"} -->