TutSynth
Qwen 3.6 27b MTP · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 42,194 bytes · SHA-256 784c043e1894
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Synth Playground — Learn Synthesis</title>
<style>
:root {
--bg: #0a0a0f;
--panel: #12121a;
--border: #1e1e2e;
--text: #c8c8d4;
--dim: #5a5a6e;
--accent: #7b5ea7;
--accent-bright: #a78bfa;
--green: #34d399;
--orange: #fb923c;
--red: #f87171;
--cyan: #22d3ee;
--yellow: #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(--border);
background: linear-gradient(180deg, #151520 0%, var(--bg) 100%);
}
header h1 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.5px;
background: linear-gradient(135deg, var(--accent-bright), var(--cyan));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
header p {
color: var(--dim);
font-size: 0.85rem;
margin-top: 4px;
}
#progress-bar-container {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
#progress-bar-container span {
font-size: 0.75rem;
color: var(--dim);
white-space: nowrap;
}
#progress-track {
flex: 1;
max-width: 400px;
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
#progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--accent-bright), var(--cyan));
border-radius: 3px;
transition: width 0.6s ease;
}
#main {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Visualizer */
#visualizer-section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
position: relative;
}
#visualizer-section canvas {
width: 100%;
height: 160px;
display: block;
}
.viz-label {
position: absolute;
top: 8px;
left: 12px;
font-size: 0.7rem;
color: var(--dim);
text-transform: uppercase;
letter-spacing: 1px;
}
/* Keyboard */
#keyboard-section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
overflow-x: auto;
}
#keyboard-hint {
text-align: center;
font-size: 0.75rem;
color: var(--dim);
margin-bottom: 10px;
}
#keyboard {
display: flex;
justify-content: center;
position: relative;
height: 120px;
user-select: none;
}
.key-white {
width: 44px;
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-bright), #c4b5fd) !important; }
.key-black {
width: 28px;
height: 76px;
background: linear-gradient(180deg, #222, #3a3a3a);
border-radius: 0 0 4px 4px;
cursor: pointer;
position: absolute;
z-index: 2;
margin-left: -14px;
transition: background 0.05s;
}
.key-black:hover { background: linear-gradient(180deg, #333, #4a4a6a); }
.key-black.active { background: linear-gradient(180deg, var(--accent), #7c3aed) !important; }
.key-label {
position: absolute;
bottom: 6px;
left: 50%;
transform: translateX(-50%);
font-size: 0.6rem;
color: #999;
pointer-events: none;
}
.key-black .key-label { color: #777; bottom: 4px; font-size: 0.55rem; }
/* Module Grid */
#modules-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.module {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
transition: border-color 0.3s, box-shadow 0.3s;
}
.module.locked { opacity: 0.45; pointer-events: none; filter: grayscale(0.6); }
.module.unlocked { border-color: var(--accent); box-shadow: 0 0 20px rgba(123,94,167,0.15); }
.module-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: rgba(255,255,255,0.02);
}
.module-header h3 {
font-size: 0.9rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.module-icon {
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
}
.module-step {
font-size: 0.65rem;
color: var(--dim);
background: rgba(255,255,255,0.05);
padding: 3px 8px;
border-radius: 10px;
}
.module-body { padding: 16px 18px; }
/* Lesson */
.lesson {
background: rgba(123,94,167,0.08);
border-left: 3px solid var(--accent-bright);
padding: 12px 14px;
border-radius: 0 8px 8px 0;
margin-bottom: 14px;
font-size: 0.8rem;
line-height: 1.6;
color: var(--text);
}
.lesson strong { color: var(--accent-bright); }
.lesson em { color: var(--cyan); font-style: normal; font-weight: 500; }
/* Controls */
.control-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.control-label {
width: 80px;
font-size: 0.75rem;
color: var(--dim);
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.control-value {
width: 48px;
text-align: right;
font-size: 0.75rem;
color: var(--accent-bright);
font-family: 'Courier New', monospace;
flex-shrink: 0;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 6px;
background: var(--border);
border-radius: 3px;
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: var(--accent-bright);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 8px rgba(167,139,250,0.4);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
background: var(--accent-bright);
border-radius: 50%;
cursor: pointer;
border: none;
}
/* Waveform selector */
.wave-selector {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.wave-btn {
flex: 1;
padding: 10px 6px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--dim);
cursor: pointer;
text-align: center;
transition: all 0.2s;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wave-btn:hover { border-color: var(--accent); color: var(--text); }
.wave-btn.active {
border-color: var(--accent-bright);
background: rgba(123,94,167,0.15);
color: var(--accent-bright);
}
.wave-canvas {
width: 100%;
height: 40px;
margin-bottom: 8px;
}
/* Learn button */
.learn-btn {
padding: 6px 14px;
background: linear-gradient(135deg, var(--accent), #6d28d9);
border: none;
border-radius: 6px;
color: white;
font-size: 0.7rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.learn-btn:hover { transform: scale(1.05); box-shadow: 0 0 16px rgba(123,94,167,0.4); }
/* Unlock button */
.unlock-btn {
padding: 8px 18px;
background: linear-gradient(135deg, var(--green), #059669);
border: none;
border-radius: 8px;
color: white;
font-size: 0.75rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
margin-top: 10px;
}
.unlock-btn:hover { transform: scale(1.05); box-shadow: 0 0 16px rgba(52,211,153,0.4); }
/* Preset buttons */
.preset-row {
display: flex;
gap: 6px;
margin-top: 10px;
flex-wrap: wrap;
}
.preset-btn {
padding: 5px 12px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--dim);
cursor: pointer;
font-size: 0.7rem;
transition: all 0.2s;
}
.preset-btn:hover { border-color: var(--cyan); color: var(--cyan); }
/* Notification */
#notification {
position: fixed;
top: 20px;
right: 20px;
background: linear-gradient(135deg, var(--green), #059669);
color: white;
padding: 14px 20px;
border-radius: 10px;
font-size: 0.85rem;
box-shadow: 0 8px 30px rgba(0,0,0,0.4);
transform: translateX(120%);
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
z-index: 100;
max-width: 300px;
}
#notification.show { transform: translateX(0); }
/* Responsive */
@media (max-width: 700px) {
#modules-grid { grid-template-columns: 1fr; }
.key-white { width: 32px; }
.key-black { width: 22px; margin-left: -11px; height: 64px; }
#keyboard { height: 90px; }
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<header>
<h1>⚡ Synth Playground</h1>
<p>Learn synthesis by doing — each module unlocks as you explore</p>
</header>
<div id="progress-bar-container">
<span>Your Progress</span>
<div id="progress-track"><div id="progress-fill"></div></div>
<span id="progress-text">0 / 5 modules</span>
</div>
<div id="main">
<!-- Visualizer -->
<div id="visualizer-section">
<canvas id="viz-canvas"></canvas>
<span class="viz-label">Waveform & Spectrum</span>
</div>
<!-- Keyboard -->
<div id="keyboard-section">
<div id="keyboard-hint">Click keys or use your keyboard (A–L for white keys, W/E/T/Y/U/O/P for black keys)</div>
<div id="keyboard"></div>
</div>
<!-- Modules -->
<div id="modules-grid">
<!-- Module 1: Oscillator -->
<div class="module unlocked" data-module="oscillator">
<div class="module-header">
<h3><span class="module-icon" style="background:rgba(167,139,250,0.2);color:var(--accent-bright)">〰</span> Oscillator</h3>
<span class="module-step">Step 1 of 5</span>
</div>
<div class="module-body">
<div class="lesson" id="osc-lesson">
The <strong>oscillator</strong> is the heart of a synth — it generates the raw sound. Different waveforms have different harmonic content, which shapes the timbre (tone color). Try each one and listen to how they differ!
</div>
<canvas class="wave-canvas" id="wave-preview"></canvas>
<div class="wave-selector">
<button class="wave-btn active" data-wave="sine">Sine</button>
<button class="wave-btn" data-wave="square">Square</button>
<button class="wave-btn" data-wave="sawtooth">Saw</button>
<button class="wave-btn" data-wave="triangle">Triangle</button>
</div>
<div class="control-row">
<span class="control-label">Octave</span>
<input type="range" id="octave-slider" min="-2" max="2" value="0" step="1">
<span class="control-value" id="octave-val">0</span>
</div>
<button class="unlock-btn" data-unlock="envelope">▶ Unlock Envelope →</button>
</div>
</div>
<!-- Module 2: ADSR Envelope -->
<div class="module locked" data-module="envelope">
<div class="module-header">
<h3><span class="module-icon" style="background:rgba(52,211,153,0.2);color:var(--green)">◈</span> ADSR Envelope</h3>
<span class="module-step">Step 2 of 5</span>
</div>
<div class="module-body">
<div class="lesson" id="env-lesson">
An <strong>ADSR envelope</strong> shapes how volume changes over time. <em>A</em>ttack = how fast sound rises, <em>D</em>ecay = how long to reach sustain level, <em>S</em>ustain = the held volume, <em>R</em>elease = how long it fades after you let go. Hold a key and tweak these!
</div>
<canvas class="wave-canvas" id="env-preview"></canvas>
<div class="control-row">
<span class="control-label">Attack</span>
<input type="range" id="attack-slider" min="0.01" max="2" value="0.05" step="0.01">
<span class="control-value" id="attack-val">0.05s</span>
</div>
<div class="control-row">
<span class="control-label">Decay</span>
<input type="range" id="decay-slider" min="0.01" max="2" value="0.3" step="0.01">
<span class="control-value" id="decay-val">0.30s</span>
</div>
<div class="control-row">
<span class="control-label">Sustain</span>
<input type="range" id="sustain-slider" min="0" max="1" value="0.7" step="0.01">
<span class="control-value" id="sustain-val">0.70</span>
</div>
<div class="control-row">
<span class="control-label">Release</span>
<input type="range" id="release-slider" min="0.01" max="3" value="0.5" step="0.01">
<span class="control-value" id="release-val">0.50s</span>
</div>
<button class="unlock-btn" data-unlock="filter">▶ Unlock Filter →</button>
</div>
</div>
<!-- Module 3: Filter -->
<div class="module locked" data-module="filter">
<div class="module-header">
<h3><span class="module-icon" style="background:rgba(251,146,60,0.2);color:var(--orange)">◉</span> Filter</h3>
<span class="module-step">Step 3 of 5</span>
</div>
<div class="module-body">
<div class="lesson" id="filter-lesson">
A <strong>filter</strong> carves away frequencies from the sound. A <em>low-pass filter (LPF)</em> lets low frequencies through and cuts highs — like turning down the treble. <strong>Cutoff</strong> sets where it starts cutting, <strong>resonance (Q)</strong> boosts the cutoff frequency for a "squelchy" sound. Try with a sawtooth wave!
</div>
<div class="control-row">
<span class="control-label">Filter</span>
<input type="range" id="filter-slider" min="20" max="15000" value="8000" step="1">
<span class="control-value" id="filter-val">8.0k</span>
</div>
<div class="control-row">
<span class="control-label">Resonance</span>
<input type="range" id="resonance-slider" min="0" max="25" value="1" step="0.1">
<span class="control-value" id="resonance-val">1.0</span>
</div>
<div class="wave-selector" style="margin-top:8px">
<button class="wave-btn active" data-filter="lowpass">Low-Pass</button>
<button class="wave-btn" data-filter="highpass">High-Pass</button>
<button class="wave-btn" data-filter="bandpass">Band-Pass</button>
</div>
<button class="unlock-btn" data-unlock="lfo">▶ Unlock LFO →</button>
</div>
</div>
<!-- Module 4: LFO -->
<div class="module locked" data-module="lfo">
<div class="module-header">
<h3><span class="module-icon" style="background:rgba(34,211,238,0.2);color:var(--cyan)">∿</span> LFO (Vibrato)</h3>
<span class="module-step">Step 4 of 5</span>
</div>
<div class="module-body">
<div class="lesson" id="lfo-lesson">
An <strong>LFO</strong> (Low Frequency Oscillator) modulates another parameter at a slow rate. Here it modulates pitch, creating <em>vibrato</em> — the subtle pitch wobble singers use. <strong>Rate</strong> controls speed of oscillation, <strong>Depth</strong> controls how much pitch bends. Hold a note and sweep the depth!
</div>
<div class="control-row">
<span class="control-label">Rate</span>
<input type="range" id="lfo-rate-slider" min="0.1" max="20" value="5" step="0.1">
<span class="control-value" id="lfo-rate-val">5.0 Hz</span>
</div>
<div class="control-row">
<span class="control-label">Depth</span>
<input type="range" id="lfo-depth-slider" min="0" max="100" value="20" step="1">
<span class="control-value" id="lfo-depth-val">20 ct</span>
</div>
<button class="unlock-btn" data-unlock="effects">▶ Unlock Effects →</button>
</div>
</div>
<!-- Module 5: Effects -->
<div class="module locked" data-module="effects">
<div class="module-header">
<h3><span class="module-icon" style="background:rgba(251,191,36,0.2);color:var(--yellow)">✦</span> Effects (Delay)</h3>
<span class="module-step">Step 5 of 5</span>
</div>
<div class="module-body">
<div class="lesson" id="fx-lesson">
<strong>Effects</strong> process the sound after it leaves the synth. A <em>delay</em> echoes your signal back at you. <strong>Time</strong> sets how long between repeats, <strong>Feedback</strong> controls how many repeats (higher = more echoes), and <strong>Mix</strong> blends dry vs wet signal. Try a plucky envelope with delay for ambient textures!
</div>
<div class="control-row">
<span class="control-label">Delay Time</span>
<input type="range" id="delay-time-slider" min="0.01" max="1" value="0.3" step="0.01">
<span class="control-value" id="delay-time-val">0.30s</span>
</div>
<div class="control-row">
<span class="control-label">Feedback</span>
<input type="range" id="delay-fb-slider" min="0" max="0.95" value="0.4" step="0.01">
<span class="control-value" id="delay-fb-val">0.40</span>
</div>
<div class="control-row">
<span class="control-label">Mix</span>
<input type="range" id="delay-mix-slider" min="0" max="1" value="0.3" step="0.01">
<span class="control-value" id="delay-mix-val">0.30</span>
</div>
</div>
</div>
</div>
<!-- Presets -->
<div style="text-align:center; padding: 8px;">
<span style="font-size:0.75rem;color:var(--dim);margin-right:12px">Presets:</span>
<button class="preset-btn" data-preset="pad">Warm Pad</button>
<button class="preset-btn" data-preset="pluck">Pluck</button>
<button class="preset-btn" data-preset="bass">Bass</button>
<button class="preset-btn" data-preset="lead">Lead</button>
<button class="preset-btn" data-preset="organ">Organ</button>
</div>
</div>
<div id="notification"></div>
<script>
(function() {
'use strict';
// ─── Audio Context & State ──────────────────────────────
let audioCtx = null;
let masterGain, analyserNode, delayNode, feedbackGain, dryGain, wetGain;
let filterNode;
const activeVoices = {};
let currentWaveform = 'sine';
let octaveOffset = 0;
let unlockedModules = new Set(['oscillator']);
// ADSR defaults
const adsr = { attack: 0.05, decay: 0.3, sustain: 0.7, release: 0.5 };
// Filter defaults
const filterState = { type: 'lowpass', cutoff: 8000, resonance: 1 };
// LFO defaults
const lfoState = { rate: 5, depth: 20 };
// Delay defaults
const delayState = { time: 0.3, feedback: 0.4, mix: 0.3 };
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Master chain: voices → filter → delay → analyser → masterGain → destination
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.5;
analyserNode = audioCtx.createAnalyser();
analyserNode.fftSize = 2048;
// Delay effect chain
delayNode = audioCtx.createDelay(2);
feedbackGain = audioCtx.createGain();
dryGain = audioCtx.createGain();
wetGain = audioCtx.createGain();
delayNode.delayTime.value = delayState.time;
feedbackGain.gain.value = delayState.feedback;
dryGain.gain.value = 1 - delayState.mix * 0.5;
wetGain.gain.value = delayState.mix;
// Routing: source → filter → [dry + delay] → analyser → master → out
filterNode = audioCtx.createBiquadFilter();
filterNode.type = filterState.type;
filterNode.frequency.value = filterState.cutoff;
filterNode.Q.value = filterState.resonance;
// Dry path: filter → dryGain → analyser
filterNode.connect(dryGain);
dryGain.connect(analyserNode);
// Wet path: filter → delayNode → feedbackGain → delayNode (loop)
filterNode.connect(delayNode);
delayNode.connect(feedbackGain);
feedbackGain.connect(delayNode);
delayNode.connect(wetGain);
wetGain.connect(analyserNode);
analyserNode.connect(masterGain);
masterGain.connect(audioCtx.destination);
}
// ─── Note Frequencies ──────────────────────────────────
const noteNames = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
function noteFreq(note, octave) {
const midi = (octave + 1) * 12 + note;
return 440 * Math.pow(2, (midi - 69) / 12);
}
// ─── Keyboard Layout ───────────────────────────────────
const keyboardMap = [
{ key: 'a', note: 0, name: 'C', type: 'white' },
{ key: 'w', note: 1, name: 'C#', type: 'black' },
{ key: 's', note: 2, name: 'D', type: 'white' },
{ key: 'e', note: 3, name: 'D#', type: 'black' },
{ key: 'd', note: 4, name: 'E', type: 'white' },
{ key: 'f', note: 5, name: 'F', type: 'white' },
{ key: 't', note: 6, name: 'F#', type: 'black' },
{ key: 'g', note: 7, name: 'G', type: 'white' },
{ key: 'y', note: 8, name: 'G#', type: 'black' },
{ key: 'h', note: 9, name: 'A', type: 'white' },
{ key: 'u', note: 10, name: 'A#', type: 'black' },
{ key: 'j', note: 11, name: 'B', type: 'white' },
{ key: 'k', note: 12, name: 'C', type: 'white' },
{ key: 'o', note: 13, name: 'C#', type: 'black' },
{ key: 'l', note: 14, name: 'D', type: 'white' },
{ key: 'p', note: 15, name: 'D#', type: 'black' },
];
function buildKeyboard() {
const kb = document.getElementById('keyboard');
kb.innerHTML = '';
let whiteIndex = 0;
keyboardMap.forEach((k) => {
const el = document.createElement('div');
el.className = `key-${k.type}`;
el.dataset.note = k.note + 4; // base octave 4
el.dataset.key = k.key;
if (k.type === 'white') {
const label = document.createElement('span');
label.className = 'key-label';
label.textContent = `${k.name}${4}`;
el.appendChild(label);
whiteIndex++;
} else {
// Position black keys between whites
const leftPos = (whiteIndex * 44) - 14;
el.style.left = leftPos + 'px';
const label = document.createElement('span');
label.className = 'key-label';
label.textContent = k.key.toUpperCase();
el.appendChild(label);
}
el.addEventListener('mousedown', (e) => { e.preventDefault(); noteOn(k.note + 4, el); });
el.addEventListener('mouseup', () => noteOff(k.note + 4));
el.addEventListener('mouseleave', () => noteOff(k.note + 4));
el.addEventListener('touchstart', (e) => { e.preventDefault(); noteOn(k.note + 4, el); });
el.addEventListener('touchend', () => noteOff(k.note + 4));
kb.appendChild(el);
});
}
// ─── Voice Management ──────────────────────────────────
function noteOn(noteMidi, keyEl) {
initAudio();
if (audioCtx.state === 'suspended') audioCtx.resume();
const id = `voice-${noteMidi}`;
if (activeVoices[id]) return; // already playing
const freq = noteFreq(noteMidi % 12, Math.floor(noteMidi / 12) + octaveOffset);
const osc = audioCtx.createOscillator();
osc.type = currentWaveform;
osc.frequency.value = freq;
const gainNode = audioCtx.createGain();
gainNode.gain.value = 0;
// LFO for vibrato
let lfoNode = null, lfoGainNode = null;
if (unlockedModules.has('lfo') && lfoState.depth > 0) {
lfoNode = audioCtx.createOscillator();
lfoGainNode = audioCtx.createGain();
lfoNode.frequency.value = lfoState.rate;
lfoGainNode.gain.value = lfoState.depth;
lfoNode.connect(lfoGainNode);
lfoGainNode.connect(osc.frequency);
lfoNode.start();
}
// ADSR envelope
const now = audioCtx.currentTime;
gainNode.gain.setValueAtTime(0, now);
gainNode.gain.linearRampToValueAtTime(0.6, now + adsr.attack);
gainNode.gain.linearRampToValueAtTime(adsr.sustain * 0.6, now + adsr.attack + adsr.decay);
osc.connect(gainNode);
gainNode.connect(filterNode);
osc.start();
activeVoices[id] = { osc, gain: gainNode, lfo: lfoNode, lfoGain: lfoGainNode };
if (keyEl) keyEl.classList.add('active');
}
function noteOff(noteMidi) {
const id = `voice-${noteMidi}`;
const voice = activeVoices[id];
if (!voice) return;
const now = audioCtx.currentTime;
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setValueAtTime(voice.gain.gain.value, now);
voice.gain.gain.linearRampToValueAtTime(0, now + adsr.release);
const stopTime = now + adsr.release + 0.05;
voice.osc.stop(stopTime);
if (voice.lfo) voice.lfo.stop(stopTime);
// Disconnect after release
setTimeout(() => {
try {
voice.gain.disconnect();
voice.osc.disconnect();
if (voice.lfoGain) voice.lfoGain.disconnect();
if (voice.lfo) voice.lfo.disconnect();
} catch(e) {}
}, (adsr.release + 0.1) * 1000);
delete activeVoices[id];
// Remove visual active state
const keyEl = document.querySelector(`[data-note="${noteMidi}"]`);
if (keyEl) keyEl.classList.remove('active');
}
// ─── Keyboard Input ────────────────────────────────────
const pressedKeys = {};
document.addEventListener('keydown', (e) => {
if (e.repeat || e.target.tagName === 'INPUT') return;
const k = keyboardMap.find(m => m.key === e.key.toLowerCase());
if (!k) return;
pressedKeys[e.key] = true;
noteOn(k.note + 4, document.querySelector(`[data-key="${e.key}"]`));
});
document.addEventListener('keyup', (e) => {
const k = keyboardMap.find(m => m.key === e.key.toLowerCase());
if (!k) return;
pressedKeys[e.key] = false;
noteOff(k.note + 4);
});
// ─── Waveform Preview Canvas ───────────────────────────
function drawWavePreview() {
const canvas = document.getElementById('wave-preview');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width = canvas.offsetWidth * 2;
const h = canvas.height = canvas.offsetHeight * 2;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = '#a78bfa';
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = 0; x < w; x++) {
const t = (x / w) * Math.PI * 4;
let y = 0;
switch(currentWaveform) {
case 'sine': y = Math.sin(t); break;
case 'square': y = Math.sin(t) >= 0 ? 1 : -1; break;
case 'sawtooth': y = 2 * ((t / (2*Math.PI)) % 1) - 1; break;
case 'triangle': y = 2 * Math.abs(2 * ((t / (2*Math.PI)) % 1) - 1) - 1; break;
}
const py = h/2 - y * (h/2 - 4);
x === 0 ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
}
ctx.stroke();
}
// ─── ADSR Preview Canvas ───────────────────────────────
function drawEnvPreview() {
const canvas = document.getElementById('env-preview');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width = canvas.offsetWidth * 2;
const h = canvas.height = canvas.offsetHeight * 2;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = '#34d399';
ctx.lineWidth = 2;
ctx.beginPath();
const totalDur = adsr.attack + adsr.decay + 1.5 + adsr.release;
let x = 0;
// Attack
const ax = (adsr.attack / totalDur) * w;
ctx.moveTo(0, h - 4);
ctx.lineTo(ax, 4);
// Decay
const dx = ax + (adsr.decay / totalDur) * w;
ctx.lineTo(dx, h - 4 - adsr.sustain * (h - 8));
// Sustain hold
const sx = dx + (1.5 / totalDur) * w;
ctx.lineTo(sx, h - 4 - adsr.sustain * (h - 8));
// Release
ctx.lineTo(w, h - 4);
ctx.stroke();
// Labels
ctx.fillStyle = '#34d399';
ctx.font = '16px sans-serif';
ctx.fillText('A', ax/2 - 4, h - 8);
ctx.fillText('D', (ax+dx)/2 - 4, h - 8);
ctx.fillText('S', (sx+dx)/2 - 4, h - 8);
ctx.fillText('R', sx + ((w-sx)/2) - 4, h - 8);
}
// ─── Main Visualizer ───────────────────────────────────
const vizCanvas = document.getElementById('viz-canvas');
const vizCtx = vizCanvas.getContext('2d');
function resizeViz() {
vizCanvas.width = vizCanvas.offsetWidth * (window.devicePixelRatio || 1);
vizCanvas.height = vizCanvas.offsetHeight * (window.devicePixelRatio || 1);
}
window.addEventListener('resize', () => { resizeViz(); drawWavePreview(); });
function drawVisualizer() {
requestAnimationFrame(drawVisualizer);
const w = vizCanvas.width;
const h = vizCanvas.height;
vizCtx.fillStyle = 'rgba(10,10,15,0.3)';
vizCtx.fillRect(0, 0, w, h);
if (!analyserNode) return;
// Waveform
const bufLen = analyserNode.fftSize;
const timeData = new Uint8Array(bufLen);
analyserNode.getByteTimeDomainData(timeData);
vizCtx.strokeStyle = '#a78bfa';
vizCtx.lineWidth = 2;
vizCtx.beginPath();
const sliceW = w / bufLen;
for (let i = 0; i < bufLen; i++) {
const v = timeData[i] / 128.0;
const y = (v * h) / 2;
i === 0 ? vizCtx.moveTo(0, y) : vizCtx.lineTo(i * sliceW, y);
}
vizCtx.stroke();
// Spectrum bars
const freqData = new Uint8Array(bufLen);
analyserNode.getByteFrequencyData(freqData);
const barCount = 64;
const barW = w / barCount;
for (let i = 0; i < barCount; i++) {
const idx = Math.floor(i * bufLen / barCount / 2);
const val = freqData[idx] / 255;
const barH = val * h * 0.4;
const hue = 260 + (i / barCount) * 80;
vizCtx.fillStyle = `hsla(${hue}, 70%, 60%, ${0.15 + val * 0.3})`;
vizCtx.fillRect(i * barW, h - barH, barW - 1, barH);
}
}
// ─── UI Wiring ─────────────────────────────────────────
// Waveform buttons
document.querySelectorAll('.wave-btn[data-wave]').forEach(btn => {
btn.addEventListener('click', () => {
currentWaveform = btn.dataset.wave;
document.querySelectorAll('.wave-btn[data-wave]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
drawWavePreview();
});
});
// Filter type buttons
document.querySelectorAll('.wave-btn[data-filter]').forEach(btn => {
btn.addEventListener('click', () => {
filterState.type = btn.dataset.filter;
if (filterNode) filterNode.type = filterState.type;
document.querySelectorAll('.wave-btn[data-filter]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Octave slider
const octaveSlider = document.getElementById('octave-slider');
octaveSlider.addEventListener('input', () => {
octaveOffset = parseInt(octaveSlider.value);
document.getElementById('octave-val').textContent = (octaveOffset >= 0 ? '+' : '') + octaveOffset;
});
// ADSR sliders
['attack','decay','sustain','release'].forEach(param => {
const slider = document.getElementById(`${param}-slider`);
const valEl = document.getElementById(`${param}-val`);
slider.addEventListener('input', () => {
adsr[param] = parseFloat(slider.value);
if (['attack','decay','release'].includes(param)) {
valEl.textContent = adsr[param].toFixed(2) + 's';
} else {
valEl.textContent = adsr[param].toFixed(2);
}
drawEnvPreview();
});
});
// Filter sliders
const filterSlider = document.getElementById('filter-slider');
filterSlider.addEventListener('input', () => {
filterState.cutoff = parseFloat(filterSlider.value);
if (filterNode) filterNode.frequency.value = filterState.cutoff;
const v = filterState.cutoff;
document.getElementById('filter-val').textContent = v >= 1000 ? (v/1000).toFixed(1)+'k' : Math.round(v);
});
const resonanceSlider = document.getElementById('resonance-slider');
resonanceSlider.addEventListener('input', () => {
filterState.resonance = parseFloat(resonanceSlider.value);
if (filterNode) filterNode.Q.value = filterState.resonance;
document.getElementById('resonance-val').textContent = filterState.resonance.toFixed(1);
});
// LFO sliders
const lfoRateSlider = document.getElementById('lfo-rate-slider');
lfoRateSlider.addEventListener('input', () => {
lfoState.rate = parseFloat(lfoRateSlider.value);
document.getElementById('lfo-rate-val').textContent = lfoState.rate.toFixed(1) + ' Hz';
});
const lfoDepthSlider = document.getElementById('lfo-depth-slider');
lfoDepthSlider.addEventListener('input', () => {
lfoState.depth = parseFloat(lfoDepthSlider.value);
document.getElementById('lfo-depth-val').textContent = Math.round(lfoState.depth) + ' ct';
});
// Delay sliders
const delayTimeSlider = document.getElementById('delay-time-slider');
delayTimeSlider.addEventListener('input', () => {
delayState.time = parseFloat(delayTimeSlider.value);
if (delayNode) delayNode.delayTime.value = delayState.time;
document.getElementById('delay-time-val').textContent = delayState.time.toFixed(2) + 's';
});
const delayFbSlider = document.getElementById('delay-fb-slider');
delayFbSlider.addEventListener('input', () => {
delayState.feedback = parseFloat(delayFbSlider.value);
if (feedbackGain) feedbackGain.gain.value = delayState.feedback;
document.getElementById('delay-fb-val').textContent = delayState.feedback.toFixed(2);
});
const delayMixSlider = document.getElementById('delay-mix-slider');
delayMixSlider.addEventListener('input', () => {
delayState.mix = parseFloat(delayMixSlider.value);
if (wetGain) wetGain.gain.value = delayState.mix;
if (dryGain) dryGain.gain.value = 1 - delayState.mix * 0.5;
document.getElementById('delay-mix-val').textContent = delayState.mix.toFixed(2);
});
// ─── Module Unlocking ──────────────────────────────────
const moduleOrder = ['oscillator','envelope','filter','lfo','effects'];
function unlockModule(name) {
if (unlockedModules.has(name)) return;
unlockedModules.add(name);
const el = document.querySelector(`[data-module="${name}"]`);
if (el) {
el.classList.remove('locked');
el.classList.add('unlocked');
}
updateProgress();
showNotification(`🎵 ${name.charAt(0).toUpperCase() + name.slice(1)} module unlocked!`);
}
document.querySelectorAll('.unlock-btn').forEach(btn => {
btn.addEventListener('click', () => {
const target = btn.dataset.unlock;
unlockModule(target);
// Scroll to the new module
setTimeout(() => {
const modEl = document.querySelector(`[data-module="${target}"]`);
if (modEl) modEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 300);
});
});
function updateProgress() {
const count = unlockedModules.size;
document.getElementById('progress-fill').style.width = ((count / moduleOrder.length) * 100) + '%';
document.getElementById('progress-text').textContent = `${count} / ${moduleOrder.length} modules`;
}
function showNotification(msg) {
const el = document.getElementById('notification');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 3000);
}
// ─── Presets ───────────────────────────────────────────
const presets = {
pad: {
wave: 'sawtooth', octave: 0,
attack: 0.8, decay: 1.5, sustain: 0.6, release: 2,
filterType: 'lowpass', cutoff: 3000, resonance: 2,
lfoRate: 4, lfoDepth: 8,
delayTime: 0.4, delayFb: 0.5, delayMix: 0.4
},
pluck: {
wave: 'triangle', octave: 1,
attack: 0.01, decay: 0.3, sustain: 0.1, release: 0.2,
filterType: 'lowpass', cutoff: 5000, resonance: 4,
lfoRate: 6, lfoDepth: 5,
delayTime: 0.25, delayFb: 0.3, delayMix: 0.2
},
bass: {
wave: 'sawtooth', octave: -1,
attack: 0.02, decay: 0.2, sustain: 0.8, release: 0.1,
filterType: 'lowpass', cutoff: 600, resonance: 8,
lfoRate: 5, lfoDepth: 3,
delayTime: 0.1, delayFb: 0.2, delayMix: 0.1
},
lead: {
wave: 'square', octave: 0,
attack: 0.01, decay: 0.5, sustain: 0.7, release: 0.3,
filterType: 'lowpass', cutoff: 6000, resonance: 3,
lfoRate: 5.5, lfoDepth: 25,
delayTime: 0.35, delayFb: 0.45, delayMix: 0.35
},
organ: {
wave: 'sine', octave: 0,
attack: 0.01, decay: 0.01, sustain: 1.0, release: 0.05,
filterType: 'lowpass', cutoff: 12000, resonance: 0.5,
lfoRate: 3, lfoDepth: 0,
delayTime: 0.2, delayFb: 0.15, delayMix: 0.15
}
};
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
const p = presets[btn.dataset.preset];
if (!p) return;
// Apply waveform
currentWaveform = p.wave;
document.querySelectorAll('.wave-btn[data-wave]').forEach(b => {
b.classList.toggle('active', b.dataset.wave === p.wave);
});
// Octave
octaveOffset = p.octave;
octaveSlider.value = p.octave;
document.getElementById('octave-val').textContent = (p.octave >= 0 ? '+' : '') + p.octave;
// ADSR
adsr.attack = p.attack; adsr.decay = p.decay; adsr.sustain = p.sustain; adsr.release = p.release;
document.getElementById('attack-slider').value = p.attack;
document.getElementById('decay-slider').value = p.decay;
document.getElementById('sustain-slider').value = p.sustain;
document.getElementById('release-slider').value = p.release;
document.getElementById('attack-val').textContent = p.attack.toFixed(2) + 's';
document.getElementById('decay-val').textContent = p.decay.toFixed(2) + 's';
document.getElementById('sustain-val').textContent = p.sustain.toFixed(2);
document.getElementById('release-val').textContent = p.release.toFixed(2) + 's';
// Filter
filterState.type = p.filterType;
filterState.cutoff = p.cutoff;
filterState.resonance = p.resonance;
if (filterNode) { filterNode.type = p.filterType; filterNode.frequency.value = p.cutoff; filterNode.Q.value = p.resonance; }
document.getElementById('filter-slider').value = p.cutoff;
document.getElementById('resonance-slider').value = p.resonance;
const cv = p.cutoff >= 1000 ? (p.cutoff/1000).toFixed(1)+'k' : Math.round(p.cutoff);
document.getElementById('filter-val').textContent = cv;
document.getElementById('resonance-val').textContent = p.resonance.toFixed(1);
document.querySelectorAll('.wave-btn[data-filter]').forEach(b => {
b.classList.toggle('active', b.dataset.filter === p.filterType);
});
// LFO
lfoState.rate = p.lfoRate; lfoState.depth = p.lfoDepth;
document.getElementById('lfo-rate-slider').value = p.lfoRate;
document.getElementById('lfo-depth-slider').value = p.lfoDepth;
document.getElementById('lfo-rate-val').textContent = p.lfoRate.toFixed(1) + ' Hz';
document.getElementById('lfo-depth-val').textContent = Math.round(p.lfoDepth) + ' ct';
// Delay
delayState.time = p.delayTime; delayState.feedback = p.delayFb; delayState.mix = p.delayMix;
if (delayNode) delayNode.delayTime.value = p.delayTime;
if (feedbackGain) feedbackGain.gain.value = p.delayFb;
if (wetGain) wetGain.gain.value = p.delayMix;
if (dryGain) dryGain.gain.value = 1 - p.delayMix * 0.5;
document.getElementById('delay-time-slider').value = p.delayTime;
document.getElementById('delay-fb-slider').value = p.delayFb;
document.getElementById('delay-mix-slider').value = p.delayMix;
document.getElementById('delay-time-val').textContent = p.delayTime.toFixed(2) + 's';
document.getElementById('delay-fb-val').textContent = p.delayFb.toFixed(2);
document.getElementById('delay-mix-val').textContent = p.delayMix.toFixed(2);
drawWavePreview();
drawEnvPreview();
showNotification(`🎹 "${btn.dataset.preset.charAt(0).toUpperCase() + btn.dataset.preset.slice(1)}" preset loaded`);
});
});
// ─── Init ──────────────────────────────────────────────
buildKeyboard();
resizeViz();
drawWavePreview();
drawEnvPreview();
updateProgress();
requestAnimationFrame(drawVisualizer);
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"43bfcdac-fc89-435d-9efc-c031805bfcbc","tokensIn":77760,"tokensOut":14658,"tokensTotal":92418,"turns":5,"toolCalls":4,"failedToolCalls":0,"timestamp":"2026-07-17T22:35:32.012Z"} -->