TutSynth
Qwen 3.7 max · 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,880 bytes · SHA-256 8335c75b7285
<!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 {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0a0a0f;
color: #e0e0e8;
min-height: 100vh;
overflow-x: hidden;
}
#app { max-width: 1100px; margin: 0 auto; padding: 20px; }
#header { text-align: center; margin-bottom: 24px; }
#header h1 {
font-size: 2rem; color: #a78bfa; letter-spacing: -1px;
text-shadow: 0 0 20px rgba(167, 139, 250, 0.3);
}
#subtitle { color: #666; margin-top: 4px; font-size: 0.95rem; }
#main-layout { display: flex; flex-direction: column; gap: 16px; }
#stages-nav {
display: flex; gap: 8px; flex-wrap: wrap; justify-content: center;
padding: 12px; background: #12121a; border-radius: 12px; border: 1px solid #1e1e2e;
}
.stage-tab {
padding: 8px 16px; border-radius: 8px; font-size: 0.85rem;
cursor: pointer; border: 1px solid #2a2a3a; background: #16161f;
color: #666; transition: all 0.3s;
}
.stage-tab.unlocked { color: #9ca3af; border-color: #3a3a4a; }
.stage-tab.unlocked:hover { background: #1e1e2e; color: #ccc; }
.stage-tab.active { background: #7c3aed; color: #fff; border-color: #7c3aed; box-shadow: 0 0 12px rgba(124, 58, 237, 0.3); }
.stage-tab.locked { opacity: 0.4; cursor: not-allowed; }
.stage-tab.locked::after { content: ' 🔒'; }
@media (max-width: 768px) {
#app { padding: 12px; }
#header h1 { font-size: 1.5rem; }
.control-section { min-width: 100% !important; }
#piano-keys { transform: scale(0.75); transform-origin: top center; }
}
/* ── css: workspace ── */
#workspace { display: flex; flex-direction: column; gap: 16px; }
#visualization-area {
display: flex; flex-direction: column; gap: 2px;
background: #12121a; border-radius: 12px; padding: 16px;
border: 1px solid #1e1e2e;
}
#waveform-canvas {
width: 100%; height: 100px; border-radius: 6px;
background: #0d0d14;
}
#spectrum-canvas {
width: 100%; height: 60px; border-radius: 6px;
background: #0d0d14;
}
#lesson-panel {
background: #12121a; border-radius: 12px; padding: 20px 24px;
border: 1px solid #1e1e2e; min-height: 120px;
}
#lesson-content h2 { color: #a78bfa; font-size: 1.2rem; margin-bottom: 8px; }
#lesson-content p { line-height: 1.6; color: #b0b0c0; margin-bottom: 8px; font-size: 0.92rem; }
#lesson-content .highlight { color: #f0abfc; font-weight: 600; }
#lesson-content .try-it {
background: #1a1a2e; border-left: 3px solid #7c3aed;
padding: 10px 14px; margin: 12px 0; border-radius: 0 8px 8px 0;
color: #c4b5fd; font-size: 0.88rem; line-height: 1.6;
}
.unlock-btn {
margin-top: 16px; padding: 10px 24px; background: linear-gradient(135deg, #7c3aed, #a855f7);
color: white; border: none; border-radius: 8px; cursor: pointer;
font-size: 0.95rem; font-weight: 600; transition: all 0.2s;
}
.unlock-btn:hover { background: linear-gradient(135deg, #6d28d9, #9333ea); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(124, 58, 237, 0.3); }
.unlock-btn.hidden { display: none; }
/* ── css: controls ── */
#controls-area {
display: flex; flex-wrap: wrap; gap: 12px;
}
.control-section {
background: #12121a; border-radius: 12px; padding: 16px;
border: 1px solid #1e1e2e; flex: 1; min-width: 220px;
transition: opacity 0.4s, transform 0.4s;
}
.control-section.locked { display: none; }
.control-section h3 {
font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px;
color: #7c3aed; margin-bottom: 12px; padding-bottom: 6px;
border-bottom: 1px solid #1e1e2e;
}
.control-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px; gap: 10px;
}
.control-row label { font-size: 0.82rem; color: #9ca3af; white-space: nowrap; }
.control-row .value-display {
font-size: 0.78rem; color: #a78bfa; min-width: 40px; text-align: right;
font-family: 'Courier New', monospace;
}
input[type="range"] {
-webkit-appearance: none; appearance: none;
width: 100%; height: 6px; background: #1e1e2e; border-radius: 3px;
outline: none; flex: 1;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 16px; height: 16px; border-radius: 50%;
background: #7c3aed; cursor: pointer; border: 2px solid #a78bfa;
}
input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%;
background: #7c3aed; cursor: pointer; border: 2px solid #a78bfa;
}
.wave-selector { display: flex; gap: 4px; }
.wave-btn {
padding: 6px 12px; border: 1px solid #2a2a3a; background: #16161f;
color: #888; border-radius: 6px; cursor: pointer; font-size: 0.78rem;
transition: all 0.2s;
}
.wave-btn:hover { border-color: #7c3aed; color: #ccc; }
.wave-btn.active { background: #7c3aed; color: white; border-color: #7c3aed; }
/* ── css: keyboard ── */
#keyboard-area {
background: #12121a; border-radius: 12px; padding: 16px;
border: 1px solid #1e1e2e;
}
#keyboard-area.hidden { display: none; }
#piano-keys {
position: relative;
height: 140px; margin: 0 auto;
}
.key-white {
height: 130px; background: linear-gradient(to bottom, #f8f8f8, #e8e8e8);
border: 1px solid #bbb; border-radius: 0 0 6px 6px; cursor: pointer;
position: absolute; z-index: 1; transition: background 0.1s;
display: flex; align-items: flex-end; justify-content: center;
padding-bottom: 8px; font-size: 0.65rem; color: #888;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.key-white:hover { background: linear-gradient(to bottom, #fff, #f0f0f0); }
.key-white.active { background: linear-gradient(to bottom, #c4b5fd, #a78bfa) !important; }
.key-black {
height: 85px; background: linear-gradient(to bottom, #333, #111);
border: 1px solid #000; border-radius: 0 0 4px 4px; cursor: pointer;
position: absolute; z-index: 2; top: 0;
display: flex; align-items: flex-end; justify-content: center;
padding-bottom: 6px; font-size: 0.55rem; color: #888;
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
}
.key-black:hover { background: linear-gradient(to bottom, #444, #222); }
.key-black.active { background: linear-gradient(to bottom, #6d28d9, #4c1d95) !important; }
.hint { text-align: center; margin-top: 10px; font-size: 0.78rem; color: #555; }
</style>
</head>
<body>
<div id="app">
<header id="header">
<h1>🎛️ Synth Playground</h1>
<p id="subtitle">Learn synthesis by doing — each stage adds a new concept</p>
</header>
<div id="main-layout">
<div id="stages-nav"></div>
<div id="workspace">
<div id="visualization-area">
<canvas id="waveform-canvas" width="1024" height="200"></canvas>
<canvas id="spectrum-canvas" width="1024" height="120"></canvas>
</div>
<div id="lesson-panel">
<div id="lesson-content"></div>
<button id="unlock-next-btn" class="unlock-btn">Unlock Next Stage →</button>
</div>
<div id="controls-area"></div>
<div id="keyboard-area" class="hidden">
<div id="piano-keys"></div>
<p class="hint">Use keys A-L for white keys, W,E,T,Y,U for black keys</p>
</div>
</div>
</div>
</div>
<script>
'use strict';
// ── const: STAGES ──
const STAGES = [
{
id: 'oscillator',
title: '1. Oscillator',
lesson: `<h2>Stage 1: The Oscillator</h2>
<p>Every synthesizer starts with a <span class="highlight">sound source</span> — the oscillator. It generates a raw waveform at a specific frequency (pitch).</p>
<p>Try the different <span class="highlight">waveforms</span> below. Each has a distinct character:</p>
<p>• <strong>Sine</strong> — pure, smooth, flute-like. Only one frequency, no harmonics.<br>
• <strong>Square</strong> — hollow, woody. Only odd harmonics.<br>
• <strong>Sawtooth</strong> — bright, buzzy. Contains ALL harmonics — great starting point for subtractive synthesis.<br>
• <strong>Triangle</strong> — soft, mellow. Odd harmonics only, but they drop off faster than square.</p>
<div class="try-it">🎮 Try: Click the Hold button, then switch between waveforms. Notice how the waveform visualization changes shape. Listen to the tonal difference.</div>
<p>The <span class="highlight">Frequency</span> control changes the pitch. The waveform repeats faster = higher pitch.</p>`,
},
{
id: 'envelope',
title: '2. Envelope',
lesson: `<h2>Stage 2: The Amplitude Envelope (ADSR)</h2>
<p>An <span class="highlight">envelope</span> shapes how a sound changes over time. Without it, notes would just click on and off instantly. The ADSR envelope has four stages:</p>
<p>• <strong>Attack</strong> — How fast the sound reaches full volume. (0 = instant, high = slow fade in)<br>
• <strong>Decay</strong> — How fast it drops from peak to sustain level.<br>
• <strong>Sustain</strong> — The volume held while the note is pressed.<br>
• <strong>Release</strong> — How fast it fades after you let go.</p>
<div class="try-it">🎮 Try: Set a long attack (~1.0s) and play a note — hear it swell in. Then set a long release (~2.0s) and let go — hear it linger. This is how pads and strings work!</div>
<p>Short attack + short decay = percussion sounds. Long attack + long release = ambient pads.</p>`,
},
{
id: 'filter',
title: '3. Filter',
lesson: `<h2>Stage 3: The Filter</h2>
<p>A <span class="highlight">filter</span> removes certain frequencies from the sound. The most common type is the <strong>low-pass filter</strong> — it lets low frequencies through and cuts high ones.</p>
<p>• <strong>Cutoff</strong> — The frequency where the filter starts cutting. Lower = darker/muffled. Higher = brighter.<br>
• <strong>Resonance</strong> — Boosts frequencies right at the cutoff point. Creates a "peaky" or "squelchy" sound. High resonance can self-oscillate!</p>
<div class="try-it">🎮 Try: Switch to the sawtooth wave (lots of harmonics to filter). Set cutoff low, then slowly raise it — hear the sound "open up." Add resonance for that classic synth "wow" sound.</div>
<p>The filter is the heart of <span class="highlight">subtractive synthesis</span>: start with a harmonically rich waveform, then sculpt it by removing frequencies.</p>`,
},
{
id: 'lfo',
title: '4. LFO',
lesson: `<h2>Stage 4: Low-Frequency Oscillator (LFO)</h2>
<p>An <span class="highlight">LFO</span> is like a regular oscillator, but it runs so slowly (below human hearing) that you hear it as a <em>rhythmic change</em> rather than a pitch.</p>
<p>Use it to modulate (wiggle) other parameters automatically over time:</p>
<p>• <strong>Rate</strong> — How fast the LFO cycles. Slow = gentle movement. Fast = dramatic tremolo/vibrato.<br>
• <strong>Depth</strong> — How much the LFO affects its target.<br>
• <strong>Target</strong> — What the LFO controls. Pitch → vibrato. Filter → wah-wah. Volume → tremolo.</p>
<div class="try-it">🎮 Try: Set Rate to ~5Hz, Depth moderate, Target to Pitch — classic vibrato! Switch Target to Filter for a dubstep wobble. Slow the rate for gentle movement.</div>
<p>LFOs add <span class="highlight">movement and life</span> to otherwise static sounds.</p>`,
},
{
id: 'effects',
title: '5. Effects',
lesson: `<h2>Stage 5: Effects</h2>
<p>Effects add <span class="highlight">space and texture</span> to your raw synth sound:</p>
<p>• <strong>Delay</strong> — Repeats the sound after a time interval. Dry/Wet controls the mix. Feedback controls how many repeats.<br>
• <strong>Distortion</strong> — Clips and saturates the signal. Adds grit and harmonics. Subtle = warmth. Extreme = destruction.</p>
<div class="try-it">🎮 Try: Add delay with moderate feedback, then play a short melody — the repeats create rhythmic texture. Add distortion gradually to hear how it adds edge.</div>
<p>In a signal chain, effects typically come <span class="highlight">last</span>, after the oscillator, filter, and envelope have shaped the core sound.</p>`,
},
{
id: 'keyboard',
title: '6. Play!',
lesson: `<h2>Stage 6: The Keyboard — Make Music!</h2>
<p>Now you have all the building blocks of a synthesizer! The signal chain is:</p>
<p style="text-align:center; font-size:1.1rem; color:#a78bfa; margin: 16px 0;"><strong>Oscillator → Filter → Envelope → Effects → Output</strong></p>
<p>With the LFO modulating parameters along the way.</p>
<p>Use the keyboard below or your computer keys to play. Experiment with all the controls together to find sounds you love!</p>
<div class="try-it">🎮 Challenge: Try to make these sounds:<br>
• <strong>Bass</strong>: Saw wave, low cutoff, short attack, medium decay<br>
• <strong>Pad</strong>: Triangle, long attack, long release, filter halfway<br>
• <strong>Lead</strong>: Square, high cutoff, short attack, add delay<br>
• <strong>Wobble</strong>: Saw, LFO on filter, medium rate & depth</div>
<p>Remember: synthesis is about <span class="highlight">sculpting sound</span>. Start with a rich source, then shape it.</p>`,
}
];
// ── const: NOTE_MAP ──
const NOTE_MAP = [
{ note: 'C4', freq: 261.63, type: 'white', key: 'a', label: 'A' },
{ note: 'C#4', freq: 277.18, type: 'black', key: 'w', label: 'W' },
{ note: 'D4', freq: 293.66, type: 'white', key: 's', label: 'S' },
{ note: 'D#4', freq: 311.13, type: 'black', key: 'e', label: 'E' },
{ note: 'E4', freq: 329.63, type: 'white', key: 'd', label: 'D' },
{ note: 'F4', freq: 349.23, type: 'white', key: 'f', label: 'F' },
{ note: 'F#4', freq: 369.99, type: 'black', key: 't', label: 'T' },
{ note: 'G4', freq: 392.00, type: 'white', key: 'g', label: 'G' },
{ note: 'G#4', freq: 415.30, type: 'black', key: 'y', label: 'Y' },
{ note: 'A4', freq: 440.00, type: 'white', key: 'h', label: 'H' },
{ note: 'A#4', freq: 466.16, type: 'black', key: 'u', label: 'U' },
{ note: 'B4', freq: 493.88, type: 'white', key: 'j', label: 'J' },
{ note: 'C5', freq: 523.25, type: 'white', key: 'k', label: 'K' },
{ note: 'C#5', freq: 554.37, type: 'black', key: 'o', label: 'O' },
{ note: 'D5', freq: 587.33, type: 'white', key: 'l', label: 'L' },
];
// ── class: AudioEngine ──
class AudioEngine {
constructor() {
this.ctx = null;
this.analyserWave = null;
this.analyserSpectrum = null;
this.masterGain = null;
this.currentVoice = null;
this.activeVoices = new Map();
// State
this.waveform = 'sine';
this.frequency = 220;
this.holding = false;
// Envelope
this.attack = 0.05;
this.decay = 0.2;
this.sustain = 0.6;
this.release = 0.3;
// Filter
this.filterCutoff = 2000;
this.filterResonance = 1;
this.filterEnabled = false;
// LFO
this.lfoRate = 3;
this.lfoDepth = 0;
this.lfoTarget = 'pitch';
this.lfoEnabled = false;
// Effects
this.delayTime = 0.3;
this.delayFeedback = 0.3;
this.delayMix = 0;
this.distortion = 0;
this.effectsEnabled = false;
}
init() {
if (this.ctx) return;
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
// Master gain
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.4;
// Analysers
this.analyserWave = this.ctx.createAnalyser();
this.analyserWave.fftSize = 2048;
this.analyserSpectrum = this.ctx.createAnalyser();
this.analyserSpectrum.fftSize = 2048;
this.masterGain.connect(this.analyserWave);
this.analyserWave.connect(this.analyserSpectrum);
this.analyserSpectrum.connect(this.ctx.destination);
}
_createSignalChain(freq) {
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
osc.type = this.waveform;
osc.frequency.value = freq;
// Gain envelope
const envGain = this.ctx.createGain();
envGain.gain.setValueAtTime(0, now);
envGain.gain.linearRampToValueAtTime(1, now + this.attack);
envGain.gain.linearRampToValueAtTime(this.sustain, now + this.attack + this.decay);
// Filter
let filter = null;
if (this.filterEnabled && this.filterCutoff < 20000) {
filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = this.filterCutoff;
filter.Q.value = this.filterResonance;
}
// LFO
let lfo = null;
let lfoGain = null;
if (this.lfoEnabled && this.lfoDepth > 0) {
lfo = this.ctx.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = this.lfoRate;
lfoGain = this.ctx.createGain();
if (this.lfoTarget === 'pitch') {
lfoGain.gain.value = this.lfoDepth * 100;
lfo.connect(lfoGain);
lfoGain.connect(osc.frequency);
} else if (this.lfoTarget === 'filter' && filter) {
lfoGain.gain.value = this.lfoDepth * this.filterCutoff;
lfo.connect(lfoGain);
lfoGain.connect(filter.frequency);
} else if (this.lfoTarget === 'amplitude') {
lfoGain.gain.value = this.lfoDepth * 0.5;
lfo.connect(lfoGain);
lfoGain.connect(envGain.gain);
}
lfo.start(now);
}
// Connect chain
let lastNode = osc;
if (filter) {
osc.connect(filter);
lastNode = filter;
}
lastNode.connect(envGain);
// Effects
let outputNode = envGain;
if (this.effectsEnabled) {
// Delay
if (this.delayMix > 0) {
const delay = this.ctx.createDelay(1.0);
delay.delayTime.value = this.delayTime;
const feedbackGain = this.ctx.createGain();
feedbackGain.gain.value = this.delayFeedback;
const delayMix = this.ctx.createGain();
delayMix.gain.value = this.delayMix;
const dryGain = this.ctx.createGain();
dryGain.gain.value = 1 - this.delayMix * 0.5;
const merger = this.ctx.createGain();
envGain.connect(dryGain);
dryGain.connect(merger);
envGain.connect(delay);
delay.connect(feedbackGain);
feedbackGain.connect(delay);
delay.connect(delayMix);
delayMix.connect(merger);
outputNode = merger;
}
// Distortion
if (this.distortion > 0) {
const waveshaper = this.ctx.createWaveShaper();
const amount = this.distortion * 100;
const samples = 256;
const curve = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
const x = (i * 2) / samples - 1;
curve[i] = ((3 + amount) * x * 20 * (Math.PI / 180)) / (Math.PI + amount * Math.abs(x));
}
waveshaper.curve = curve;
waveshaper.oversample = '4x';
outputNode.connect(waveshaper);
outputNode = waveshaper;
}
}
outputNode.connect(this.masterGain);
osc.start(now);
return { osc, envGain, lfo, filter, startTime: now };
}
noteOn(freq, noteId) {
this.init();
if (this.ctx.state === 'suspended') this.ctx.resume();
// Kill existing voice for this note
if (this.activeVoices.has(noteId || freq)) {
this.noteOff(noteId || freq);
}
const voice = this._createSignalChain(freq);
const key = noteId || freq;
this.activeVoices.set(key, voice);
if (!noteId) {
this.currentVoice = voice;
this.holding = true;
}
}
noteOff(noteId) {
const key = noteId || this.frequency;
const voice = this.activeVoices.get(key);
if (!voice) return;
const now = this.ctx.currentTime;
voice.envGain.gain.cancelScheduledValues(now);
voice.envGain.gain.setValueAtTime(voice.envGain.gain.value, now);
voice.envGain.gain.linearRampToValueAtTime(0, now + this.release);
const stopTime = now + this.release + 0.05;
voice.osc.stop(stopTime);
if (voice.lfo) voice.lfo.stop(stopTime);
this.activeVoices.delete(key);
if (!noteId) {
this.holding = false;
this.currentVoice = null;
}
}
toggleHold() {
if (this.holding) {
this.noteOff();
} else {
this.noteOn(this.frequency);
}
}
setWaveform(type) {
this.waveform = type;
// Update active oscillators
this.activeVoices.forEach(v => { v.osc.type = type; });
}
setFrequency(freq) {
this.frequency = freq;
if (this.holding && this.currentVoice) {
this.currentVoice.osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
}
}
getWaveformData() {
if (!this.analyserWave) return new Float32Array(0);
const data = new Float32Array(this.analyserWave.fftSize);
this.analyserWave.getFloatTimeDomainData(data);
return data;
}
getSpectrumData() {
if (!this.analyserSpectrum) return new Uint8Array(0);
const data = new Uint8Array(this.analyserSpectrum.frequencyBinCount);
this.analyserSpectrum.getByteFrequencyData(data);
return data;
}
}
// ── function: createSlider ──
function createSlider(parent, label, min, max, value, step, onChange) {
const row = document.createElement('div');
row.className = 'control-row';
const lbl = document.createElement('label');
lbl.textContent = label;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = min;
slider.max = max;
slider.value = value;
slider.step = step;
const display = document.createElement('span');
display.className = 'value-display';
display.textContent = parseFloat(value).toFixed(step < 1 ? 2 : 0);
slider.addEventListener('input', () => {
display.textContent = parseFloat(slider.value).toFixed(step < 1 ? 2 : 0);
onChange(parseFloat(slider.value));
});
row.appendChild(lbl);
row.appendChild(slider);
row.appendChild(display);
parent.appendChild(row);
return { slider, display };
}
// ── function: createWaveSelector ──
function createWaveSelector(parent, activeType, onChange) {
const row = document.createElement('div');
row.className = 'control-row';
const lbl = document.createElement('label');
lbl.textContent = 'Waveform';
row.appendChild(lbl);
const btnGroup = document.createElement('div');
btnGroup.className = 'wave-selector';
const waves = [
{ type: 'sine', symbol: '∿' },
{ type: 'square', symbol: '⊓' },
{ type: 'sawtooth', symbol: '⩘' },
{ type: 'triangle', symbol: '△' },
];
waves.forEach(w => {
const btn = document.createElement('button');
btn.className = 'wave-btn' + (w.type === activeType ? ' active' : '');
btn.textContent = w.symbol + ' ' + w.type;
btn.addEventListener('click', () => {
btnGroup.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
onChange(w.type);
});
btnGroup.appendChild(btn);
});
row.appendChild(btnGroup);
parent.appendChild(row);
}
// ── function: createTargetSelector ──
function createTargetSelector(parent, activeTarget, onChange) {
const row = document.createElement('div');
row.className = 'control-row';
const lbl = document.createElement('label');
lbl.textContent = 'Target';
row.appendChild(lbl);
const btnGroup = document.createElement('div');
btnGroup.className = 'wave-selector';
['pitch', 'filter', 'amplitude'].forEach(t => {
const btn = document.createElement('button');
btn.className = 'wave-btn' + (t === activeTarget ? ' active' : '');
btn.textContent = t;
btn.addEventListener('click', () => {
btnGroup.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
onChange(t);
});
btnGroup.appendChild(btn);
});
row.appendChild(btnGroup);
parent.appendChild(row);
}
// ── function: buildKeyboard ──
function buildKeyboard(container, engine) {
const keysDiv = document.getElementById('piano-keys');
keysDiv.innerHTML = '';
const whiteWidth = 48;
const blackWidth = 30;
const whiteNotes = NOTE_MAP.filter(n => n.type === 'white');
const totalWidth = whiteNotes.length * whiteWidth;
keysDiv.style.width = totalWidth + 'px';
let whiteIndex = 0;
NOTE_MAP.forEach((note, i) => {
const key = document.createElement('div');
if (note.type === 'white') {
key.className = 'key-white';
key.style.left = (whiteIndex * whiteWidth) + 'px';
key.style.width = (whiteWidth - 2) + 'px';
key.style.position = 'absolute';
key.style.top = '0';
key.textContent = note.label;
whiteIndex++;
} else {
key.className = 'key-black';
const prevWhiteCount = NOTE_MAP.slice(0, i).filter(n => n.type === 'white').length;
const blackLeft = prevWhiteCount * whiteWidth - blackWidth / 2;
key.style.left = blackLeft + 'px';
key.style.width = blackWidth + 'px';
key.textContent = note.label;
}
key.dataset.note = note.note;
key.dataset.freq = note.freq;
key.addEventListener('mousedown', (e) => {
e.preventDefault();
engine.noteOn(note.freq, note.note);
key.classList.add('active');
});
key.addEventListener('mouseup', () => {
engine.noteOff(note.note);
key.classList.remove('active');
});
key.addEventListener('mouseleave', () => {
engine.noteOff(note.note);
key.classList.remove('active');
});
// Touch events for mobile
key.addEventListener('touchstart', (e) => {
e.preventDefault();
engine.noteOn(note.freq, note.note);
key.classList.add('active');
}, { passive: false });
key.addEventListener('touchend', (e) => {
e.preventDefault();
engine.noteOff(note.note);
key.classList.remove('active');
});
keysDiv.appendChild(key);
});
}
// ── function: drawVisualization ──
function drawVisualization(engine, waveCanvas, specCanvas) {
const wCtx = waveCanvas.getContext('2d');
const sCtx = specCanvas.getContext('2d');
let prevActive = false;
function draw() {
requestAnimationFrame(draw);
const wW = waveCanvas.width;
const wH = waveCanvas.height;
const sW = specCanvas.width;
const sH = specCanvas.height;
const hasAudio = engine.activeVoices && engine.activeVoices.size > 0;
// Waveform
wCtx.fillStyle = '#0d0d14';
wCtx.fillRect(0, 0, wW, wH);
// Grid
wCtx.strokeStyle = '#181828';
wCtx.lineWidth = 1;
wCtx.setLineDash([4, 4]);
for (let i = 1; i < 4; i++) {
wCtx.beginPath();
wCtx.moveTo(wW * i / 4, 0);
wCtx.lineTo(wW * i / 4, wH);
wCtx.stroke();
}
wCtx.beginPath();
wCtx.moveTo(0, wH / 2);
wCtx.lineTo(wW, wH / 2);
wCtx.stroke();
wCtx.setLineDash([]);
// Center line
wCtx.strokeStyle = '#252540';
wCtx.lineWidth = 1;
wCtx.beginPath();
wCtx.moveTo(0, wH / 2);
wCtx.lineTo(wW, wH / 2);
wCtx.stroke();
const waveData = engine.getWaveformData();
if (waveData.length > 0 && hasAudio) {
// Show clean cycles
const sampleRate = engine.ctx ? engine.ctx.sampleRate : 44100;
const freq = engine.frequency || 220;
const samplesPerCycle = sampleRate / freq;
const cyclesToShow = 4;
const samplesToShow = Math.min(waveData.length, Math.floor(samplesPerCycle * cyclesToShow));
const sliceWidth = wW / samplesToShow;
// Glow background
wCtx.strokeStyle = 'rgba(167, 139, 250, 0.3)';
wCtx.lineWidth = 6;
wCtx.shadowColor = '#7c3aed';
wCtx.shadowBlur = 12;
wCtx.beginPath();
for (let i = 0; i < samplesToShow; i++) {
const v = waveData[i];
const y = (1 - v) * wH / 2;
if (i === 0) wCtx.moveTo(0, y);
else wCtx.lineTo(i * sliceWidth, y);
}
wCtx.stroke();
// Main line
wCtx.strokeStyle = '#a78bfa';
wCtx.lineWidth = 2;
wCtx.shadowColor = '#7c3aed';
wCtx.shadowBlur = 4;
wCtx.beginPath();
for (let i = 0; i < samplesToShow; i++) {
const v = waveData[i];
const y = (1 - v) * wH / 2;
if (i === 0) wCtx.moveTo(0, y);
else wCtx.lineTo(i * sliceWidth, y);
}
wCtx.stroke();
wCtx.shadowBlur = 0;
prevActive = true;
} else if (prevActive) {
// Fade out effect
wCtx.fillStyle = '#0d0d14';
wCtx.fillRect(0, 0, wW, wH);
prevActive = false;
}
// Labels
wCtx.fillStyle = hasAudio ? '#666' : '#333';
wCtx.font = '11px monospace';
wCtx.fillText('WAVEFORM', 10, 16);
wCtx.fillStyle = '#333';
wCtx.fillText(engine.waveform || 'sine', wW - (engine.waveform || '').length * 7 - 10, 16);
if (!hasAudio) {
wCtx.fillStyle = '#333';
wCtx.font = '12px sans-serif';
wCtx.textAlign = 'center';
wCtx.fillText('Click Hold or play a key to see the waveform', wW / 2, wH / 2 + 4);
wCtx.textAlign = 'left';
}
// Spectrum
sCtx.fillStyle = '#0d0d14';
sCtx.fillRect(0, 0, sW, sH);
const specData = engine.getSpectrumData();
if (specData.length > 0) {
const barCount = Math.min(specData.length, 200);
const barWidth = sW / barCount;
for (let i = 0; i < barCount; i++) {
const val = specData[i] / 255;
if (val < 0.005) continue;
const barHeight = val * sH * 0.95;
const hue = 260 + val * 50;
sCtx.fillStyle = `hsla(${hue}, 75%, ${45 + val * 20}%, ${0.5 + val * 0.5})`;
sCtx.fillRect(i * barWidth, sH - barHeight, Math.max(barWidth - 1, 1), barHeight);
}
}
sCtx.fillStyle = hasAudio ? '#666' : '#333';
sCtx.font = '11px monospace';
sCtx.fillText('SPECTRUM', 10, 16);
}
draw();
}
// ── const: PRESETS ──
const PRESETS = [
{
name: '🔔 Bell',
settings: { waveform: 'sine', frequency: 440, attack: 0.01, decay: 0.8, sustain: 0.0, release: 1.5, filterCutoff: 5000, filterResonance: 1 }
},
{
name: '🎸 Bass',
settings: { waveform: 'sawtooth', frequency: 80, attack: 0.01, decay: 0.3, sustain: 0.4, release: 0.2, filterCutoff: 400, filterResonance: 4 }
},
{
name: '🌊 Pad',
settings: { waveform: 'triangle', frequency: 220, attack: 1.2, decay: 0.5, sustain: 0.7, release: 2.0, filterCutoff: 2000, filterResonance: 2 }
},
{
name: '🎹 Lead',
settings: { waveform: 'square', frequency: 330, attack: 0.02, decay: 0.1, sustain: 0.8, release: 0.3, filterCutoff: 4000, filterResonance: 1 }
},
{
name: '🤖 Wobble',
settings: { waveform: 'sawtooth', frequency: 150, attack: 0.01, decay: 0.2, sustain: 0.6, release: 0.3, filterCutoff: 800, filterResonance: 8, lfoRate: 4, lfoDepth: 0.5, lfoTarget: 'filter' }
},
];
// ── function: createPresetSection ──
function createPresetSection(parent, engine, rebuildControls) {
const section = document.createElement('div');
section.className = 'control-section';
section.style.minWidth = '100%';
section.innerHTML = '<h3>🎨 Presets — Quick Start Sounds</h3>';
const btnRow = document.createElement('div');
btnRow.className = 'wave-selector';
btnRow.style.flexWrap = 'wrap';
btnRow.style.gap = '6px';
PRESETS.forEach(preset => {
const btn = document.createElement('button');
btn.className = 'wave-btn';
btn.textContent = preset.name;
btn.style.flex = '1';
btn.style.minWidth = '100px';
btn.addEventListener('click', () => {
const s = preset.settings;
engine.waveform = s.waveform;
engine.frequency = s.frequency;
engine.attack = s.attack;
engine.decay = s.decay;
engine.sustain = s.sustain;
engine.release = s.release;
engine.filterCutoff = s.filterCutoff;
engine.filterResonance = s.filterResonance;
engine.lfoRate = s.lfoRate || 3;
engine.lfoDepth = s.lfoDepth || 0;
engine.lfoTarget = s.lfoTarget || 'pitch';
engine.lfoEnabled = (s.lfoDepth || 0) > 0;
// Highlight active preset button
btnRow.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Rebuild controls with new values
rebuildControls();
});
btnRow.appendChild(btn);
});
section.appendChild(btnRow);
section.appendChild(document.createElement('br'));
const hint = document.createElement('p');
hint.style.cssText = 'color: #666; font-size: 0.8rem; margin-top: 8px;';
hint.textContent = 'Click a preset to load it, then use Hold or play the keyboard to hear it.';
section.appendChild(hint);
parent.appendChild(section);
}
// ── main ──
const engine = new AudioEngine();
let currentStageIndex = 0;
let unlockedStages = 1;
const nav = document.getElementById('stages-nav');
const lessonContent = document.getElementById('lesson-content');
const unlockBtn = document.getElementById('unlock-next-btn');
const controlsArea = document.getElementById('controls-area');
const keyboardArea = document.getElementById('keyboard-area');
const waveCanvas = document.getElementById('waveform-canvas');
const specCanvas = document.getElementById('spectrum-canvas');
function buildNav() {
nav.innerHTML = '';
STAGES.forEach((stage, i) => {
const tab = document.createElement('button');
tab.className = 'stage-tab';
tab.textContent = stage.title;
if (i < unlockedStages) {
tab.classList.add('unlocked');
tab.addEventListener('click', () => switchToStage(i));
} else {
tab.classList.add('locked');
}
if (i === currentStageIndex) tab.classList.add('active');
nav.appendChild(tab);
});
}
function buildControls(stageIndex) {
controlsArea.innerHTML = '';
// Presets shown at the top when all unlocked
if (unlockedStages >= 6) {
createPresetSection(controlsArea, engine, () => buildControls(stageIndex));
}
// Oscillator always shown
const oscSection = document.createElement('div');
oscSection.className = 'control-section';
oscSection.innerHTML = '<h3>🔊 Oscillator</h3>';
createWaveSelector(oscSection, engine.waveform, type => {
engine.setWaveform(type);
});
createSlider(oscSection, 'Frequency (Hz)', 50, 880, engine.frequency, 1, val => {
engine.setFrequency(val);
});
createSlider(oscSection, 'Volume', 0, 0.8, engine.masterGain ? engine.masterGain.gain.value : 0.4, 0.01, val => {
engine.init();
engine.masterGain.gain.setTargetAtTime(val, engine.ctx.currentTime, 0.02);
});
const holdRow = document.createElement('div');
holdRow.className = 'control-row';
const holdBtn = document.createElement('button');
holdBtn.className = 'wave-btn';
holdBtn.id = 'hold-btn';
holdBtn.textContent = '▶ Hold';
holdBtn.style.width = '100%';
holdBtn.addEventListener('click', () => {
engine.toggleHold();
holdBtn.classList.toggle('active');
holdBtn.textContent = engine.holding ? '⏸ Stop' : '▶ Hold';
});
holdRow.appendChild(holdBtn);
oscSection.appendChild(holdRow);
controlsArea.appendChild(oscSection);
if (unlockedStages >= 2) {
const section = document.createElement('div');
section.className = 'control-section';
section.innerHTML = '<h3>📈 Envelope (ADSR)</h3>';
createSlider(section, 'Attack (s)', 0.01, 2.0, engine.attack, 0.01, v => engine.attack = v);
createSlider(section, 'Decay (s)', 0.01, 2.0, engine.decay, 0.01, v => engine.decay = v);
createSlider(section, 'Sustain', 0.0, 1.0, engine.sustain, 0.01, v => engine.sustain = v);
createSlider(section, 'Release (s)', 0.01, 3.0, engine.release, 0.01, v => engine.release = v);
controlsArea.appendChild(section);
}
if (unlockedStages >= 3) {
const section = document.createElement('div');
section.className = 'control-section';
section.innerHTML = '<h3>🔩 Filter (Low-pass)</h3>';
engine.filterEnabled = true;
createSlider(section, 'Cutoff (Hz)', 20, 8000, engine.filterCutoff, 10, v => engine.filterCutoff = v);
createSlider(section, 'Resonance (Q)', 0.1, 20, engine.filterResonance, 0.1, v => engine.filterResonance = v);
controlsArea.appendChild(section);
}
if (unlockedStages >= 4) {
const section = document.createElement('div');
section.className = 'control-section';
section.innerHTML = '<h3>〰️ LFO</h3>';
engine.lfoEnabled = true;
createSlider(section, 'Rate (Hz)', 0.1, 20, engine.lfoRate, 0.1, v => engine.lfoRate = v);
createSlider(section, 'Depth', 0, 1, engine.lfoDepth, 0.01, v => {
engine.lfoDepth = v;
engine.lfoEnabled = v > 0;
});
createTargetSelector(section, engine.lfoTarget, t => engine.lfoTarget = t);
controlsArea.appendChild(section);
}
if (unlockedStages >= 5) {
const section = document.createElement('div');
section.className = 'control-section';
section.innerHTML = '<h3>✨ Effects</h3>';
engine.effectsEnabled = true;
createSlider(section, 'Delay Time (s)', 0.05, 0.8, engine.delayTime, 0.01, v => engine.delayTime = v);
createSlider(section, 'Feedback', 0, 0.7, engine.delayFeedback, 0.01, v => engine.delayFeedback = v);
createSlider(section, 'Delay Mix', 0, 1, engine.delayMix, 0.01, v => engine.delayMix = v);
createSlider(section, 'Distortion', 0, 1, engine.distortion, 0.01, v => engine.distortion = v);
controlsArea.appendChild(section);
}
if (unlockedStages >= 6) {
keyboardArea.classList.remove('hidden');
buildKeyboard(keyboardArea, engine);
} else {
keyboardArea.classList.add('hidden');
}
}
function switchToStage(index) {
currentStageIndex = index;
lessonContent.innerHTML = STAGES[index].lesson;
const canUnlock = unlockedStages < STAGES.length;
unlockBtn.classList.toggle('hidden', !canUnlock);
buildNav();
buildControls(index);
}
unlockBtn.addEventListener('click', () => {
if (unlockedStages < STAGES.length) {
unlockedStages++;
switchToStage(unlockedStages - 1);
}
});
const pressedKeys = new Set();
document.addEventListener('keydown', (e) => {
if (unlockedStages < 6) return;
const key = e.key.toLowerCase();
if (pressedKeys.has(key)) return;
const note = NOTE_MAP.find(n => n.key === key);
if (note) {
e.preventDefault();
pressedKeys.add(key);
engine.noteOn(note.freq, note.note);
const el = document.querySelector(`[data-note="${note.note}"]`);
if (el) el.classList.add('active');
}
});
document.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
pressedKeys.delete(key);
const note = NOTE_MAP.find(n => n.key === key);
if (note) {
engine.noteOff(note.note);
const el = document.querySelector(`[data-note="${note.note}"]`);
if (el) el.classList.remove('active');
}
});
buildNav();
switchToStage(0);
drawVisualization(engine, waveCanvas, specCanvas);
</script>
</body>
</html>