TutSynth
thinkingcap qwen3.6 27b · composer

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 38,817 bytes · SHA-256 b8715ff362dc
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Synth Playground — Learn Synthesis Interactively</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a12;
color: #e0e0f0;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
#synth-header {
text-align: center;
padding: 24px 16px 12px;
background: linear-gradient(180deg, #15152a 0%, transparent 100%);
}
#synth-header h1 { font-size: 2em; margin-bottom: 4px; }
.subtitle { color: #8888aa; font-size: 0.95em; margin-bottom: 12px; }
#progress-bar {
width: 300px; height: 6px; background: #222240; border-radius: 3px;
margin: 0 auto; overflow: hidden;
}
#progress-fill {
height: 100%; width: 20%; background: linear-gradient(90deg, #6c5ce7, #a855f7);
border-radius: 3px; transition: width 0.6s ease;
}
#synth-body {
max-width: 800px; margin: 0 auto; padding: 12px 16px 40px;
display: flex; flex-direction: column; gap: 12px;
}
.synth-section {
background: #13132a; border: 1px solid #252548; border-radius: 12px;
padding: 16px; transition: all 0.4s ease; position: relative; overflow: hidden;
}
.synth-section.locked { opacity: 0.45; filter: grayscale(0.5); pointer-events: none; }
.synth-section.unlocked { border-color: #3a3a6e; }
.synth-section.active { border-color: #7c5ce7; box-shadow: 0 0 20px rgba(108,92,231,0.15); }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.section-header h2 { font-size: 1.1em; display: flex; align-items: center; gap: 8px; }
.icon { font-size: 1.3em; }
.learn-btn {
background: #252548; border: 1px solid #3a3a6e; color: #aaaacc; padding: 6px 14px;
border-radius: 6px; cursor: pointer; font-size: 0.85em; transition: all 0.2s;
}
.learn-btn:hover { background: #3a3a6e; color: #fff; }
.learn-panel {
background: #1a1a35; border-radius: 8px; padding: 14px; margin-bottom: 14px;
font-size: 0.9em; line-height: 1.6; color: #b0b0d0;
}
.learn-panel ul { margin: 8px 0 8px 20px; }
.learn-panel li { margin-bottom: 4px; }
.learn-panel strong { color: #c8b0ff; }
.controls { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; justify-content: center; }
.wave-buttons { display: flex; gap: 6px; }
.wave-btn {
width: 48px; height: 40px; border-radius: 8px; border: 2px solid #3a3a6e;
background: #1a1a35; color: #aaaacc; cursor: pointer; font-size: 1.2em;
transition: all 0.2s; display: flex; align-items: center; justify-content: center;
}
.wave-btn:hover { border-color: #7c5ce7; }
.wave-btn.active { border-color: #a855f7; background: #2d1b69; color: #fff; box-shadow: 0 0 10px rgba(168,85,247,0.3); }
.knob-group {
display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 80px;
}
.knob-group label { font-size: 0.75em; color: #8888aa; text-transform: uppercase; letter-spacing: 1px; }
.knob-group span { font-size: 0.8em; color: #a855f7; font-weight: bold; min-height: 1.2em; }
select {
background: #1a1a35; border: 1px solid #3a3a6e; color: #ccccee; padding: 6px 10px;
border-radius: 6px; font-size: 0.85em; cursor: pointer;
}
input[type="range"] {
-webkit-appearance: none; appearance: none; width: 90px; height: 6px;
background: #252548; border-radius: 3px; outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; width: 16px; height: 16px;
background: #a855f7; border-radius: 50%; cursor: pointer; box-shadow: 0 0 6px rgba(168,85,247,0.5);
}
input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px; background: #a855f7; border-radius: 50%;
cursor: pointer; border: none; box-shadow: 0 0 6px rgba(168,85,247,0.5);
}
#env-canvas { border-radius: 8px; background: #0d0d1a; margin-bottom: 8px; }
#waveform-display {
width: 100%; height: 80px; border-radius: 8px; background: #0d0d1a;
display: block; margin-bottom: 12px;
}
#keyboard {
display: flex; justify-content: center; position: relative; height: 140px; user-select: none;
}
.key {
width: 44px; border-radius: 0 0 8px 8px; cursor: pointer; transition: all 0.05s;
display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px;
font-size: 0.7em; color: #666; position: relative;
}
.key-white {
background: linear-gradient(180deg, #e8e8f0, #d0d0dd); border: 1px solid #aaa;
height: 140px; z-index: 1; margin: 0 1px;
}
.key-white:hover { background: linear-gradient(180deg, #f0f0ff, #e0e0ee); }
.key-white.pressed { background: linear-gradient(180deg, #c8b0ff, #a855f7); color: #fff; transform: scaleY(0.98); }
.key-black {
background: linear-gradient(180deg, #333, #111); border: 1px solid #000;
height: 90px; width: 28px; position: absolute; z-index: 2; margin-left: -14px;
}
.key-black:hover { background: linear-gradient(180deg, #444, #222); }
.key-black.pressed { background: linear-gradient(180deg, #7c5ce7, #6c3ce7); transform: scaleY(0.97); }
.hint { text-align: center; color: #555577; font-size: 0.8em; margin-top: 12px; }
.hidden { display: none !important; }
#unlock-toast {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
background: linear-gradient(135deg, #6c5ce7, #a855f7); color: #fff;
padding: 14px 28px; border-radius: 12px; font-weight: bold; font-size: 1.05em;
box-shadow: 0 8px 30px rgba(108,92,231,0.4); z-index: 100;
animation: toastIn 0.4s ease, toastOut 0.4s ease 2.6s forwards;
}
@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(-20px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; pointer-events: none; } }
@media (max-width: 600px) {
.controls { gap: 8px; }
.knob-group { min-width: 65px; }
input[type="range"] { width: 70px; }
}
/* ── css: locked-hints ── */
.synth-section.locked::after {
content: attr(data-lock-hint);
position: absolute; bottom: 8px; right: 12px;
font-size: 0.7em; color: #555577; font-style: italic;
}
.synth-section.all-unlocked {
border-color: #a855f7 !important;
box-shadow: 0 0 30px rgba(168, 85, 247, 0.2);
}
#celebration {
display: none; text-align: center; padding: 20px; margin-top: 12px;
background: linear-gradient(135deg, #1a1a35, #2d1b69); border-radius: 12px;
border: 1px solid #7c5ce7; animation: pulseGlow 2s infinite;
}
#celebration h3 { color: #a855f7; margin-bottom: 8px; }
#celebration p { color: #b0b0d0; font-size: 0.9em; }
@keyframes pulseGlow {
0%, 100% { box-shadow: 0 0 20px rgba(168, 85, 247, 0.1); }
50% { box-shadow: 0 0 30px rgba(168, 85, 247, 0.3); }
}
.preset-btns { display: flex; gap: 8px; justify-content: center; margin-top: 12px; flex-wrap: wrap; }
.preset-btn {
background: #2d1b69; border: 1px solid #7c5ce7; color: #ccccee; padding: 8px 16px;
border-radius: 8px; cursor: pointer; font-size: 0.85em; transition: all 0.2s;
}
.preset-btn:hover { background: #3d2b79; color: #fff; transform: scale(1.05); }
</style>
</head>
<body>
<div id="synth-app">
<header id="synth-header">
<h1>🎹 Synth Playground</h1>
<p class="subtitle">Learn synthesis by building sounds — one concept at a time</p>
<div id="progress-bar"><div id="progress-fill"></div></div>
</header>
<main id="synth-body">
<!-- Section: Oscillator -->
<section id="sec-osc" class="synth-section unlocked">
<div class="section-header">
<h2><span class="icon">〰️</span> Oscillators</h2>
<button class="learn-btn" data-section="osc">Learn</button>
</div>
<div class="learn-panel hidden" id="learn-osc">
<p>An <strong>oscillator</strong> generates the raw sound wave. Different waveforms have different harmonic content:</p>
<ul><li><strong>Sine</strong> — pure tone, no harmonics (flute-like)</li><li><strong>Square</strong> — odd harmonics, hollow/retro (8-bit games)</li><li><strong>Sawtooth</strong> — all harmonics, bright/buzzy (strings, brass)</li><li><strong>Triangle</strong> — soft, between sine and square</li></ul>
<p>The more harmonics a wave has, the "richer" it sounds.</p>
</div>
<div class="controls">
<div class="wave-buttons" id="wave-select"></div>
<div class="knob-group"><label>Octave</label><input type="range" id="octave" min="-2" max="2" value="0" step="1"><span id="octave-val">0</span></div>
</div>
</section>
<!-- Section: Filter -->
<section id="sec-filter" class="synth-section locked" data-lock-hint="Play 5 notes to unlock">
<div class="section-header">
<h2><span class="icon">🔽</span> Filter</h2>
<button class="learn-btn" data-section="filter">Learn</button>
</div>
<div class="learn-panel hidden" id="learn-filter">
<p>A <strong>filter</strong> shapes the tone by removing certain frequencies:</p>
<ul><li><strong>Cutoff</strong> — frequency threshold. Lower = darker, higher = brighter</li><li><strong>Resonance (Q)</strong> — boosts near cutoff for a "squelchy" sound</li></ul>
<p>Think of it like an equalizer that carves out the character of your sound.</p>
</div>
<div class="controls">
<select id="filter-type"><option value="lowpass">Lowpass</option><option value="highpass">Highpass</option></select>
<div class="knob-group"><label>Cutoff</label><input type="range" id="cutoff" min="20" max="18000" value="5000" step="1"><span id="cutoff-val">5000 Hz</span></div>
<div class="knob-group"><label>Resonance</label><input type="range" id="resonance" min="0" max="25" value="1" step="0.1"><span id="resonance-val">1.0</span></div>
</div>
</section>
<!-- Section: Envelope -->
<section id="sec-envelope" class="synth-section locked" data-lock-hint="Learn filter + play 12 notes">
<div class="section-header">
<h2><span class="icon">📈</span> Envelope (ADSR)</h2>
<button class="learn-btn" data-section="envelope">Learn</button>
</div>
<div class="learn-panel hidden" id="learn-envelope">
<p>An <strong>envelope</strong> controls how volume changes over time:</p>
<ul><li><strong>A</strong>tack — speed to full volume (fast = pluck, slow = swell)</li><li><strong>D</strong>ecay — time from peak down to sustain level</li><li><strong>S</strong>ustain — held volume while key is pressed</li><li><strong>R</strong>elease — fade time after releasing the key</li></ul>
<p>Piano = fast attack, medium decay. Pad = slow attack, long release.</p>
</div>
<div class="controls">
<canvas id="env-canvas" width="300" height="120"></canvas>
<div class="knob-group"><label>Attack</label><input type="range" id="attack" min="0.01" max="2" value="0.05" step="0.01"><span id="attack-val">0.05s</span></div>
<div class="knob-group"><label>Decay</label><input type="range" id="decay" min="0.01" max="2" value="0.3" step="0.01"><span id="decay-val">0.3s</span></div>
<div class="knob-group"><label>Sustain</label><input type="range" id="sustain" min="0" max="1" value="0.7" step="0.01"><span id="sustain-val">0.70</span></div>
<div class="knob-group"><label>Release</label><input type="range" id="release" min="0.01" max="3" value="0.5" step="0.01"><span id="release-val">0.5s</span></div>
</div>
</section>
<!-- Section: LFO -->
<section id="sec-lfo" class="synth-section locked" data-lock-hint="Learn envelope + play 20 notes">
<div class="section-header">
<h2><span class="icon">🔄</span> LFO (Modulation)</h2>
<button class="learn-btn" data-section="lfo">Learn</button>
</div>
<div class="learn-panel hidden" id="learn-lfo">
<p>A <strong>Low-Frequency Oscillator</strong> modulates other parameters over time:</p>
<ul><li><strong>Rate</strong> — how fast modulation cycles (slow = subtle, fast = vibrato)</li><li><strong>Depth</strong> — how much the parameter changes</li><li><strong>Target</strong> — what gets modulated: pitch, volume, or filter</li></ul>
<p>LFOs add movement and life to otherwise static sounds.</p>
</div>
<div class="controls">
<select id="lfo-target"><option value="pitch">Pitch (Vibrato)</option><option value="volume">Volume (Tremolo)</option><option value="filter">Filter Cutoff</option></select>
<div class="knob-group"><label>Rate</label><input type="range" id="lfo-rate" min="0.1" max="20" value="5" step="0.1"><span id="lfo-rate-val">5 Hz</span></div>
<div class="knob-group"><label>Depth</label><input type="range" id="lfo-depth" min="0" max="1" value="0.3" step="0.01"><span id="lfo-depth-val">0.30</span></div>
</div>
</section>
<!-- Section: Effects -->
<section id="sec-effects" class="synth-section locked" data-lock-hint="Learn LFO + play 30 notes">
<div class="section-header">
<h2><span class="icon">✨</span> Effects</h2>
<button class="learn-btn" data-section="effects">Learn</button>
</div>
<div class="learn-panel hidden" id="learn-effects">
<p><strong>Effects</strong> add space and character to your sound:</p>
<ul><li><strong>Reverb</strong> — simulates acoustic spaces. Makes sounds feel "big"</li><li><strong>Delay</strong> — echoes the signal. Time = spacing, feedback = repeats</li></ul>
<p>Effects transform a thin sound into something that fills space.</p>
</div>
<div class="controls">
<div class="knob-group"><label>Reverb Mix</label><input type="range" id="reverb-mix" min="0" max="1" value="0.2" step="0.01"><span id="reverb-val">0.20</span></div>
<div class="knob-group"><label>Delay Time</label><input type="range" id="delay-time" min="0" max="1" value="0.3" step="0.01"><span id="delay-time-val">0.3s</span></div>
<div class="knob-group"><label>Delay Feedback</label><input type="range" id="delay-feedback" min="0" max="0.9" value="0.4" step="0.01"><span id="delay-fb-val">0.40</span></div>
</div>
</section>
<!-- Keyboard -->
<div id="celebration"><h3>🎉 All Sections Unlocked!</h3><p>You've explored every concept in analog synthesis. Try the presets below or experiment freely!</p><div class="preset-btns" id="presets"></div></div><section id="keyboard-section">
<canvas id="waveform-display" width="600" height="100"></canvas>
<div id="keyboard"></div>
<p class="hint">Use your computer keyboard: A S D F G H J K L ; (white keys) and W E T Y U O P (black keys)</p>
</section>
</main>
<!-- Unlock notification -->
<div id="unlock-toast" class="hidden"></div>
</div>
<script>
'use strict';
// ── class: SynthEngine ──
class SynthEngine {
constructor() {
this.ctx = null;
this.masterGain = null;
this.filterNode = null;
this.reverbGain = null;
this.dryGain = null;
this.delayNode = null;
this.delayFeedback = null;
this.convolver = null;
this.activeVoices = {};
this.waveform = 'sine';
this.octave = 0;
this.cutoff = 5000;
this.resonance = 1;
this.filterType = 'lowpass';
this.adsr = { attack: 0.05, decay: 0.3, sustain: 0.7, release: 0.5 };
this.lfo = { rate: 5, depth: 0.3, target: 'pitch' };
this.effects = { reverbMix: 0.2, delayTime: 0.3, delayFeedback: 0.4 };
this.unlockedSections = new Set(['osc']);
this.learnCount = {};
this.totalLearned = 0;
}
init() {
if (this.ctx) return;
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
// Master gain
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.35;
// Filter
this.filterNode = this.ctx.createBiquadFilter();
this.filterNode.type = 'lowpass';
this.filterNode.frequency.value = this.cutoff;
this.filterNode.Q.value = this.resonance;
// Effects chain: dry/wet for reverb
this.dryGain = this.ctx.createGain();
this.reverbGain = this.ctx.createGain();
this.dryGain.gain.value = 1 - this.effects.reverbMix;
this.reverbGain.gain.value = this.effects.reverbMix;
// Convolver for reverb (simple impulse response)
this.convolver = this.ctx.createConvolver();
this.convolver.buffer = this.createReverbIR(2, 3);
// Delay
this.delayNode = this.ctx.createDelay(2);
this.delayNode.delayTime.value = this.effects.delayTime;
this.delayFeedback = this.ctx.createGain();
this.delayFeedback.gain.value = this.effects.delayFeedback;
// Routing: filter -> dry + reverb + delay -> master
this.filterNode.connect(this.dryGain);
this.filterNode.connect(this.convolver);
this.convolver.connect(this.reverbGain);
this.filterNode.connect(this.delayNode);
this.delayNode.connect(this.delayFeedback);
this.delayFeedback.connect(this.delayNode);
this.delayNode.connect(this.masterGain);
this.dryGain.connect(this.masterGain);
this.reverbGain.connect(this.masterGain);
this.masterGain.connect(this.ctx.destination);
}
createReverbIR(duration, decay) {
const rate = this.ctx.sampleRate;
const length = rate * duration;
const buffer = this.ctx.createBuffer(2, length, rate);
for (let ch = 0; ch < 2; ch++) {
const data = buffer.getChannelData(ch);
for (let i = 0; i < length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay);
}
}
return buffer;
}
noteOn(note, freq) {
this.init();
if (this.ctx.state === 'suspended') this.ctx.resume();
// Remove existing voice for this note
if (this.activeVoices[note]) {
this.noteOff(note);
}
const now = this.ctx.currentTime;
const baseFreq = freq * Math.pow(2, this.octave);
// Oscillator
const osc = this.ctx.createOscillator();
osc.type = this.waveform;
osc.frequency.value = baseFreq;
// Voice gain for envelope
const voiceGain = this.ctx.createGain();
voiceGain.gain.setValueAtTime(0, now);
voiceGain.gain.linearRampToValueAtTime(1, now + this.adsr.attack);
voiceGain.gain.linearRampToValueAtTime(this.adsr.sustain, now + this.adsr.attack + this.adsr.decay);
// LFO
let lfoNode = null;
let lfoGain = null;
if (this.unlockedSections.has('lfo') && this.lfo.depth > 0) {
lfoNode = this.ctx.createOscillator();
lfoGain = this.ctx.createGain();
lfoNode.frequency.value = this.lfo.rate;
const maxMod = this.lfo.target === 'pitch' ? baseFreq * 0.1 :
this.lfo.target === 'filter' ? this.cutoff * 0.5 : 1;
lfoGain.gain.value = this.lfo.depth * maxMod;
if (this.lfo.target === 'pitch') {
lfoNode.connect(lfoGain);
lfoGain.connect(osc.frequency);
} else if (this.lfo.target === 'volume') {
lfoNode.connect(lfoGain);
lfoGain.connect(voiceGain.gain);
} else if (this.lfo.target === 'filter') {
lfoNode.connect(lfoGain);
lfoGain.connect(this.filterNode.frequency);
}
lfoNode.start(now);
}
// Connect: osc -> voiceGain -> filter
osc.connect(voiceGain);
voiceGain.connect(this.filterNode);
osc.start(now);
this.activeVoices[note] = { osc, voiceGain, lfoNode, lfoGain };
}
noteOff(note) {
const voice = this.activeVoices[note];
if (!voice) return;
const now = this.ctx.currentTime;
voice.voiceGain.gain.cancelScheduledValues(now);
voice.voiceGain.gain.setValueAtTime(voice.voiceGain.gain.value, now);
voice.voiceGain.gain.linearRampToValueAtTime(0, now + this.adsr.release);
const stopTime = now + this.adsr.release + 0.1;
voice.osc.stop(stopTime);
if (voice.lfoNode) voice.lfoNode.stop(stopTime);
delete this.activeVoices[note];
}
setWaveform(w) { this.waveform = w; }
setOctave(o) { this.octave = parseInt(o); }
setCutoff(c) {
this.cutoff = c;
if (this.filterNode) this.filterNode.frequency.setTargetAtTime(c, this.ctx.currentTime, 0.02);
}
setResonance(r) {
this.resonance = r;
if (this.filterNode) this.filterNode.Q.value = r;
}
setFilterType(t) {
this.filterType = t;
if (this.filterNode) this.filterNode.type = t;
}
}
// ── const: NOTE_MAP ──
// Keyboard note mappings (middle C area)
const NOTE_MAP = {
'a': { note: 'C4', freq: 261.63, type: 'white' },
'w': { note: 'C#4', freq: 277.18, type: 'black' },
's': { note: 'D4', freq: 293.66, type: 'white' },
'e': { note: 'D#4', freq: 311.13, type: 'black' },
'd': { note: 'E4', freq: 329.63, type: 'white' },
'f': { note: 'F4', freq: 349.23, type: 'white' },
't': { note: 'F#4', freq: 369.99, type: 'black' },
'g': { note: 'G4', freq: 392.00, type: 'white' },
'y': { note: 'G#4', freq: 415.30, type: 'black' },
'h': { note: 'A4', freq: 440.00, type: 'white' },
'u': { note: 'A#4', freq: 466.16, type: 'black' },
'j': { note: 'B4', freq: 493.88, type: 'white' },
'k': { note: 'C5', freq: 523.25, type: 'white' },
'o': { note: 'C#5', freq: 554.37, type: 'black' },
'l': { note: 'D5', freq: 587.33, type: 'white' },
'p': { note: 'D#5', freq: 622.25, type: 'black' },
';': { note: 'E5', freq: 659.25, type: 'white' }
};
// ── const: WAVEFORMS ──
const WAVEFORMS = [
{ name: 'sine', icon: '∿', label: 'Sine' },
{ name: 'square', icon: '⊓', label: 'Square' },
{ name: 'sawtooth', icon: '⩘', label: 'Saw' },
{ name: 'triangle', icon: '△', label: 'Tri' }
];
// ── const: SECTION_ORDER ──
const SECTION_ORDER = ['osc', 'filter', 'envelope', 'lfo', 'effects'];
// ── function: buildKeyboard ──
function buildKeyboard() {
const kb = document.getElementById('keyboard');
const whiteKeys = Object.entries(NOTE_MAP).filter(([, v]) => v.type === 'white');
// Build white keys in order
let whiteIndex = 0;
for (const [keyChar, info] of Object.entries(NOTE_MAP)) {
if (info.type !== 'white') continue;
const keyEl = document.createElement('div');
keyEl.className = `key key-white`;
keyEl.dataset.note = info.note;
keyEl.dataset.key = keyChar;
keyEl.textContent = keyChar.toUpperCase();
kb.appendChild(keyEl);
// Check if there's a black key after this white key
const nextEntry = Object.entries(NOTE_MAP).find(([k]) => {
const idx1 = Object.keys(NOTE_MAP).indexOf(keyChar);
const idx2 = Object.keys(NOTE_MAP).indexOf(k);
return idx2 === idx1 + 1;
});
if (nextEntry && nextEntry[1].type === 'black') {
const blackEl = document.createElement('div');
blackEl.className = `key key-black`;
blackEl.dataset.note = nextEntry[1].note;
blackEl.dataset.key = nextEntry[0];
// Position: right edge of current white key
const leftPos = (whiteIndex + 1) * 46 - 14;
blackEl.style.left = leftPos + 'px';
kb.appendChild(blackEl);
}
whiteIndex++;
}
// Click handlers for keys
kb.querySelectorAll('.key').forEach(el => {
const noteInfo = NOTE_MAP[el.dataset.key];
if (!noteInfo) return;
el.addEventListener('mousedown', (e) => {
e.preventDefault();
pressKey(noteInfo.note, noteInfo.freq);
el.classList.add('pressed');
});
el.addEventListener('mouseup', () => releaseKey(noteInfo.note));
el.addEventListener('mouseleave', () => releaseKey(noteInfo.note));
// Touch support
el.addEventListener('touchstart', (e) => {
e.preventDefault();
pressKey(noteInfo.note, noteInfo.freq);
el.classList.add('pressed');
});
el.addEventListener('touchend', () => {
releaseKey(noteInfo.note);
el.classList.remove('pressed');
});
});
}
// ── function: buildWaveButtons ──
function buildWaveButtons() {
const container = document.getElementById('wave-select');
WAVEFORMS.forEach(wf => {
const btn = document.createElement('button');
btn.className = 'wave-btn' + (wf.name === 'sine' ? ' active' : '');
btn.textContent = wf.icon;
btn.title = wf.label;
btn.dataset.waveform = wf.name;
btn.addEventListener('click', () => {
container.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
synth.setWaveform(wf.name);
});
container.appendChild(btn);
});
}
// ── function: drawEnvelope ──
function drawEnvelope() {
const canvas = document.getElementById('env-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const a = synth.adsr.attack;
const d = synth.adsr.decay;
const s = synth.adsr.sustain;
const r = synth.adsr.release;
const totalTime = Math.max(a + d + 1 + r, 0.5);
const pad = 20;
const drawW = w - pad * 2;
const drawH = h - pad * 2;
// Calculate points
const attackEnd = pad + (a / totalTime) * drawW;
const decayEnd = pad + ((a + d) / totalTime) * drawW;
const sustainEnd = pad + ((a + d + 1) / totalTime) * drawW;
const releaseEnd = pad + drawW;
// Draw grid lines
ctx.strokeStyle = '#1a1a35';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = pad + (drawH / 4) * i;
ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(w - pad, y); ctx.stroke();
}
// Draw envelope curve
ctx.beginPath();
ctx.moveTo(pad, h - pad);
ctx.lineTo(attackEnd, pad);
ctx.lineTo(decayEnd, pad + drawH * (1 - s));
ctx.lineTo(sustainEnd, pad + drawH * (1 - s));
ctx.lineTo(releaseEnd, h - pad);
// Gradient fill
const gradient = ctx.createLinearGradient(0, pad, 0, h - pad);
gradient.addColorStop(0, 'rgba(168, 85, 247, 0.3)');
gradient.addColorStop(1, 'rgba(168, 85, 247, 0.02)');
ctx.fillStyle = gradient;
ctx.fill();
// Stroke
ctx.strokeStyle = '#a855f7';
ctx.lineWidth = 2;
ctx.stroke();
// Labels
ctx.fillStyle = '#6666aa';
ctx.font = '10px sans-serif';
ctx.fillText('A', (pad + attackEnd) / 2 - 3, h - pad + 14);
ctx.fillText('D', (attackEnd + decayEnd) / 2 - 3, h - pad + 14);
ctx.fillText('S', (decayEnd + sustainEnd) / 2 - 3, h - pad + 14);
ctx.fillText('R', (sustainEnd + releaseEnd) / 2 - 3, h - pad + 14);
}
// ── function: drawWaveform ──
function drawWaveform() {
const canvas = document.getElementById('waveform-display');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
// Draw center line
ctx.strokeStyle = '#1a1a35';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, h / 2);
ctx.lineTo(w, h / 2);
ctx.stroke();
// Draw waveform
const waveforms = {
sine: (t) => Math.sin(t * Math.PI * 2),
square: (t) => t % 1 < 0.5 ? 0.8 : -0.8,
sawtooth: (t) => 2 * (t % 1) - 1,
triangle: (t) => { const f = t % 1; return f < 0.25 ? f*4 : f < 0.75 ? 2-f*4 : (f-1)*4; }
};
const fn = waveforms[synth.waveform] || waveforms.sine;
// Glow effect
ctx.shadowColor = '#a855f7';
ctx.shadowBlur = 8;
ctx.strokeStyle = '#a855f7';
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = 0; x < w; x++) {
const t = x / w * 3; // 3 cycles
const y = h / 2 - fn(t) * (h / 2 - 10);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
}
// ── function: showToast ──
function showToast(message) {
const toast = document.getElementById('unlock-toast');
toast.textContent = message;
toast.classList.remove('hidden');
// Force reflow for animation restart
void toast.offsetWidth;
toast.style.animation = 'none';
void toast.offsetWidth;
toast.style.animation = '';
setTimeout(() => { toast.classList.add('hidden'); }, 3200);
}
// ── function: updateProgress ──
function updateProgress() {
const fill = document.getElementById('progress-fill');
const pct = (synth.totalLearned / SECTION_ORDER.length) * 100;
fill.style.width = pct + '%';
}
// ── function: pressKey ──
function pressKey(note, freq) {
notesPlayed++;
synth.noteOn(note, freq);
checkAutoUnlock();
}
// ── function: releaseKey ──
function releaseKey(note) {
synth.noteOff(note);
const keyEl = document.querySelector(`.key[data-note="${note}"]`);
if (keyEl) keyEl.classList.remove('pressed');
}
// ── let: notesPlayed ──
let notesPlayed = 0;
// ── const: PRESETS ──
const PRESETS = [
{ name: '🎹 Piano', wave: 'triangle', cutoff: 8000, resonance: 1, attack: 0.01, decay: 0.3, sustain: 0.4, release: 0.5, lfoRate: 0, lfoDepth: 0 },
{ name: '🎸 Bass', wave: 'sawtooth', cutoff: 600, resonance: 8, attack: 0.01, decay: 0.2, sustain: 0.6, release: 0.1, lfoRate: 5, lfoDepth: 0.05 },
{ name: '🌊 Pad', wave: 'sine', cutoff: 3000, resonance: 2, attack: 0.8, decay: 0.5, sustain: 0.7, release: 1.5, lfoRate: 0.5, lfoDepth: 0.1 },
{ name: '🚀 Lead', wave: 'sawtooth', cutoff: 4000, resonance: 5, attack: 0.01, decay: 0.1, sustain: 0.8, release: 0.2, lfoRate: 6, lfoDepth: 0.15 },
{ name: '🎵 Pluck', wave: 'square', cutoff: 2000, resonance: 3, attack: 0.005, decay: 0.4, sustain: 0.1, release: 0.3, lfoRate: 0, lfoDepth: 0 },
];
// ── function: applyPreset ──
function applyPreset(p) {
synth.setWaveform(p.wave);
synth.setCutoff(p.cutoff);
synth.setResonance(p.resonance);
// Update sliders to match preset values
document.getElementById('cutoff').value = p.cutoff;
document.getElementById('cutoff-val').textContent = Math.round(p.cutoff) + ' Hz';
document.getElementById('resonance').value = p.resonance;
document.getElementById('resonance-val').textContent = p.resonance.toFixed(1);
synth.adsr.attack = p.attack;
synth.adsr.decay = p.decay;
synth.adsr.sustain = p.sustain;
synth.adsr.release = p.release;
document.getElementById('attack').value = p.attack;
document.getElementById('attack-val').textContent = p.attack.toFixed(2) + 's';
document.getElementById('decay').value = p.decay;
document.getElementById('decay-val').textContent = p.decay.toFixed(2) + 's';
document.getElementById('sustain').value = p.sustain;
document.getElementById('sustain-val').textContent = p.sustain.toFixed(2);
document.getElementById('release').value = p.release;
document.getElementById('release-val').textContent = p.release.toFixed(2) + 's';
synth.lfo.rate = p.lfoRate;
synth.lfo.depth = p.lfoDepth;
document.getElementById('lfo-rate').value = p.lfoRate;
document.getElementById('lfo-rate-val').textContent = p.lfoRate.toFixed(1) + ' Hz';
document.getElementById('lfo-depth').value = p.lfoDepth;
document.getElementById('lfo-depth-val').textContent = p.lfoDepth.toFixed(2);
// Update wave button active state
document.querySelectorAll('.wave-btn').forEach(b => {
b.classList.toggle('active', b.dataset.waveform === p.wave);
});
drawEnvelope();
}
// ── function: buildPresets ──
function buildPresets() {
const container = document.getElementById('presets');
PRESETS.forEach(p => {
const btn = document.createElement('button');
btn.className = 'preset-btn';
btn.textContent = p.name;
btn.addEventListener('click', () => applyPreset(p));
container.appendChild(btn);
});
}
// ── function: checkAllUnlocked ──
function checkAllUnlocked() {
if (synth.unlockedSections.size === SECTION_ORDER.length) {
const celeb = document.getElementById('celebration');
celeb.style.display = 'block';
// Mark all sections as fully unlocked visually
document.querySelectorAll('.synth-section').forEach(s => s.classList.add('all-unlocked'));
}
}
// ── function: unlockSection ──
function unlockSection(sectionId) {
const section = document.getElementById('sec-' + sectionId);
if (!section || synth.unlockedSections.has(sectionId)) return;
synth.unlockedSections.add(sectionId);
section.classList.remove('locked');
section.classList.add('unlocked', 'active');
setTimeout(() => section.classList.remove('active'), 1500);
const names = { filter: '🔽 Filter', envelope: '📈 Envelope (ADSR)', lfo: '🔄 LFO Modulation', effects: '✨ Effects' };
showToast(`Unlocked: ${names[sectionId] || sectionId}!`);
updateProgress();
checkAllUnlocked();
}
// ── function: checkAutoUnlock ──
function checkAutoUnlock() {
// Unlock filter after playing 5 notes
if (notesPlayed >= 5 && !synth.unlockedSections.has('filter')) {
synth.learnCount['filter'] = 1;
unlockSection('filter');
}
// Unlock envelope after playing 12 notes AND having learned/unlocked filter
if (notesPlayed >= 12 && synth.unlockedSections.has('filter') && !synth.unlockedSections.has('envelope')) {
synth.learnCount['envelope'] = 1;
unlockSection('envelope');
}
// Unlock LFO after playing 20 notes AND having learned/unlocked envelope
if (notesPlayed >= 20 && synth.unlockedSections.has('envelope') && !synth.unlockedSections.has('lfo')) {
synth.learnCount['lfo'] = 1;
unlockSection('lfo');
}
// Unlock effects after playing 30 notes AND having learned/unlocked LFO
if (notesPlayed >= 30 && synth.unlockedSections.has('lfo') && !synth.unlockedSections.has('effects')) {
synth.learnCount['effects'] = 1;
unlockSection('effects');
}
}
// ── main ──
const synth = new SynthEngine();
// Build UI
buildKeyboard();
buildWaveButtons();
drawEnvelope();
drawWaveform();
buildPresets();
// Waveform display update loop
let animFrame;
function renderLoop() {
drawWaveform();
animFrame = requestAnimationFrame(renderLoop);
}
renderLoop();
// Keyboard input
const pressedKeys = new Set();
document.addEventListener('keydown', (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (!NOTE_MAP[key] || pressedKeys.has(key)) return;
pressedKeys.add(key);
pressKey(NOTE_MAP[key].note, NOTE_MAP[key].freq);
const el = document.querySelector(`.key[data-key="${key}"]`);
if (el) el.classList.add('pressed');
});
document.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
pressedKeys.delete(key);
if (NOTE_MAP[key]) {
releaseKey(NOTE_MAP[key].note);
const el = document.querySelector(`.key[data-key="${key}"]`);
if (el) el.classList.remove('pressed');
}
});
// Learn buttons
document.querySelectorAll('.learn-btn').forEach(btn => {
btn.addEventListener('click', () => {
const sectionId = btn.dataset.section;
const panel = document.getElementById('learn-' + sectionId);
if (panel.classList.contains('hidden')) {
panel.classList.remove('hidden');
btn.textContent = 'Close';
// Track learning and unlock next section
synth.learnCount[sectionId] = (synth.learnCount[sectionId] || 0) + 1;
synth.totalLearned++;
// Unlock the current section if locked
if (!synth.unlockedSections.has(sectionId)) {
unlockSection(sectionId);
}
// Check if we should unlock the next section
const idx = SECTION_ORDER.indexOf(sectionId);
if (idx < SECTION_ORDER.length - 1) {
setTimeout(() => checkAutoUnlock(), 800);
}
updateProgress();
} else {
panel.classList.add('hidden');
btn.textContent = 'Learn';
}
});
});
// Slider controls - Octave
document.getElementById('octave').addEventListener('input', (e) => {
synth.setOctave(e.target.value);
document.getElementById('octave-val').textContent = e.target.value;
});
// Filter controls
document.getElementById('cutoff').addEventListener('input', (e) => {
synth.setCutoff(parseFloat(e.target.value));
document.getElementById('cutoff-val').textContent = Math.round(e.target.value) + ' Hz';
});
document.getElementById('resonance').addEventListener('input', (e) => {
synth.setResonance(parseFloat(e.target.value));
document.getElementById('resonance-val').textContent = parseFloat(e.target.value).toFixed(1);
});
document.getElementById('filter-type').addEventListener('change', (e) => {
synth.setFilterType(e.target.value);
});
// ADSR controls
['attack', 'decay', 'sustain', 'release'].forEach(param => {
const el = document.getElementById(param);
const valEl = document.getElementById(param + '-val');
el.addEventListener('input', (e) => {
synth.adsr[param] = parseFloat(e.target.value);
if (param === 'attack' || param === 'decay' || param === 'release') {
valEl.textContent = parseFloat(e.target.value).toFixed(2) + 's';
} else {
valEl.textContent = parseFloat(e.target.value).toFixed(2);
}
drawEnvelope();
});
});
// LFO controls
document.getElementById('lfo-rate').addEventListener('input', (e) => {
synth.lfo.rate = parseFloat(e.target.value);
document.getElementById('lfo-rate-val').textContent = parseFloat(e.target.value).toFixed(1) + ' Hz';
});
document.getElementById('lfo-depth').addEventListener('input', (e) => {
synth.lfo.depth = parseFloat(e.target.value);
document.getElementById('lfo-depth-val').textContent = parseFloat(e.target.value).toFixed(2);
});
document.getElementById('lfo-target').addEventListener('change', (e) => {
synth.lfo.target = e.target.value;
});
// Effects controls
document.getElementById('reverb-mix').addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
synth.effects.reverbMix = val;
document.getElementById('reverb-val').textContent = val.toFixed(2);
if (synth.dryGain && synth.reverbGain) {
synth.dryGain.gain.setTargetAtTime(1 - val, synth.ctx.currentTime, 0.05);
synth.reverbGain.gain.setTargetAtTime(val, synth.ctx.currentTime, 0.05);
}
});
document.getElementById('delay-time').addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
synth.effects.delayTime = val;
document.getElementById('delay-time-val').textContent = val.toFixed(1) + 's';
if (synth.delayNode) {
synth.delayNode.delayTime.setTargetAtTime(val, synth.ctx.currentTime, 0.05);
}
});
document.getElementById('delay-feedback').addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
synth.effects.delayFeedback = val;
document.getElementById('delay-fb-val').textContent = val.toFixed(2);
if (synth.delayFeedback) {
synth.delayFeedback.gain.setTargetAtTime(val, synth.ctx.currentTime, 0.05);
}
});
// Initial progress update
updateProgress();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"composer","sessionId":"08eda6a0-e3e5-4441-bae2-8f3d642cd69b","tokensIn":676599,"tokensOut":21510,"tokensTotal":698109,"turns":33,"toolCalls":32,"failedToolCalls":1,"timestamp":"2026-07-18T18:12:09.266Z"} -->