TutSynth
muse spark 1.2 · basic

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 52,371 bytes · SHA-256 2e1532f372f9
<!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 by Building Sound</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Space+Grotesk:wght@400;500;700&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
<style>
:root{
--bg:#080a0f;
--panel:#13161f;
--panel2:#1a1e2b;
--border:#262c3f;
--accent:#00ffd1;
--accent2:#ff3b82;
--accent3:#7c5cff;
--text:#e6e8ee;
--muted:#8a90a8;
--yellow:#ffd028;
--radius:16px;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--text);font-family:'Space Grotesk',system-ui,sans-serif;overflow-x:hidden}
button{font:inherit}
a{color:inherit}
.topbar{
height:56px;display:flex;align-items:center;justify-content:space-between;
padding:0 18px;border-bottom:1px solid var(--border);background:rgba(8,10,15,0.9);backdrop-filter:blur(10px);
position:sticky;top:0;z-index:50
}
.logo{display:flex;align-items:center;gap:12px;font-weight:700;letter-spacing:0.04em}
.logo-mark{width:32px;height:32px;background:var(--accent);color:#000;display:grid;place-items:center;font-family:'JetBrains Mono',monospace;font-weight:700;border-radius:6px}
.logo span{font-family:'JetBrains Mono',monospace;font-size:13px}
.topbar-right{display:flex;align-items:center;gap:14px}
.badge{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.08em;background:#1a1e2b;border:1px solid var(--border);padding:6px 10px;border-radius:999px;color:var(--muted)}
.power-btn{
width:44px;height:26px;background:#1a1e2b;border:1px solid var(--border);border-radius:999px;position:relative;cursor:pointer;display:flex;align-items:center;padding:2px
}
.power-knob{width:20px;height:20px;background:var(--accent);border-radius:50%;transition:0.25s;box-shadow:0 0 10px rgba(0,255,209,0.5)}
.power-btn.on .power-knob{transform:translateX(18px);background:var(--accent)}
.power-btn.off .power-knob{transform:translateX(0);background:#3a4058}
.meter{width:90px;height:6px;background:#1a1e2b;border-radius:999px;overflow:hidden;border:1px solid var(--border)}
.meter-fill{height:100%;width:36%;background:linear-gradient(90deg,var(--accent),var(--yellow));}
.progress-wrap{
display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--border);background:#0d0f16;overflow-x:auto
}
.step{
display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:999px;border:1px solid var(--border);background:var(--panel);cursor:pointer;white-space:nowrap;transition:0.2s;opacity:0.5
}
.step.active{opacity:1;border-color:var(--accent);background:#11141e;box-shadow:0 0 0 1px rgba(0,255,209,0.2)}
.step.done{opacity:1;border-color:#2a3350}
.step.done .step-num{background:var(--accent);color:#000}
.step-num{width:26px;height:26px;border-radius:50%;background:#1e2335;color:var(--muted);display:grid;place-items:center;font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700}
.step.active .step-num{background:var(--accent);color:#000}
.step-label{font-size:13px;font-weight:600}
.step-sub{font-size:11px;color:var(--muted);font-family:'JetBrains Mono',monospace}
.arrow{color:var(--muted);opacity:0.4}
.layout{
display:grid;grid-template-columns:420px 1fr;min-height:calc(100vh - 112px)
}
@media(max-width:980px){.layout{grid-template-columns:1fr}}
.lesson{
border-right:1px solid var(--border);background:linear-gradient(180deg,#0f121b 0%, #0a0c12 100%);padding:18px;display:flex;flex-direction:column;gap:16px;
}
.lesson-card{
background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;
}
.lesson-head{padding:18px 18px 0 18px}
.kicker{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.14em;color:var(--accent);font-weight:700}
.lesson-title{font-family:'Instrument Serif',serif;font-size:30px;line-height:0.95;margin:6px 0 8px}
.lesson-desc{color:var(--muted);font-size:13.5px;line-height:1.55}
.lesson-viz{margin:14px 18px;background:#0a0d14;border:1px solid var(--border);border-radius:12px;padding:12px;position:relative;overflow:hidden}
.lesson-viz canvas{width:100%;height:110px;display:block}
.viz-label{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.1em;color:var(--muted);display:flex;justify-content:space-between;margin-bottom:6px}
.tasks{background:#0a0d14;border:1px solid var(--border);border-radius:12px;padding:14px;margin:0 18px 14px}
.task{ display:flex;gap:10px;align-items:flex-start;font-size:13px;line-height:1.4;padding:8px 0;border-bottom:1px dashed #23293e}
.task:last-child{border:none}
.task input{accent-color:var(--accent);margin-top:3px}
.task b{color:var(--text)}
.btn-primary{
background:var(--accent);color:#000;border:none;padding:12px 16px;border-radius:999px;font-weight:700;font-size:13px;cursor:pointer;width:100%;display:flex;justify-content:center;align-items:center;gap:8px;
}
.btn-ghost{background:transparent;border:1px solid var(--border);color:var(--text);padding:10px 14px;border-radius:999px;font-size:12px;font-family:'JetBrains Mono',monospace;cursor:pointer}
.lesson-foot{padding:0 18px 18px;display:flex;gap:10px}
.hint{font-size:11px;color:var(--muted);font-family:'JetBrains Mono',monospace;text-align:center;padding:0 18px 6px}
.synth{
padding:18px;background:
radial-gradient(600px 400px at 70% 0%, rgba(124,92,255,0.12), transparent 60%),
radial-gradient(500px 300px at 0% 0%, rgba(0,255,209,0.08), transparent 60%),
var(--bg);
}
.oscilloscope{
background:#0e111a;border:1px solid var(--border);border-radius:16px;padding:12px;margin-bottom:14px;position:relative;overflow:hidden
}
.scope-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.scope-title{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.1em;color:var(--muted)}
.scope-actions{display:flex;gap:8px}
.pill{font-family:'JetBrains Mono',monospace;font-size:10px;padding:5px 8px;border-radius:999px;border:1px solid var(--border);background:#151a27;color:var(--muted)}
.pill.live{color:var(--accent);border-color:rgba(0,255,209,0.3);background:rgba(0,255,209,0.08)}
.scope-canvas-wrap{background:#070a11;border:1px solid #1c2132;border-radius:12px;position:relative;height:148px;overflow:hidden}
#scope{width:100%;height:100%;display:block}
.scope-grid{position:absolute;inset:0;background:repeating-linear-gradient(to right, transparent 0 39px, rgba(255,255,255,0.03) 40px), repeating-linear-gradient(to bottom, transparent 0 23px, rgba(255,255,255,0.03) 24px);pointer-events:none}
.scope-overlay{position:absolute;bottom:10px;left:10px;display:flex;gap:6px}
.tag{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.6);border:1px solid rgba(255,255,255,0.1);padding:4px 6px;border-radius:6px;color:#b9c0d9;backdrop-filter:blur(6px)}
.modules{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@media(max-width:700px){.modules{grid-template-columns:1fr}}
.module{
background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:14px;position:relative;overflow:hidden;transition:0.2s
}
.module.locked{opacity:0.45;filter:grayscale(0.2)}
.module.locked::after{content:"LOCKED — complete previous lesson";position:absolute;inset:0;background:rgba(8,10,15,0.55);backdrop-filter:blur(1px);display:grid;place-items:center;font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.08em;color:#fff;text-align:center;padding:20px}
.module-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.module-title{font-weight:800;font-size:13px;letter-spacing:0.06em;display:flex;align-items:center;gap:8px}
.dot{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent)}
.module-num{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted);background:#0e111a;border:1px solid var(--border);padding:4px 7px;border-radius:999px}
.wave-choices{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:12px}
.wave-btn{
background:#0e111a;border:1px solid var(--border);border-radius:10px;padding:8px 6px;cursor:pointer;text-align:center;transition:0.15s
}
.wave-btn.active{border-color:var(--accent);background:rgba(0,255,209,0.08);box-shadow:0 0 0 1px rgba(0,255,209,0.15) inset}
.wave-icon{height:28px;display:grid;place-items:center;margin-bottom:4px}
.wave-btn span{font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:700;color:var(--muted)}
.wave-btn.active span{color:var(--accent)}
.ctrl-row{display:flex;gap:10px;margin-bottom:10px}
.ctrl{flex:1;background:#0e111a;border:1px solid var(--border);border-radius:12px;padding:10px}
.ctrl label{font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:0.1em;color:var(--muted);display:flex;justify-content:space-between}
.ctrl input[type=range]{width:100%;margin:8px 0 2px;accent-color:var(--accent);height:3px}
.ctrl .val{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--yellow)}
.knob-row{display:flex;gap:10px}
.note{font-size:10px;color:var(--muted);line-height:1.4}
.filter-viz{height:70px;background:#070a11;border:1px solid #1c2132;border-radius:10px;margin-bottom:10px;position:relative;overflow:hidden}
.filter-viz canvas{width:100%;height:100%;display:block}
.toggle-row{display:flex;gap:8px;margin-bottom:10px}
.toggle{flex:1;padding:8px;border-radius:999px;border:1px solid var(--border);background:#0e111a;font-family:'JetBrains Mono',monospace;font-size:11px;cursor:pointer;text-align:center;color:var(--muted)}
.toggle.on{background:var(--accent);color:#000;border-color:var(--accent);font-weight:700}
.seq-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:6px;margin-bottom:10px}
.seq-step{aspect-ratio:1;background:#0e111a;border:1px solid var(--border);border-radius:8px;cursor:pointer;position:relative;overflow:hidden}
.seq-step.on{background:var(--accent);border-color:var(--accent);box-shadow:0 0 12px rgba(0,255,209,0.4)}
.seq-step.playing{outline:2px solid var(--yellow)}
.seq-step b{position:absolute;bottom:2px;left:50%;transform:translateX(-50%);font-family:'JetBrains Mono',monospace;font-size:8px;color:inherit}
.keyboard-wrap{
margin-top:14px;background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:12px
}
.kb-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
.kb-title{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.08em;color:var(--muted)}
.oct-shift{display:flex;gap:6px;align-items:center}
.oct-btn{width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:#0e111a;color:var(--text);cursor:pointer;display:grid;place-items:center}
.piano{position:relative;display:flex;user-select:none;overflow:hidden;border-radius:10px;border:1px solid #1e2335;background:#0a0d14;height:108px}
.white{flex:1;background:linear-gradient(180deg,#f8f9ff 0%, #e6e8ee 100%);border-right:1px solid #c9cde0;position:relative;cursor:pointer;display:flex;align-items:flex-end;justify-content:center;padding-bottom:8px;font-family:'JetBrains Mono',monospace;font-size:10px;color:#5a638a}
.white.active{background:var(--accent)}
.white:last-child{border:none}
.black{position:absolute;top:0;width:5.5%;height:62%;background:linear-gradient(180deg,#1a1e2b,#0a0d14);border:1px solid #000;border-radius:0 0 6px 6px;z-index:2;cursor:pointer;display:grid;place-items:end center;padding-bottom:6px;color:#8a90a8;font-size:8px}
.black.active{background:var(--accent2)}
.preset-row{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
.preset{padding:7px 12px;border-radius:999px;border:1px solid var(--border);background:#151a27;font-family:'JetBrains Mono',monospace;font-size:11px;cursor:pointer;color:var(--muted)}
.preset.active{background:var(--yellow);color:#000;border-color:var(--yellow);font-weight:700}
.level-badge{position:absolute;top:10px;right:10px;font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:0.08em;background:var(--yellow);color:#000;padding:4px 8px;border-radius:999px;font-weight:700}
</style>
</head>
<body>
<div class="topbar">
<div class="logo"><div class="logo-mark">◈</div><span>SYNTH PLAYGROUND</span><span style="color:var(--muted);font-weight:400;margin-left:6px">LEARN BY BUILDING SOUND</span></div>
<div class="topbar-right">
<div class="badge" id="powerLabel">AUDIO • CLICK TO START</div>
<div class="power-btn off" id="powerBtn" title="Toggle audio"><div class="power-knob"></div></div>
<div class="meter"><div class="meter-fill" id="meterFill"></div></div>
</div>
</div>
<div class="progress-wrap" id="progress"></div>
<div class="layout">
<aside class="lesson">
<div class="lesson-card" id="lessonCard"></div>
<div class="hint">TIP: Use your computer keyboard <b style="color:var(--yellow)">A S D F G H J</b> to play • <b>Z / X</b> octave</div>
<div style="display:flex;gap:8px">
<button class="btn-ghost" style="flex:1" id="prevBtn">← Previous</button>
<button class="btn-ghost" style="flex:1" id="nextBtn">Next →</button>
</div>
</aside>
<main class="synth">
<div class="oscilloscope">
<div class="scope-top">
<div class="scope-title">● OSCILLOSCOPE & SPECTRUM — REAL TIME OUTPUT</div>
<div class="scope-actions"><span class="pill live">● LIVE</span><span class="pill" id="noteDisplay">—</span></div>
</div>
<div class="scope-canvas-wrap">
<div class="scope-grid"></div>
<canvas id="scope" width="900" height="148"></canvas>
<div class="scope-overlay"><span class="tag" id="waveTag">SAW • 440Hz</span><span class="tag" id="filterTag">FILTER 3.2kHz</span><span class="tag" id="adsrTag">ADSR 0.1 / 0.2 / 0.7 / 0.4</span></div>
</div>
</div>
<div class="preset-row" id="presetRow"></div>
<div class="modules" id="modules"></div>
<div class="keyboard-wrap">
<div class="kb-top">
<div class="kb-title">KEYBOARD — CLICK, DRAG, OR TYPE</div>
<div class="oct-shift">
<span style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">OCTAVE <b id="octLabel" style="color:var(--yellow)">4</b></span>
<button class="oct-btn" id="octDown">−</button>
<button class="oct-btn" id="octUp">+</button>
<span style="font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--muted);margin-left:6px">VEL <span id="velLabel" style="color:var(--text)">80%</span></span>
<input type="range" id="velSlider" min="20" max="127" value="100" style="width:80px;accent-color:var(--accent)">
</div>
</div>
<div class="piano" id="piano"></div>
<div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap">
<span class="pill">A S D F G H J K = white keys</span>
<span class="pill">W E T Y U = black keys</span>
<span class="pill">HOLD SHIFT = sustain</span>
</div>
</div>
</main>
</div>
<script>
const lessons = [
{
kicker:"LEVEL 01 — THE SOURCE",
title:"The Oscillator:<br>Where sound begins",
desc:`Every synth sound starts with a <b style="color:var(--accent)">vibrating oscillator</b>. It just wiggles back and forth — the shape of that wiggle <i>is</i> the timbre. No oscillator, no sound.`,
viz:"wave",
tasks:[
"Switch between <b>Sine</b> (pure, flute-like) and <b>Saw</b> (bright, buzzy — sum of all harmonics)",
"Play a low note vs high note. Hear how <b>frequency = pitch</b>",
"Try <b>Square</b> — hollow, video-gamey (only odd harmonics)"
],
try:"Try this: Hold <b>A</b> (C) and flip waveforms while the note sustains — hear the harmonic content change in the scope."
},
{
kicker:"LEVEL 02 — SHAPING TIME",
title:"ADSR Envelope:<br>Sculpting volume",
desc:`A raw oscillator drones forever. An <b style="color:var(--yellow)">ADSR envelope</b> shapes loudness over time: <b>Attack</b> (fade in), <b>Decay</b> (fall to Sustain), <b>Sustain</b> (held level), <b>Release</b> (fade out). Pluck vs pad is just ADSR.`,
viz:"adsr",
tasks:[
"Set <b>Attack 0, Decay 0.2, Sustain 0, Release 0.2</b> → sharp pluck",
"Set <b>Attack 0.8, Sustain 0.9</b> → slow swelling pad",
"Notice the envelope drawing mirrors the volume you hear"
],
try:"Challenge: Make a percussive <b>‘blip’</b> with almost no sustain and short release."
},
{
kicker:"LEVEL 03 — COLOUR",
title:"The Filter:<br>Remove to reveal",
desc:`Subtractive synthesis = start bright, then <b style="color:var(--accent2)">carve away</b>. A <b>Low-Pass Filter</b> lets lows through, shaves off highs. <b>Cutoff</b> = where it starts cutting, <b>Resonance</b> = peak at the cutoff (that classic squelch).`,
viz:"filter",
tasks:[
"Start with Saw wave, <b>Low-pass, Cutoff ~800Hz</b> → muffled & warm",
"Crank <b>Resonance to 8+</b> and sweep cutoff → acid whistle",
"Switch to <b>High-pass</b> → thin, telephone-like"
],
try:"Try: Play a chord, slowly sweep cutoff from 200Hz → 8000Hz. You’re literally hearing harmonics being removed."
},
{
kicker:"LEVEL 04 — MOVEMENT",
title:"LFO & Modulation:<br>Sound that moves",
desc:`An <b style="color:var(--accent3)">LFO (Low Frequency Oscillator)</b> wobbles too slow to hear, but perfect to <i>wiggle</i> other controls. Route it to <b>pitch → vibrato</b>, to <b>filter → wobble</b>, to <b>volume → tremolo</b>.`,
viz:"lfo",
tasks:[
"Target = <b>Pitch</b>, Rate 5Hz, Depth 30% → classic vibrato",
"Target = <b>Filter</b>, Rate 2Hz, Depth 70% → dub wobble",
"Rate 0.5Hz + high depth → slow sweeping evolution"
],
try:"Pro tip: Fast LFO on pitch (30Hz+) stops sounding like vibrato and starts sounding like FM metallics."
},
{
kicker:"LEVEL 05 — SPACE & GRIT",
title:"Effects:<br>Delay, Reverb & Drive",
desc:`<b>Delay</b> = echo in time, <b>Reverb</b> = echo in space, <b>Distortion</b> = adds harmonics by clipping. Effects are post-synth, they turn a dry beep into a record.`,
viz:"fx",
tasks:[
"Turn on <b>Delay 320ms, Feedback 40%</b> → rhythmic slapback",
"Add <b>Reverb 50%</b> → put it in a hall",
"Push <b>Drive 40%</b> on a Saw → angry and thick"
],
try:"Stack them: Mild drive + short delay + long reverb = instant lo-fi pad."
},
{
kicker:"LEVEL 06 — PATTERNS",
title:"Sequencer:<br>Let it play itself",
desc:`A <b>16-step sequencer</b> loops your notes. Each step = 1/16th note. Activate steps to create ostinatos, basslines, arpeggios. This is how electronic music composes itself.`,
viz:"seq",
tasks:[
"Click steps <b>1, 5, 9, 13</b> → four-on-the-floor",
"Press <b>PLAY</b>, tweak filter cutoff while it loops",
"Change <b>Tempo</b> and hear the LFO drift against the sequence"
],
try:"Final challenge: Make a 2-bar bassline, add filter LFO, and you’ve got a track."
}
];
let currentLevel = 0;
let audioCtx, masterGain, analyser, delayNode, delayFb, delayMix, reverbNode, reverbGain, reverbMix, distortionNode, convolverReady=false;
let octave=4, velocity=100, isSustain=false;
let voices=new Map();
let settings={
wave:'sawtooth',
detune:0,
attack:0.02, decay:0.18, sustain:0.65, release:0.35,
filterType:'lowpass', cutoff:2800, resonance:1.2,
lfoRate:4.5, lfoDepth:0, lfoTarget:'pitch',
delayOn:false, delayTime:0.32, delayFb:0.35, delayMix:0.25,
reverbOn:false, reverbMix:0.3,
drive:0,
tempo:120
};
const presets=[
{name:'Init Saw', wave:'sawtooth', cutoff:8000, resonance:0.7, attack:0.01, decay:0.1, sustain:0.8, release:0.2, drive:0},
{name:'Warm Pad', wave:'sawtooth', cutoff:1200, resonance:1.2, attack:0.6, decay:0.4, sustain:0.85, release:0.9, lfoDepth:0.25, lfoTarget:'filter', lfoRate:1.2},
{name:'Pluck', wave:'sine', cutoff:2600, resonance:2.5, attack:0, decay:0.25, sustain:0, release:0.25, drive:0.15},
{name:'Acid Bass', wave:'square', cutoff:700, resonance:8, attack:0, decay:0.15, sustain:0.4, release:0.15, drive:0.4, lfoDepth:0.35, lfoTarget:'filter', lfoRate:2.4},
{name:'Wobble', wave:'sawtooth', cutoff:900, resonance:4, attack:0.05, decay:0.2, sustain:0.7, release:0.3, lfoDepth:0.75, lfoTarget:'filter', lfoRate:2.0},
{name:'Crystal', wave:'triangle', cutoff:5000, resonance:0.9, attack:0.15, decay:0.6, sustain:0.5, release:0.8, delayOn:true, reverbOn:true, drive:0},
];
function initAudio(){
if(audioCtx) return;
audioCtx = new (window.AudioContext||window.webkitAudioContext)();
masterGain = audioCtx.createGain(); masterGain.gain.value=0.7;
analyser = audioCtx.createAnalyser(); analyser.fftSize=2048;
// effects
distortionNode = audioCtx.createWaveShaper(); distortionNode.curve=makeDistortion(0); distortionNode.oversample='4x';
delayNode = audioCtx.createDelay(1); delayNode.delayTime.value=settings.delayTime;
delayFb = audioCtx.createGain(); delayFb.gain.value=settings.delayFb;
delayMix = audioCtx.createGain(); delayMix.gain.value=0;
reverbNode = audioCtx.createConvolver();
reverbGain = audioCtx.createGain(); reverbGain.gain.value=0;
// routing: master -> analyser -> destination, with effect sends
masterGain.connect(analyser);
analyser.connect(audioCtx.destination);
// delay loop
delayNode.connect(delayFb); delayFb.connect(delayNode);
// reverb impulse
buildReverb(2.2, 0.85);
document.getElementById('powerBtn').className='power-btn on';
document.getElementById('powerLabel').textContent='AUDIO • ON';
document.getElementById('powerLabel').style.color='var(--accent)';
}
function ensureAudio(){ if(!audioCtx) initAudio(); if(audioCtx && audioCtx.state==='suspended') audioCtx.resume(); }
function makeDistortion(amount){
const k = amount*100;
const n=44100, curve=new Float32Array(n);
for(let i=0;i<n;i++){
const x=i*2/n-1;
curve[i]=(3+k)*x*20*Math.PI/180 / (Math.PI + k*Math.abs(x));
}
return curve;
}
function buildReverb(sec, decay){
if(!audioCtx) return;
const rate=audioCtx.sampleRate, len=rate*sec, impulse=audioCtx.createBuffer(2, len, rate);
for(let c=0;c<2;c++){
const ch=impulse.getChannelData(c);
for(let i=0;i<len;i++) ch[i]=(Math.random()*2-1)*Math.pow(1-i/len, decay);
}
reverbNode.buffer=impulse;
}
function midiToFreq(m){ return 440*Math.pow(2,(m-69)/12); }
function noteOn(midi, vel=100){
ensureAudio();
const freq=midiToFreq(midi);
const now=audioCtx.currentTime;
const osc=audioCtx.createOscillator(); osc.type=settings.wave; osc.frequency.value=freq;
osc.detune.value=settings.detune;
const filt=audioCtx.createBiquadFilter(); filt.type=settings.filterType; filt.frequency.value=settings.cutoff; filt.Q.value=settings.resonance;
const amp=audioCtx.createGain(); amp.gain.value=0;
// envelope
amp.gain.setValueAtTime(0, now);
amp.gain.linearRampToValueAtTime(1, now+Math.max(0.001,settings.attack));
amp.gain.linearRampToValueAtTime(settings.sustain, now+settings.attack+settings.decay);
// chain
let src=osc;
// drive first
if(settings.drive>0.01){
distortionNode.curve=makeDistortion(settings.drive);
src.connect(distortionNode); src=distortionNode;
// need to reconnect: use intermediate gain to avoid sharing distortion node between voices causing glitch
// Create per-voice shaper
const ws=audioCtx.createWaveShaper(); ws.curve=makeDistortion(settings.drive); ws.oversample='4x';
osc.disconnect(); osc.connect(ws); ws.connect(filt);
} else {
osc.connect(filt);
}
filt.connect(amp);
// effects sends
amp.connect(masterGain);
if(settings.delayOn){
const dSend=audioCtx.createGain(); dSend.gain.value=settings.delayMix;
amp.connect(dSend); dSend.connect(delayNode);
delayNode.connect(masterGain);
}
if(settings.reverbOn){
const rSend=audioCtx.createGain(); rSend.gain.value=settings.reverbMix;
amp.connect(rSend); rSend.connect(reverbNode); reverbNode.connect(masterGain);
}
// lfo
let lfo, lfoGain;
if(settings.lfoDepth>0.01){
lfo=audioCtx.createOscillator(); lfo.frequency.value=settings.lfoRate;
lfoGain=audioCtx.createGain();
if(settings.lfoTarget==='pitch') lfoGain.gain.value=settings.lfoDepth*60; // cents-ish
else if(settings.lfoTarget==='filter') lfoGain.gain.value=settings.lfoDepth*2500;
else lfoGain.gain.value=settings.lfoDepth*0.6;
lfo.connect(lfoGain);
if(settings.lfoTarget==='pitch') lfoGain.connect(osc.frequency);
else if(settings.lfoTarget==='filter') lfoGain.connect(filt.frequency);
else lfoGain.connect(amp.gain);
lfo.start(now);
}
osc.start(now);
voices.set(midi, {osc,filt,amp,lfo, lfoGain, vel, midi});
updateMeter();
setNoteDisplay(midi);
}
function noteOff(midi){
const v=voices.get(midi);
if(!v||!audioCtx) return;
const now=audioCtx.currentTime;
const rel=Math.max(0.01, settings.release);
v.amp.gain.cancelScheduledValues(now);
v.amp.gain.setValueAtTime(v.amp.gain.value, now);
v.amp.gain.linearRampToValueAtTime(0, now+rel);
v.osc.stop(now+rel+0.05);
if(v.lfo) v.lfo.stop(now+rel+0.05);
setTimeout(()=>voices.delete(midi), (rel+0.1)*1000);
setNoteDisplay(null);
updateMeter();
}
function setNoteDisplay(midi){
const el=document.getElementById('noteDisplay');
if(midi==null){ el.textContent='—'; return;}
const names=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
el.textContent = names[midi%12]+Math.floor(midi/12-1) + ' • ' + Math.round(midiToFreq(midi))+'Hz';
}
// UI Builders
function renderProgress(){
const c=document.getElementById('progress');
c.innerHTML='';
lessons.forEach((l,i)=>{
const s=document.createElement('div');
s.className='step '+(i===currentLevel?'active':'')+' '+(i<currentLevel?'done':'');
s.innerHTML=`<div class="step-num">${i+1}</div><div><div class="step-label">${l.kicker.split('—')[1].trim()}</div><div class="step-sub">${['OSC','ENV','FLT','LFO','FX','SEQ'][i]}</div></div>`;
s.onclick=()=>{ currentLevel=i; renderAll(); };
c.appendChild(s);
if(i<lessons.length-1){
const a=document.createElement('div'); a.className='arrow'; a.textContent='→'; c.appendChild(a);
}
});
}
function renderLesson(){
const l=lessons[currentLevel];
const card=document.getElementById('lessonCard');
card.innerHTML=`
<div class="lesson-head">
<div class="kicker">${l.kicker}</div>
<div class="lesson-title">${l.title}</div>
<div class="lesson-desc">${l.desc}</div>
</div>
<div class="lesson-viz" id="lessonVizBox">
<div class="viz-label"><span>${l.viz==='wave'?'WAVE SHAPE':l.viz==='adsr'?'ENVELOPE SHAPE':l.viz==='filter'?'FILTER RESPONSE':l.viz==='lfo'?'LFO ROUTING':l.viz==='fx'?'EFFECTS CHAIN':'SEQUENCER'}</span><span style="color:var(--yellow)">LEVEL ${currentLevel+1}/6</span></div>
<canvas id="lessonCanvas" width="380" height="110"></canvas>
</div>
<div class="tasks">
${l.tasks.map(t=>`<label class="task"><input type="checkbox"><span>${t}</span></label>`).join('')}
<div style="margin-top:10px;background:#111526;border:1px solid #252c45;border-radius:10px;padding:10px;font-size:12px;line-height:1.5"><span style="color:var(--yellow);font-weight:700">TRY THIS →</span> ${l.try}</div>
</div>
<div class="lesson-foot">
<button class="btn-primary" id="unlockBtn">${currentLevel<lessons.length-1?'Complete & Unlock Next →':'You’ve completed the playground! ↺ Restart'}</button>
</div>
`;
document.getElementById('unlockBtn').onclick=()=>{
if(currentLevel<lessons.length-1){ currentLevel++; renderAll(); }
else { currentLevel=0; renderAll();}
};
requestAnimationFrame(drawLessonViz);
}
function renderModules(){
const wrap=document.getElementById('modules');
wrap.innerHTML='';
// Module 1 OSC
wrap.appendChild(moduleCard(0, `
<div class="wave-choices" id="waveChoices">
${['sine','triangle','sawtooth','square'].map(w=>`
<div class="wave-btn ${settings.wave===w?'active':''}" data-wave="${w}">
<div class="wave-icon">${waveSVG(w)}</div><span>${w==='sawtooth'?'SAW':w.toUpperCase()}</span>
</div>`).join('')}
</div>
<div class="ctrl-row">
<div class="ctrl"><label>DETUNE <span class="val" id="detuneVal">${settings.detune}¢</span></label><input type="range" id="detune" min="-50" max="50" value="${settings.detune}"></div>
<div class="ctrl"><label>OCTAVE <span class="val" id="octVal">${octave}</span></label><input type="range" id="octCtrl" min="2" max="6" value="${octave}"></div>
</div>
<div class="note">Sine = pure fundamental. Saw = all harmonics. Square = hollow. Triangle = soft. Detune thickens.</div>
`, 'OSCILLATOR','01'));
// Module 2 ADSR
wrap.appendChild(moduleCard(1, `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
${[
['attack','ATTACK',0,2,0.01,settings.attack],
['decay','DECAY',0,2,0.01,settings.decay],
['sustain','SUSTAIN',0,1,0.01,settings.sustain],
['release','RELEASE',0,3,0.01,settings.release],
].map(([id,label,min,max,step,val])=>`
<div class="ctrl"><label>${label} <span class="val" id="${id}Val">${Number(val).toFixed(2)}</span></label><input type="range" id="${id}" min="${min}" max="${max}" step="${step}" value="${val}"></div>
`).join('')}
</div>
<div style="margin-top:8px" class="note">A D S R controls loudness shape. Short A+D+S≈0 = pluck. Long A = pad.</div>
`,'ENVELOPE','02'));
// Module 3 Filter
wrap.appendChild(moduleCard(2, `
<div class="filter-viz"><canvas id="filterCanvas" width="300" height="70"></canvas></div>
<div class="toggle-row">
${['lowpass','highpass','bandpass'].map(t=>`<div class="toggle ${settings.filterType===t?'on':''}" data-ftype="${t}">${t.toUpperCase()}</div>`).join('')}
</div>
<div class="knob-row">
<div class="ctrl"><label>CUTOFF <span class="val" id="cutoffVal">${settings.cutoff} Hz</span></label><input type="range" id="cutoff" min="100" max="8000" value="${settings.cutoff}"></div>
<div class="ctrl"><label>RESONANCE <span class="val" id="resVal">${settings.resonance}</span></label><input type="range" id="resonance" min="0.1" max="12" step="0.1" value="${settings.resonance}"></div>
</div>
`,'FILTER','03'));
// Module 4 LFO
wrap.appendChild(moduleCard(3, `
<div class="toggle-row">
${[['pitch','PITCH'],['filter','FILTER'],['amp','VOLUME']].map(([v,l])=>`<div class="toggle ${settings.lfoTarget===v?'on':''}" data-lfo="${v}">${l}</div>`).join('')}
</div>
<div class="ctrl-row">
<div class="ctrl"><label>RATE <span class="val" id="lfoRateVal">${settings.lfoRate} Hz</span></label><input type="range" id="lfoRate" min="0.1" max="18" step="0.1" value="${settings.lfoRate}"></div>
<div class="ctrl"><label>DEPTH <span class="val" id="lfoDepthVal">${Math.round(settings.lfoDepth*100)}%</span></label><input type="range" id="lfoDepth" min="0" max="1" step="0.02" value="${settings.lfoDepth}"></div>
</div>
<div class="note">Rate = speed of wobble. Depth = how far it wobbles. Try Filter + 2Hz for dub.</div>
`,'LFO / MOD','04'));
// Module 5 FX
wrap.appendChild(moduleCard(4, `
<div style="display:flex;flex-direction:column;gap:8px">
<div class="ctrl"><label style="display:flex;justify-content:space-between;align-items:center">DELAY <span class="val">${settings.delayOn?'ON':'OFF'}</span> <input type="checkbox" id="delayOn" ${settings.delayOn?'checked':''}></label>
<label>TIME <span class="val">${settings.delayTime}s</span></label><input type="range" id="delayTime" min="0.05" max="0.6" step="0.01" value="${settings.delayTime}">
<label>FEEDBACK <span class="val">${Math.round(settings.delayFb*100)}%</span></label><input type="range" id="delayFb" min="0" max="0.85" step="0.05" value="${settings.delayFb}">
</div>
<div class="ctrl"><label>REVERB <input type="checkbox" id="reverbOn" ${settings.reverbOn?'checked':''}> <span class="val">${settings.reverbOn?'ON':'OFF'}</span></label>
<label>MIX <span class="val">${Math.round(settings.reverbMix*100)}%</span></label><input type="range" id="reverbMix" min="0" max="0.9" step="0.05" value="${settings.reverbMix}">
</div>
<div class="ctrl"><label>DRIVE <span class="val">${Math.round(settings.drive*100)}%</span></label><input type="range" id="drive" min="0" max="1" step="0.05" value="${settings.drive}"></div>
</div>
`,'SPACE & GRIT','05'));
// Module 6 Sequencer
wrap.appendChild(moduleCard(5, `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<button class="btn-ghost" id="seqPlay" style="padding:8px 14px;background:var(--yellow);color:#000;border-color:var(--yellow);font-weight:700">▶ PLAY</button>
<div style="display:flex;gap:8px;align-items:center">
<span style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">TEMPO <b id="tempoVal" style="color:var(--yellow)">${settings.tempo}</b></span>
<input type="range" id="tempo" min="60" max="180" value="${settings.tempo}" style="width:90px;accent-color:var(--yellow)">
<button class="btn-ghost" id="seqClear">CLEAR</button>
</div>
</div>
<div class="seq-grid" id="seqGrid"></div>
<div class="note">Click steps to toggle. Root note = C${octave}. Plays 16th notes. Tweak cutoff while it loops!</div>
`,'SEQUENCER','06'));
bindModuleEvents();
drawFilterViz();
buildSeq();
}
function moduleCard(level, inner, title,num){
const d=document.createElement('div');
d.className='module '+(level>currentLevel?'locked':'');
d.innerHTML=`<div class="module-head"><div class="module-title"><span class="dot"></span>${title}</div><div class="module-num">${num}</div></div>${inner}`;
return d;
}
function waveSVG(type){
if(type==='sine') return `<svg width="44" height="18" viewBox="0 0 44 18"><path d="M0 9 Q6 1 12 9 T24 9 T36 9 T44 9" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>`;
if(type==='triangle') return `<svg width="44" height="18" viewBox="0 0 44 18"><path d="M0 14 L11 2 L22 14 L33 2 L44 14" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>`;
if(type==='sawtooth') return `<svg width="44" height="18" viewBox="0 0 44 18"><path d="M0 14 L12 2 L12 14 L24 2 L24 14 L36 2 L36 14 L44 2" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>`;
if(type==='square') return `<svg width="44" height="18" viewBox="0 0 44 18"><path d="M0 14 H11 V2 H22 V14 H33 V2 H44 V14" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>`;
return '';
}
function bindModuleEvents(){
document.querySelectorAll('.wave-btn').forEach(b=>b.onclick=()=>{ settings.wave=b.dataset.wave; renderModules(); updateTags(); drawLessonViz(); });
const det=document.getElementById('detune'); if(det) det.oninput=e=>{ settings.detune=+e.target.value; document.getElementById('detuneVal').textContent=settings.detune+'¢'; };
const octC=document.getElementById('octCtrl'); if(octC) octC.oninput=e=>{ octave=+e.target.value; document.getElementById('octVal').textContent=octave; document.getElementById('octLabel').textContent=octave; buildPiano(); };
['attack','decay','sustain','release'].forEach(id=>{
const el=document.getElementById(id); if(!el) return;
el.oninput=e=>{ settings[id]=+e.target.value; document.getElementById(id+'Val').textContent=Number(settings[id]).toFixed(2); updateTags(); drawLessonViz(); };
});
document.querySelectorAll('[data-ftype]').forEach(t=>t.onclick=()=>{ settings.filterType=t.dataset.ftype; renderModules(); });
const cutoff=document.getElementById('cutoff'); if(cutoff) cutoff.oninput=e=>{ settings.cutoff=+e.target.value; document.getElementById('cutoffVal').textContent=settings.cutoff+' Hz'; drawFilterViz(); updateTags(); };
const res=document.getElementById('resonance'); if(res) res.oninput=e=>{ settings.resonance=+e.target.value; document.getElementById('resVal').textContent=settings.resonance; drawFilterViz(); };
document.querySelectorAll('[data-lfo]').forEach(t=>t.onclick=()=>{ settings.lfoTarget=t.dataset.lfo; renderModules(); });
const lr=document.getElementById('lfoRate'); if(lr) lr.oninput=e=>{ settings.lfoRate=+e.target.value; document.getElementById('lfoRateVal').textContent=settings.lfoRate+' Hz'; };
const ld=document.getElementById('lfoDepth'); if(ld) ld.oninput=e=>{ settings.lfoDepth=+e.target.value; document.getElementById('lfoDepthVal').textContent=Math.round(settings.lfoDepth*100)+'%'; };
const dOn=document.getElementById('delayOn'); if(dOn) dOn.onchange=e=>{ settings.delayOn=e.target.checked; if(audioCtx){ delayMix.gain.value=settings.delayOn?settings.delayMix:0; if(settings.delayOn) ensureAudio(); } renderModules(); };
const dTime=document.getElementById('delayTime'); if(dTime) dTime.oninput=e=>{ settings.delayTime=+e.target.value; if(delayNode) delayNode.delayTime.value=settings.delayTime; renderModules(); };
const dFb=document.getElementById('delayFb'); if(dFb) dFb.oninput=e=>{ settings.delayFb=+e.target.value; if(delayFb) delayFb.gain.value=settings.delayFb; renderModules(); };
const rOn=document.getElementById('reverbOn'); if(rOn) rOn.onchange=e=>{ settings.reverbOn=e.target.checked; renderModules(); };
const rMix=document.getElementById('reverbMix'); if(rMix) rMix.oninput=e=>{ settings.reverbMix=+e.target.value; renderModules(); };
const drv=document.getElementById('drive'); if(drv) drv.oninput=e=>{ settings.drive=+e.target.value; renderModules(); };
const tempo=document.getElementById('tempo'); if(tempo) tempo.oninput=e=>{ settings.tempo=+e.target.value; document.getElementById('tempoVal').textContent=settings.tempo; };
const playBtn=document.getElementById('seqPlay'); if(playBtn) playBtn.onclick=toggleSeq;
const clearBtn=document.getElementById('seqClear'); if(clearBtn) clearBtn.onclick=()=>{ seqSteps.fill(false); buildSeq(); };
}
function renderPresets(){
const row=document.getElementById('presetRow');
row.innerHTML=presets.map((p,i)=>`<button class="preset" data-pi="${i}">${p.name}</button>`).join('');
row.querySelectorAll('.preset').forEach(b=>b.onclick=()=>{
const p=presets[b.dataset.pi];
Object.assign(settings, p);
if(p.octave) octave=p.octave;
row.querySelectorAll('.preset').forEach(x=>x.classList.remove('active')); b.classList.add('active');
renderModules(); updateTags(); drawLessonViz();
});
}
function buildPiano(){
const piano=document.getElementById('piano');
piano.innerHTML='';
const whiteNotes=[0,2,4,5,7,9,11,12,14,16,17,19,21,23]; // two octaves
const blackMap={1:0,3:1,6:2,8:3,10:4,13:5,15:6,18:7,20:8};
// whites
whiteNotes.forEach((semi, idx)=>{
const midi=octave*12 + semi;
const w=document.createElement('div'); w.className='white'; w.dataset.midi=midi;
w.innerHTML=`<span>${['C','D','E','F','G','A','B'][idx%7]}<br><small style="opacity:0.6">${['A','S','D','F','G','H','J'][idx]||''}</small></span>`;
w.onmousedown=e=>{ e.preventDefault(); noteOn(midi, velocity); w.classList.add('active'); };
w.onmouseup=()=>{ if(!isSustain) noteOff(midi); w.classList.remove('active'); };
w.onmouseleave=()=>w.classList.remove('active');
piano.appendChild(w);
});
// blacks - positioned absolute
const positions=[0.72,1.72,3.15,4.15,5.15,7.1,8.1,9.85,10.85]; // rough
const blackSemis=[1,3,6,8,10,13,15,18,20];
blackSemis.forEach((semi,i)=>{
const midi=octave*12+semi;
const b=document.createElement('div'); b.className='black'; b.dataset.midi=midi;
const pct=(i+1)*6.55 + (i>4?2:0); // distribute
// compute left as percentage
const left = [7.1,14.2,28.5,35.7,42.8,64.2,71.3,85.6,92.7][i];
b.style.left=`calc(${left}% - 14px)`;
b.innerHTML=['W','E','','T','Y','U','','',''][i]||'';
b.onmousedown=e=>{ e.preventDefault(); e.stopPropagation(); noteOn(midi, velocity); b.classList.add('active'); };
b.onmouseup=e=>{ e.stopPropagation(); if(!isSustain) noteOff(midi); b.classList.remove('active'); };
b.onmouseleave=()=>b.classList.remove('active');
piano.appendChild(b);
});
}
function updateTags(){
document.getElementById('waveTag').textContent=`${settings.wave.toUpperCase()} • DET ${settings.detune}¢`;
document.getElementById('filterTag').textContent=`${settings.filterType.toUpperCase()} ${settings.cutoff}Hz Q${settings.resonance}`;
document.getElementById('adsrTag').textContent=`ADSR ${settings.attack.toFixed(2)} / ${settings.decay.toFixed(2)} / ${settings.sustain.toFixed(2)} / ${settings.release.toFixed(2)}`;
}
function drawLessonViz(){
const c=document.getElementById('lessonCanvas'); if(!c) return; const ctx=c.getContext('2d');
const w=c.width, h=c.height; ctx.clearRect(0,0,w,h);
ctx.fillStyle='#070a11'; ctx.fillRect(0,0,w,h);
ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1;
for(let x=0;x<w;x+=28){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
for(let y=0;y<h;y+=22){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
const l=lessons[currentLevel];
if(l.viz==='wave'){
ctx.strokeStyle='#00ffd1'; ctx.lineWidth=2.5; ctx.beginPath();
for(let x=0;x<w;x++){
const t=x/w*4*Math.PI;
let y;
if(settings.wave==='sine') y=Math.sin(t*2);
else if(settings.wave==='square') y=Math.sign(Math.sin(t*2));
else if(settings.wave==='triangle') y=2*Math.abs(2*(t/(Math.PI*2)-Math.floor(t/(Math.PI*2)+0.5)))-1;
else y=2*(t/(Math.PI*2)-Math.floor(t/(Math.PI*2)+0.5)); // saw
const py=h/2 - y*36;
if(x===0) ctx.moveTo(x,py); else ctx.lineTo(x,py);
}
ctx.stroke();
ctx.fillStyle='rgba(0,255,209,0.12)'; ctx.lineTo(w,h); ctx.lineTo(0,h); ctx.closePath(); ctx.fill();
} else if(l.viz==='adsr'){
ctx.strokeStyle='#ffd028'; ctx.lineWidth=2.5; ctx.beginPath();
const aW=settings.attack*60, dW=settings.decay*40, sH=h*0.5*settings.sustain, rW=settings.release*50;
const sustainX=40+aW+dW;
const pts=[[0,h-10],[40,h-10-aW*0.6],[40+aW,20],[sustainX, h-10 - sH],[sustainX+120, h-10 - sH],[sustainX+120+rW, h-10]];
// simplified drawing
ctx.moveTo(0,h-12);
ctx.lineTo(40,h-12);
ctx.lineTo(40+aW,12);
ctx.lineTo(40+aW+dW, h-12 - (h*0.35*settings.sustain+12));
ctx.lineTo(240, h-12 - (h*0.35*settings.sustain+12));
ctx.lineTo(240+rW, h-12);
ctx.stroke();
// labels
ctx.fillStyle='#8a90a8'; ctx.font='9px JetBrains Mono'; ctx.fillText('A',20,h-2); ctx.fillText('D', 40+aW/2,h-2); ctx.fillText('S',150,h-2); ctx.fillText('R',250,h-2);
} else if(l.viz==='filter'){
ctx.strokeStyle='#ff3b82'; ctx.lineWidth=2.2; ctx.beginPath();
for(let x=0;x<w;x++){
const f=100 + (x/w)*7900;
const cutoff=settings.cutoff; const q=settings.resonance;
let gain;
if(settings.filterType==='lowpass') gain= 1/ Math.sqrt(1+ Math.pow(f/cutoff, 4)) + (f>cutoff*0.8&&f<cutoff*1.3?q*0.15*Math.exp(-Math.pow((f-cutoff)/200,2)):0);
else if(settings.filterType==='highpass') gain= 1/ Math.sqrt(1+ Math.pow(cutoff/f, 4));
else gain= Math.exp(-Math.pow((f-cutoff)/600,2))* (1+q*0.08);
const y=h-14 - gain* (h*0.65);
if(x===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
}
ctx.stroke();
} else if(l.viz==='lfo'){
ctx.strokeStyle='#7c5cff'; ctx.lineWidth=1.6; ctx.beginPath();
for(let x=0;x<w;x++){ const y=h/2 + Math.sin(x*0.06* (settings.lfoRate/2))*28* (settings.lfoDepth||0.5); if(x===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);}
ctx.stroke();
ctx.strokeStyle='rgba(124,92,255,0.2)'; ctx.setLineDash([4,4]); ctx.beginPath(); ctx.moveTo(0,h/2); ctx.lineTo(w,h/2); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle='#8a90a8'; ctx.font='9px JetBrains Mono'; ctx.fillText(`LFO ${settings.lfoRate}Hz → ${settings.lfoTarget.toUpperCase()}`, 10,14);
} else if(l.viz==='fx'){
ctx.fillStyle='#1a1e2b'; ctx.strokeStyle='#262c3f';
const boxes=[['OSC','0,255,209'],['FILTER','255,59,130'],['DRIVE','255,208,40'],['DELAY','124,92,255'],['REVERB','0,255,209']];
let x=8;
boxes.forEach(([label,col])=>{
ctx.fillStyle='#0e111a'; ctx.strokeStyle='rgba(255,255,255,0.08)'; ctx.beginPath(); ctx.roundRect(x,28,62,50,8); ctx.fill(); ctx.stroke();
ctx.fillStyle=`rgb(${col})`; ctx.font='700 9px JetBrains Mono'; ctx.fillText(label, x+10,52);
if(x>8){ ctx.strokeStyle='rgba(255,255,255,0.25)'; ctx.beginPath(); ctx.moveTo(x-6,53); ctx.lineTo(x,53); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x-2,49); ctx.lineTo(x,53); ctx.lineTo(x-2,57); ctx.stroke();}
x+=70;
});
} else if(l.viz==='seq'){
ctx.fillStyle='#0e111a';
for(let i=0;i<16;i++){
const on=seqSteps[i];
const xx=10 + i*22, yy=30;
ctx.fillStyle=on?'#00ffd1':'#1a1e2b'; ctx.strokeStyle=on?'#00ffd1':'#262c3f';
ctx.beginPath(); ctx.roundRect(xx,yy,18,48,6); ctx.fill(); ctx.stroke();
if(on){ ctx.fillStyle='rgba(0,0,0,0.2)'; ctx.fillRect(xx,yy+36,18,12); }
ctx.fillStyle=on?'#000':'#5a638a'; ctx.font='7px JetBrains Mono'; ctx.fillText(String(i+1), xx+5, yy+66);
}
ctx.fillStyle='#ffd028'; if(seqPlaying){ const px=10+seqPos*22; ctx.fillRect(px,22,18,2); }
}
}
function drawFilterViz(){
const c=document.getElementById('filterCanvas'); if(!c) return;
const ctx=c.getContext('2d'); const w=c.width, h=c.height;
ctx.clearRect(0,0,w,h);
ctx.fillStyle='#070a11'; ctx.fillRect(0,0,w,h);
ctx.strokeStyle='#ff3b82'; ctx.lineWidth=2;
ctx.beginPath();
for(let x=0;x<w;x++){
const f=80*Math.pow(100, x/w); // log
const cutoff=settings.cutoff;
let g;
if(settings.filterType==='lowpass') g= 1/(1+Math.pow(f/cutoff,3));
else if(settings.filterType==='highpass') g= 1/(1+Math.pow(cutoff/f,3));
else g= Math.exp(-Math.pow(Math.log(f/cutoff),2)*6);
// resonance peak
if(Math.abs(f-cutoff)<cutoff*0.18) g+= (settings.resonance/12)*Math.exp(-Math.pow((f-cutoff)/(cutoff*0.08),2));
const y=h-8 - g*(h-18);
if(x===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
}
ctx.stroke();
// fill under
ctx.lineTo(w,h); ctx.lineTo(0,h); ctx.closePath(); ctx.fillStyle='rgba(255,59,130,0.12)'; ctx.fill();
// cutoff line
const cx = Math.log(settings.cutoff/80)/Math.log(100) * w;
ctx.strokeStyle='rgba(255,208,40,0.9)'; ctx.setLineDash([4,4]); ctx.beginPath(); ctx.moveTo(cx,0); ctx.lineTo(cx,h); ctx.stroke(); ctx.setLineDash([]);
}
let seqSteps=Array(16).fill(false);
let seqPos=0, seqTimer=null, seqPlaying=false;
function buildSeq(){
const g=document.getElementById('seqGrid'); if(!g) return;
g.innerHTML='';
seqSteps.forEach((on,i)=>{
const s=document.createElement('div'); s.className='seq-step '+(on?'on':'')+' '+(seqPlaying&&seqPos===i?'playing':'');
s.innerHTML=`<b>${i+1}</b>`; s.onclick=()=>{ seqSteps[i]=!seqSteps[i]; buildSeq(); drawLessonViz(); };
g.appendChild(s);
});
}
function toggleSeq(){
seqPlaying=!seqPlaying;
const btn=document.getElementById('seqPlay');
btn.textContent=seqPlaying?'■ STOP':'▶ PLAY';
btn.style.background=seqPlaying?'var(--accent2)':'var(--yellow)';
btn.style.color=seqPlaying?'#fff':'#000';
if(seqPlaying){
ensureAudio();
const stepTime=()=> (60/settings.tempo)/4; // 16th
const tick=()=>{
if(!seqPlaying) return;
const isOn=seqSteps[seqPos];
if(isOn){
const midi=octave*12 + [0,2,4,5,7,9,11,12][seqPos%8]; // simple pattern around C major
// use pentatonic variation
const base=octave*12; const scale=[0,2,4,7,9];
const midi2=base + scale[seqPos%5] + 12*Math.floor(seqPos/5)%2;
noteOn(midi2, 95); setTimeout(()=>noteOff(midi2), stepTime()*900);
}
seqPos=(seqPos+1)%16;
buildSeq(); drawLessonViz();
seqTimer=setTimeout(tick, stepTime()*1000);
};
seqPos=0;
tick();
} else {
clearTimeout(seqTimer);
buildSeq();
}
}
// Scope analyser
function animateScope(){
const canvas=document.getElementById('scope'); if(!canvas) return; const ctx=canvas.getContext('2d');
const w=canvas.width, h=canvas.height;
function frame(){
requestAnimationFrame(frame);
ctx.clearRect(0,0,w,h);
// bg
ctx.fillStyle='#070a11'; ctx.fillRect(0,0,w,h);
// grid
ctx.strokeStyle='rgba(255,255,255,0.04)'; ctx.lineWidth=1;
for(let y=0;y<h;y+=24){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();}
for(let x=0;x<w;x+=60){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke();}
if(!analyser){ // idle wave
ctx.strokeStyle='rgba(0,255,209,0.9)'; ctx.lineWidth=2; ctx.beginPath();
for(let x=0;x<w;x++){ const y=h/2 + Math.sin(x*0.02 + Date.now()*0.004)*22; if(x===0)ctx.moveTo(x,y); else ctx.lineTo(x,y);}
ctx.stroke(); return;
}
const buffer=new Uint8Array(analyser.fftSize);
analyser.getByteTimeDomainData(buffer);
ctx.strokeStyle='#00ffd1'; ctx.lineWidth=2; ctx.shadowColor='#00ffd1'; ctx.shadowBlur=8; ctx.beginPath();
const slice=w/buffer.length;
for(let i=0;i<buffer.length;i++){
const v=buffer[i]/128 -1;
const y=v*h*0.42 + h*0.5;
const x=i*slice;
if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
}
ctx.stroke(); ctx.shadowBlur=0;
// spectrum faint
analyser.getByteFrequencyData(buffer);
ctx.fillStyle='rgba(124,92,255,0.18)';
for(let i=0;i<120;i++){
const val=buffer[i]/255;
ctx.fillRect(i*7, h-2, 5, -val*40);
}
}
frame();
}
function updateMeter(){
const f=document.getElementById('meterFill');
const active=voices.size>0? 85+Math.random()*15 : 12+Math.sin(Date.now()*0.005)*6;
f.style.width=active+'%';
f.style.background=voices.size>0? 'linear-gradient(90deg,var(--accent),var(--yellow))':'#2a3350';
requestAnimationFrame(()=>setTimeout(updateMeter,100));
}
// Keyboard handling
const keyMap={
'a':0,'w':1,'s':2,'e':3,'d':4,'f':5,'t':6,'g':7,'y':8,'h':9,'u':10,'j':11,'k':12,'o':13,'l':14
};
let heldKeys=new Set();
window.addEventListener('keydown',e=>{
if(e.repeat) return;
if(e.key==='Shift') isSustain=true;
if(e.key==='z'||e.key==='Z'){ octave=Math.max(2,octave-1); document.getElementById('octLabel').textContent=octave; buildPiano(); return;}
if(e.key==='x'||e.key==='X'){ octave=Math.min(6,octave+1); document.getElementById('octLabel').textContent=octave; buildPiano(); return;}
const k=e.key.toLowerCase();
if(keyMap[k]!==undefined){
const midi=octave*12 + keyMap[k];
if(!heldKeys.has(midi)){ heldKeys.add(midi); noteOn(midi, velocity); highlightKey(midi,true); }
}
});
window.addEventListener('keyup',e=>{
if(e.key==='Shift'){ isSustain=false; heldKeys.forEach(m=>noteOff(m)); heldKeys.clear(); document.querySelectorAll('.white.active,.black.active').forEach(el=>el.classList.remove('active')); return;}
const k=e.key.toLowerCase();
if(keyMap[k]!==undefined){
const midi=octave*12 + keyMap[k];
heldKeys.delete(midi);
if(!isSustain) noteOff(midi);
highlightKey(midi,false);
}
});
function highlightKey(midi,on){
document.querySelectorAll(`[data-midi="${midi}"]`).forEach(el=>el.classList.toggle('active',on));
}
function renderAll(){
renderProgress(); renderLesson(); renderModules(); renderPresets(); buildPiano(); updateTags();
}
document.getElementById('powerBtn').onclick=()=>{ ensureAudio(); };
document.getElementById('octUp').onclick=()=>{ octave=Math.min(6,octave+1); document.getElementById('octLabel').textContent=octave; buildPiano(); };
document.getElementById('octDown').onclick=()=>{ octave=Math.max(2,octave-1); document.getElementById('octLabel').textContent=octave; buildPiano(); };
document.getElementById('velSlider').oninput=e=>{ velocity=+e.target.value; document.getElementById('velLabel').textContent=Math.round(velocity/127*100)+'%'; };
document.getElementById('prevBtn').onclick=()=>{ currentLevel=Math.max(0,currentLevel-1); renderAll(); };
document.getElementById('nextBtn').onclick=()=>{ currentLevel=Math.min(lessons.length-1,currentLevel+1); renderAll(); };
// init
renderAll();
animateScope();
updateMeter();
updateTags();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"566ab9f6-1405-4c49-a457-e81b0c234f32","tokensIn":209,"tokensOut":17666,"tokensTotal":17875,"cost":0.07521745,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:28:28.476Z"} -->