TutSynth
thinkingcap qwen3.6 27b · middle

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 40,664 bytes · SHA-256 6772fe78968c
<!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: #0a0a12;
--panel: #14142a;
--panel-border: #2a2a5a;
--accent: #7b61ff;
--accent-glow: #9d8aff;
--text: #e0dff0;
--text-dim: #8888aa;
--success: #4ade80;
--warn: #fbbf24;
}
* { 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(--panel-border);
background: linear-gradient(180deg, #1a1a3a 0%, var(--bg) 100%);
}
header h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.5px;
background: linear-gradient(135deg, #a78bfa, #7c3aed, #6d28d9);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
header p { color: var(--text-dim); margin-top: 4px; font-size: 14px; }
.progress-bar-container {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: var(--panel);
border-bottom: 1px solid var(--panel-border);
}
.progress-track {
flex: 1;
height: 6px;
background: #2a2a4a;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--success));
border-radius: 3px;
transition: width 0.6s ease;
}
.progress-label { font-size: 12px; color: var(--text-dim); white-space: nowrap; }
.main-layout {
display: grid;
grid-template-columns: 340px 1fr;
min-height: calc(100vh - 120px);
}
/* Tutorial Panel */
.tutorial-panel {
padding: 24px;
border-right: 1px solid var(--panel-border);
overflow-y: auto;
max-height: calc(100vh - 120px);
}
.step-card {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
transition: all 0.3s ease;
}
.step-card.active {
border-color: var(--accent);
box-shadow: 0 0 20px rgba(123, 97, 255, 0.15);
}
.step-card.completed { opacity: 0.6; }
.step-card.locked { opacity: 0.3; pointer-events: none; filter: grayscale(1); }
.step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px; height: 28px;
border-radius: 50%;
background: var(--accent);
color: white;
font-size: 13px;
font-weight: 700;
margin-bottom: 10px;
}
.step-card.completed .step-number { background: var(--success); }
.step-card.locked .step-number { background: #444; }
.step-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
.step-desc { font-size: 13px; line-height: 1.6; color: var(--text-dim); }
.step-desc strong { color: var(--accent-glow); }
.step-hint {
margin-top: 12px;
padding: 10px 14px;
background: rgba(123, 97, 255, 0.1);
border-left: 3px solid var(--accent);
border-radius: 0 8px 8px 0;
font-size: 12px;
line-height: 1.5;
color: #c4b5fd;
}
.complete-btn {
margin-top: 14px;
width: 100%;
padding: 10px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), #6d28d9);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.complete-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 15px rgba(123,97,255,0.4); }
.complete-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
/* Synth Panel */
.synth-panel {
padding: 24px;
overflow-y: auto;
max-height: calc(100vh - 120px);
}
.module-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.synth-module {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 20px;
transition: all 0.3s ease;
}
.synth-module.locked {
opacity: 0.25;
pointer-events: none;
filter: grayscale(1);
}
.module-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.module-icon {
width: 32px; height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.module-name { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; }
/* Controls */
.control-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.control-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
min-width: 60px;
}
.control-value {
font-size: 12px;
color: var(--accent-glow);
min-width: 40px;
text-align: right;
font-variant-numeric: tabular-nums;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 6px;
background: #2a2a4a;
border-radius: 3px;
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 0 8px rgba(123,97,255,0.5);
}
input[type="range"]::-moz-range-thumb {
width: 18px; height: 18px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: none;
box-shadow: 0 0 8px rgba(123,97,255,0.5);
}
select {
flex: 1;
padding: 6px 10px;
background: #1e1e3a;
border: 1px solid var(--panel-border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
outline: none;
cursor: pointer;
}
select:focus { border-color: var(--accent); }
/* Keyboard */
.keyboard-container {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
}
.keyboard-label { font-size: 13px; color: var(--text-dim); margin-bottom: 12px; text-align: center; }
.keyboard {
display: flex;
justify-content: center;
position: relative;
height: 120px;
user-select: none;
}
.key-white {
width: 48px;
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.active { background: linear-gradient(180deg, var(--accent), var(--accent-glow)); border-color: var(--accent); }
.key-black {
width: 32px;
height: 75px;
background: linear-gradient(180deg, #222, #444);
border-radius: 0 0 4px 4px;
cursor: pointer;
position: absolute;
z-index: 2;
margin-left: -16px;
transition: background 0.05s;
}
.key-black:hover { background: linear-gradient(180deg, #333, #555); }
.key-black.active { background: linear-gradient(180deg, var(--accent), var(--accent-glow)); }
/* Waveform Display */
.waveform-container {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
}
canvas#waveCanvas {
width: 100%;
height: 160px;
border-radius: 8px;
background: #0a0a18;
}
/* Signal Flow */
.signal-flow {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 16px;
margin-bottom: 24px;
background: var(--panel);
border-radius: 12px;
border: 1px solid var(--panel-border);
flex-wrap: wrap;
}
.flow-node {
padding: 8px 14px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
background: #2a2a4a;
color: var(--text-dim);
transition: all 0.3s;
}
.flow-node.active {
background: rgba(123,97,255,0.2);
color: var(--accent-glow);
border: 1px solid var(--accent);
}
.flow-arrow { color: #444; font-size: 16px; transition: color 0.3s; }
.flow-arrow.active { color: var(--accent); }
/* Preset buttons */
.preset-row {
display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;
}
.preset-btn {
padding: 6px 14px;
border-radius: 20px;
border: 1px solid var(--panel-border);
background: transparent;
color: var(--text-dim);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.preset-btn:hover { border-color: var(--accent); color: var(--accent-glow); background: rgba(123,97,255,0.1); }
/* Responsive */
@media (max-width: 800px) {
.main-layout { grid-template-columns: 1fr; }
.tutorial-panel { max-height: none; border-right: none; border-bottom: 1px solid var(--panel-border); }
.synth-panel { max-height: none; }
}
/* Animations */
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 5px rgba(123,97,255,0.2); }
50% { box-shadow: 0 0 20px rgba(123,97,255,0.4); }
}
.synth-module.unlocked-anim { animation: pulse-glow 1s ease; }
/* Tooltip */
.info-tip {
display: inline-block;
width: 16px; height: 16px;
border-radius: 50%;
background: #3a3a5a;
color: var(--text-dim);
font-size: 10px;
text-align: center;
line-height: 16px;
cursor: help;
margin-left: 4px;
position: relative;
}
.info-tip:hover::after {
content: attr(data-tip);
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: #1a1a3a;
border: 1px solid var(--panel-border);
padding: 8px 12px;
border-radius: 8px;
font-size: 11px;
white-space: nowrap;
z-index: 100;
color: var(--text);
text-transform: none;
letter-spacing: normal;
font-weight: 400;
}
.key-hint {
display: inline-block;
background: #2a2a4a;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
color: var(--text-dim);
margin-left: 4px;
}
.master-vol {
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
background: rgba(123,97,255,0.08); border-radius: 8px; margin-bottom: 16px;
}
.master-vol label { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
</style>
</head>
<body>
<header>
<h1>🎹 Synth Playground</h1>
<p>Learn synthesis by building sounds — one module at a time</p>
</header>
<div class="progress-bar-container">
<span class="progress-label" id="progressLabel">Step 1 of 7</span>
<div class="progress-track"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
</div>
<div class="main-layout">
<!-- Tutorial Panel -->
<div class="tutorial-panel" id="tutorialPanel"></div>
<!-- Synth Panel -->
<div class="synth-panel">
<!-- Signal Flow Diagram -->
<div class="signal-flow" id="signalFlow"></div>
<!-- Master Volume -->
<div class="master-vol">
<label>🔊 Master</label>
<input type="range" min="0" max="100" value="70" id="masterVol">
<span class="control-value" id="masterVolVal">70%</span>
</div>
<!-- Waveform Display -->
<div class="waveform-container">
<canvas id="waveCanvas"></canvas>
</div>
<!-- Keyboard -->
<div class="keyboard-container">
<div class="keyboard-label">Click keys or use your keyboard (A–L row)</div>
<div class="keyboard" id="keyboard"></div>
</div>
<!-- Modules Grid -->
<div class="module-grid" id="moduleGrid"></div>
</div>
</div>
<script>
// ==================== AUDIO ENGINE ====================
const AudioCtx = window.AudioContext || window.webkitAudioContext;
let audioCtx = null;
let masterGain = null;
let analyser = null;
let activeNotes = {};
function initAudio() {
if (audioCtx) return;
audioCtx = new AudioCtx();
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.7;
analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
masterGain.connect(analyser);
analyser.connect(audioCtx.destination);
}
// Synth parameters
const params = {
// Oscillator
waveform: 'sawtooth',
detune: 0,
octave: 0,
// ADSR
attack: 0.05,
decay: 0.2,
sustain: 0.6,
release: 0.3,
// Filter
filterType: 'lowpass',
cutoffFreq: 8000,
resonance: 1,
envAmount: 0,
// LFO
lfoWaveform: 'sine',
lfoRate: 5,
lfoDepth: 0,
lfoTarget: 'pitch',
// Delay
delayTime: 0.3,
delayFeedback: 0.3,
delayMix: 0,
// Reverb
reverbMix: 0,
};
// Note frequencies
const noteFreqs = {
'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
};
const keyMap = {
'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'
};
const keyLabels = {
'a':'A','w':'W','s':'S','e':'E','d':'D',
'f':'F','t':'T','g':'G','y':'Y','h':'H',
'u':'U','j':'J','k':'K'
};
function noteOn(note) {
initAudio();
if (activeNotes[note]) return;
const freq = noteFreqs[note] * Math.pow(2, params.octave);
const now = audioCtx.currentTime;
// Oscillator
const osc = audioCtx.createOscillator();
osc.type = params.waveform;
osc.frequency.value = freq;
osc.detune.value = params.detune;
// LFO on pitch
if (params.lfoDepth > 0 && params.lfoTarget === 'pitch') {
const lfo = audioCtx.createOscillator();
const lfoGain = audioCtx.createGain();
lfo.type = params.lfoWaveform;
lfo.frequency.value = params.lfoRate;
lfoGain.gain.value = freq * params.lfoDepth * 0.01;
lfo.connect(lfoGain);
lfoGain.connect(osc.detune);
lfo.start(now);
activeNotes[note] = { osc, lfo };
} else {
activeNotes[note] = { osc };
}
// Filter
const filter = audioCtx.createBiquadFilter();
filter.type = params.filterType;
filter.frequency.value = params.cutoffFreq;
filter.Q.value = params.resonance;
// ADSR Envelope
const envGain = audioCtx.createGain();
envGain.gain.setValueAtTime(0, now);
envGain.gain.linearRampToValueAtTime(1, now + Math.max(params.attack, 0.005));
envGain.gain.linearRampToValueAtTime(params.sustain, now + params.attack + Math.max(params.decay, 0.005));
// Filter envelope
if (params.envAmount > 0) {
const filterEnvTarget = Math.min(18000, params.cutoffFreq * (1 + params.envAmount));
filter.frequency.setValueAtTime(params.cutoffFreq, now);
filter.frequency.linearRampToValueAtTime(filterEnvTarget, now + Math.max(params.attack, 0.005));
filter.frequency.linearRampToValueAtTime(params.cutoffFreq, now + params.attack + Math.max(params.decay, 0.005));
}
// LFO on filter
if (params.lfoDepth > 0 && params.lfoTarget === 'filter') {
const lfo = audioCtx.createOscillator();
const lfoGain = audioCtx.createGain();
lfo.type = params.lfoWaveform;
lfo.frequency.value = params.lfoRate;
lfoGain.gain.value = params.cutoffFreq * params.lfoDepth * 0.3;
lfo.connect(lfoGain);
lfoGain.connect(filter.frequency);
lfo.start(now);
activeNotes[note].lfoFilter = { lfo, lfoGain };
}
// LFO on amplitude (tremolo)
let tremoloLfo = null;
if (params.lfoDepth > 0 && params.lfoTarget === 'amplitude') {
const lfo = audioCtx.createOscillator();
const lfoGain = audioCtx.createGain();
lfo.type = params.lfoWaveform;
lfo.frequency.value = params.lfoRate;
lfoGain.gain.value = 0.5 * params.lfoDepth;
lfo.connect(lfoGain);
lfoGain.connect(envGain.gain);
lfo.start(now);
tremoloLfo = { lfo, lfoGain };
}
// Delay effect
let delayNode = null, delayFeedbackNode = null, delayDry = null, delayWet = null;
if (params.delayMix > 0) {
delayNode = audioCtx.createDelay(1);
delayNode.delayTime.value = params.delayTime;
delayFeedbackNode = audioCtx.createGain();
delayFeedbackNode.gain.value = params.delayFeedback;
delayDry = audioCtx.createGain();
delayWet = audioCtx.createGain();
delayDry.gain.value = 1 - params.delayMix * 0.5;
delayWet.gain.value = params.delayMix;
osc.connect(filter);
filter.connect(envGain);
envGain.connect(delayDry);
envGain.connect(delayNode);
delayNode.connect(delayFeedbackNode);
delayFeedbackNode.connect(delayNode);
delayNode.connect(delayWet);
delayDry.connect(masterGain);
delayWet.connect(masterGain);
} else {
osc.connect(filter);
filter.connect(envGain);
envGain.connect(masterGain);
}
activeNotes[note].filter = filter;
activeNotes[note].envGain = envGain;
if (delayNode) {
activeNotes[note].delayDry = delayDry;
activeNotes[note].delayWet = delayWet;
activeNotes[note].tremoloLfo = tremoloLfo;
}
osc.start(now);
}
function noteOff(note) {
const data = activeNotes[note];
if (!data) return;
const now = audioCtx.currentTime;
const envGain = data.envGain;
// Release phase
envGain.gain.cancelScheduledValues(now);
envGain.gain.setValueAtTime(envGain.gain.value, now);
envGain.gain.linearRampToValueAtTime(0, now + Math.max(params.release, 0.01));
const stopTime = now + params.release + 0.1;
data.osc.stop(stopTime);
if (data.lfo) data.lfo.stop(stopTime);
if (data.tremoloLfo && data.tremoloLfo.lfo) data.tremoloLfo.lfo.stop(stopTime);
if (data.lfoFilter && data.lfoFilter.lfo) data.lfoFilter.lfo.stop(stopTime);
setTimeout(() => {
try {
data.osc.disconnect();
if (data.filter) data.filter.disconnect();
envGain.disconnect();
if (data.delayDry) data.delayDry.disconnect();
if (data.delayWet) data.delayWet.disconnect();
} catch(e) {}
}, (params.release + 0.2) * 1000);
delete activeNotes[note];
}
// ==================== TUTORIAL STEPS ====================
const steps = [
{
id: 'oscillator',
title: 'The Oscillator — Your Sound Source',
desc: `Every synthesizer starts with an <strong>oscillator</strong> — it generates the raw sound wave. Different waveforms produce different timbres (colors of sound). A <strong>sine</strong> is pure and smooth, a <strong>square</strong> is hollow and buzzy, a <strong>sawtooth</strong> is rich and bright, and a <strong>triangle</strong> is soft and mellow.`,
hint: '🎵 Try each waveform by clicking the dropdown. Play notes on the keyboard to hear the difference!',
check: () => true,
},
{
id: 'adsr',
title: 'ADSR Envelope — Shaping Time',
desc: `Raw oscillator tones are lifeless. An <strong>envelope</strong> shapes how a note evolves over time. <strong>A</strong>ttack is how fast the sound rises, <strong>D</strong>ecay is how quickly it drops to sustain level, <strong>S</strong>ustain is the held volume, and <strong>R</strong>elease is how long it fades after you let go.`,
hint: '🎵 Try setting Attack=0.5s for a soft pad sound, or Release=2s for a long fade-out!',
check: () => true,
},
{
id: 'filter',
title: 'The Filter — Sculpting Tone',
desc: `A <strong>filter</strong> removes frequencies from the signal. A <strong>lowpass filter</strong> cuts high frequencies (making sounds darker), while a <strong>highpass</strong> cuts lows. The <strong>cutoff frequency</strong> sets where the cut begins, and <strong>resonance</strong> emphasizes frequencies near the cutoff for a "squelchy" sound.`,
hint: '🎵 Lower the cutoff to ~500Hz for a muffled bass tone, then add resonance=8 for that classic acid squelch!',
check: () => true,
},
{
id: 'filterEnv',
title: 'Filter Envelope — Dynamic Filtering',
desc: `The <strong>filter envelope amount</strong> makes the filter cutoff open and close with each note. This is what gives keys their "pluck" or "wah" character. More amount = more movement from bright to dark as the note plays.`,
hint: '🎵 Set Env Amount high (0.8+) with a short attack for a plucky FM-like sound!',
check: () => true,
},
{
id: 'lfo',
title: 'LFO — Modulation Magic',
desc: `A <strong>Low Frequency Oscillator</strong> (LFO) is an oscillator too slow to hear as a pitch. Instead, it modulates other parameters. Set the target to <strong>Pitch</strong> for vibrato (pitch wobble), <strong>Filter</strong> for auto-wah, or <strong>Amplitude</strong> for tremolo (volume pulsing).`,
hint: '🎵 Try LFO→Pitch at Rate=6Hz, Depth=0.3 for classic vibrato! Or LFO→Filter for a throbbing bass!',
check: () => true,
},
{
id: 'delay',
title: 'Delay — Space and Echo',
desc: `<strong>Delay</strong> repeats your signal after a time interval. The <strong>time</strong> sets the echo spacing, <strong>feedback</strong> controls how many repeats you get, and <strong>mix</strong> blends wet/dry signals. Small amounts add depth; large amounts create rhythmic echoes.`,
hint: '🎵 Try Time=0.4s, Feedback=0.5, Mix=0.3 for a spacious echo that adds dimension!',
check: () => true,
},
{
id: 'master',
title: 'You\'re a Synth Wizard! 🧙',
desc: `You've explored all the core modules of a subtractive synthesizer. Real synths chain these together in <strong>signal paths</strong>: Oscillator → Filter → Envelope → Effects → Output. Now experiment freely — try combining everything to design your own sounds!`,
hint: '🎵 Try the presets below for inspiration, then tweak each parameter to understand how they interact.',
check: () => true,
},
];
let currentStep = 0;
const unlockedModules = new Set();
// ==================== UI RENDERING ====================
function renderTutorial() {
const panel = document.getElementById('tutorialPanel');
panel.innerHTML = steps.map((step, i) => {
const isActive = i === currentStep;
const isCompleted = i < currentStep;
const isLocked = i > currentStep;
return `
<div class="step-card ${isActive ? 'active' : ''} ${isCompleted ? 'completed' : ''} ${isLocked ? 'locked' : ''}" id="step-${i}">
<div class="step-number">${isCompleted ? '✓' : i + 1}</div>
<div class="step-title">${step.title}</div>
<div class="step-desc">${step.desc}</div>
${isActive ? `<div class="step-hint">${step.hint}</div>` : ''}
${isActive ? `<button class="complete-btn" onclick="advanceStep()" id="completeBtn">Got it — Next →</button>` : ''}
</div>
`;
}).join('');
// Update progress
const pct = (currentStep / (steps.length - 1)) * 100;
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('progressLabel').textContent = `Step ${currentStep + 1} of ${steps.length}`;
}
function renderSignalFlow() {
const flow = document.getElementById('signalFlow');
const nodes = [
{ label: 'Oscillator', active: unlockedModules.has('oscillator') },
{ label: 'Filter', active: unlockedModules.has('filter') || unlockedModules.has('filterEnv') },
{ label: 'Envelope', active: unlockedModules.has('adsr') },
{ label: 'LFO', active: unlockedModules.has('lfo') },
{ label: 'Delay', active: unlockedModules.has('delay') },
{ label: 'Output', active: true },
];
flow.innerHTML = nodes.map((n, i) => `
<div class="flow-node ${n.active ? 'active' : ''}">${n.label}</div>
${i < nodes.length - 1 ? `<span class="flow-arrow ${n.active ? 'active' : ''}">→</span>` : ''}
`).join('');
}
function renderModules() {
const grid = document.getElementById('moduleGrid');
const modules = [
// Oscillator Module
{
id: 'oscillator',
name: 'Oscillator',
icon: '🎵',
color: '#7c3aed',
controls: `
<div class="control-row">
<span class="control-label">Waveform</span>
<select id="ctrl-waveform" onchange="setParam('waveform', this.value)">
<option value="sine">Sine — Pure & Smooth</option>
<option value="square">Square — Hollow & Buzzy</option>
<option value="sawtooth" selected>Sawtooth — Rich & Bright</option>
<option value="triangle">Triangle — Soft & Mellow</option>
</select>
</div>
<div class="control-row">
<span class="control-label">Detune<span class="info-tip" data-tip="Shift pitch slightly for a thicker sound">?</span></span>
<input type="range" min="-100" max="100" value="${params.detune}" oninput="setParam('detune', +this.value); this.nextElementSibling.textContent=this.value+'¢'">
<span class="control-value">${params.detune}¢</span>
</div>
<div class="control-row">
<span class="control-label">Octave<span class="info-tip" data-tip="Shift the pitch up or down by octaves">?</span></span>
<input type="range" min="-2" max="2" value="${params.octave}" step="1" oninput="setParam('octave', +this.value); this.nextElementSibling.textContent=this.value">
<span class="control-value">${params.octave}</span>
</div>
`,
},
// ADSR Module
{
id: 'adsr',
name: 'ADSR Envelope',
icon: '📈',
color: '#2563eb',
controls: `
<div class="control-row">
<span class="control-label">Attack<span class="info-tip" data-tip="Time from note-on to full volume (0=instant)">?</span></span>
<input type="range" min="1" max="2000" value="${params.attack*1000}" oninput="setParam('attack', this.value/1000); this.nextElementSibling.textContent=(this.value/1000).toFixed(2)+'s'">
<span class="control-value">${params.attack.toFixed(2)}s</span>
</div>
<div class="control-row">
<span class="control-label">Decay<span class="info-tip" data-tip="Time to drop from peak to sustain level">?</span></span>
<input type="range" min="1" max="2000" value="${params.decay*1000}" oninput="setParam('decay', this.value/1000); this.nextElementSibling.textContent=(this.value/1000).toFixed(2)+'s'">
<span class="control-value">${params.decay.toFixed(2)}s</span>
</div>
<div class="control-row">
<span class="control-label">Sustain<span class="info-tip" data-tip="Volume level while holding the note (0=silent, 1=full)">?</span></span>
<input type="range" min="0" max="100" value="${params.sustain*100}" oninput="setParam('sustain', this.value/100); this.nextElementSibling.textContent=Math.round(this.value)+'%'">
<span class="control-value">${Math.round(params.sustain*100)}%</span>
</div>
<div class="control-row">
<span class="control-label">Release<span class="info-tip" data-tip="Time for sound to fade after releasing the key">?</span></span>
<input type="range" min="1" max="3000" value="${params.release*1000}" oninput="setParam('release', this.value/1000); this.nextElementSibling.textContent=(this.value/1000).toFixed(2)+'s'">
<span class="control-value">${params.release.toFixed(2)}s</span>
</div>
`,
},
// Filter Module
{
id: 'filter',
name: 'Filter',
icon: '🔬',
color: '#059669',
controls: `
<div class="control-row">
<span class="control-label">Type</span>
<select id="ctrl-filterType" onchange="setParam('filterType', this.value)">
<option value="lowpass" selected>Lowpass — Cuts highs</option>
<option value="highpass">Highpass — Cuts lows</option>
<option value="bandpass">Bandpass — Keeps middle</option>
</select>
</div>
<div class="control-row">
<span class="control-label">Cutoff<span class="info-tip" data-tip="Frequency where filtering begins (Hz)">?</span></span>
<input type="range" min="20" max="18000" value="${params.cutoffFreq}" oninput="setParam('cutoffFreq', +this.value); this.nextElementSibling.textContent=this.value+'Hz'">
<span class="control-value">${params.cutoffFreq}Hz</span>
</div>
<div class="control-row">
<span class="control-label">Resonance<span class="info-tip" data-tip="Emphasizes frequencies near cutoff (0=flat, 15=squelchy)">?</span></span>
<input type="range" min="0.1" max="20" step="0.1" value="${params.resonance}" oninput="setParam('resonance', +this.value); this.nextElementSibling.textContent=parseFloat(this.value).toFixed(1)">
<span class="control-value">${params.resonance.toFixed(1)}</span>
</div>
`,
},
// Filter Envelope Module
{
id: 'filterEnv',
name: 'Filter Envelope',
icon: '🌊',
color: '#d97706',
controls: `
<div class="control-row">
<span class="control-label">Amount<span class="info-tip" data-tip="How much the filter opens on each note (0=none, 1=max)">?</span></span>
<input type="range" min="0" max="100" value="${params.envAmount*100}" oninput="setParam('envAmount', this.value/100); this.nextElementSibling.textContent=Math.round(this.value)+'%'">
<span class="control-value">${Math.round(params.envAmount*100)}%</span>
</div>
`,
},
// LFO Module
{
id: 'lfo',
name: 'LFO Modulation',
icon: '🔄',
color: '#dc2626',
controls: `
<div class="control-row">
<span class="control-label">Waveform</span>
<select id="ctrl-lfoWave" onchange="setParam('lfoWaveform', this.value)">
<option value="sine" selected>Sine — Smooth</option>
<option value="square">Square — Stepped</option>
<option value="sawtooth">Sawtooth — Ramping</option>
</select>
</div>
<div class="control-row">
<span class="control-label">Rate<span class="info-tip" data-tip="Speed of modulation in Hz (0.1=slow, 20=fast)">?</span></span>
<input type="range" min="1" max="200" value="${params.lfoRate*10}" oninput="setParam('lfoRate', this.value/10); this.nextElementSibling.textContent=(this.value/10).toFixed(1)+'Hz'">
<span class="control-value">${params.lfoRate.toFixed(1)}Hz</span>
</div>
<div class="control-row">
<span class="control-label">Depth<span class="info-tip" data-tip="Intensity of modulation (0=off, 1=max)">?</span></span>
<input type="range" min="0" max="100" value="${params.lfoDepth*100}" oninput="setParam('lfoDepth', this.value/100); this.nextElementSibling.textContent=Math.round(this.value)+'%'">
<span class="control-value">${Math.round(params.lfoDepth*100)}%</span>
</div>
<div class="control-row">
<span class="control-label">Target</span>
<select id="ctrl-lfoTarget" onchange="setParam('lfoTarget', this.value)">
<option value="pitch" selected>Pitch (Vibrato)</option>
<option value="filter">Filter (Auto-wah)</option>
<option value="amplitude">Amplitude (Tremolo)</option>
</select>
</div>
`,
},
// Delay Module
{
id: 'delay',
name: 'Delay Effect',
icon: '🔁',
color: '#7c3aed',
controls: `
<div class="control-row">
<span class="control-label">Time<span class="info-tip" data-tip="Echo spacing in seconds">?</span></span>
<input type="range" min="10" max="1000" value="${params.delayTime*1000}" oninput="setParam('delayTime', this.value/1000); this.nextElementSibling.textContent=(this.value/1000).toFixed(2)+'s'">
<span class="control-value">${params.delayTime.toFixed(2)}s</span>
</div>
<div class="control-row">
<span class="control-label">Feedback<span class="info-tip" data-tip="How many echoes (0=one repeat, 0.9=infinite)">?</span></span>
<input type="range" min="0" max="95" value="${params.delayFeedback*100}" oninput="setParam('delayFeedback', this.value/100); this.nextElementSibling.textContent=Math.round(this.value)+'%'">
<span class="control-value">${Math.round(params.delayFeedback*100)}%</span>
</div>
<div class="control-row">
<span class="control-label">Mix<span class="info-tip" data-tip="Blend of echoed signal (0=dry, 1=all echo)">?</span></span>
<input type="range" min="0" max="100" value="${params.delayMix*100}" oninput="setParam('delayMix', this.value/100); this.nextElementSibling.textContent=Math.round(this.value)+'%'">
<span class="control-value">${Math.round(params.delayMix*100)}%</span>
</div>
`,
},
];
grid.innerHTML = modules.map(m => `
<div class="synth-module ${unlockedModules.has(m.id) ? '' : 'locked'}" id="module-${m.id}">
<div class="module-header">
<div class="module-icon" style="background:${m.color}22;color:${m.color}">${m.icon}</div>
<div class="module-name">${m.name}</div>
</div>
${m.controls}
</div>
`).join('');
}
function unlockModule(id) {
if (unlockedModules.has(id)) return;
unlockedModules.add(id);
const el = document.getElementById(`module-${id}`);
if (el) {
el.classList.remove('locked');
el.classList.add('unlocked-anim');
setTimeout(() => el.classList.remove('unlocked-anim'), 1000);
}
}
function setParam(key, value) {
params[key] = value;
}
// ==================== KEYBOARD ====================
const noteNames = ['C4','C#4','D4','D#4','E4','F4','F#4','G4','G#4','A4','A#4','B4','C5'];
const isBlack = [false,true,false,true,false,false,true,false,true,false,true,false,false];
function buildKeyboard() {
const kb = document.getElementById('keyboard');
let whiteIdx = 0;
noteNames.forEach((note, i) => {
if (!isBlack[i]) {
const key = document.createElement('div');
key.className = 'key-white';
key.dataset.note = note;
key.addEventListener('mousedown', () => { initAudio(); noteOn(note); key.classList.add('active'); });
key.addEventListener('mouseup', () => { noteOff(note); key.classList.remove('active'); });
key.addEventListener('mouseleave', () => { if (key.classList.contains('active')) { noteOff(note); key.classList.remove('active'); } });
// Touch support
key.addEventListener('touchstart', e => { e.preventDefault(); initAudio(); noteOn(note); key.classList.add('active'); });
key.addEventListener('touchend', e => { e.preventDefault(); noteOff(note); key.classList.remove('active'); });
kb.appendChild(key);
whiteIdx++;
} else {
const key = document.createElement('div');
key.className = 'key-black';
key.dataset.note = note;
// Position: after the previous white key
const leftPos = whiteIdx * 48 - 16;
key.style.left = leftPos + 'px';
key.addEventListener('mousedown', e => { e.stopPropagation(); initAudio(); noteOn(note); key.classList.add('active'); });
key.addEventListener('mouseup', () => { noteOff(note); key.classList.remove('active'); });
key.addEventListener('mouseleave', () => { if (key.classList.contains('active')) { noteOff(note); key.classList.remove('active'); } });
key.addEventListener('touchstart', e => { e.preventDefault(); e.stopPropagation(); initAudio(); noteOn(note); key.classList.add('active'); });
key.addEventListener('touchend', e => { e.preventDefault(); noteOff(note); key.classList.remove('active'); });
kb.appendChild(key);
}
});
}
// Computer keyboard input
const pressedKeys = new Set();
document.addEventListener('keydown', e => {
if (e.repeat) return;
const k = e.key.toLowerCase();
if (keyMap[k] && !pressedKeys.has(k)) {
pressedKeys.add(k);
initAudio();
noteOn(keyMap[k]);
// Highlight visual key
const visKey = document.querySelector(`.keyboard [data-note="${keyMap[k]}"]`);
if (visKey) visKey.classList.add('active');
}
});
document.addEventListener('keyup', e => {
const k = e.key.toLowerCase();
if (keyMap[k]) {
pressedKeys.delete(k);
noteOff(keyMap[k]);
const visKey = document.querySelector(`.keyboard [data-note="${keyMap[k]}"]`);
if (visKey) visKey.classList.remove('active');
}
});
// ==================== WAVEFORM VISUALIZER ====================
function drawWaveform() {
const canvas = document.getElementById('waveCanvas');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth * 2;
canvas.height = canvas.offsetHeight * 2;
ctx.scale(2, 2);
function draw() {
requestAnimationFrame(draw);
const w = canvas.offsetWidth;
const h = canvas.offsetHeight;
ctx.fillStyle = '#0a0a18';
ctx.fillRect(0, 0, w, h);
// Grid lines
ctx.strokeStyle = '#1a1a3a';
ctx.lineWidth = 0.5;
for (let y = 0; y < h; y += 20) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
if (!analyser) return;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteTimeDomainData(dataArray);
// Glow effect
ctx.shadowColor = '#7c3aed';
ctx.shadowBlur = 10;
ctx.strokeStyle = '#a78bfa';
ctx.lineWidth = 2;
ctx.beginPath();
const sliceWidth = w / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = (v * h) / 2;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
ctx.stroke();
ctx.shadowBlur = 0;
// Center line
ctx.strokeStyle = '#1a1a3a';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, h/2);
ctx.lineTo(w, h/2);
ctx.stroke();
}
draw();
}
// ==================== MASTER VOLUME ====================
document.getElementById('masterVol').addEventListener('input', function() {
const v = this.value / 100;
document.getElementById('masterVolVal').textContent = Math.round(this.value) + '%';
if (masterGain) masterGain.gain.value = v;
});
// ==================== STEP MANAGEMENT ====================
function advanceStep() {
if (currentStep >= steps.length - 1) return;
const stepIds = ['oscillator', 'adsr', 'filter', 'filterEnv', 'lfo', 'delay', 'master'];
currentStep++;
// Unlock corresponding module
if (stepIds[currentStep]) {
unlockModule(stepIds[currentStep]);
}
renderTutorial();
renderSignalFlow();
}
// ==================== PRESETS ====================
const presets = [
{ name: '🎹 Warm Pad', params: { waveform:'sawtooth', attack:0.8, decay:0.5, sustain:0.7, release:1.5, cutoffFreq:2000, resonance:2, envAmount:0.3, lfoDepth:0.15, lfoRate:4, lfoTarget:'pitch', delayMix:0.2 } },
{ name: '🎸 Pluck Bass', params: { waveform:'square', attack:0.01, decay:0.3, sustain:0.1, release:0.2, cutoffFreq:800, resonance:6, envAmount:0.9, lfoDepth:0, delayMix:0 } },
{ name: '🌊 Wobble', params: { waveform:'sawtooth', attack:0.05, decay:0.2, sustain:0.8, release:0.3, cutoffFreq:1500, resonance:8, envAmount:0.4, lfoDepth:0.7, lfoRate:6, lfoTarget:'filter', delayMix:0 } },
{ name: '✨ Sparkle', params: { waveform:'sine', attack:0.01, decay:0.5, sustain:0.3, release:2, cutoffFreq:6000, resonance:1, envAmount:0, lfoDepth:0.1, lfoRate:3, lfoTarget:'amplitude', delayMix:0.4 } },
];
function applyPreset(idx) {
const p = presets[idx].params;
Object.assign(params, p);
renderModules();
}
// ==================== INIT ====================
function init() {
// Unlock first module immediately
unlockModule('oscillator');
buildKeyboard();
renderTutorial();
renderSignalFlow();
renderModules();
drawWaveform();
// Add presets to last step area after rendering
setTimeout(() => {
const panel = document.getElementById('tutorialPanel');
const presetDiv = document.createElement('div');
presetDiv.style.cssText = 'padding:16px;';
presetDiv.innerHTML = `
<div style="font-size:13px;color:var(--text-dim);margin-bottom:8px;">🎛️ Quick Presets</div>
<div class="preset-row">
${presets.map((p, i) => `<button class="preset-btn" onclick="applyPreset(${i})">${p.name}</button>`).join('')}
</div>
`;
panel.appendChild(presetDiv);
}, 100);
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"middle","sessionId":"d08b84cf-9cb7-4ca1-827b-f8dcb25a5d65","tokensIn":15704,"tokensOut":13686,"tokensTotal":29390,"turns":2,"toolCalls":1,"failedToolCalls":0,"timestamp":"2026-07-18T23:39:24.215Z"} -->