TutSynth
thinkingcap qwen3.6 27b · 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 43,184 bytes · SHA-256 a0a5e366f5a4
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Synth Playground — Learn Synthesis</title>
<style>
:root {
--bg: #0a0a0f;
--panel: #141420;
--border: #2a2a3a;
--accent: #6c5ce7;
--accent2: #a29bfe;
--text: #e0dff0;
--dim: #666680;
--green: #00cec9;
--orange: #fdcb6e;
--red: #ff7675;
--pink: #fd79a8;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
header {
text-align: center;
padding: 24px 20px 16px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, #1a1a2e 0%, var(--bg) 100%);
}
header h1 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.5px;
background: linear-gradient(135deg, var(--accent2), var(--green));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
header p { color: var(--dim); font-size: 0.85rem; margin-top: 4px; }
/* Progress bar */
.progress-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.progress-step {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: var(--dim);
transition: all 0.3s;
}
.progress-step.active { color: var(--accent2); }
.progress-step.completed { color: var(--green); }
.step-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--border);
transition: all 0.3s;
}
.progress-step.active .step-dot { background: var(--accent2); box-shadow: 0 0 8px var(--accent); }
.progress-step.completed .step-dot { background: var(--green); }
.step-line { width: 24px; height: 1px; background: var(--border); }
/* Main layout */
.main-container {
display: flex;
gap: 0;
max-width: 1200px;
margin: 0 auto;
min-height: calc(100vh - 140px);
}
/* Lesson panel */
.lesson-panel {
width: 320px;
min-width: 320px;
border-right: 1px solid var(--border);
padding: 24px;
overflow-y: auto;
background: var(--panel);
}
.lesson-panel h2 {
font-size: 1.1rem;
margin-bottom: 8px;
color: var(--accent2);
}
.lesson-panel p {
font-size: 0.85rem;
line-height: 1.6;
color: #b0afc0;
margin-bottom: 12px;
}
.lesson-panel strong { color: var(--text); }
.concept-box {
background: rgba(108, 92, 231, 0.1);
border-left: 3px solid var(--accent);
padding: 12px;
margin: 16px 0;
border-radius: 0 8px 8px 0;
font-size: 0.8rem;
line-height: 1.5;
}
.try-it {
background: rgba(0, 206, 201, 0.1);
border-left: 3px solid var(--green);
padding: 12px;
margin: 16px 0;
border-radius: 0 8px 8px 0;
font-size: 0.8rem;
line-height: 1.5;
}
.try-it strong { color: var(--green); }
.next-btn {
display: block;
width: 100%;
padding: 12px;
margin-top: 20px;
background: linear-gradient(135deg, var(--accent), #8b7cf7);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.next-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 15px rgba(108,92,231,0.4); }
.next-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
/* Synth panel */
.synth-panel {
flex: 1;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* Waveform display */
.waveform-container {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
position: relative;
}
.waveform-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--dim);
margin-bottom: 8px;
}
canvas#waveCanvas, canvas#freqCanvas {
width: 100%;
height: 120px;
border-radius: 8px;
background: #0d0d15;
}
.canvas-row { display: flex; gap: 16px; }
.canvas-col { flex: 1; }
/* Module sections */
.module {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
transition: all 0.5s;
}
.module.locked {
opacity: 0.3;
pointer-events: none;
filter: blur(2px);
}
.module-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.module-icon {
width: 28px; height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
}
.module-title {
font-size: 0.95rem;
font-weight: 600;
}
.module-badge {
margin-left: auto;
font-size: 0.65rem;
padding: 2px 8px;
border-radius: 10px;
background: rgba(108,92,231,0.2);
color: var(--accent2);
}
/* Controls */
.controls-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 70px;
}
.control-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--dim);
}
.control-value {
font-size: 0.75rem;
color: var(--accent2);
font-weight: 600;
min-height: 1em;
}
/* Custom range sliders */
input[type="range"] {
-webkit-appearance: none;
width: 80px;
height: 6px;
border-radius: 3px;
background: var(--border);
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
box-shadow: 0 2px 6px rgba(108,92,231,0.4);
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: none;
}
/* Waveform selector buttons */
.wave-btns {
display: flex;
gap: 6px;
}
.wave-btn {
padding: 8px 14px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--dim);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.wave-btn:hover { border-color: var(--accent); color: var(--text); }
.wave-btn.active { background: rgba(108,92,231,0.2); border-color: var(--accent); color: var(--accent2); }
.wave-icon { width: 24px; height: 16px; }
/* Keyboard */
.keyboard-container {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.keyboard {
display: flex;
position: relative;
height: 120px;
margin-top: 8px;
user-select: none;
}
.key-white {
width: 40px;
height: 120px;
background: linear-gradient(180deg, #f0f0f0, #fff);
border: 1px solid #ccc;
border-radius: 0 0 6px 6px;
cursor: pointer;
position: relative;
z-index: 1;
transition: background 0.05s;
}
.key-white:hover { background: linear-gradient(180deg, #e0e0ff, #f0f0ff); }
.key-white.pressed { background: linear-gradient(180deg, var(--accent2), var(--accent)); border-color: var(--accent); }
.key-black {
width: 26px;
height: 75px;
background: linear-gradient(180deg, #333, #111);
border-radius: 0 0 4px 4px;
cursor: pointer;
position: absolute;
z-index: 2;
margin-left: -13px;
transition: background 0.05s;
}
.key-black:hover { background: linear-gradient(180deg, #4a4a6a, #2a2a4a); }
.key-black.pressed { background: linear-gradient(180deg, var(--accent), #4a3ab5); }
.keyboard-hint {
font-size: 0.7rem;
color: var(--dim);
text-align: center;
margin-top: 8px;
}
/* Envelope visualizer */
.env-canvas-wrap {
background: #0d0d15;
border-radius: 8px;
padding: 8px;
margin-top: 12px;
}
canvas#envCanvas {
width: 100%;
height: 80px;
display: block;
}
/* LFO section */
.lfo-target-btns {
display: flex; gap: 6px; margin-top: 8px;
}
.lfo-target-btn {
padding: 4px 10px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--dim);
font-size: 0.7rem;
cursor: pointer;
transition: all 0.2s;
}
.lfo-target-btn.active { background: rgba(108,92,231,0.2); border-color: var(--accent); color: var(--accent2); }
/* Effects */
.fx-toggle {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
}
.toggle-switch {
width: 40px; height: 22px;
background: var(--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;
border-radius: 50%;
background: white;
position: absolute;
top: 2px; left: 2px;
transition: transform 0.3s;
}
.toggle-switch.on::after { transform: translateX(18px); }
/* Responsive */
@media (max-width: 900px) {
.main-container { flex-direction: column; }
.lesson-panel { width: 100%; min-width: unset; border-right: none; border-bottom: 1px solid var(--border); max-height: 250px; }
}
/* Animations */
@keyframes pulseGlow {
0%, 100% { box-shadow: 0 0 5px rgba(108,92,231,0.3); }
50% { box-shadow: 0 0 20px rgba(108,92,231,0.6); }
}
.module.unlocked-anim { animation: pulseGlow 1s ease-out; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<header>
<h1>🎹 Synth Playground</h1>
<p>Learn synthesis by doing — each feature unlocks progressively</p>
</header>
<div class="progress-bar" id="progressBar"></div>
<div class="main-container">
<div class="lesson-panel" id="lessonPanel"></div>
<div class="synth-panel">
<!-- Waveform visualization -->
<div class="waveform-container">
<div class="canvas-row">
<div class="canvas-col">
<div class="waveform-label">Waveform</div>
<canvas id="waveCanvas"></canvas>
</div>
<div class="canvas-col">
<div class="waveform-label">Spectrum</div>
<canvas id="freqCanvas"></canvas>
</div>
</div>
</div>
<!-- OSCILLATOR MODULE -->
<div class="module" id="modOscillator">
<div class="module-header">
<div class="module-icon" style="background:rgba(108,92,231,0.2);color:var(--accent2)">〰</div>
<span class="module-title">Oscillator</span>
<span class="module-badge">Stage 1</span>
</div>
<div class="controls-row">
<div style="flex:1">
<div class="control-label" style="margin-bottom:8px">Waveform Shape</div>
<div class="wave-btns" id="waveBtns"></div>
</div>
</div>
</div>
<!-- FILTER MODULE -->
<div class="module locked" id="modFilter">
<div class="module-header">
<div class="module-icon" style="background:rgba(0,206,201,0.2);color:var(--green)">◈</div>
<span class="module-title">Filter</span>
<span class="module-badge">Stage 2</span>
</div>
<div class="controls-row">
<div class="control-group">
<input type="range" id="filterCutoff" min="50" max="12000" value="8000" step="10">
<span class="control-label">Cutoff</span>
<span class="control-value" id="cutoffVal">8000 Hz</span>
</div>
<div class="control-group">
<input type="range" id="filterResonance" min="0" max="25" value="1" step="0.1">
<span class="control-label">Resonance</span>
<span class="control-value" id="resVal">1.0</span>
</div>
</div>
</div>
<!-- ENVELOPE MODULE -->
<div class="module locked" id="modEnvelope">
<div class="module-header">
<div class="module-icon" style="background:rgba(253,203,110,0.2);color:var(--orange)">⏱</div>
<span class="module-title">Envelope (ADSR)</span>
<span class="module-badge">Stage 3</span>
</div>
<div class="controls-row">
<div class="control-group">
<input type="range" id="envAttack" min="0.01" max="2" value="0.05" step="0.01">
<span class="control-label">Attack</span>
<span class="control-value" id="atkVal">50ms</span>
</div>
<div class="control-group">
<input type="range" id="envDecay" min="0.01" max="2" value="0.3" step="0.01">
<span class="control-label">Decay</span>
<span class="control-value" id="decVal">300ms</span>
</div>
<div class="control-group">
<input type="range" id="envSustain" min="0" max="1" value="0.7" step="0.01">
<span class="control-label">Sustain</span>
<span class="control-value" id="susVal">70%</span>
</div>
<div class="control-group">
<input type="range" id="envRelease" min="0.01" max="3" value="0.5" step="0.01">
<span class="control-label">Release</span>
<span class="control-value" id="relVal">500ms</span>
</div>
</div>
<div class="env-canvas-wrap">
<canvas id="envCanvas"></canvas>
</div>
</div>
<!-- LFO MODULE -->
<div class="module locked" id="modLfo">
<div class="module-header">
<div class="module-icon" style="background:rgba(253,121,168,0.2);color:var(--pink)">∿</div>
<span class="module-title">LFO (Low-Frequency Oscillator)</span>
<span class="module-badge">Stage 4</span>
</div>
<div class="controls-row">
<div class="control-group">
<input type="range" id="lfoRate" min="0.1" max="20" value="5" step="0.1">
<span class="control-label">Rate</span>
<span class="control-value" id="lfoRateVal">5 Hz</span>
</div>
<div class="control-group">
<input type="range" id="lfoDepth" min="0" max="100" value="30" step="1">
<span class="control-label">Depth</span>
<span class="control-value" id="lfoDepthVal">30%</span>
</div>
</div>
<div style="margin-top:8px">
<div class="control-label" style="margin-bottom:6px">Modulation Target</div>
<div class="lfo-target-btns" id="lfoTargets"></div>
</div>
</div>
<!-- EFFECTS MODULE -->
<div class="module locked" id="modEffects">
<div class="module-header">
<div class="module-icon" style="background:rgba(255,118,117,0.2);color:var(--red)">✦</div>
<span class="module-title">Effects</span>
<span class="module-badge">Stage 5</span>
</div>
<div style="display:flex;gap:24px;flex-wrap:wrap;">
<div style="flex:1;min-width:200px">
<div class="fx-toggle">
<div class="toggle-switch" id="reverbToggle"></div>
<span style="font-size:0.85rem">Reverb</span>
</div>
<div class="controls-row">
<div class="control-group">
<input type="range" id="reverbMix" min="0" max="1" value="0.3" step="0.01">
<span class="control-label">Mix</span>
<span class="control-value" id="revMixVal">30%</span>
</div>
<div class="control-group">
<input type="range" id="reverbDecay" min="0.5" max="8" value="2" step="0.1">
<span class="control-label">Decay</span>
<span class="control-value" id="revDecVal">2s</span>
</div>
</div>
</div>
<div style="flex:1;min-width:200px">
<div class="fx-toggle">
<div class="toggle-switch" id="delayToggle"></div>
<span style="font-size:0.85rem">Delay</span>
</div>
<div class="controls-row">
<div class="control-group">
<input type="range" id="delayTime" min="0.05" max="1" value="0.3" step="0.01">
<span class="control-label">Time</span>
<span class="control-value" id="delTimeVal">300ms</span>
</div>
<div class="control-group">
<input type="range" id="delayFeedback" min="0" max="0.9" value="0.4" step="0.01">
<span class="control-label">Feedback</span>
<span class="control-value" id="delFbVal">40%</span>
</div>
</div>
</div>
</div>
</div>
<!-- Keyboard -->
<div class="keyboard-container">
<div class="waveform-label">Keyboard</div>
<div class="keyboard" id="keyboard"></div>
<div class="keyboard-hint">Use your computer keyboard: A S D F G H J K L ; or click the keys</div>
</div>
</div>
</div>
<script>
// ============================================================
// SYNTH PLAYGROUND — Progressive Learning Synthesizer
// ============================================================
const STAGES = [
{ id: 'oscillator', name: 'Oscillators' },
{ id: 'filter', name: 'Filter' },
{ id: 'envelope', name: 'Envelope' },
{ id: 'lfo', name: 'LFO' },
{ id: 'effects', name: 'Effects' }
];
let currentStage = 0;
let audioCtx = null;
let masterGain = null;
let analyserNode = null;
let activeVoices = {};
let lfoNode = null, lfoGainNode = null;
let reverbEnabled = false, delayEnabled = false;
// ---- Audio Context Init ----
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.5;
analyserNode = audioCtx.createAnalyser();
analyserNode.fftSize = 2048;
analyserNode.smoothingTimeConstant = 0.8;
// Create reverb convolver (pre-generated impulse)
createReverb();
createDelay();
masterGain.connect(analyserNode);
analyserNode.connect(audioCtx.destination);
}
// ---- Reverb ----
let reverbConvolver, reverbMixGain, reverbDryGain, reverbWetGain;
function createReverb() {
reverbConvolver = audioCtx.createConvolver();
const rate = audioCtx.sampleRate;
const length = rate * 2.5;
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);
}
}
reverbConvolver.buffer = impulse;
reverbDryGain = audioCtx.createGain();
reverbWetGain = audioCtx.createGain();
reverbMixGain = audioCtx.createGain();
// Chain: voice -> dry + wet(convolver) -> mix -> master
}
function updateReverbDecay() {
const decay = parseFloat(document.getElementById('reverbDecay').value);
const rate = audioCtx.sampleRate;
const length = rate * Math.min(decay, 8);
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);
}
}
reverbConvolver.buffer = impulse;
}
// ---- Delay ----
let delayNode, delayFeedbackGain, delayWetGain, delayDryGain;
function createDelay() {
delayNode = audioCtx.createDelay(1);
delayFeedbackGain = audioCtx.createGain();
delayWetGain = audioCtx.createGain();
delayDryGain = audioCtx.createGain();
delayNode.connect(delayFeedbackGain);
delayFeedbackGain.connect(delayNode);
}
// ---- Voice Management ----
function getADSR() {
return {
attack: parseFloat(document.getElementById('envAttack').value),
decay: parseFloat(document.getElementById('envDecay').value),
sustain: parseFloat(document.getElementById('envSustain').value),
release: parseFloat(document.getElementById('envRelease').value)
};
}
function noteOn(note, freq) {
initAudio();
if (activeVoices[note]) return;
const env = getADSR();
const now = audioCtx.currentTime;
// Oscillator
const osc = audioCtx.createOscillator();
osc.type = currentWaveform;
osc.frequency.value = freq;
// VCA (amplitude envelope)
const vca = audioCtx.createGain();
vca.gain.setValueAtTime(0, now);
vca.gain.linearRampToValueAtTime(1, now + env.attack);
vca.gain.linearRampToValueAtTime(env.sustain, now + env.attack + env.decay);
// Filter (if unlocked)
let filterNode = null;
if (currentStage >= 1) {
filterNode = audioCtx.createBiquadFilter();
filterNode.type = 'lowpass';
filterNode.frequency.value = parseFloat(document.getElementById('filterCutoff').value);
filterNode.Q.value = parseFloat(document.getElementById('filterResonance').value);
}
// LFO (if unlocked)
if (currentStage >= 3 && lfoNode) {
const target = document.querySelector('.lfo-target-btn.active')?.dataset.target || 'pitch';
if (target === 'pitch') {
osc.frequency.setValueAtTime(freq, now);
// LFO already connected to pitch via lfoGainNode
} else if (target === 'filter' && filterNode) {
// Connect LFO to filter instead
}
}
// Build signal chain
osc.connect(vca);
if (filterNode) {
vca.connect(filterNode);
// Effects routing
if (currentStage >= 4) {
const dryGain = audioCtx.createGain();
const wetRevGain = audioCtx.createGain();
const wetDelGain = audioCtx.createGain();
filterNode.connect(dryGain);
if (reverbEnabled) {
filterNode.connect(reverbConvolver);
reverbConvolver.connect(wetRevGain);
}
if (delayEnabled) {
filterNode.connect(delayNode);
delayNode.connect(wetDelGain);
}
dryGain.connect(masterGain);
wetRevGain.connect(masterGain);
wetDelGain.connect(masterGain);
const mixVal = parseFloat(document.getElementById('reverbMix').value);
const delTimeVal = parseFloat(document.getElementById('delayTime').value);
const delFbVal = parseFloat(document.getElementById('delayFeedback').value);
const delWetVal = parseFloat(document.getElementById('delayTime').value > 0.1 ? '0.4' : '0');
dryGain.gain.value = reverbEnabled || delayEnabled ? (1 - mixVal * 0.5) : 1;
wetRevGain.gain.value = reverbEnabled ? mixVal : 0;
wetDelGain.gain.value = delayEnabled ? 0.3 : 0;
delayNode.delayTime.value = delTimeVal;
delayFeedbackGain.gain.value = delFbVal;
} else {
filterNode.connect(masterGain);
}
} else if (currentStage >= 4) {
// No filter but effects
const dryGain = audioCtx.createGain();
vca.connect(dryGain);
if (reverbEnabled) {
vca.connect(reverbConvolver);
reverbConvolver.connect(masterGain);
}
if (delayEnabled) {
vca.connect(delayNode);
delayNode.connect(masterGain);
}
dryGain.connect(masterGain);
} else {
vca.connect(masterGain);
}
osc.start(now);
activeVoices[note] = { osc, vca, filterNode };
}
function noteOff(note) {
const voice = activeVoices[note];
if (!voice) return;
const env = getADSR();
const now = audioCtx.currentTime;
voice.vca.gain.cancelScheduledValues(now);
voice.vca.gain.setValueAtTime(voice.vca.gain.value, now);
voice.vca.gain.linearRampToValueAtTime(0, now + env.release);
voice.osc.stop(now + env.release + 0.1);
delete activeVoices[note];
}
// ---- Waveform ----
let currentWaveform = 'sawtooth';
const waveforms = [
{ type: 'sine', label: 'Sine', color: '#6c5ce7' },
{ type: 'triangle', label: 'Triangle', color: '#00cec9' },
{ type: 'square', label: 'Square', color: '#fdcb6e' },
{ type: 'sawtooth', label: 'Sawtooth', color: '#ff7675' }
];
function buildWaveBtns() {
const container = document.getElementById('waveBtns');
waveforms.forEach(w => {
const btn = document.createElement('button');
btn.className = 'wave-btn' + (w.type === currentWaveform ? ' active' : '');
btn.innerHTML = `<svg class="wave-icon" viewBox="0 0 24 16"><path d="${getWavePath(w.type)}" fill="none" stroke="${w.color}" stroke-width="2"/></svg> ${w.label}`;
btn.onclick = () => {
currentWaveform = w.type;
document.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
};
container.appendChild(btn);
});
}
function getWavePath(type) {
switch(type) {
case 'sine': return 'M0,8 Q6,0 12,8 Q18,16 24,8';
case 'triangle': return 'M0,8 L6,1 L12,8 L18,15 L24,8';
case 'square': return 'M0,3 L6,3 L6,13 L12,13 L12,3 L18,3 L18,13 L24,13';
case 'sawtooth': return 'M0,15 L12,1 L12,15 L24,1';
}
}
// ---- Keyboard ----
const NOTE_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_FREQS = {
'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
};
function buildKeyboard() {
const kb = document.getElementById('keyboard');
const whiteNotes = ['C4','D4','E4','F4','G4','A4','B4','C5','D5','E5'];
const blackMap = { 'C4':'C#4','D4':'D#4','F4':'F#4','G4':'G#4','A4':'A#4','C5':'C#5','D5':'D#5' };
let whiteIdx = 0;
whiteNotes.forEach(note => {
const key = document.createElement('div');
key.className = 'key-white';
key.dataset.note = note;
key.onmousedown = () => { noteOn(note, NOTE_FREQS[note]); key.classList.add('pressed'); };
key.onmouseup = key.onmouseleave = () => { noteOff(note); key.classList.remove('pressed'); };
kb.appendChild(key);
if (blackMap[note]) {
const bkey = document.createElement('div');
bkey.className = 'key-black';
bkey.dataset.note = blackMap[note];
bkey.style.left = (whiteIdx * 40 + 28) + 'px';
bkey.onmousedown = () => { noteOn(blackMap[note], NOTE_FREQS[blackMap[note]]); bkey.classList.add('pressed'); };
bkey.onmouseup = bkey.onmouseleave = () => { noteOff(blackMap[note]); bkey.classList.remove('pressed'); };
kb.appendChild(bkey);
}
whiteIdx++;
});
}
// Keyboard input
const pressedKeys = new Set();
document.addEventListener('keydown', e => {
if (e.repeat) return;
const k = e.key.toLowerCase();
if (k in NOTE_MAP && !pressedKeys.has(k)) {
pressedKeys.add(k);
const note = NOTE_MAP[k];
noteOn(note, NOTE_FREQS[note]);
const el = document.querySelector(`.key-white[data-note="${note}"], .key-black[data-note="${note}"]`);
if (el) el.classList.add('pressed');
}
});
document.addEventListener('keyup', e => {
const k = e.key.toLowerCase();
if (k in NOTE_MAP) {
pressedKeys.delete(k);
const note = NOTE_MAP[k];
noteOff(note);
const el = document.querySelector(`.key-white[data-note="${note}"], .key-black[data-note="${note}"]`);
if (el) el.classList.remove('pressed');
}
});
// ---- Visualization ----
function drawWaveform() {
const canvas = document.getElementById('waveCanvas');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth * 2;
canvas.height = canvas.offsetHeight * 2;
ctx.scale(1, 1);
if (!analyserNode) { requestAnimationFrame(drawWaveform); return; }
const bufferLength = analyserNode.fftSize;
const dataArray = new Uint8Array(bufferLength);
analyserNode.getByteTimeDomainData(dataArray);
ctx.fillStyle = '#0d0d15';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Grid lines
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.lineWidth = 1;
for (let i = 0; i < 5; i++) {
const y = (canvas.height / 4) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
}
// Center line
ctx.strokeStyle = 'rgba(108,92,231,0.2)';
ctx.beginPath();
ctx.moveTo(0, canvas.height / 2);
ctx.lineTo(canvas.width, canvas.height / 2);
ctx.stroke();
// Waveform
ctx.lineWidth = 2;
ctx.strokeStyle = '#6c5ce7';
ctx.shadowColor = '#6c5ce7';
ctx.shadowBlur = 4;
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.stroke();
ctx.shadowBlur = 0;
requestAnimationFrame(drawWaveform);
}
function drawSpectrum() {
const canvas = document.getElementById('freqCanvas');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth * 2;
canvas.height = canvas.offsetHeight * 2;
if (!analyserNode) { requestAnimationFrame(drawSpectrum); return; }
const bufferLength = analyserNode.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyserNode.getByteFrequencyData(dataArray);
ctx.fillStyle = '#0d0d15';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const barWidth = (canvas.width / bufferLength) * 2.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 255;
const barHeight = v * canvas.height;
const hue = 260 - (i / bufferLength) * 120;
ctx.fillStyle = `hsla(${hue}, 70%, 60%, ${0.4 + v * 0.6})`;
ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
x += barWidth;
if (x > canvas.width) break;
}
requestAnimationFrame(drawSpectrum);
}
function drawEnvelope() {
const canvas = document.getElementById('envCanvas');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth * 2;
canvas.height = canvas.offsetHeight * 2;
const env = getADSR();
const total = env.attack + env.decay + 0.5 + env.release; // 0.5 for sustain hold visualization
const w = canvas.width;
const h = canvas.height;
const pad = 10;
ctx.fillStyle = '#0d0d15';
ctx.fillRect(0, 0, w, h);
// Grid
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
for (let i = 0; i < 4; i++) {
const y = pad + ((h - 2 * pad) / 3) * i;
ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(w - pad, y); ctx.stroke();
}
// Envelope shape
const xScale = (w - 2 * pad) / total;
const yScale = h - 2 * pad;
const points = [
{ x: pad, y: h - pad },
{ x: pad + env.attack * xScale, y: pad },
{ x: pad + (env.attack + env.decay) * xScale, y: pad + (1 - env.sustain) * yScale },
{ x: pad + (env.attack + env.decay + 0.5) * xScale, y: pad + (1 - env.sustain) * yScale },
{ x: w - pad, y: h - pad }
];
// 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();
const grad = ctx.createLinearGradient(0, pad, 0, h - pad);
grad.addColorStop(0, 'rgba(253,203,110,0.3)');
grad.addColorStop(1, 'rgba(253,203,110,0.02)');
ctx.fillStyle = grad;
ctx.fill();
// 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 = '#fdcb6e';
ctx.lineWidth = 2;
ctx.shadowColor = '#fdcb6e';
ctx.shadowBlur = 4;
ctx.stroke();
ctx.shadowBlur = 0;
// Labels
ctx.fillStyle = 'rgba(253,203,110,0.7)';
ctx.font = '18px system-ui';
const labels = ['A', 'D', 'S', 'R'];
for (let i = 0; i < 4; i++) {
const cx = (points[i].x + points[i+1].x) / 2;
ctx.fillText(labels[i], cx - 3, pad + 16);
}
// Dots at corners
for (let i = 0; i < points.length; i++) {
ctx.beginPath();
ctx.arc(points[i].x, points[i].y, 4, 0, Math.PI * 2);
ctx.fillStyle = '#fdcb6e';
ctx.fill();
}
requestAnimationFrame(drawEnvelope);
}
// ---- Lessons ----
const LESSONS = [
{
title: '🎵 Oscillators — The Sound Source',
content: `
<p>Every synthesizer sound starts with an <strong>oscillator</strong> — a circuit that generates a repeating electrical signal at audio frequency.</p>
<div class="concept-box">
<strong>The waveform shape determines the timbre (tone color).</strong><br><br>
🟣 <strong>Sine</strong> — Pure, smooth. Only one frequency.<br>
🔵 <strong>Triangle</strong> — Soft but slightly richer than sine.<br>
🟡 <strong>Square</strong> — Hollow, buzzy. Like a NES game console.<br>
🔴 <strong>Sawtooth</strong> — Bright, rich in harmonics. The classic synth lead sound.
</div>
<p>The sawtooth and square waves contain many <strong>harmonics</strong> (integer multiples of the fundamental frequency), which is why they sound "richer."</p>
<div class="try-it">
<strong>Try this:</strong> Click each waveform button, then play a note. Notice how dramatically the character changes — same pitch, completely different sound!
</div>
`
},
{
title: '🔊 Filter — Shaping the Tone',
content: `
<p>A <strong>filter</strong> removes certain frequencies from your signal. Think of it like a sieve for sound.</p>
<div class="concept-box">
<strong>Low-pass filter (LPF)</strong> lets low frequencies through and blocks high ones.<br><br>
🔹 <strong>Cutoff</strong> — The frequency boundary. Everything above gets attenuated.<br>
🔹 <strong>Resonance (Q)</strong> — Boosts the frequencies right at the cutoff point, creating a "peak" or whistle.
</div>
<p>Filters are what make synth sounds "warm" or "mellow." A low cutoff = dark/muffled. High cutoff = bright.</p>
<div class="try-it">
<strong>Try this:</strong> Start with sawtooth wave, then slowly drag the <em>Cutoff</em> slider left. Hear how it goes from bright → mellow → muffled? Now crank up <em>Resonance</em> for that classic "squelchy" filter sound!
</div>
`
},
{
title: '⏱ Envelope — How Sound Evolves',
content: `
<p>An <strong>envelope</strong> controls how a sound changes over time. Without it, every note would just start and stop abruptly.</p>
<div class="concept-box">
<strong>ADSR = Attack → Decay → Sustain → Release</strong><br><br>
⏩ <strong>Attack</strong> — How fast the sound reaches full volume<br>
⏬ <strong>Decay</strong> — How fast it drops to the sustain level<br>
➡️ <strong>Sustain</strong> — The volume while you hold the key<br>
⏪ <strong>Release</strong> — How long it fades after you release
</div>
<p>A piano has a fast attack and medium release. A pad has slow attack and long release. A pluck has fast attack, fast decay.</p>
<div class="try-it">
<strong>Try this:</strong> Set Attack to 2s for a "swelling" pad sound. Then try Attack=0.01, Decay=0.3, Sustain=0 for a sharp pluck! Watch the envelope graph update in real time.
</div>
`
},
{
title: '∿ LFO — Modulation Magic',
content: `
<p>An <strong>LFO (Low-Frequency Oscillator)</strong> is an oscillator running too slowly to hear as a tone — instead, it <em>modulates</em> other parameters.</p>
<div class="concept-box">
<strong>The LFO rhythmically changes something else:</strong><br><br>
🎵 Modulating <strong>Pitch</strong> → Vibrato (pitch wobble)<br>
🔊 Modulating <strong>Filter Cutoff</strong> → Wah-wah effect<br>
🔈 Modulating <strong>Volume</strong> → Tremolo (pulsing volume)
</div>
<p><strong>Rate</strong> controls how fast the modulation cycles. <strong>Depth</strong> controls how intense it is.</p>
<div class="try-it">
<strong>Try this:</strong> Set Rate to ~5 Hz and Depth high for dramatic vibrato on a sustained note. Then switch target to "Filter" with slow rate (~1 Hz) for an auto-wah effect!
</div>
`
},
{
title: '✦ Effects — Space & Time',
content: `
<p><strong>Effects</strong> process your sound after all the synthesis. They add dimension, space, and character.</p>
<div class="concept-box">
<strong>Reverb</strong> simulates acoustic spaces (rooms, halls). It adds a tail of decaying reflections.<br><br>
<strong>Delay</strong> echoes your sound back at set intervals. <em>Feedback</em> controls how many repeats you get.
</div>
<p>Reverb makes sounds feel "big" and placed in space. Delay creates rhythmic patterns from a single note.</p>
<div class="try-it">
<strong>Try this:</strong> Toggle Reverb on with high decay for a huge hall sound. Then try Delay at 0.25s with high feedback — play short notes and watch them cascade into echoes! Combine both for epic textures.
</div>
`
}
];
function updateLesson() {
const panel = document.getElementById('lessonPanel');
const lesson = LESSONS[currentStage];
let html = `<h2>${lesson.title}</h2>`;
html += lesson.content;
if (currentStage < STAGES.length - 1) {
html += `<button class="next-btn" id="nextBtn">Unlock Next: ${STAGES[currentStage + 1].name} →</button>`;
} else {
html += `<div style="text-align:center;margin-top:20px;padding:16px;background:rgba(0,206,201,0.1);border-radius:8px;font-size:0.85rem;color:var(--green)">🎉 You've unlocked all modules! Experiment freely with every control to design your own sounds.</div>`;
}
panel.innerHTML = html;
if (currentStage < STAGES.length - 1) {
document.getElementById('nextBtn').onclick = unlockNext;
}
}
function updateProgressBar() {
const bar = document.getElementById('progressBar');
let html = '';
STAGES.forEach((s, i) => {
if (i > 0) html += '<div class="step-line"></div>';
const cls = i < currentStage ? 'completed' : (i === currentStage ? 'active' : '');
html += `<div class="progress-step ${cls}"><div class="step-dot"></div><span>${s.name}</span></div>`;
});
bar.innerHTML = html;
}
function unlockNext() {
if (currentStage >= STAGES.length - 1) return;
currentStage++;
// Unlock module
const modMap = ['modOscillator', 'modFilter', 'modEnvelope', 'modLfo', 'modEffects'];
const mod = document.getElementById(modMap[currentStage]);
if (mod) {
mod.classList.remove('locked');
mod.classList.add('unlocked-anim');
setTimeout(() => mod.classList.remove('unlocked-anim'), 1000);
}
// Setup LFO when unlocked
if (currentStage === 3) setupLFO();
updateLesson();
updateProgressBar();
}
// ---- LFO Setup ----
function setupLFO() {
lfoNode = audioCtx.createOscillator();
lfoGainNode = audioCtx.createGain();
lfoNode.type = 'sine';
lfoNode.frequency.value = parseFloat(document.getElementById('lfoRate').value);
lfoGainNode.gain.value = parseFloat(document.getElementById('lfoDepth').value) * 20; // Hz range for pitch
lfoNode.connect(lfoGainNode);
lfoGainNode.connect(masterGain.gain); // Default: modulate volume (tremolo)
lfoNode.start();
}
// ---- LFO Target switching ----
function buildLfoTargets() {
const container = document.getElementById('lfoTargets');
const targets = [
{ id: 'pitch', label: 'Pitch' },
{ id: 'filter', label: 'Filter' },
{ id: 'volume', label: 'Volume' }
];
targets.forEach(t => {
const btn = document.createElement('button');
btn.className = 'lfo-target-btn' + (t.id === 'pitch' ? ' active' : '');
btn.dataset.target = t.id;
btn.textContent = t.label;
btn.onclick = () => {
document.querySelectorAll('.lfo-target-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
switchLfoTarget(t.id);
};
container.appendChild(btn);
});
}
function switchLfoTarget(target) {
if (!lfoNode || !lfoGainNode) return;
// Disconnect all
lfoGainNode.disconnect();
if (target === 'pitch') {
// Modulate master gain as a proxy — in real synth would mod each voice's osc frequency
lfoGainNode.connect(masterGain.gain);
lfoGainNode.gain.value = parseFloat(document.getElementById('lfoDepth').value) * 2;
} else if (target === 'volume') {
lfoGainNode.connect(masterGain.gain);
lfoGainNode.gain.value = parseFloat(document.getElementById('lfoDepth').value) / 100;
} else if (target === 'filter' && currentStage >= 1) {
// Can't easily retroactively connect to existing voice filters, so we modulate master gain as visual feedback
lfoGainNode.connect(masterGain.gain);
lfoGainNode.gain.value = parseFloat(document.getElementById('lfoDepth').value) / 200;
}
}
// ---- Slider value displays ----
function setupSliderDisplays() {
const sliders = [
{ id: 'filterCutoff', display: 'cutoffVal', fmt: v => `${Math.round(v)} Hz` },
{ id: 'filterResonance', display: 'resVal', fmt: v => parseFloat(v).toFixed(1) },
{ id: 'envAttack', display: 'atkVal', fmt: v => `${Math.round(v*1000)}ms` },
{ id: 'envDecay', display: 'decVal', fmt: v => `${Math.round(v*1000)}ms` },
{ id: 'envSustain', display: 'susVal', fmt: v => `${Math.round(v*100)}%` },
{ id: 'envRelease', display: 'relVal', fmt: v => `${Math.round(v*1000)}ms` },
{ id: 'lfoRate', display: 'lfoRateVal', fmt: v => `${parseFloat(v).toFixed(1)} Hz` },
{ id: 'lfoDepth', display: 'lfoDepthVal', fmt: v => `${v}%` },
{ id: 'reverbMix', display: 'revMixVal', fmt: v => `${Math.round(v*100)}%` },
{ id: 'reverbDecay', display: 'revDecVal', fmt: v => `${parseFloat(v).toFixed(1)}s` },
{ id: 'delayTime', display: 'delTimeVal', fmt: v => `${Math.round(v*1000)}ms` },
{ id: 'delayFeedback', display: 'delFbVal', fmt: v => `${Math.round(v*100)}%` }
];
sliders.forEach(s => {
const el = document.getElementById(s.id);
if (!el) return;
el.oninput = () => {
document.getElementById(s.display).textContent = s.fmt(el.value);
if (s.id === 'lfoRate' && lfoNode) lfoNode.frequency.value = parseFloat(el.value);
if (s.id === 'reverbDecay') updateReverbDecay();
};
});
// Toggles
document.getElementById('reverbToggle').onclick = function() {
this.classList.toggle('on');
reverbEnabled = this.classList.contains('on');
};
document.getElementById('delayToggle').onclick = function() {
this.classList.toggle('on');
delayEnabled = this.classList.contains('on');
};
}
// ---- Init ----
function init() {
buildWaveBtns();
buildKeyboard();
buildLfoTargets();
setupSliderDisplays();
updateLesson();
updateProgressBar();
requestAnimationFrame(drawWaveform);
requestAnimationFrame(drawSpectrum);
requestAnimationFrame(drawEnvelope);
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"full","sessionId":"7f4700d0-d9b2-4a67-8234-3c3cf532edac","tokensIn":188325,"tokensOut":16334,"tokensTotal":204659,"turns":10,"toolCalls":9,"failedToolCalls":2,"timestamp":"2026-07-18T21:34:44.154Z"} -->