TutSynth
Qwen 3.6 35b a3b · full

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 59,345 bytes · SHA-256 5fbca9eec809
<!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 How Synths Work</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--bg: #0a0a0f;
--panel: #12121a;
--panel-border: #1e1e2e;
--accent: #6c5ce7;
--accent-glow: #a29bfe;
--accent-dim: #4a3db5;
--green: #00cec9;
--orange: #fdcb6e;
--pink: #fd79a8;
--red: #ff6b6b;
--text: #e2e2e8;
--text-dim: #8888a0;
--text-bright: #ffffff;
--knob-size: 64px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', sans-serif;
overflow-x: hidden;
min-height: 100vh;
}
/* ── Header ── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--panel);
border-bottom: 1px solid var(--panel-border);
}
.header h1 {
font-size: 18px;
font-weight: 600;
color: var(--text-bright);
display: flex;
align-items: center;
gap: 8px;
}
.header h1 .icon { font-size: 22px; }
.header-controls { display: flex; gap: 8px; align-items: center; }
.btn {
padding: 8px 16px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
font-family: 'Inter', sans-serif;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.btn:hover { border-color: var(--accent); color: var(--accent-glow); }
.btn.active { background: var(--accent); border-color: var(--accent); color: white; }
.btn-accent {
background: var(--accent);
border-color: var(--accent);
color: white;
font-weight: 500;
}
.btn-accent:hover { background: var(--accent-dim); }
/* ── Layout ── */
.app { display: flex; height: calc(100vh - 57px); }
/* ── Tutorial Panel ── */
.tutorial-panel {
width: 320px;
min-width: 320px;
background: var(--panel);
border-right: 1px solid var(--panel-border);
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 0.3s;
}
.tutorial-panel.collapsed { width: 0; min-width: 0; border: none; }
.tutorial-header {
padding: 16px;
border-bottom: 1px solid var(--panel-border);
font-weight: 600;
font-size: 14px;
color: var(--text-bright);
display: flex;
align-items: center;
gap: 8px;
}
.tutorial-progress {
padding: 12px 16px;
border-bottom: 1px solid var(--panel-border);
}
.progress-bar {
height: 4px;
background: var(--panel-border);
border-radius: 2px;
overflow: hidden;
margin-bottom: 6px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--green));
border-radius: 2px;
transition: width 0.4s;
}
.progress-text { font-size: 11px; color: var(--text-dim); }
.tutorial-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.tutorial-content::-webkit-scrollbar { width: 4px; }
.tutorial-content::-webkit-scrollbar-thumb { background: var(--panel-border); border-radius: 2px; }
.lesson {
margin-bottom: 20px;
padding: 14px;
border-radius: 10px;
border: 1px solid var(--panel-border);
background: rgba(255,255,255,0.02);
transition: all 0.3s;
}
.lesson.locked { opacity: 0.35; pointer-events: none; }
.lesson.active {
border-color: var(--accent);
background: rgba(108, 92, 231, 0.08);
box-shadow: 0 0 20px rgba(108, 92, 231, 0.1);
}
.lesson.completed { border-color: var(--green); }
.lesson-num {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--accent-glow);
margin-bottom: 6px;
}
.lesson.completed .lesson-num { color: var(--green); }
.lesson.locked .lesson-num { color: var(--text-dim); }
.lesson h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 8px;
}
.lesson p {
font-size: 12px;
line-height: 1.6;
color: var(--text-dim);
margin-bottom: 10px;
}
.lesson .tip {
font-size: 11px;
padding: 8px 10px;
background: rgba(0, 206, 201, 0.08);
border-left: 2px solid var(--green);
border-radius: 0 6px 6px 0;
color: var(--green);
line-height: 1.5;
}
.lesson .action-btn {
display: inline-block;
margin-top: 8px;
padding: 6px 14px;
background: var(--accent);
border: none;
border-radius: 6px;
color: white;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.lesson .action-btn:hover { background: var(--accent-dim); }
/* ── Synth Area ── */
.synth-area {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
.synth-area::-webkit-scrollbar { width: 6px; }
.synth-area::-webkit-scrollbar-thumb { background: var(--panel-border); border-radius: 3px; }
/* ── Visualizer ── */
.visualizer {
height: 160px;
background: var(--panel);
border-bottom: 1px solid var(--panel-border);
position: relative;
flex-shrink: 0;
}
.visualizer canvas { width: 100%; height: 100%; }
.viz-label {
position: absolute;
top: 8px;
left: 12px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-dim);
background: rgba(0,0,0,0.4);
padding: 3px 8px;
border-radius: 4px;
}
/* ── Synth Panels ── */
.synth-body { padding: 20px; }
.synth-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.synth-panel {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 16px;
position: relative;
}
.synth-panel .panel-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.panel-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
}
.synth-panel.active .panel-dot { background: var(--green); box-shadow: 0 0 8px var(--green); }
/* ── Waveform Selector ── */
.waveform-selector {
display: flex;
gap: 6px;
}
.waveform-btn {
width: 56px;
height: 56px;
border: 1px solid var(--panel-border);
border-radius: 10px;
background: rgba(255,255,255,0.02);
color: var(--text-dim);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
transition: all 0.2s;
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
}
.waveform-btn:hover { border-color: var(--accent); color: var(--accent-glow); }
.waveform-btn.selected {
border-color: var(--accent);
background: rgba(108, 92, 231, 0.15);
color: var(--accent-glow);
}
.waveform-btn svg { width: 36px; height: 24px; }
/* ── Controls Row ── */
.controls-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.control {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.control-label {
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-dim);
text-align: center;
}
.control-value {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--accent-glow);
background: rgba(108, 92, 231, 0.1);
padding: 2px 8px;
border-radius: 4px;
}
/* ── Knob ── */
.knob-container {
position: relative;
width: var(--knob-size);
height: var(--knob-size);
}
.knob-svg { width: 100%; height: 100%; }
.knob-track {
fill: none;
stroke: var(--panel-border);
stroke-width: 4;
}
.knob-fill {
fill: none;
stroke: var(--accent);
stroke-width: 4;
stroke-linecap: round;
transition: stroke-dashoffset 0.1s;
}
.knob-fill.green { stroke: var(--green); }
.knob-fill.orange { stroke: var(--orange); }
.knob-fill.pink { stroke: var(--pink); }
.knob-indicator {
fill: var(--accent-glow);
transition: cx 0.1s, cy 0.1s;
}
.knob-indicator.green { fill: var(--green); }
.knob-indicator.orange { fill: var(--orange); }
.knob-indicator.pink { fill: var(--pink); }
.knob-center {
fill: var(--panel);
stroke: var(--panel-border);
stroke-width: 1;
}
.knob-dragging .knob-fill { stroke: var(--orange); }
.knob-dragging .knob-indicator { fill: var(--orange); }
/* ── Range Slider ── */
.range-control {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.range-track {
position: relative;
height: 6px;
background: var(--panel-border);
border-radius: 3px;
cursor: pointer;
}
.range-fill {
position: absolute;
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-glow));
border-radius: 3px;
pointer-events: none;
}
.range-thumb {
position: absolute;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
top: 50%;
transform: translate(-50%, -50%);
cursor: grab;
box-shadow: 0 0 8px rgba(108, 92, 231, 0.4);
pointer-events: none;
}
input[type="range"] {
-webkit-appearance: none;
width: 100%;
height: 6px;
background: transparent;
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 6px;
background: var(--panel-border);
border-radius: 3px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
margin-top: -5px;
box-shadow: 0 0 8px rgba(108, 92, 231, 0.4);
cursor: grab;
}
/* ── ADSR Visualization ── */
.adsr-visual {
width: 100%;
height: 80px;
margin-top: 8px;
background: rgba(0,0,0,0.2);
border-radius: 8px;
overflow: hidden;
}
.adsr-visual canvas { width: 100%; height: 100%; }
/* ── Keyboard ── */
.keyboard-container {
background: var(--panel);
border-top: 1px solid var(--panel-border);
padding: 16px 20px 20px;
flex-shrink: 0;
}
.keyboard-label {
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-dim);
margin-bottom: 10px;
text-align: center;
}
.keyboard {
display: flex;
justify-content: center;
position: relative;
height: 140px;
user-select: none;
}
.key {
position: relative;
cursor: pointer;
transition: all 0.05s;
}
.key-white {
width: 44px;
height: 140px;
background: linear-gradient(180deg, #f0f0f0, #e0e0e0);
border: 1px solid #ccc;
border-radius: 0 0 6px 6px;
margin: 0 1px;
z-index: 1;
}
.key-white:hover { background: linear-gradient(180deg, #fff, #eee); }
.key-white.active {
background: linear-gradient(180deg, var(--accent-glow), var(--accent));
border-color: var(--accent);
box-shadow: 0 0 20px rgba(108, 92, 231, 0.5);
}
.key-black {
width: 28px;
height: 90px;
background: linear-gradient(180deg, #333, #111);
border: 1px solid #000;
border-radius: 0 0 4px 4px;
position: absolute;
z-index: 2;
margin-left: -14px;
}
.key-black:hover { background: linear-gradient(180deg, #444, #222); }
.key-black.active {
background: linear-gradient(180deg, var(--accent), var(--accent-dim));
box-shadow: 0 0 15px rgba(108, 92, 231, 0.5);
}
.key-label {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
font-size: 9px;
font-weight: 500;
color: #999;
pointer-events: none;
}
.key-black .key-label { color: #666; bottom: 6px; font-size: 8px; }
.key-white.active .key-label { color: white; }
.key-black.active .key-label { color: var(--accent-glow); }
/* ── Presets ── */
.presets-bar {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.preset-btn {
padding: 6px 12px;
border: 1px solid var(--panel-border);
border-radius: 6px;
background: rgba(255,255,255,0.03);
color: var(--text-dim);
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
font-family: 'Inter', sans-serif;
}
.preset-btn:hover { border-color: var(--accent); color: var(--accent-glow); }
.preset-btn.active { border-color: var(--accent); background: rgba(108,92,231,0.15); color: var(--accent-glow); }
/* ── Tooltip ── */
.tooltip {
position: fixed;
background: #1a1a2e;
border: 1px solid var(--panel-border);
border-radius: 8px;
padding: 10px 14px;
font-size: 12px;
color: var(--text);
max-width: 240px;
z-index: 1000;
pointer-events: none;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
line-height: 1.5;
opacity: 0;
transition: opacity 0.15s;
}
.tooltip.visible { opacity: 1; }
.tooltip .tt-title {
font-weight: 600;
color: var(--accent-glow);
margin-bottom: 4px;
font-size: 12px;
}
/* ── Responsive ── */
@media (max-width: 900px) {
.tutorial-panel { display: none; }
.controls-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); }
}
/* ── Glow animations ── */
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.pulse { animation: pulse 2s ease-in-out infinite; }
/* ── Filter visualization ── */
.filter-viz {
width: 100%;
height: 60px;
margin-top: 8px;
background: rgba(0,0,0,0.2);
border-radius: 8px;
overflow: hidden;
}
.filter-viz canvas { width: 100%; height: 100%; }
/* ── LFO section ── */
.lfo-waveform {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.lfo-wave-btn {
width: 36px;
height: 30px;
border: 1px solid var(--panel-border);
border-radius: 6px;
background: rgba(255,255,200,0.03);
color: var(--text-dim);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.lfo-wave-btn:hover { border-color: var(--orange); }
.lfo-wave-btn.selected { border-color: var(--orange); background: rgba(253,203,110,0.1); color: var(--orange); }
.lfo-wave-btn svg { width: 22px; height: 14px; }
/* ── Effect section ── */
.effect-toggle {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.toggle {
width: 36px;
height: 20px;
background: var(--panel-border);
border-radius: 10px;
position: relative;
cursor: pointer;
transition: background 0.2s;
}
.toggle.on { background: var(--accent); }
.toggle::after {
content: '';
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: left 0.2s;
}
.toggle.on::after { left: 18px; }
.toggle-label { font-size: 12px; color: var(--text-dim); }
.note-display {
position: absolute;
top: 8px;
right: 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-glow);
background: rgba(108, 92, 231, 0.1);
padding: 3px 10px;
border-radius: 4px;
min-width: 60px;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1><span class="icon">🎛️</span> Synth Playground</h1>
<div class="header-controls">
<button class="btn" id="toggleTutorial">📖 Tutorial</button>
<button class="btn" id="resetBtn">↺ Reset</button>
</div>
</div>
<div class="app">
<!-- Tutorial Panel -->
<div class="tutorial-panel" id="tutorialPanel">
<div class="tutorial-header">📚 Learn Synthesis</div>
<div class="tutorial-progress">
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width: 0%"></div></div>
<div class="progress-text" id="progressText">Lesson 0 of 7</div>
</div>
<div class="tutorial-content" id="tutorialContent"></div>
</div>
<!-- Synth Area -->
<div class="synth-area" id="synthArea">
<!-- Visualizer -->
<div class="visualizer">
<div class="viz-label">Waveform</div>
<div class="note-display" id="noteDisplay">—</div>
<canvas id="vizCanvas"></canvas>
</div>
<div class="synth-body">
<!-- Presets -->
<div class="presets-bar">
<span style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:1px;color:var(--text-dim);margin-right:4px;">Presets:</span>
<button class="preset-btn" data-preset="sine-bell">🔔 Sine Bell</button>
<button class="preset-btn" data-preset="saw-lead">🎸 Saw Lead</button>
<button class="preset-btn" data-preset="square-bass">🎵 Square Bass</button>
<button class="preset-btn" data-preset="tri-pad">🎹 Tri Pad</button>
<button class="preset-btn" data-preset="fm-bell">✨ FM Bell</button>
<button class="preset-btn" data-preset="filtered-noise">💨 Filtered Noise</button>
<button class="preset-btn" data-preset="warm-sub">🌊 Warm Sub</button>
</div>
<!-- Oscillator -->
<div class="synth-row">
<div class="synth-panel" id="oscPanel">
<div class="panel-label"><span class="panel-dot"></span> Oscillator</div>
<div class="controls-grid">
<div style="grid-column: 1 / -1;">
<div class="control-label" style="margin-bottom:8px;">Waveform</div>
<div class="waveform-selector">
<button class="waveform-btn selected" data-wave="sine">
<svg viewBox="0 0 36 24"><path d="M2 12 Q11 0 18 12 Q25 24 34 12" fill="none" stroke="currentColor" stroke-width="2"/></svg>
Sine
</button>
<button class="waveform-btn" data-wave="square">
<svg viewBox="0 0 36 24"><path d="M2 18 L2 6 L18 6 L18 18 L34 18 L34 6" fill="none" stroke="currentColor" stroke-width="2"/></svg>
Square
</button>
<button class="waveform-btn" data-wave="sawtooth">
<svg viewBox="0 0 36 24"><path d="M2 18 L18 6 L18 18 L34 6" fill="none" stroke="currentColor" stroke-width="2"/></svg>
Saw
</button>
<button class="waveform-btn" data-wave="triangle">
<svg viewBox="0 0 36 24"><path d="M2 18 L9 6 L27 18 L34 6" fill="none" stroke="currentColor" stroke-width="2"/></svg>
Tri
</button>
</div>
</div>
<div class="control">
<div class="control-label">Octave</div>
<input type="range" id="octaveSlider" min="-2" max="2" value="0" step="1">
<div class="control-value" id="octaveValue">0</div>
</div>
<div class="control">
<div class="control-label">Detune</div>
<input type="range" id="detuneSlider" min="-100" max="100" value="0" step="1">
<div class="control-value" id="detuneValue">0¢</div>
</div>
<div class="control">
<div class="control-label">Volume</div>
<input type="range" id="oscVolume" min="0" max="100" value="70">
<div class="control-value" id="oscVolumeValue">70%</div>
</div>
</div>
</div>
</div>
<!-- Filter -->
<div class="synth-row">
<div class="synth-panel" id="filterPanel">
<div class="panel-label"><span class="panel-dot"></span> Filter</div>
<div class="controls-grid">
<div class="control">
<div class="control-label">Type</div>
<div class="waveform-selector">
<button class="waveform-btn selected" data-filter="lowpass" style="width:48px;height:44px;font-size:8px;">LP</button>
<button class="waveform-btn" data-filter="highpass" style="width:48px;height:44px;font-size:8px;">HP</button>
<button class="waveform-btn" data-filter="bandpass" style="width:48px;height:44px;font-size:8px;">BP</button>
</div>
</div>
<div class="control">
<div class="control-label">Cutoff</div>
<input type="range" id="filterCutoff" min="20" max="20000" value="5000" step="1">
<div class="control-value" id="filterCutoffValue">5000 Hz</div>
</div>
<div class="control">
<div class="control-label">Resonance</div>
<input type="range" id="filterRes" min="0" max="30" value="1" step="0.1">
<div class="control-value" id="filterResValue">1.0</div>
</div>
<div class="control" style="grid-column: 1 / -1;">
<div class="control-label">Filter Response</div>
<div class="filter-viz"><canvas id="filterVizCanvas"></canvas></div>
</div>
</div>
</div>
</div>
<!-- ADSR Envelope -->
<div class="synth-row">
<div class="synth-panel" id="adsrPanel">
<div class="panel-label"><span class="panel-dot"></span> Amplifier (ADSR)</div>
<div class="controls-grid">
<div class="control">
<div class="control-label">Attack</div>
<input type="range" id="attackSlider" min="0" max="2000" value="10" step="1">
<div class="control-value" id="attackValue">10ms</div>
</div>
<div class="control">
<div class="control-label">Decay</div>
<input type="range" id="decaySlider" min="0" max="2000" value="200" step="1">
<div class="control-value" id="decayValue">200ms</div>
</div>
<div class="control">
<div class="control-label">Sustain</div>
<input type="range" id="sustainSlider" min="0" max="100" value="70" step="1">
<div class="control-value" id="sustainValue">70%</div>
</div>
<div class="control">
<div class="control-label">Release</div>
<input type="range" id="releaseSlider" min="0" max="3000" value="300" step="1">
<div class="control-value" id="releaseValue">300ms</div>
</div>
<div class="control" style="grid-column: 1 / -1;">
<div class="control-label">Envelope Shape</div>
<div class="adsr-visual"><canvas id="adsrCanvas"></canvas></div>
</div>
</div>
</div>
</div>
<!-- LFO -->
<div class="synth-row">
<div class="synth-panel" id="lfoPanel">
<div class="panel-label"><span class="panel-dot"></span> LFO (Modulation)</div>
<div class="controls-grid">
<div class="control">
<div class="control-label">Rate</div>
<input type="range" id="lfoRate" min="0.1" max="20" value="4" step="0.1">
<div class="control-value" id="lfoRateValue">4 Hz</div>
</div>
<div class="control">
<div class="control-label">Depth</div>
<input type="range" id="lfoDepth" min="0" max="100" value="0" step="1">
<div class="control-value" id="lfoDepthValue">0</div>
</div>
<div class="control">
<div class="control-label">Target</div>
<select id="lfoTarget" style="background:var(--panel);border:1px solid var(--panel-border);border-radius:6px;color:var(--text);padding:6px 10px;font-size:12px;font-family:'Inter',sans-serif;">
<option value="pitch">Pitch</option>
<option value="filter">Filter</option>
<option value="volume">Volume</option>
</select>
</div>
<div class="control">
<div class="control-label">Waveform</div>
<div class="lfo-waveform">
<button class="lfo-wave-btn selected" data-lfo="sine"><svg viewBox="0 0 22 14"><path d="M1 7 Q6 0 11 7 Q16 14 21 7" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></button>
<button class="lfo-wave-btn" data-lfo="triangle"><svg viewBox="0 0 22 14"><path d="M1 7 L5 1 L11 13 L17 1 L21 7" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></button>
<button class="lfo-wave-btn" data-lfo="sawtooth"><svg viewBox="0 0 22 14"><path d="M1 13 L11 1 L11 13 L21 1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></button>
<button class="lfo-wave-btn" data-lfo="square"><svg viewBox="0 0 22 14"><path d="M1 12 L1 2 L11 2 L11 12 L21 12 L21 2" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></button>
</div>
</div>
</div>
</div>
</div>
<!-- Effects -->
<div class="synth-row">
<div class="synth-panel" id="fxPanel">
<div class="panel-label"><span class="panel-dot"></span> Effects</div>
<div class="controls-grid">
<div class="control">
<div class="effect-toggle">
<div class="toggle" id="delayToggle"></div>
<span class="toggle-label">Delay</span>
</div>
<input type="range" id="delayTime" min="0.01" max="1" value="0.3" step="0.01" style="width:100%;margin-top:6px;">
<div class="control-value" id="delayTimeValue" style="text-align:center;margin-top:2px;">300ms</div>
<input type="range" id="delayFeedback" min="0" max="0.9" value="0.4" step="0.01" style="width:100%;margin-top:6px;">
<div class="control-value" id="delayFeedbackValue" style="text-align:center;margin-top:2px;">40%</div>
</div>
<div class="control">
<div class="effect-toggle">
<div class="toggle" id="reverbToggle"></div>
<span class="toggle-label">Reverb</span>
</div>
<input type="range" id="reverbMix" min="0" max="100" value="30" step="1" style="width:100%;margin-top:6px;">
<div class="control-value" id="reverbMixValue" style="text-align:center;margin-top:2px;">30%</div>
</div>
</div>
</div>
</div>
</div>
<!-- Keyboard -->
<div class="keyboard-container">
<div class="keyboard-label">Use your computer keyboard (A-K = white keys, W-U = black keys) or click below</div>
<div class="keyboard" id="keyboard"></div>
</div>
</div>
</div>
<div class="tooltip" id="tooltip">
<div class="tt-title"></div>
<div class="tt-body"></div>
</div>
<script>
// ═══════════════════════════════════════════════════════════
// SYNTH ENGINE
// ═══════════════════════════════════════════════════════════
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const KEY_MAP = {
'a':'C4','w':'C#4','s':'D4','e':'D#4','d':'E4',
'f':'F4','t':'F#4','g':'G4','y':'G#4','h':'A4',
'u':'A#4','j':'B4','k':'C5','o':'C#5','l':'D5',
'p':'D#5',';':'E5'
};
const NOTE_FREQ = {
'C0':16.35,'C#0':17.32,'D0':18.35,'D#0':19.45,'E0':20.60,'F0':21.83,
'F#0':23.12,'G0':24.50,'G#0':25.96,'A0':27.50,'A#0':29.14,'B0':30.87,
'C1':32.70,'C#1':34.65,'D1':36.71,'D#1':38.89,'E1':41.20,'F1':43.65,
'F#1':46.25,'G1':49.00,'G#1':51.91,'A1':55.00,'A#1':58.27,'B1':61.74,
'C2':65.41,'C#2':69.30,'D2':73.42,'D#2':77.78,'E2':82.41,'F2':87.31,
'F#2':92.50,'G2':98.00,'G#2':103.83,'A2':110.00,'A#2':116.54,'B2':123.47,
'C3':130.81,'C#3':138.59,'D3':146.83,'D#3':155.56,'E3':164.81,'F3':174.61,
'F#3':185.00,'G3':196.00,'G#3':207.65,'A3':220.00,'A#3':233.08,'B3':246.94,
'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,'F5':698.46,
'F#5':739.99,'G5':783.99,'G#5':830.61,'A5':880.00,'A#5':932.33,'B5':987.77,
'C6':1046.50
};
let audioCtx = null;
let masterGain = null;
let analyser = null;
let filterNode = null;
let delayNode = null;
let delayFeedbackNode = null;
let reverbNode = null;
let reverbGain = null;
let dryGain = null;
let lfoNode = null;
let lfoGain = null;
let lfoConnected = false;
const state = {
waveform: 'sine',
filterType: 'lowpass',
filterCutoff: 5000,
filterRes: 1,
attack: 10,
decay: 200,
sustain: 70,
release: 300,
octave: 0,
detune: 0,
oscVolume: 70,
lfoRate: 4,
lfoDepth: 0,
lfoTarget: 'pitch',
lfoWaveform: 'sine',
delayTime: 0.3,
delayFeedback: 0.4,
delayEnabled: false,
reverbMix: 30,
reverbEnabled: false,
activeNotes: {},
tutorialStep: 0,
completedLessons: new Set()
};
const presets = {
'sine-bell': { waveform:'sine', filterCutoff:8000, filterRes:0.5, attack:5, decay:800, sustain:20, release:1500, lfoDepth:0, filterType:'lowpass' },
'saw-lead': { waveform:'sawtooth', filterCutoff:3000, filterRes:4, attack:5, decay:200, sustain:80, release:300, lfoDepth:15, lfoTarget:'pitch', lfoRate:5, filterType:'lowpass' },
'square-bass': { waveform:'square', filterCutoff:600, filterRes:2, attack:10, decay:300, sustain:60, release:100, octave:-1, filterType:'lowpass' },
'tri-pad': { waveform:'triangle', filterCutoff:4000, filterRes:1, attack:400, decay:300, sustain:70, release:800, filterType:'lowpass' },
'fm-bell': { waveform:'sine', filterCutoff:12000, filterRes:0, attack:1, decay:500, sustain:0, release:2000, lfoDepth:30, lfoTarget:'pitch', lfoRate:8, filterType:'lowpass' },
'filtered-noise': { waveform:'sawtooth', filterCutoff:1500, filterRes:8, attack:50, decay:400, sustain:40, release:500, filterType:'bandpass' },
'warm-sub': { waveform:'sine', filterCutoff:500, filterRes:0, attack:20, decay:100, sustain:80, release:200, octave:-2, filterType:'lowpass' }
};
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Master gain
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.8;
// Analyser
analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
// Filter
filterNode = audioCtx.createBiquadFilter();
filterNode.type = state.filterType;
filterNode.frequency.value = state.filterCutoff;
filterNode.Q.value = state.filterRes;
// Delay
delayNode = audioCtx.createDelay(2);
delayNode.delayTime.value = state.delayTime;
delayFeedbackNode = audioCtx.createGain();
delayFeedbackNode.gain.value = state.delayFeedback;
// Reverb (convolver with generated impulse)
reverbNode = audioCtx.createConvolver();
reverbGain = audioCtx.createGain();
dryGain = audioCtx.createGain();
dryGain.gain.value = 1;
reverbGain.gain.value = state.reverbMix / 100;
generateReverbIR();
// LFO
lfoNode = audioCtx.createOscillator();
lfoGain = audioCtx.createGain();
lfoNode.type = state.lfoWaveform;
lfoNode.frequency.value = state.lfoRate;
lfoGain.gain.value = state.lfoDepth;
// Routing
filterNode.connect(masterGain);
masterGain.connect(dryGain);
dryGain.connect(analyser);
analyser.connect(audioCtx.destination);
// Delay routing
masterGain.connect(delayNode);
delayNode.connect(delayFeedbackNode);
delayFeedbackNode.connect(delayNode);
delayNode.connect(reverbNode);
// Reverb routing
masterGain.connect(reverbNode);
reverbNode.connect(reverbGain);
reverbGain.connect(analyser);
// LFO routing
lfoNode.connect(lfoGain);
lfoNode.start();
// Update LFO connections
updateLFOConnections();
}
function generateReverbIR() {
const rate = audioCtx.sampleRate;
const length = rate * 2;
const impulse = audioCtx.createBuffer(2, length, rate);
for (let ch = 0; ch < 2; ch++) {
const data = impulse.getChannelData(ch);
for (let i = 0; i < length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2.5);
}
}
reverbNode.buffer = impulse;
}
function getNoteFreq(note) {
const baseFreq = NOTE_FREQ[note];
if (!baseFreq) return 440;
return baseFreq * Math.pow(2, state.octave);
}
function noteOn(note) {
initAudio();
if (state.activeNotes[note]) return;
const freq = getNoteFreq(note);
const osc = audioCtx.createOscillator();
const envGain = audioCtx.createGain();
osc.type = state.waveform;
osc.frequency.value = freq;
osc.detune.value = state.detune;
// ADSR
const now = audioCtx.currentTime;
const a = state.attack / 1000;
const d = state.decay / 1000;
const s = state.sustain / 100;
const vol = state.oscVolume / 100;
envGain.gain.setValueAtTime(0, now);
envGain.gain.linearRampToValueAtTime(vol, now + a);
envGain.gain.linearRampToValueAtTime(vol * s, now + a + d);
osc.connect(envGain);
envGain.connect(filterNode);
osc.start(now);
state.activeNotes[note] = { osc, envGain };
updateNoteDisplay(note);
updateActiveKeys();
}
function noteOff(note) {
const entry = state.activeNotes[note];
if (!entry) return;
const { osc, envGain } = entry;
const now = audioCtx.currentTime;
const r = state.release / 1000;
envGain.gain.cancelScheduledValues(now);
envGain.gain.setValueAtTime(envGain.gain.value, now);
envGain.gain.linearRampToValueAtTime(0, now + r);
osc.stop(now + r + 0.05);
delete state.activeNotes[note];
updateNoteDisplay(null);
updateActiveKeys();
}
function updateLFOConnections() {
if (!audioCtx) return;
// Disconnect previous
try { lfoGain.disconnect(); } catch(e) {}
lfoNode.frequency.value = state.lfoRate;
lfoNode.type = state.lfoWaveform;
lfoGain.gain.value = state.lfoDepth;
if (state.lfoDepth > 0) {
if (state.lfoTarget === 'pitch') {
for (const note in state.activeNotes) {
state.activeNotes[note].osc.detune.connect(lfoGain);
}
} else if (state.lfoTarget === 'filter') {
lfoGain.connect(filterNode.frequency);
} else if (state.lfoTarget === 'volume') {
for (const note in state.activeNotes) {
lfoGain.connect(state.activeNotes[note].envGain.gain);
}
}
}
}
function updateFilter() {
if (!filterNode) return;
filterNode.type = state.filterType;
filterNode.frequency.setTargetAtTime(state.filterCutoff, audioCtx.currentTime, 0.01);
filterNode.Q.setTargetAtTime(state.filterRes, audioCtx.currentTime, 0.01);
drawFilterViz();
}
// ═══════════════════════════════════════════════════════════
// KEYBOARD
// ═══════════════════════════════════════════════════════════
const keyboardKeys = [
{ note:'C4', type:'white', key:'A' },
{ note:'C#4', type:'black', key:'W' },
{ note:'D4', type:'white', key:'S' },
{ note:'D#4', type:'black', key:'E' },
{ note:'E4', type:'white', key:'D' },
{ note:'F4', type:'white', key:'F' },
{ note:'F#4', type:'black', key:'T' },
{ note:'G4', type:'white', key:'G' },
{ note:'G#4', type:'black', key:'Y' },
{ note:'A4', type:'white', key:'H' },
{ note:'A#4', type:'black', key:'U' },
{ note:'B4', type:'white', key:'J' },
{ note:'C5', type:'white', key:'K' },
{ note:'C#5', type:'black', key:'O' },
{ note:'D5', type:'white', key:'L' },
{ note:'D#5', type:'black', key:'P' },
{ note:'E5', type:'white', key:';' },
];
function buildKeyboard() {
const kb = document.getElementById('keyboard');
kb.innerHTML = '';
let whiteIndex = 0;
keyboardKeys.forEach((k) => {
const keyEl = document.createElement('div');
keyEl.className = `key key-${k.type}`;
keyEl.dataset.note = k.note;
keyEl.dataset.key = k.key;
const label = document.createElement('span');
label.className = 'key-label';
label.textContent = k.key;
keyEl.appendChild(label);
if (k.type === 'black') {
const whiteWidth = 46; // 44 + 2 margin
const leftPos = whiteIndex * whiteWidth - 14;
keyEl.style.left = leftPos + 'px';
} else {
whiteIndex++;
}
keyEl.addEventListener('mousedown', (e) => { e.preventDefault(); noteOn(k.note); });
keyEl.addEventListener('mouseup', () => noteOff(k.note));
keyEl.addEventListener('mouseleave', () => noteOff(k.note));
keyEl.addEventListener('touchstart', (e) => { e.preventDefault(); noteOn(k.note); });
keyEl.addEventListener('touchend', () => noteOff(k.note));
kb.appendChild(keyEl);
});
}
function updateActiveKeys() {
document.querySelectorAll('.key').forEach(el => {
const note = el.dataset.note;
el.classList.toggle('active', !!state.activeNotes[note]);
});
}
// ═══════════════════════════════════════════════════════════
// VISUALIZATION
// ═══════════════════════════════════════════════════════════
function drawWaveform() {
const canvas = document.getElementById('vizCanvas');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
ctx.scale(dpr, dpr);
const w = canvas.offsetWidth;
const h = canvas.offsetHeight;
ctx.clearRect(0, 0, w, h);
// Grid
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
for (let y = 0; y < h; y += h/8) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
for (let x = 0; x < w; x += w/16) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
// Center line
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.beginPath(); ctx.moveTo(0, h/2); ctx.lineTo(w, h/2); ctx.stroke();
if (!analyser) return;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteTimeDomainData(dataArray);
// Glow
ctx.shadowColor = '#6c5ce7';
ctx.shadowBlur = 15;
ctx.lineWidth = 2.5;
ctx.strokeStyle = '#6c5ce7';
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.lineTo(w, h/2);
ctx.stroke();
ctx.shadowBlur = 0;
// Fill under curve
ctx.lineTo(w, h/2);
ctx.lineTo(0, h/2);
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, w, 0);
grad.addColorStop(0, 'rgba(108,92,231,0.15)');
grad.addColorStop(0.5, 'rgba(0,206,201,0.1)');
grad.addColorStop(1, 'rgba(108,92,231,0.15)');
ctx.fillStyle = grad;
ctx.fill();
}
function drawFilterViz() {
const canvas = document.getElementById('filterVizCanvas');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
ctx.scale(dpr, dpr);
const w = canvas.offsetWidth;
const h = canvas.offsetHeight;
ctx.clearRect(0, 0, w, h);
// Draw frequency response curve
const numPoints = 200;
ctx.lineWidth = 2;
ctx.strokeStyle = '#fdcb6e';
ctx.shadowColor = '#fdcb6e';
ctx.shadowBlur = 8;
ctx.beginPath();
for (let i = 0; i < numPoints; i++) {
const freq = 20 * Math.pow(1000, i / (numPoints - 1));
const normalizedFreq = freq / 22050;
let gain = 0;
if (state.filterType === 'lowpass') {
const ratio = freq / state.filterCutoff;
gain = -20 * Math.log10(Math.max(1, Math.pow(ratio, state.filterRes / 2)));
gain = Math.max(-60, Math.min(20, gain));
if (state.filterRes > 5) {
const peak = Math.abs(Math.log10(ratio)) < 0.1 ? state.filterRes * 3 : 0;
gain += peak * Math.exp(-Math.pow(Math.log10(ratio) * 4, 2));
}
} else if (state.filterType === 'highpass') {
const ratio = state.filterCutoff / freq;
gain = -20 * Math.log10(Math.max(1, Math.pow(ratio, state.filterRes / 2)));
gain = Math.max(-60, Math.min(20, gain));
} else if (state.filterType === 'bandpass') {
const center = state.filterCutoff;
const bw = center * (state.filterRes / 10);
const dist = Math.abs(Math.log(freq / center));
const bwLog = Math.log(bw / center);
gain = -20 * Math.log10(Math.max(1, dist / Math.max(bwLog, 0.01)));
gain = Math.max(-60, Math.min(20, gain));
if (state.filterRes > 5) {
gain += state.filterRes * 2 * Math.exp(-Math.pow(dist / 0.3, 2));
}
}
const x = (i / (numPoints - 1)) * w;
const y = h - ((gain + 60) / 80) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Fill under
ctx.lineTo(w, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = 'rgba(253,203,110,0.06)';
ctx.fill();
// Cutoff marker
const cutoffX = (Math.log10(state.filterCutoff / 20) / Math.log10(20000 / 20)) * w;
ctx.strokeStyle = 'rgba(253,203,110,0.4)';
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(cutoffX, 0);
ctx.lineTo(cutoffX, h);
ctx.stroke();
ctx.setLineDash([]);
}
function drawADSR() {
const canvas = document.getElementById('adsrCanvas');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
ctx.scale(dpr, dpr);
const w = canvas.offsetWidth;
const h = canvas.offsetHeight;
ctx.clearRect(0, 0, w, h);
const a = Math.min(state.attack / 2000, 1);
const d = Math.min(state.decay / 2000, 1);
const s = state.sustain / 100;
const total = 0.15; // display window
const r = Math.min(state.release / 3000, 1);
const ax = a * total;
const dx = ax + d * total;
const sx = dx + (1 - a - d) * total * 0.5;
const rx = sx + r * total;
const topY = 8;
const botY = h - 8;
const susY = botY - s * (botY - topY);
ctx.lineWidth = 2;
ctx.strokeStyle = '#00cec9';
ctx.shadowColor = '#00cec9';
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.moveTo(0, botY);
ctx.lineTo(ax, topY);
ctx.lineTo(dx, susY);
ctx.lineTo(sx, susY);
ctx.lineTo(rx, botY);
ctx.stroke();
ctx.shadowBlur = 0;
// Fill
ctx.lineTo(rx, botY);
ctx.lineTo(0, botY);
ctx.closePath();
ctx.fillStyle = 'rgba(0,206,201,0.08)';
ctx.fill();
// Labels
ctx.font = '9px Inter';
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.textAlign = 'center';
ctx.fillText('A', ax, botY + 14);
ctx.fillText('D', dx, botY + 14);
ctx.fillText('S', sx, topY - 4);
ctx.fillText('R', rx, botY + 14);
}
// ═══════════════════════════════════════════════════════════
// TUTORIAL
// ═══════════════════════════════════════════════════════════
const lessons = [
{
num: '01',
title: 'Oscillators — The Sound Source',
desc: 'Every synth starts with an oscillator — a wave that vibrates at a specific frequency. The waveform determines the basic timbre (tone color) of the sound.',
tip: '🎯 <b>Try it:</b> Click each waveform and play a note. Sine is pure and smooth. Square has hollow, hollow tones. Saw is bright and buzzy. Triangle is softer than saw.',
complete: () => {
return Object.keys(state.activeNotes).length > 0;
}
},
{
num: '02',
title: 'Frequency & Octave',
desc: 'Frequency (in Hz) determines pitch. Higher frequency = higher pitch. The octave control shifts the entire keyboard up or down by 2 octaves.',
tip: '🎯 <b>Try it:</b> Play a note, then change the octave. Each step doubles or halves the frequency. Try octave -2 for bass sounds!',
complete: () => state.octave !== 0
},
{
num: '03',
title: 'Detune — Thickening the Sound',
desc: 'Detune slightly offsets the oscillator frequency (in cents). Even tiny detuning creates a "chorus" effect that makes sounds feel wider and richer.',
tip: '🎯 <b>Try it:</b> Hold a note, then slowly increase detune. At ±10¢ you\'ll hear the beating effect. Go further for dissonance!',
complete: () => state.detune !== 0
},
{
num: '04',
title: 'Filter — Shaping the Tone',
desc: 'A filter removes or emphasizes certain frequencies. A lowpass filter lets low frequencies through and cuts highs (like muffling). Highpass does the opposite.',
tip: '🎯 <b>Try it:</b> Play a saw wave and sweep the cutoff knob. Low = muffled. High = bright and open. Increase resonance for a "whistling" peak at the cutoff.',
complete: () => state.filterCutoff < 3000
},
{
num: '05',
title: 'ADSR Envelope — The Shape of a Note',
desc: 'ADSR controls how a note evolves over time: <b>A</b>ttack (how fast it reaches max volume), <b>D</b>ecay (time to fall to sustain level), <b>S</b>ustain (volume while held), <b>R</b>elease (fade out when released).',
tip: '🎯 <b>Try it:</b> Hold a key and watch the envelope visualization. Set attack to 0 for instant. Set release long for a pad-like fade-out. Try attack=0, decay=2000, sustain=0 for a bell sound!',
complete: () => state.attack === 0 || state.release > 500
},
{
num: '06',
title: 'LFO — Automatic Modulation',
desc: 'An LFO (Low Frequency Oscillator) modulates a parameter at a rate too slow to hear as pitch — but you hear its <b>effect</b>. It\'s like an auto-vibrato or auto-wah.',
tip: '🎯 <b>Try it:</b> Set LFO rate to 4 Hz and depth to 50, target to "Pitch" — you get vibrato! Try "Filter" for auto-wah. Change the waveform to hear different modulation flavors.',
complete: () => state.lfoDepth > 0
},
{
num: '07',
title: 'Effects — Space & Depth',
desc: '<b>Delay</b> repeats your sound (echo). <b>Reverb</b> simulates acoustic space (room, hall, cave). Together they make dry synth sounds feel alive.',
tip: '🎯 <b>Try it:</b> Enable delay and sweep the time. Short = slapback echo. Long = spacious. Enable reverb and adjust mix. Combine both for massive sounds!',
complete: () => state.delayEnabled || state.reverbEnabled
}
];
function buildTutorial() {
const container = document.getElementById('tutorialContent');
container.innerHTML = '';
lessons.forEach((lesson, i) => {
const div = document.createElement('div');
div.className = `lesson ${i < state.tutorialStep ? 'completed' : i === state.tutorialStep ? 'active' : 'locked'}`;
div.id = `lesson-${i}`;
div.innerHTML = `
<div class="lesson-num">${lesson.num}${i < state.tutorialStep ? ' ✓' : i === state.tutorialStep ? ' ▶' : ' 🔒'}</div>
<h3>${lesson.title}</h3>
<p>${lesson.desc}</p>
<div class="tip">${lesson.tip}</div>
${i === state.tutorialStep ? `<button class="action-btn" onclick="checkLesson(${i})">Check Progress →</button>` : ''}
`;
container.appendChild(div);
});
updateProgress();
}
function checkLesson(step) {
if (step !== state.tutorialStep) return;
if (lessons[step].complete()) {
state.completedLessons.add(step);
state.tutorialStep++;
buildTutorial();
if (state.tutorialStep >= lessons.length) {
const container = document.getElementById('tutorialContent');
const div = document.createElement('div');
div.className = 'lesson completed';
div.innerHTML = `
<div class="lesson-num">🎉</div>
<h3>You've completed all lessons!</h3>
<p>Now experiment freely. Try the presets, combine effects, and discover your own sounds.</p>
`;
container.appendChild(div);
}
}
}
function updateProgress() {
const pct = (state.tutorialStep / lessons.length) * 100;
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('progressText').textContent = `Lesson ${state.tutorialStep} of ${lessons.length}`;
}
// ═══════════════════════════════════════════════════════════
// UI BINDINGS
// ═══════════════════════════════════════════════════════════
function bindSlider(id, valueId, suffix, callback) {
const slider = document.getElementById(id);
const valueEl = document.getElementById(valueId);
slider.addEventListener('input', () => {
const val = parseFloat(slider.value);
valueEl.textContent = val + suffix;
if (callback) callback(val);
});
}
function initBindings() {
// Waveform selector
document.querySelectorAll('.waveform-btn[data-wave]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.waveform-btn[data-wave]').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
state.waveform = btn.dataset.wave;
});
});
// Filter type
document.querySelectorAll('.waveform-btn[data-filter]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.waveform-btn[data-filter]').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
state.filterType = btn.dataset.filter;
updateFilter();
});
});
// LFO waveform
document.querySelectorAll('.lfo-wave-btn[data-lfo]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.lfo-wave-btn[data-lfo]').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
state.lfoWaveform = btn.dataset.lfo;
if (lfoNode) lfoNode.type = state.lfoWaveform;
});
});
// Sliders
bindSlider('octaveSlider', 'octaveValue', '', v => { state.octave = v; });
bindSlider('detuneSlider', 'detuneValue', '¢', v => { state.detune = v; });
bindSlider('oscVolume', 'oscVolumeValue', '%', v => { state.oscVolume = v; });
bindSlider('filterCutoff', 'filterCutoffValue', ' Hz', v => { state.filterCutoff = v; updateFilter(); });
bindSlider('filterRes', 'filterResValue', '', v => { state.filterRes = v; updateFilter(); });
bindSlider('attackSlider', 'attackValue', 'ms', v => { state.attack = v; drawADSR(); });
bindSlider('decaySlider', 'decayValue', 'ms', v => { state.decay = v; drawADSR(); });
bindSlider('sustainSlider', 'sustainValue', '%', v => { state.sustain = v; drawADSR(); });
bindSlider('releaseSlider', 'releaseValue', 'ms', v => { state.release = v; drawADSR(); });
bindSlider('lfoRate', 'lfoRateValue', ' Hz', v => { state.lfoRate = v; if(lfoNode) lfoNode.frequency.value = v; });
bindSlider('lfoDepth', 'lfoDepthValue', '', v => {
state.lfoDepth = v;
if (lfoGain) lfoGain.gain.value = v;
updateLFOConnections();
});
bindSlider('delayTime', 'delayTimeValue', 'ms', v => {
state.delayTime = v;
if (delayNode) delayNode.delayTime.value = v / 1000;
});
bindSlider('delayFeedback', 'delayFeedbackValue', '%', v => {
state.delayFeedback = v;
if (delayFeedbackNode) delayFeedbackNode.gain.value = v / 100;
});
bindSlider('reverbMix', 'reverbMixValue', '%', v => {
state.reverbMix = v;
if (reverbGain) reverbGain.gain.value = v / 100;
});
// LFO target
document.getElementById('lfoTarget').addEventListener('change', (e) => {
state.lfoTarget = e.target.value;
updateLFOConnections();
});
// Delay toggle
document.getElementById('delayToggle').addEventListener('click', function() {
this.classList.toggle('on');
state.delayEnabled = this.classList.contains('on');
if (delayNode) {
delayNode.connect(analyser);
}
});
// Reverb toggle
document.getElementById('reverbToggle').addEventListener('click', function() {
this.classList.toggle('on');
state.reverbEnabled = this.classList.contains('on');
});
// Presets
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => applyPreset(btn.dataset.preset));
});
// Tutorial toggle
document.getElementById('toggleTutorial').addEventListener('click', () => {
document.getElementById('tutorialPanel').classList.toggle('collapsed');
});
// Reset
document.getElementById('resetBtn').addEventListener('click', () => {
Object.assign(state, {
waveform:'sine', filterType:'lowpass', filterCutoff:5000, filterRes:1,
attack:10, decay:200, sustain:70, release:300, octave:0, detune:0,
oscVolume:70, lfoRate:4, lfoDepth:0, lfoTarget:'pitch', lfoWaveform:'sine',
delayTime:0.3, delayFeedback:0.4, delayEnabled:false, reverbMix:30, reverbEnabled:false
});
// Reset UI
document.querySelectorAll('.waveform-btn[data-wave]').forEach(b => b.classList.remove('selected'));
document.querySelector('.waveform-btn[data-wave="sine"]').classList.add('selected');
document.querySelectorAll('.waveform-btn[data-filter]').forEach(b => b.classList.remove('selected'));
document.querySelector('.waveform-btn[data-filter="lowpass"]').classList.add('selected');
document.querySelectorAll('.lfo-wave-btn[data-lfo]').forEach(b => b.classList.remove('selected'));
document.querySelector('.lfo-wave-btn[data-lfo="sine"]').classList.add('selected');
document.getElementById('delayToggle').classList.remove('on');
document.getElementById('reverbToggle').classList.remove('on');
document.getElementById('lfoTarget').value = 'pitch';
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
// Reset sliders
['octaveSlider','detuneSlider','oscVolume','filterCutoff','filterRes','attackSlider','decaySlider','sustainSlider','releaseSlider','lfoRate','lfoDepth','delayTime','delayFeedback','reverbMix'].forEach(id => {
document.getElementById(id).dispatchEvent(new Event('input'));
});
drawFilterViz();
drawADSR();
buildTutorial();
});
// Keyboard input
const pressedKeys = new Set();
window.addEventListener('keydown', (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (KEY_MAP[key] && !pressedKeys.has(key)) {
pressedKeys.add(key);
noteOn(KEY_MAP[key]);
}
});
window.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
if (KEY_MAP[key]) {
pressedKeys.delete(key);
noteOff(KEY_MAP[key]);
}
});
}
function applyPreset(name) {
const p = presets[name];
if (!p) return;
Object.assign(state, p);
// Update UI
document.querySelectorAll('.waveform-btn[data-wave]').forEach(b => {
b.classList.toggle('selected', b.dataset.wave === p.waveform);
});
document.querySelectorAll('.waveform-btn[data-filter]').forEach(b => {
b.classList.toggle('selected', b.dataset.filter === p.filterType);
});
document.getElementById('octaveSlider').value = p.octave || 0;
document.getElementById('octaveSlider').dispatchEvent(new Event('input'));
document.getElementById('detuneSlider').value = p.detune || 0;
document.getElementById('detuneSlider').dispatchEvent(new Event('input'));
document.getElementById('oscVolume').value = p.oscVolume || 70;
document.getElementById('oscVolume').dispatchEvent(new Event('input'));
document.getElementById('filterCutoff').value = p.filterCutoff;
document.getElementById('filterCutoff').dispatchEvent(new Event('input'));
document.getElementById('filterRes').value = p.filterRes;
document.getElementById('filterRes').dispatchEvent(new Event('input'));
document.getElementById('attackSlider').value = p.attack;
document.getElementById('attackSlider').dispatchEvent(new Event('input'));
document.getElementById('decaySlider').value = p.decay;
document.getElementById('decaySlider').dispatchEvent(new Event('input'));
document.getElementById('sustainSlider').value = p.sustain;
document.getElementById('sustainSlider').dispatchEvent(new Event('input'));
document.getElementById('releaseSlider').value = p.release;
document.getElementById('releaseSlider').dispatchEvent(new Event('input'));
document.getElementById('lfoRate').value = p.lfoRate || 4;
document.getElementById('lfoRate').dispatchEvent(new Event('input'));
document.getElementById('lfoDepth').value = p.lfoDepth || 0;
document.getElementById('lfoDepth').dispatchEvent(new Event('input'));
document.getElementById('lfoTarget').value = p.lfoTarget || 'pitch';
document.querySelectorAll('.lfo-wave-btn[data-lfo]').forEach(b => {
b.classList.toggle('selected', b.dataset.lfo === (p.lfoWaveform || 'sine'));
});
document.getElementById('delayTime').value = p.delayTime || 0.3;
document.getElementById('delayTime').dispatchEvent(new Event('input'));
document.getElementById('delayFeedback').value = p.delayFeedback || 0.4;
document.getElementById('delayFeedback').dispatchEvent(new Event('input'));
document.getElementById('reverbMix').value = p.reverbMix || 30;
document.getElementById('reverbMix').dispatchEvent(new Event('input'));
updateFilter();
drawADSR();
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
document.querySelector(`.preset-btn[data-preset="${name}"]`).classList.add('active');
}
function updateNoteDisplay(note) {
const el = document.getElementById('noteDisplay');
el.textContent = note || '—';
}
// ═══════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════
buildKeyboard();
buildTutorial();
initBindings();
drawFilterViz();
drawADSR();
// Animation loop
let vizFrame = 0;
function animate() {
vizFrame++;
if (vizFrame % 3 === 0) drawWaveform();
requestAnimationFrame(animate);
}
animate();
// Resize handler
window.addEventListener('resize', () => {
drawFilterViz();
drawADSR();
});
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.6-35b-a3b","provider":"lmstudio","persona":"full","sessionId":"7fdc9ec3-f832-4b94-9046-b3a7ba3d2d02","tokensIn":53075,"tokensOut":20290,"tokensTotal":73365,"turns":3,"toolCalls":2,"failedToolCalls":0,"timestamp":"2026-07-21T03:22:14.826Z"} -->