TutSynth
thinkingcap qwen3.6 27b · basic

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 47,262 bytes · SHA-256 8d113e6908c3
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Synth Explorer — Learn Synthesis Interactively</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');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a0f;
--panel: #12121a;
--panel-border: #1e1e2e;
--accent: #6c5ce7;
--accent-glow: rgba(108, 92, 231, 0.4);
--text: #e0e0e8;
--text-dim: #6b6b80;
--success: #00cec9;
--warning: #fdcb6e;
--danger: #ff7675;
--knob-size: 52px;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
padding: 30px 0 20px;
}
header h1 {
font-size: 2.2em;
font-weight: 700;
background: linear-gradient(135deg, #6c5ce7, #a29bfe, #fd79a8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 6px;
}
header p {
color: var(--text-dim);
font-size: 0.95em;
}
.progress-bar-container {
display: flex;
align-items: center;
gap: 12px;
margin: 20px auto;
max-width: 600px;
}
.progress-track {
flex: 1;
height: 6px;
background: var(--panel-border);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #6c5ce7, #a29bfe);
border-radius: 3px;
transition: width 0.6s ease;
}
.progress-label {
font-size: 0.8em;
color: var(--text-dim);
white-space: nowrap;
font-family: 'JetBrains Mono', monospace;
}
.main-layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 20px;
margin-top: 16px;
}
@media (max-width: 900px) {
.main-layout {
grid-template-columns: 1fr;
}
}
/* Tutorial Panel */
.tutorial-panel {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 16px;
padding: 24px;
position: sticky;
top: 20px;
max-height: calc(100vh - 40px);
overflow-y: auto;
}
.tutorial-panel::-webkit-scrollbar { width: 4px; }
.tutorial-panel::-webkit-scrollbar-track { background: transparent; }
.tutorial-panel::-webkit-scrollbar-thumb { background: var(--panel-border); border-radius: 2px; }
.step-indicator {
display: flex;
gap: 6px;
margin-bottom: 20px;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--panel-border);
transition: all 0.3s;
}
.step-dot.active {
background: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
}
.step-dot.completed {
background: var(--success);
}
.tutorial-title {
font-size: 1.3em;
font-weight: 600;
margin-bottom: 12px;
line-height: 1.3;
}
.tutorial-body {
color: var(--text-dim);
font-size: 0.9em;
line-height: 1.7;
margin-bottom: 20px;
}
.tutorial-body strong {
color: var(--text);
}
.tutorial-body code {
background: rgba(108, 92, 231, 0.15);
padding: 2px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
color: #a29bfe;
}
.challenge-box {
background: rgba(108, 92, 231, 0.08);
border: 1px solid rgba(108, 92, 231, 0.2);
border-radius: 10px;
padding: 14px;
margin-bottom: 16px;
}
.challenge-box h4 {
font-size: 0.85em;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
}
.challenge-box p {
font-size: 0.85em;
line-height: 1.6;
}
.nav-buttons {
display: flex;
gap: 10px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-family: 'Inter', sans-serif;
font-size: 0.9em;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: white;
flex: 1;
}
.btn-primary:hover {
background: #7c6cf7;
box-shadow: 0 4px 15px var(--accent-glow);
}
.btn-secondary {
background: transparent;
color: var(--text-dim);
border: 1px solid var(--panel-border);
}
.btn-secondary:hover {
border-color: var(--text-dim);
color: var(--text);
}
/* Synth Area */
.synth-area {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Visualizer */
.visualizer-container {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 16px;
padding: 20px;
position: relative;
}
.visualizer-label {
font-size: 0.75em;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
margin-bottom: 12px;
}
#waveformCanvas {
width: 100%;
height: 140px;
border-radius: 8px;
background: rgba(0,0,0,0.3);
}
/* Control Modules */
.modules-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.module {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 14px;
padding: 18px;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.module.locked {
opacity: 0.35;
pointer-events: none;
filter: grayscale(0.8);
}
.module.unlocked-anim {
animation: unlockPulse 0.6s ease;
}
@keyframes unlockPulse {
0% { box-shadow: 0 0 0 0 var(--accent-glow); }
50% { box-shadow: 0 0 30px 10px var(--accent-glow); }
100% { box-shadow: none; }
}
.module-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.module-name {
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
font-weight: 600;
}
.lock-icon {
font-size: 0.9em;
opacity: 0.5;
}
.module.unlocked-anim .lock-icon,
.module:not(.locked) .lock-icon {
display: none;
}
/* Waveform Selector */
.wave-selector {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.wave-btn {
flex: 1;
padding: 10px 6px;
background: rgba(0,0,0,0.3);
border: 2px solid transparent;
border-radius: 8px;
color: var(--text-dim);
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-family: 'Inter', sans-serif;
font-size: 0.7em;
}
.wave-btn:hover { border-color: var(--panel-border); color: var(--text); }
.wave-btn.active {
border-color: var(--accent);
color: var(--accent);
background: rgba(108, 92, 231, 0.1);
}
.wave-icon {
width: 40px;
height: 20px;
}
/* Sliders */
.param-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.param-label {
font-size: 0.75em;
color: var(--text-dim);
min-width: 28px;
font-family: 'JetBrains Mono', monospace;
text-align: right;
}
.param-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
background: var(--panel-border);
border-radius: 2px;
outline: none;
}
.param-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
box-shadow: 0 2px 8px var(--accent-glow);
transition: transform 0.15s;
}
.param-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.param-value {
font-size: 0.75em;
color: var(--text-dim);
min-width: 40px;
text-align: right;
font-family: 'JetBrains Mono', monospace;
}
/* Keyboard */
.keyboard-container {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 16px;
padding: 20px;
}
.keyboard-hint {
font-size: 0.75em;
color: var(--text-dim);
margin-bottom: 14px;
text-align: center;
}
.keyboard-hint kbd {
background: rgba(255,255,255,0.08);
padding: 2px 7px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.9em;
border: 1px solid var(--panel-border);
}
.keyboard {
display: flex;
justify-content: center;
gap: 3px;
position: relative;
height: 140px;
user-select: none;
}
.key-white {
width: 48px;
height: 140px;
background: linear-gradient(180deg, #f0f0f5, #d8d8e0);
border: 1px solid #c0c0c8;
border-radius: 0 0 6px 6px;
cursor: pointer;
transition: all 0.08s;
position: relative;
z-index: 1;
}
.key-white:hover { background: linear-gradient(180deg, #e8e8f0, #d0d0d8); }
.key-white.active {
background: linear-gradient(180deg, #a29bfe, #6c5ce7);
border-color: #6c5ce7;
transform: scaleY(0.98);
transform-origin: top;
}
.key-black {
width: 32px;
height: 90px;
background: linear-gradient(180deg, #2a2a35, #1a1a25);
border: 1px solid #111;
border-radius: 0 0 4px 4px;
cursor: pointer;
position: absolute;
z-index: 2;
transition: all 0.08s;
}
.key-black:hover { background: linear-gradient(180deg, #3a3a45, #2a2a35); }
.key-black.active {
background: linear-gradient(180deg, #6c5ce7, #4a3db5);
transform: scaleY(0.97);
transform-origin: top;
}
.key-label {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
font-size: 0.6em;
color: #999;
font-family: 'JetBrains Mono', monospace;
pointer-events: none;
}
.key-black .key-label { color: #666; bottom: 6px; }
/* ADSR Visual */
.adsr-visual {
margin-top: 10px;
height: 50px;
position: relative;
}
#adsrCanvas {
width: 100%;
height: 50px;
border-radius: 6px;
background: rgba(0,0,0,0.2);
}
/* Filter visual */
.filter-visual {
margin-top: 10px;
height: 50px;
}
#filterCanvas {
width: 100%;
height: 50px;
border-radius: 6px;
background: rgba(0,0,0,0.2);
}
/* Notification */
.notification {
position: fixed;
top: 20px;
right: 20px;
background: var(--panel);
border: 1px solid var(--accent);
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 8px 30px rgba(0,0,0,0.5);
z-index: 1000;
transform: translateX(120%);
transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
max-width: 300px;
}
.notification.show { transform: translateX(0); }
.notification h4 {
color: var(--accent);
font-size: 0.85em;
margin-bottom: 4px;
}
.notification p {
color: var(--text-dim);
font-size: 0.8em;
line-height: 1.5;
}
/* Toggle switch */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.toggle-label {
font-size: 0.75em;
color: var(--text-dim);
}
.toggle-switch {
width: 40px;
height: 22px;
background: var(--panel-border);
border-radius: 11px;
position: relative;
cursor: pointer;
transition: background 0.3s;
}
.toggle-switch.on { background: var(--accent); }
.toggle-switch::after {
content: '';
width: 18px;
height: 18px;
background: white;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.3s;
}
.toggle-switch.on::after { transform: translateX(18px); }
/* Octave selector */
.octave-control {
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
margin-top: 12px;
}
.octave-btn {
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--panel-border);
color: var(--text-dim);
cursor: pointer;
font-size: 1em;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.octave-btn:hover { border-color: var(--accent); color: var(--text); }
.octave-display {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
color: var(--text);
min-width: 60px;
text-align: center;
}
/* Completed badge */
.completed-badge {
display: none;
position: absolute;
top: 12px;
right: 12px;
background: var(--success);
color: #000;
font-size: 0.65em;
padding: 3px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.module.completed .completed-badge { display: block; }
/* Scrollbar for body */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--panel-border); border-radius: 4px; }
</style>
</head>
<body>
<div class="app-container">
<header>
<h1>🎹 Synth Explorer</h1>
<p>An interactive guide to understanding synthesizers</p>
<div class="progress-bar-container">
<span class="progress-label" id="progressLabel">Step 1 / 6</span>
<div class="progress-track">
<div class="progress-fill" id="progressFill" style="width: 16.6%"></div>
</div>
</div>
</header>
<div class="main-layout">
<!-- Tutorial Panel -->
<div class="tutorial-panel" id="tutorialPanel">
<div class="step-indicator" id="stepIndicator"></div>
<h2 class="tutorial-title" id="tutorialTitle"></h2>
<div class="tutorial-body" id="tutorialBody"></div>
<div class="challenge-box" id="challengeBox">
<h4>🎯 Try This</h4>
<p id="challengeText"></p>
</div>
<div class="nav-buttons">
<button class="btn btn-secondary" id="prevBtn" onclick="prevStep()">← Back</button>
<button class="btn btn-primary" id="nextBtn" onclick="nextStep()">Next →</button>
</div>
</div>
<!-- Synth Area -->
<div class="synth-area">
<!-- Visualizer -->
<div class="visualizer-container">
<div class="visualizer-label">Waveform Output</div>
<canvas id="waveformCanvas"></canvas>
</div>
<!-- Modules -->
<div class="modules-grid" id="modulesGrid">
<!-- Oscillator Module -->
<div class="module" id="mod-oscillator">
<span class="completed-badge">✓ Done</span>
<div class="module-header">
<span class="module-name">Oscillator</span>
<span class="lock-icon">🔒</span>
</div>
<div class="wave-selector" id="waveSelector">
<button class="wave-btn active" data-wave="sine" onclick="setWaveform('sine')">
<svg class="wave-icon" viewBox="0 0 40 20"><path d="M0,10 Q10,0 20,10 Q30,20 40,10" fill="none" stroke="currentColor" stroke-width="2"/></svg>
Sine
</button>
<button class="wave-btn" data-wave="triangle" onclick="setWaveform('triangle')">
<svg class="wave-icon" viewBox="0 0 40 20"><path d="M0,10 L10,2 L20,18 L30,2 L40,10" fill="none" stroke="currentColor" stroke-width="2"/></svg>
Triangle
</button>
<button class="wave-btn" data-wave="sawtooth" onclick="setWaveform('sawtooth')">
<svg class="wave-icon" viewBox="0 0 40 20"><path d="M0,18 L13,2 L13,18 L26,2 L26,18 L40,2" fill="none" stroke="currentColor" stroke-width="2"/></svg>
Sawtooth
</button>
<button class="wave-btn" data-wave="square" onclick="setWaveform('square')">
<svg class="wave-icon" viewBox="0 0 40 20"><path d="M0,18 L0,2 L13,2 L13,18 L26,18 L26,2 L40,2 L40,18" fill="none" stroke="currentColor" stroke-width="2"/></svg>
Square
</button>
</div>
</div>
<!-- Envelope Module -->
<div class="module locked" id="mod-envelope">
<span class="completed-badge">✓ Done</span>
<div class="module-header">
<span class="module-name">Envelope (ADSR)</span>
<span class="lock-icon">🔒</span>
</div>
<div class="param-row">
<span class="param-label">ATK</span>
<input type="range" class="param-slider" id="attackSlider" min="0.01" max="2" step="0.01" value="0.05" oninput="updateEnvelope()">
<span class="param-value" id="attackVal">0.05</span>
</div>
<div class="param-row">
<span class="param-label">DEC</span>
<input type="range" class="param-slider" id="decaySlider" min="0.01" max="2" step="0.01" value="0.3" oninput="updateEnvelope()">
<span class="param-value" id="decayVal">0.30</span>
</div>
<div class="param-row">
<span class="param-label">SUS</span>
<input type="range" class="param-slider" id="sustainSlider" min="0" max="1" step="0.01" value="0.7" oninput="updateEnvelope()">
<span class="param-value" id="sustainVal">0.70</span>
</div>
<div class="param-row">
<span class="param-label">REL</span>
<input type="range" class="param-slider" id="releaseSlider" min="0.01" max="3" step="0.01" value="0.5" oninput="updateEnvelope()">
<span class="param-value" id="releaseVal">0.50</span>
</div>
<div class="adsr-visual">
<canvas id="adsrCanvas"></canvas>
</div>
</div>
<!-- Filter Module -->
<div class="module locked" id="mod-filter">
<span class="completed-badge">✓ Done</span>
<div class="module-header">
<span class="module-name">Filter</span>
<span class="lock-icon">🔒</span>
</div>
<div class="param-row">
<span class="param-label">CUT</span>
<input type="range" class="param-slider" id="cutoffSlider" min="100" max="15000" step="10" value="8000" oninput="updateFilter()">
<span class="param-value" id="cutoffVal">8000</span>
</div>
<div class="param-row">
<span class="param-label">RES</span>
<input type="range" class="param-slider" id="resonanceSlider" min="0" max="25" step="0.1" value="1" oninput="updateFilter()">
<span class="param-value" id="resonanceVal">1.0</span>
</div>
<div class="filter-visual">
<canvas id="filterCanvas"></canvas>
</div>
</div>
<!-- LFO Module -->
<div class="module locked" id="mod-lfo">
<span class="completed-badge">✓ Done</span>
<div class="module-header">
<span class="module-name">LFO</span>
<span class="lock-icon">🔒</span>
</div>
<div class="toggle-row">
<span class="toggle-label">Enable LFO</span>
<div class="toggle-switch" id="lfoToggle" onclick="toggleLFO()"></div>
</div>
<div class="param-row">
<span class="param-label">RATE</span>
<input type="range" class="param-slider" id="lfoRateSlider" min="0.1" max="20" step="0.1" value="5" oninput="updateLFO()">
<span class="param-value" id="lfoRateVal">5.0</span>
</div>
<div class="param-row">
<span class="param-label">DEPTH</span>
<input type="range" class="param-slider" id="lfoDepthSlider" min="0" max="100" step="1" value="30" oninput="updateLFO()">
<span class="param-value" id="lfoDepthVal">30</span>
</div>
</div>
<!-- Effects Module -->
<div class="module locked" id="mod-effects">
<span class="completed-badge">✓ Done</span>
<div class="module-header">
<span class="module-name">Effects</span>
<span class="lock-icon">🔒</span>
</div>
<div class="toggle-row">
<span class="toggle-label">Delay</span>
<div class="toggle-switch" id="delayToggle" onclick="toggleEffect('delay')"></div>
</div>
<div class="param-row">
<span class="param-label">TIME</span>
<input type="range" class="param-slider" id="delayTimeSlider" min="0.01" max="1" step="0.01" value="0.3" oninput="updateEffect('delay')">
<span class="param-value" id="delayTimeVal">0.30</span>
</div>
<div class="param-row">
<span class="param-label">FEED</span>
<input type="range" class="param-slider" id="delayFeedbackSlider" min="0" max="0.9" step="0.01" value="0.4" oninput="updateEffect('delay')">
<span class="param-value" id="delayFeedVal">0.40</span>
</div>
<div class="toggle-row" style="margin-top: 8px;">
<span class="toggle-label">Reverb</span>
<div class="toggle-switch" id="reverbToggle" onclick="toggleEffect('reverb')"></div>
</div>
</div>
<!-- Master Module -->
<div class="module locked" id="mod-master">
<span class="completed-badge">✓ Done</span>
<div class="module-header">
<span class="module-name">Master</span>
<span class="lock-icon">🔒</span>
</div>
<div class="param-row">
<span class="param-label">VOL</span>
<input type="range" class="param-slider" id="volumeSlider" min="0" max="1" step="0.01" value="0.5" oninput="updateMaster()">
<span class="param-value" id="volumeVal">0.50</span>
</div>
</div>
</div>
<!-- Keyboard -->
<div class="keyboard-container">
<div class="keyboard-hint">
Play with your mouse or keyboard: <kbd>A</kbd>–<kbd>L</kbd> for white keys, <kbd>W</kbd><kbd>E</kbd><kbd>T</kbd><kbd>Y</kbd><kbd>U</kbd> for black keys | Octave: <kbd>Z</kbd>/<kbd>X</kbd>
</div>
<div class="keyboard" id="keyboard"></div>
<div class="octave-control">
<button class="octave-btn" onclick="changeOctave(-1)">−</button>
<span class="octave-display" id="octaveDisplay">Oct 4</span>
<button class="octave-btn" onclick="changeOctave(1)">+</button>
</div>
</div>
</div>
</div>
</div>
<div class="notification" id="notification">
<h4 id="notifTitle"></h4>
<p id="notifText"></p>
</div>
<script>
// ==================== AUDIO ENGINE ====================
let audioCtx = null;
let masterGain = null;
let filterNode = null;
let delayNode = null;
let feedbackNode = null;
let reverbNode = null;
let dryGain = null;
let wetGain = null;
let lfoNode = null;
let lfoGain = null;
let analyser = null;
const activeNotes = {};
let currentWaveform = 'sine';
let octave = 4;
let envelope = { attack: 0.05, decay: 0.3, sustain: 0.7, release: 0.5 };
let filterSettings = { cutoff: 8000, resonance: 1 };
let lfoSettings = { enabled: false, rate: 5, depth: 30 };
let effectSettings = { delay: { enabled: false, time: 0.3, feedback: 0.4 }, reverb: { enabled: false } };
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.5;
filterNode = audioCtx.createBiquadFilter();
filterNode.type = 'lowpass';
filterNode.frequency.value = 8000;
filterNode.Q.value = 1;
analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
// Delay setup
delayNode = audioCtx.createDelay(2);
delayNode.delayTime.value = 0.3;
feedbackNode = audioCtx.createGain();
feedbackNode.gain.value = 0.4;
dryGain = audioCtx.createGain();
dryGain.gain.value = 1;
wetGain = audioCtx.createGain();
wetGain.gain.value = 0;
// Reverb setup (convolver with generated impulse)
reverbNode = audioCtx.createConvolver();
reverbNode.buffer = generateReverbImpulse(2, 3);
// Routing: source -> filter -> [dry] -> masterGain -> analyser -> destination
// -> [delay -> feedback loop] -> wet -> masterGain
filterNode.connect(dryGain);
dryGain.connect(masterGain);
filterNode.connect(delayNode);
delayNode.connect(feedbackNode);
feedbackNode.connect(delayNode);
delayNode.connect(wetGain);
wetGain.connect(masterGain);
// Reverb routing
const reverbDry = audioCtx.createGain();
const reverbWet = audioCtx.createGain();
reverbWet.gain.value = 0;
masterGain.disconnect();
masterGain.connect(reverbDry);
masterGain.connect(reverbNode);
reverbNode.connect(reverbWet);
reverbDry.connect(analyser);
reverbWet.connect(analyser);
analyser.connect(audioCtx.destination);
// LFO setup (modulates filter frequency)
lfoNode = audioCtx.createOscillator();
lfoGain = audioCtx.createGain();
lfoGain.gain.value = 0;
lfoNode.connect(lfoGain);
lfoGain.connect(filterNode.frequency);
lfoNode.start();
window._reverbDry = reverbDry;
window._reverbWet = reverbWet;
}
function generateReverbImpulse(length, decay) {
const rate = audioCtx.sampleRate;
const lengthInSamples = Math.floor(rate * length);
const impulse = audioCtx.createBuffer(2, lengthInSamples, rate);
for (let ch = 0; ch < 2; ch++) {
const data = impulse.getChannelData(ch);
for (let i = 0; i < lengthInSamples; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / lengthInSamples, decay);
}
}
return impulse;
}
function noteOn(note, freq) {
initAudio();
if (activeNotes[note]) return;
const osc = audioCtx.createOscillator();
osc.type = currentWaveform;
osc.frequency.value = freq;
const envGain = audioCtx.createGain();
envGain.gain.setValueAtTime(0, audioCtx.currentTime);
envGain.gain.linearRampToValueAtTime(1, audioCtx.currentTime + envelope.attack);
envGain.gain.linearRampToValueAtTime(envelope.sustain, audioCtx.currentTime + envelope.attack + envelope.decay);
osc.connect(envGain);
envGain.connect(filterNode);
osc.start();
activeNotes[note] = { osc, envGain };
}
function noteOff(note) {
const n = activeNotes[note];
if (!n) return;
n.envGain.gain.cancelScheduledValues(audioCtx.currentTime);
n.envGain.gain.setValueAtTime(n.envGain.gain.value, audioCtx.currentTime);
n.envGain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + envelope.release);
n.osc.stop(audioCtx.currentTime + envelope.release + 0.1);
setTimeout(() => {
try { n.envGain.disconnect(); } catch(e) {}
}, (envelope.release + 0.2) * 1000);
delete activeNotes[note];
}
// ==================== NOTE FREQUENCIES ====================
const noteNames = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
function getFreq(note, oct) {
const idx = noteNames.indexOf(note);
if (idx === -1) return 440;
const midi = (oct + 1) * 12 + idx;
return 440 * Math.pow(2, (midi - 69) / 12);
}
// ==================== KEYBOARD SETUP ====================
const whiteKeys = ['C','D','E','F','G','A','B'];
const blackKeyMap = { 'C#': 0, 'D#': 1, 'F#': 3, 'G#': 4, 'A#': 5 };
function buildKeyboard() {
const kb = document.getElementById('keyboard');
kb.innerHTML = '';
whiteKeys.forEach((note, i) => {
const key = document.createElement('div');
key.className = 'key-white';
key.dataset.note = note;
key.dataset.index = i;
key.addEventListener('mousedown', () => playKey(note));
key.addEventListener('mouseup', () => stopKey(note));
key.addEventListener('mouseleave', () => stopKey(note));
key.addEventListener('touchstart', (e) => { e.preventDefault(); playKey(note); });
key.addEventListener('touchend', () => stopKey(note));
const label = document.createElement('span');
label.className = 'key-label';
label.textContent = note;
key.appendChild(label);
kb.appendChild(key);
});
// Black keys positioned absolutely
const blackNotes = ['C#','D#','F#','G#','A#'];
const whiteKeyWidth = 48;
const gap = 3;
blackNotes.forEach((note) => {
const idx = blackKeyMap[note];
if (idx === undefined) return;
const key = document.createElement('div');
key.className = 'key-black';
key.dataset.note = note;
const leftPos = (idx + 1) * (whiteKeyWidth + gap) - 18;
key.style.left = leftPos + 'px';
key.addEventListener('mousedown', () => playKey(note));
key.addEventListener('mouseup', () => stopKey(note));
key.addEventListener('mouseleave', () => stopKey(note));
key.addEventListener('touchstart', (e) => { e.preventDefault(); playKey(note); });
key.addEventListener('touchend', () => stopKey(note));
const label = document.createElement('span');
label.className = 'key-label';
label.textContent = note;
key.appendChild(label);
kb.appendChild(key);
});
}
function playKey(note) {
initAudio();
const freq = getFreq(note, octave);
noteOn(note, freq);
highlightKey(note, true);
}
function stopKey(note) {
noteOff(note);
highlightKey(note, false);
}
function highlightKey(note, on) {
document.querySelectorAll(`[data-note="${note}"]`).forEach(el => {
el.classList.toggle('active', on);
});
}
// Computer keyboard mapping
const keyMap = {
'a': 'C', 'w': 'C#', 's': 'D', 'e': 'D#', 'd': 'E',
'f': 'F', 't': 'F#', 'g': 'G', 'y': 'G#', 'h': 'A',
'u': 'A#', 'j': 'B'
};
const pressedKeys = new Set();
document.addEventListener('keydown', (e) => {
if (e.repeat) return;
const k = e.key.toLowerCase();
if (k === 'z') { changeOctave(-1); return; }
if (k === 'x') { changeOctave(1); return; }
if (keyMap[k] && !pressedKeys.has(k)) {
pressedKeys.add(k);
playKey(keyMap[k]);
}
});
document.addEventListener('keyup', (e) => {
const k = e.key.toLowerCase();
if (keyMap[k]) {
pressedKeys.delete(k);
stopKey(keyMap[k]);
}
});
function changeOctave(dir) {
octave = Math.max(1, Math.min(7, octave + dir));
document.getElementById('octaveDisplay').textContent = `Oct ${octave}`;
}
// ==================== PARAMETER UPDATES ====================
function setWaveform(wave) {
currentWaveform = wave;
document.querySelectorAll('.wave-btn').forEach(b => b.classList.toggle('active', b.dataset.wave === wave));
}
function updateEnvelope() {
envelope.attack = parseFloat(document.getElementById('attackSlider').value);
envelope.decay = parseFloat(document.getElementById('decaySlider').value);
envelope.sustain = parseFloat(document.getElementById('sustainSlider').value);
envelope.release = parseFloat(document.getElementById('releaseSlider').value);
document.getElementById('attackVal').textContent = envelope.attack.toFixed(2);
document.getElementById('decayVal').textContent = envelope.decay.toFixed(2);
document.getElementById('sustainVal').textContent = envelope.sustain.toFixed(2);
document.getElementById('releaseVal').textContent = envelope.release.toFixed(2);
drawADSR();
}
function updateFilter() {
filterSettings.cutoff = parseFloat(document.getElementById('cutoffSlider').value);
filterSettings.resonance = parseFloat(document.getElementById('resonanceSlider').value);
document.getElementById('cutoffVal').textContent = Math.round(filterSettings.cutoff);
document.getElementById('resonanceVal').textContent = filterSettings.resonance.toFixed(1);
if (filterNode) {
filterNode.frequency.setTargetAtTime(filterSettings.cutoff, audioCtx.currentTime, 0.02);
filterNode.Q.setTargetAtTime(filterSettings.resonance, audioCtx.currentTime, 0.02);
}
drawFilterResponse();
}
function updateLFO() {
lfoSettings.rate = parseFloat(document.getElementById('lfoRateSlider').value);
lfoSettings.depth = parseFloat(document.getElementById('lfoDepthSlider').value);
document.getElementById('lfoRateVal').textContent = lfoSettings.rate.toFixed(1);
document.getElementById('lfoDepthVal').textContent = Math.round(lfoSettings.depth);
if (lfoNode) {
lfoNode.frequency.setTargetAtTime(lfoSettings.rate, audioCtx.currentTime, 0.02);
const depthHz = (filterSettings.cutoff * lfoSettings.depth / 100) * 0.5;
lfoGain.gain.setTargetAtTime(depthHz, audioCtx.currentTime, 0.02);
}
}
function toggleLFO() {
lfoSettings.enabled = !lfoSettings.enabled;
document.getElementById('lfoToggle').classList.toggle('on', lfoSettings.enabled);
if (lfoGain) {
const depthHz = lfoSettings.enabled ? (filterSettings.cutoff * lfoSettings.depth / 100) * 0.5 : 0;
lfoGain.gain.setTargetAtTime(depthHz, audioCtx.currentTime, 0.02);
}
}
function toggleEffect(type) {
effectSettings[type].enabled = !effectSettings[type].enabled;
document.getElementById(type + 'Toggle').classList.toggle('on', effectSettings[type].enabled);
if (type === 'delay' && wetGain) {
wetGain.gain.setTargetAtTime(effectSettings.delay.enabled ? 0.5 : 0, audioCtx.currentTime, 0.02);
}
if (type === 'reverb' && window._reverbWet) {
window._reverbWet.gain.setTargetAtTime(effectSettings.reverb.enabled ? 0.6 : 0, audioCtx.currentTime, 0.02);
}
}
function updateEffect(type) {
if (type === 'delay') {
effectSettings.delay.time = parseFloat(document.getElementById('delayTimeSlider').value);
effectSettings.delay.feedback = parseFloat(document.getElementById('delayFeedbackSlider').value);
document.getElementById('delayTimeVal').textContent = effectSettings.delay.time.toFixed(2);
document.getElementById('delayFeedVal').textContent = effectSettings.delay.feedback.toFixed(2);
if (delayNode) {
delayNode.delayTime.setTargetAtTime(effectSettings.delay.time, audioCtx.currentTime, 0.02);
feedbackNode.gain.setTargetAtTime(effectSettings.delay.feedback, audioCtx.currentTime, 0.02);
}
}
}
function updateMaster() {
const vol = parseFloat(document.getElementById('volumeSlider').value);
document.getElementById('volumeVal').textContent = vol.toFixed(2);
if (masterGain) masterGain.gain.setTargetAtTime(vol, audioCtx.currentTime, 0.02);
}
// ==================== VISUALIZERS ====================
function drawWaveform() {
const canvas = document.getElementById('waveformCanvas');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth * 2;
canvas.height = canvas.offsetHeight * 2;
ctx.scale(1, 1);
function render() {
requestAnimationFrame(render);
if (!analyser) return;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteTimeDomainData(dataArray);
ctx.fillStyle = 'rgba(10, 10, 15, 0.3)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 2;
ctx.strokeStyle = '#6c5ce7';
ctx.shadowColor = '#6c5ce7';
ctx.shadowBlur = 8;
ctx.beginPath();
const sliceWidth = canvas.width / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = (v * canvas.height) / 2;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
ctx.lineTo(canvas.width, canvas.height / 2);
ctx.stroke();
ctx.shadowBlur = 0;
}
render();
}
function drawADSR() {
const canvas = document.getElementById('adsrCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth * 2;
canvas.height = canvas.offsetHeight * 2;
const w = canvas.width;
const h = canvas.height;
const pad = 10;
ctx.clearRect(0, 0, w, h);
// Calculate envelope shape
const totalDuration = envelope.attack + envelope.decay + 1.5 + envelope.release;
const scaleX = (w - pad * 2) / totalDuration;
let x = pad;
const points = [];
// Attack
points.push({ x, y: h - pad });
x += envelope.attack * scaleX;
points.push({ x, y: pad });
// Decay
x += envelope.decay * scaleX;
points.push({ x, y: pad + (h - pad * 2) * (1 - envelope.sustain) });
// Sustain
x += 1.5 * scaleX;
points.push({ x, y: pad + (h - pad * 2) * (1 - envelope.sustain) });
// Release
x += envelope.release * scaleX;
points.push({ x, y: h - pad });
// Draw fill
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.closePath();
ctx.fillStyle = 'rgba(108, 92, 231, 0.15)';
ctx.fill();
// Draw line
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.strokeStyle = '#6c5ce7';
ctx.lineWidth = 2;
ctx.stroke();
// Labels
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = `${Math.floor(h * 0.18)}px JetBrains Mono`;
const labels = ['A', 'D', 'S', 'R'];
let lx = pad + envelope.attack * scaleX;
ctx.fillText(labels[0], lx - 4, h - 2);
lx += envelope.decay * scaleX / 2;
ctx.fillText(labels[1], lx - 3, h - 2);
lx += (1.5 * scaleX) / 2;
ctx.fillText(labels[2], lx - 3, h - 2);
lx += (envelope.release * scaleX) / 2;
ctx.fillText(labels[3], lx - 3, h - 2);
}
function drawFilterResponse() {
const canvas = document.getElementById('filterCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth * 2;
canvas.height = canvas.offsetHeight * 2;
const w = canvas.width;
const h = canvas.height;
const pad = 10;
ctx.clearRect(0, 0, w, h);
// Draw lowpass filter response curve
const cutoffFreq = filterSettings.cutoff;
const resonance = filterSettings.resonance;
ctx.beginPath();
for (let px = 0; px < w - pad * 2; px++) {
const freq = Math.pow(10, 2 + (px / (w - pad * 2)) * 4); // 100Hz to 10kHz
let gain;
if (freq === cutoffFreq) {
gain = resonance > 0 ? Math.min(resonance / 5, 1) : 0.707;
} else {
const ratio = freq / cutoffFreq;
gain = 1 / Math.sqrt(1 + Math.pow(ratio, 2));
if (resonance > 1 && Math.abs(Math.log2(ratio)) < 0.5) {
gain *= (1 + resonance * 0.3);
}
}
const y = h - pad - gain * (h - pad * 2);
if (px === 0) ctx.moveTo(pad + px, y);
else ctx.lineTo(pad + px, y);
}
ctx.strokeStyle = '#6c5ce7';
ctx.lineWidth = 2;
ctx.stroke();
// Fill under curve
ctx.lineTo(w - pad, h - pad);
ctx.lineTo(pad, h - pad);
ctx.closePath();
ctx.fillStyle = 'rgba(108, 92, 231, 0.1)';
ctx.fill();
// Cutoff marker
const cutoffPx = ((Math.log10(cutoffFreq) - 2) / 4) * (w - pad * 2);
if (cutoffPx > 0 && cutoffPx < w - pad * 2) {
ctx.beginPath();
ctx.moveTo(pad + cutoffPx, pad);
ctx.lineTo(pad + cutoffPx, h - pad);
ctx.strokeStyle = 'rgba(108, 92, 231, 0.4)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#a29bfe';
ctx.font = `${Math.floor(h * 0.18)}px JetBrains Mono`;
ctx.fillText(Math.round(cutoffFreq) + 'Hz', pad + cutoffPx + 4, h - 2);
}
}
// ==================== TUTORIAL SYSTEM ====================
const steps = [
{
title: "🎵 Welcome to Synthesis!",
body: `A <strong>synthesizer</strong> generates sound electronically. At its heart is an <strong>oscillator</strong> — a circuit that creates a repeating waveform. The shape of this wave determines the <strong>timbre</strong> (tone color) you hear.<br><br>Every synth sound starts here. Let's explore!`,
challenge: "Click on the keyboard keys below to play notes. Try each waveform type — notice how dramatically they change the sound!",
unlockModule: 'oscillator',
checkComplete: () => true // Always completable for step 1
},
{
title: "📈 The Envelope (ADSR)",
body: `An <strong>envelope</strong> shapes how a sound evolves over time. <code>ADSR</code> stands for:<br><br>• <strong>A</strong>tack — how fast the sound reaches full volume<br>• <strong>D</strong>ecay — how quickly it drops to sustain level<br>• <strong>S</strong>ustain — the held volume while you keep the key pressed<br>• <strong>R</strong>elease — how long it fades after you release<br><br>This is why a piano sounds different from an organ!`,
challenge: "Try setting Attack to 0.5s and Release to 2s for a soft, pad-like sound. Then try Attack=0.01, Decay=0.1, Sustain=0.1 for a percussive pluck!",
unlockModule: 'envelope',
checkComplete: () => {
const atk = parseFloat(document.getElementById('attackSlider').value);
const rel = parseFloat(document.getElementById('releaseSlider').value);
return atk > 0.2 || rel > 1; // User has experimented with envelope
}
},
{
title: "🔊 The Filter",
body: `A <strong>filter</strong> removes certain frequencies from the sound. A <strong>low-pass filter</strong> lets low frequencies through and blocks high ones.<br><br><code>Cutoff</code> sets where the filter starts cutting. <code>Resonance</code> boosts frequencies near the cutoff, creating that classic "squelchy" synth sound.<br><br>Filters are what make a synth go from bright to dark — essential for expressive sounds!`,
challenge: "Start with Cutoff at 2000Hz and slowly raise it while holding a note. Then crank Resonance up to 15+ and sweep the cutoff again!",
unlockModule: 'filter',
checkComplete: () => {
const cut = parseFloat(document.getElementById('cutoffSlider').value);
return cut < 4000 || cut > 12000; // User has moved cutoff significantly
}
},
{
title: "🔄 LFO — Low Frequency Oscillator",
body: `An <strong>LFO</strong> is a slow oscillator that doesn't produce sound itself. Instead, it <strong>modulates</strong> (changes) other parameters over time.<br><br>When an LFO modulates pitch → you get <strong>vibrato</strong>.<br>When it modulates volume → you get <strong>tremolo</strong>.<br><br>In this synth, the LFO modulates the filter cutoff for a pulsing effect!`,
challenge: "Enable the LFO and try different rates. Slow (~2Hz) gives a gentle pulse. Fast (~10Hz+) creates a throbbing effect. Adjust depth to control intensity!",
unlockModule: 'lfo',
checkComplete: () => lfoSettings.enabled && (lfoSettings.rate < 3 || lfoSettings.rate > 8)
},
{
title: "✨ Effects — Delay & Reverb",
body: `<strong>Effects</strong> add space and depth to your sound.<br><br><code>Delay</code> repeats the signal after a time interval. <em>Feedback</em> controls how many repeats you get.<br><br><code>Reverb</code> simulates the sound of being in a physical space — a small room or a huge hall.<br><br>Effects transform a dry, thin sound into something lush and atmospheric!`,
challenge: "Enable both Delay and Reverb. Set delay time to 0.4s with feedback around 0.5 for a spacious echo effect!",
unlockModule: 'effects',
checkComplete: () => (effectSettings.delay.enabled || effectSettings.reverb.enabled)
},
{
title: "🎛️ Master & You're a Synth Wizard!",
body: `The <strong>Master Volume</strong> controls the overall output level. Always keep this reasonable to protect your ears!<br><br>You've now explored all the core building blocks of synthesis:<br><br>✅ Oscillator — generates the raw sound<br>✅ Envelope — shapes it over time<br>✅ Filter — sculpts its frequency content<br>✅ LFO — adds movement and modulation<br>✅ Effects — adds space and atmosphere<br><br>Mix these together creatively to design your own sounds!`,
challenge: "Design a unique patch! Combine everything you've learned — pick a waveform, shape the envelope, set a filter, add LFO modulation, and layer on effects. Make something that sounds like YOU!",
unlockModule: 'master',
checkComplete: () => true
}
];
let currentStep = 0;
let completedSteps = new Set();
function initTutorial() {
const indicator = document.getElementById('stepIndicator');
indicator.innerHTML = '';
steps.forEach((_, i) => {
const dot = document.createElement('div');
dot.className = 'step-dot' + (i === 0 ? ' active' : '');
indicator.appendChild(dot);
});
loadStep(0);
}
function loadStep(index) {
currentStep = index;
const step = steps[index];
document.getElementById('tutorialTitle').textContent = step.title;
document.getElementById('tutorialBody').innerHTML = step.body;
document.getElementById('challengeText').textContent = step.challenge;
// Update progress
document.getElementById('progressLabel').textContent = `Step ${index + 1} / ${steps.length}`;
document.getElementById('progressFill').style.width = ((index + 1) / steps.length * 100) + '%';
// Update step dots
document.querySelectorAll('.step-dot').forEach((dot, i) => {
dot.className = 'step-dot';
if (i < index) dot.classList.add('completed');
if (i === index) dot.classList.add('active');
});
// Unlock module for this step
if (step.unlockModule && !document.getElementById('mod-' + step.unlockModule).classList.contains('locked')) {
// Already unlocked
} else if (step.unlockModule) {
unlockModule(step.unlockModule);
}
// Button states
document.getElementById('prevBtn').style.visibility = index === 0 ? 'hidden' : 'visible';
document.getElementById('nextBtn').textContent = index === steps.length - 1 ? '🎉 Finish!' : 'Next →';
}
function unlockModule(name) {
const mod = document.getElementById('mod-' + name);
if (!mod) return;
mod.classList.remove('locked');
mod.classList.add('unlocked-anim');
setTimeout(() => mod.classList.remove('unlocked-anim'), 700);
showNotification(`🔓 ${name.charAt(0).toUpperCase() + name.slice(1)} Unlocked!`, 'Try adjusting the controls in this module.');
}
function nextStep() {
// Check if current step is "complete" enough to advance
const step = steps[currentStep];
if (step.checkComplete && !step.checkComplete()) {
showNotification('🎯 Keep Exploring!', 'Try the challenge before moving on — experiment with the controls!');
return;
}
completedSteps.add(currentStep);
const mod = document.getElementById('mod-' + steps[currentStep].unlockModule);
if (mod) mod.classList.add('completed');
if (currentStep < steps.length - 1) {
loadStep(currentStep + 1);
} else {
showNotification('🎉 Congratulations!', 'You\'ve explored all the fundamentals of synthesis! Keep experimenting and designing your own sounds.');
}
}
function prevStep() {
if (currentStep > 0) {
loadStep(currentStep - 1);
}
}
// ==================== NOTIFICATIONS ====================
let notifTimeout = null;
function showNotification(title, text) {
const el = document.getElementById('notification');
document.getElementById('notifTitle').textContent = title;
document.getElementById('notifText').textContent = text;
el.classList.add('show');
clearTimeout(notifTimeout);
notifTimeout = setTimeout(() => el.classList.remove('show'), 4000);
}
// ==================== INIT ====================
buildKeyboard();
initTutorial();
drawWaveform();
drawADSR();
drawFilterResponse();
window.addEventListener('resize', () => {
drawADSR();
drawFilterResponse();
});
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"basic","sessionId":"89a88119-78b0-4dc2-9187-c04c8cb91b1e","tokensIn":217,"tokensOut":15400,"tokensTotal":15617,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-07-19T02:30:34.489Z"} -->