TutSynth
muse spark 1.1 · composer

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 56,879 bytes · SHA-256 cd5df940665a
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SYNTH.LAB — Interactive Playground</title>
<style>
/* ── css: main ── */
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@400;600;700&display=swap');
:root{
--bg:#0c0c0f; --bg2:#15151a; --bg3:#1d1d24; --line:#25252f;
--accent:#e8ff5a; --accent2:#8afff5; --pink:#ff5a9a; --orange:#ff8a3d;
--text:#ececf0; --muted:#87879a; --dim:#4a4a5c;
--knob:#22222c; --knob-ring:#2e2e3b;
--locked: blur(6px) grayscale(0.8);
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font-family:'Space Grotesk',system-ui,sans-serif;overflow:hidden}
#app{height:100vh;display:flex;flex-direction:column}
#app-inner{display:flex;flex-direction:column;height:100vh}
/* topbar */
#topbar{height:54px;background:var(--bg2);border-bottom:1px solid var(--line);display:flex;align-items:center;padding:0 18px;gap:18px;flex-shrink:0}
.brand{font-weight:700;letter-spacing:.12em;font-size:14px;display:flex;align-items:center;gap:10px}
.brand .dot{width:10px;height:10px;background:var(--accent);border-radius:50%;box-shadow:0 0 12px var(--accent)}
.top-meta{display:flex;align-items:center;gap:12px;font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--muted);flex:1}
.progress-track{width:160px;height:4px;background:var(--bg3);border-radius:2px;overflow:hidden}
#progressBar{height:100%;background:var(--accent);width:12%;transition:width .4s}
.top-actions{display:flex;gap:8px}
.ghost{background:transparent;border:1px solid var(--line);color:var(--muted);padding:6px 12px;border-radius:6px;font-family:'JetBrains Mono';font-size:11px;letter-spacing:.05em;cursor:pointer}
.ghost:hover{border-color:var(--text);color:var(--text)}
/* layout */
#layout{flex:1;display:grid;grid-template-columns:280px 1fr 360px;overflow:hidden;min-height:0}
#sidebar{background:var(--bg2);border-right:1px solid var(--line);display:flex;flex-direction:column;overflow:auto}
#lessonList{padding:12px;display:flex;flex-direction:column;gap:4px}
.lesson-item{padding:10px 12px;border-radius:8px;cursor:pointer;border:1px solid transparent;display:flex;gap:10px;align-items:center;transition:.2s}
.lesson-item:hover{background:var(--bg3)}
.lesson-item.active{background:var(--bg3);border-color:var(--line);box-shadow:inset 0 0 0 1px var(--line)}
.lesson-item .idx{font-family:'JetBrains Mono';font-size:10px;color:var(--dim);width:20px}
.lesson-item .lt{font-size:13px;font-weight:600;line-height:1.1}
.lesson-item .ld{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:150px}
.lesson-item.done .idx{color:var(--accent)}
.lesson-item.locked{opacity:.35;pointer-events:none}
.lesson-item.active .lt{color:var(--accent)}
#currentLessonBox{margin:12px;padding:16px;background:var(--bg3);border-radius:12px;border:1px solid var(--line)}
#lessonTitle{margin:0 0 8px;font-size:18px;line-height:1.2;letter-spacing:-.01em}
#lessonDesc{margin:0;font-size:13px;line-height:1.5;color:var(--muted)}
#lessonTip{margin-top:12px;padding:10px 12px;background:#0f0f14;border-radius:8px;border-left:2px solid var(--accent);font-size:12px;line-height:1.5;color:#cfd0d8}
#lessonGoal{margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}
#lessonGoal .label{font-family:'JetBrains Mono';font-size:9px;letter-spacing:.15em;color:var(--accent)}
#goalText{margin:6px 0 0;font-size:12px;line-height:1.4;color:var(--text)}
#synthPanel{background:linear-gradient(180deg,var(--bg) 0%,#101016 100%);overflow:auto;padding:18px;display:flex;flex-direction:column;gap:16px}
.panel-section{background:var(--bg2);border:1px solid var(--line);border-radius:14px;overflow:hidden;transition:.4s}
.panel-section.locked{opacity:.45;filter:grayscale(.6);pointer-events:none;position:relative}
.panel-section.locked::after{content:'🔒 UNLOCKED IN LATER LESSON';position:absolute;right:16px;top:14px;font-family:'JetBrains Mono';font-size:9px;color:var(--dim);letter-spacing:.1em}
.sec-head{height:42px;display:flex;align-items:center;gap:10px;padding:0 16px;border-bottom:1px solid var(--line);background:linear-gradient(90deg,rgba(232,255,90,.06),transparent)}
.sec-num{width:24px;height:24px;border-radius:6px;background:var(--bg3);border:1px solid var(--line);display:grid;place-items:center;font-family:'JetBrains Mono';font-size:11px;color:var(--muted)}
.sec-head h3{margin:0;font-size:12px;letter-spacing:.12em;color:var(--text)}
.sec-led{margin-left:auto;width:6px;height:6px;border-radius:50%;background:var(--dim)}
.panel-section:not(.locked) .sec-led{background:var(--accent);box-shadow:0 0 8px var(--accent)}
.sec-body{padding:14px 16px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.group{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:12px}
.group.dimmed{opacity:.5}
.g-label{font-family:'JetBrains Mono';font-size:10px;letter-spacing:.12em;color:var(--muted);margin-bottom:10px;display:flex;gap:8px;align-items:center}
.level-badge{font-size:8px;padding:2px 5px;border-radius:4px;background:var(--bg3);border:1px solid var(--line)}
.row{display:flex;gap:16px;align-items:flex-start}
.ctrl-hint{font-size:11px;color:var(--dim);line-height:1.3;margin-left:8px;max-width:120px}
.control{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:64px}
.control label{font-family:'JetBrains Mono';font-size:9px;letter-spacing:.1em;color:var(--muted)}
.knob{width:56px;height:56px;border-radius:50%;background:radial-gradient(circle at 30% 30%,#2e2e3a,#1a1a22);border:2px solid var(--knob-ring);position:relative;cursor:grab;box-shadow:inset 0 2px 6px rgba(0,0,0,.6), 0 2px 10px rgba(0,0,0,.3);touch-action:none}
.knob:active{cursor:grabbing}
.knob .pointer{position:absolute;width:3px;height:18px;background:var(--accent);left:50%;top:6px;transform-origin:50% 22px;border-radius:2px;box-shadow:0 0 6px var(--accent)}
.knob .val{position:absolute;bottom:-18px;left:50%;transform:translateX(-50%);font-family:'JetBrains Mono';font-size:9px;color:var(--muted);white-space:nowrap}
.select{display:flex;gap:2px;background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:3px}
.select button{background:transparent;border:0;padding:5px 9px;border-radius:5px;font-family:'JetBrains Mono';font-size:10px;color:var(--muted);cursor:pointer;letter-spacing:.04em}
.select button.active{background:var(--text);color:var(--bg);font-weight:700}
.filter-viz-row{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
.filter-controls{display:flex;gap:14px;flex-wrap:wrap}
.env-row,.lfo-row,.fx-row{display:flex;gap:16px;align-items:flex-end;flex-wrap:wrap}
.env-controls{display:flex;gap:14px}
canvas#filterCanvas,canvas#envCanvas{background:#0a0a0f;border:1px solid var(--line);border-radius:8px}
/* viz panel */
#vizPanel{background:var(--bg2);border-left:1px solid var(--line);display:flex;flex-direction:column;overflow:auto}
.viz-block{padding:14px;border-bottom:1px solid var(--line)}
.viz-head{display:flex;align-items:center;justify-content:space-between;font-family:'JetBrains Mono';font-size:10px;letter-spacing:.12em;color:var(--muted);margin-bottom:10px}
.viz-tabs{display:flex;gap:4px}
.viz-tabs button{font-family:'JetBrains Mono';font-size:9px;padding:3px 7px;border-radius:4px;border:1px solid var(--line);background:transparent;color:var(--dim);cursor:pointer}
.viz-tabs button.active{background:var(--bg3);color:var(--text);border-color:var(--text)}
#scopeCanvas{width:100%;background:#08080c;border:1px solid var(--line);border-radius:8px;display:block}
.viz-readout{display:flex;justify-content:space-between;margin-top:6px;font-family:'JetBrains Mono';font-size:11px;color:var(--muted)}
#explainBox{font-size:12.5px;line-height:1.6;color:#c9c9d4;background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:12px;min-height:112px}
#explainBox strong{color:var(--text)}
#explainBox .wave{color:var(--accent)}
#explainBox .filt{color:var(--accent2)}
#explainBox .env{color:var(--orange)}
.patch pre{margin:0;font-family:'JetBrains Mono';font-size:10px;line-height:1.5;color:var(--muted);background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:10px;overflow:auto;max-height:140px}
.tiny{font-family:'JetBrains Mono';font-size:9px;padding:4px 8px;border-radius:5px;border:1px solid var(--line);background:var(--bg3);color:var(--muted);cursor:pointer}
/* keyboard */
#keyboardWrap{background:#0e0e12;border-top:1px solid var(--line);padding:12px 14px 8px;flex-shrink:0}
#keyboard{position:relative;height:96px;user-select:none;display:flex}
.kb-white{flex:1;display:flex;gap:2px}
.kb-key.white{flex:1;background:linear-gradient(180deg,#f6f6f8,#d4d4d8);border-radius:0 0 6px 6px;border:1px solid #999;position:relative;cursor:pointer;display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;font-family:'JetBrains Mono';font-size:10px;color:#555}
.kb-key.white.active{background:var(--accent)!important;color:#000;box-shadow:inset 0 0 20px rgba(232,255,90,.6)}
.kb-black{position:absolute;top:0;left:0;right:0;height:58%;pointer-events:none}
.kb-key.black{position:absolute;width:36px;height:100%;background:linear-gradient(180deg,#2a2a32,#0e0e12);border-radius:0 0 5px 5px;border:1px solid #000;pointer-events:auto;cursor:pointer;display:flex;align-items:flex-end;justify-content:center;padding-bottom:4px;color:#aaa;font-size:8px;transform:translateX(-50%)}
.kb-key.black.active{background:var(--pink)!important;box-shadow:0 0 10px var(--pink)}
.kb-hint{margin-top:8px;font-family:'JetBrains Mono';font-size:9px;color:var(--dim);letter-spacing:.04em;text-align:center}
@media(max-width:1150px){
#layout{grid-template-columns:280px 1fr}
#vizPanel{display:none}
}
@media(max-width:760px){
#layout{grid-template-columns:1fr}
#sidebar{max-height:38vh}
.grid2{grid-template-columns:1fr}
#keyboard{height:76px}
}
</style>
</head>
<body>
<div id="app-inner">
<header id="topbar">
<div class="brand"><span class="dot"></span>SYNTH.LAB</div>
<div class="top-meta"><span id="lessonCounter">01 / 08</span><div class="progress-track"><div id="progressBar"></div></div></div>
<div class="top-actions">
<button id="btnFree" class="ghost">FREE PLAY</button>
<button id="btnInit" class="ghost">PANIC</button>
</div>
</header>
<div id="layout">
<aside id="sidebar">
<div id="lessonList"></div>
<div id="currentLessonBox">
<h2 id="lessonTitle"></h2>
<p id="lessonDesc"></p>
<div id="lessonTip"></div>
<div id="lessonGoal"><span class="label">TRY THIS</span><p id="goalText"></p></div>
</div>
</aside>
<main id="synthPanel">
<div class="panel-section" data-lock="osc">
<div class="sec-head"><span class="sec-num">I</span><h3>OSCILLATORS</h3><div class="sec-led"></div></div>
<div class="sec-body grid2">
<div class="group" id="g-osc1"><div class="g-label">OSC 1 <span class="level-badge">MAIN</span></div>
<div class="row">
<div class="control" data-ctrl="osc1-type"><label>SHAPE</label><div class="select-wrap" id="sel-osc1-type"></div></div>
<div class="control" data-ctrl="osc1-oct"><label>OCTAVE</label><div class="knob-wrap" id="knob-osc1-oct"></div></div>
<div class="control" data-ctrl="osc1-detune"><label>DETUNE</label><div class="knob-wrap" id="knob-osc1-detune"></div></div>
</div>
</div>
<div class="group dimmed" id="g-osc2"><div class="g-label">OSC 2 <span class="level-badge">LAYER</span></div>
<div class="row">
<div class="control" data-ctrl="osc2-type"><label>SHAPE</label><div class="select-wrap" id="sel-osc2-type"></div></div>
<div class="control" data-ctrl="osc2-detune"><label>DETUNE</label><div class="knob-wrap" id="knob-osc2-detune"></div></div>
<div class="control" data-ctrl="osc2-mix"><label>MIX</label><div class="knob-wrap" id="knob-osc2-mix"></div></div>
</div>
</div>
<div class="group dimmed" id="g-noise"><div class="g-label">NOISE</div>
<div class="row"><div class="control" data-ctrl="noise-mix"><label>AMOUNT</label><div class="knob-wrap" id="knob-noise-mix"></div></div><div class="ctrl-hint">Adds breath, percussion, air</div></div>
</div>
</div>
</div>
<div class="panel-section" data-lock="filter">
<div class="sec-head"><span class="sec-num">II</span><h3>FILTER — SCULPT</h3><div class="sec-led"></div></div>
<div class="sec-body">
<div class="filter-viz-row">
<canvas id="filterCanvas" width="400" height="110"></canvas>
<div class="filter-controls">
<div class="control" data-ctrl="filter-type"><label>TYPE</label><div class="select-wrap" id="sel-filter-type"></div></div>
<div class="control" data-ctrl="filter-cutoff"><label>CUTOFF</label><div class="knob-wrap" id="knob-filter-cutoff"></div></div>
<div class="control" data-ctrl="filter-res"><label>RES</label><div class="knob-wrap" id="knob-filter-res"></div></div>
<div class="control" data-ctrl="filter-env"><label>ENV → CUT</label><div class="knob-wrap" id="knob-filter-env"></div></div>
</div>
</div>
</div>
</div>
<div class="panel-section" data-lock="env">
<div class="sec-head"><span class="sec-num">III</span><h3>ENVELOPE — SHAPE TIME</h3><div class="sec-led"></div></div>
<div class="sec-body">
<div class="env-row">
<canvas id="envCanvas" width="320" height="110"></canvas>
<div class="env-controls">
<div class="control" data-ctrl="env-a"><label>ATTACK</label><div class="knob-wrap" id="knob-env-a"></div></div>
<div class="control" data-ctrl="env-d"><label>DECAY</label><div class="knob-wrap" id="knob-env-d"></div></div>
<div class="control" data-ctrl="env-s"><label>SUSTAIN</label><div class="knob-wrap" id="knob-env-s"></div></div>
<div class="control" data-ctrl="env-r"><label>RELEASE</label><div class="knob-wrap" id="knob-env-r"></div></div>
</div>
</div>
</div>
</div>
<div class="panel-section" data-lock="lfo">
<div class="sec-head"><span class="sec-num">IV</span><h3>LFO — MOVEMENT</h3><div class="sec-led"></div></div>
<div class="sec-body">
<div class="lfo-row">
<div class="control" data-ctrl="lfo-target"><label>TARGET</label><div class="select-wrap" id="sel-lfo-target"></div></div>
<div class="control" data-ctrl="lfo-shape"><label>SHAPE</label><div class="select-wrap" id="sel-lfo-shape"></div></div>
<div class="control" data-ctrl="lfo-rate"><label>RATE</label><div class="knob-wrap" id="knob-lfo-rate"></div></div>
<div class="control" data-ctrl="lfo-amt"><label>AMOUNT</label><div class="knob-wrap" id="knob-lfo-amt"></div></div>
</div>
</div>
</div>
<div class="panel-section" data-lock="fx">
<div class="sec-head"><span class="sec-num">V</span><h3>SPACE — DELAY & DRIVE</h3><div class="sec-led"></div></div>
<div class="sec-body fx-row">
<div class="control" data-ctrl="delay-mix"><label>DELAY</label><div class="knob-wrap" id="knob-delay-mix"></div></div>
<div class="control" data-ctrl="delay-time"><label>TIME</label><div class="knob-wrap" id="knob-delay-time"></div></div>
<div class="control" data-ctrl="drive"><label>DRIVE</label><div class="knob-wrap" id="knob-drive"></div></div>
<div class="control" data-ctrl="glide"><label>GLIDE</label><div class="knob-wrap" id="knob-glide"></div></div>
</div>
</div>
</main>
<aside id="vizPanel">
<div class="viz-block">
<div class="viz-head"><span>OSCILLOSCOPE</span><div class="viz-tabs"><button class="active" data-viz="wave">WAVE</button><button data-viz="spec">SPEC</button></div></div>
<canvas id="scopeCanvas" width="340" height="150"></canvas>
<div class="viz-readout"><span id="freqRead">-- Hz</span><span id="noteRead">--</span></div>
</div>
<div class="viz-block">
<div class="viz-head"><span>WHAT YOU'RE HEARING</span></div>
<div id="explainBox"></div>
</div>
<div class="viz-block patch">
<div class="viz-head"><span>CURRENT PATCH</span><button id="btnRandom" class="tiny">RANDOMIZE</button></div>
<pre id="patchCode"></pre>
</div>
</aside>
</div>
<div id="keyboardWrap">
<div id="keyboard"></div>
<div class="kb-hint">PLAY: Mouse / Touch — QWERTY Row: A S D F G H J = white keys, W E T Y U = black · Hold SHIFT = sustain · [ / ] = octave</div>
</div>
</div>
<script>
'use strict';
// ── const: LESSONS ──
const LESSONS = [
{
id:0, num:"01", title:"Sine = The Pure Tone",
unlocks:["osc1-type","osc1-oct"],
what:"switch osc, hold a note",
desc:`Every sound starts with an oscillator — something vibrating. A sine wave is the purest, no harmonics. It's your tuning fork, your sub-bass, your blank canvas.`,
tip:`Sine has only the fundamental. Look at the oscilloscope — it's a smooth curve. No buzz, no edge. Everything else adds harmonics on top of this.`,
goal:"Hold a note. Switch OSC 1 to SINE. Hear how pure it is. Then try TRI for slightly more air.",
explain:`<strong class="wave">SINE = 1 harmonic</strong><br>Just the fundamental pitch. No overtones. Used for test tones and warm sub-bass. Your scope shows a perfect smooth wave.`,
params:{'osc1-type':'sine','osc1-oct':0}
},
{
id:1, num:"02", title:"Saw vs Square — Harmonics",
unlocks:["osc1-type","osc1-oct","osc1-detune"],
what:"explore all shapes",
desc:`Real instruments are never pure. Saw adds every harmonic, buzzy and bright — the string section of synths. Square adds only odd harmonics, hollow and reedy.`,
tip:`Saw = all harmonics descending in amplitude. Square = odd only. That's why square sounds hollow. Triangle is soft odd harmonics, between sine and square.`,
goal:"Play same note on SAW, then SQUARE. Check the scope — saw is jagged, square is blocky. Notice brightness difference.",
explain:`<strong class="wave">HARMONICS ARE COLOR</strong><br>• <b>Saw</b>: all harmonics → bright, full. Good for pads, strings<br>• <b>Square</b>: odd only → hollow, woodwind<br>• <b>Triangle</b>: soft odd → flutey, muted`,
params:{}
},
{
id:2, num:"03", title:"Stacking — Two Osc + Detune",
unlocks:["osc1-type","osc1-oct","osc1-detune","osc2-type","osc2-detune","osc2-mix","noise-mix"],
what:"detune osc2, add noise",
desc:`One oscillator plays one note. Two slightly detuned oscillators beat against each other — chorus, width, fatness. This is how you get supersaws, unison leads, monsters.`,
tip:`Detune by <10 cents = thickening. >20 cents = dissonant beating you can hear pulse. Mix in noise for breath, hi-hats, or lo-fi character.`,
goal:"Set OSC2 MIX to 60%. Choose SAW for both. Detune OSC2 to +7 cents. Hear it get wide. Add 10% noise.",
explain:`<strong>DETUNE = FAT</strong><br>Two identical waves at almost same pitch interfere, creating slow amplitude beating. Producers stack 7+ saw waves detuned to make supersaw. Noise adds aperiodic energy — air, not pitch.`,
params:{'osc2-mix':0.6}
},
{
id:3, num:"04", title:"Filter — Remove to Sculpt",
unlocks:["osc1-type","osc1-oct","osc1-detune","osc2-type","osc2-detune","osc2-mix","noise-mix","filter-type","filter-cutoff","filter-res"],
what:"play with cutoff",
desc:`Subtractive synthesis: start bright, remove. The filter is scissors. Low-pass lets lows through, cuts highs — like closing your mouth. High-pass is opposite.`,
tip:`Cutoff is where cutting starts. Resonance boosts the edge at cutoff — makes it ring. Push res -> 80% and sweep cutoff for classic acid squelch.`,
goal:"Set OSC to SAW. Play a note, drag CUTOFF from 20k down to 200Hz. Hear brightness die. Push RES to 40% and sweep again.",
explain:`<strong class="filt">FILTER = EQ WITH ATTITUDE</strong><br><b>LP</b>: keep lows, shave highs (warm)<br><b>HP</b>: keep highs (thin, telephone)<br><b>CUTOFF</b>: frequency of cut<br><b>RES</b>: peak at cut = resonant, vocal-ish`,
params:{'filter-cutoff':0.6}
},
{
id:4, num:"05", title:"ADSR — Shape Over Time",
unlocks:["osc1-type","osc1-oct","osc1-detune","osc2-type","osc2-detune","osc2-mix","noise-mix","filter-type","filter-cutoff","filter-res","env-a","env-d","env-s","env-r"],
what:"make pluck then pad",
desc:`Envelope is how a note behaves over time. Attack = fade in, Decay = drop to Sustain level, Sustain = held level, Release = fade after key up. Every sound tells a time-story.`,
tip:`Piano = fast Attack, no Sustain, long Release. Pad = slow Attack, high Sustain. Pluck = fast Attack, fast Decay, no Sustain. Organ = instant A, full S.`,
goal:"Make a pluck: Attack 0, Decay 300ms, Sustain 0, Release 200ms. Then make a pad: Attack 800ms, Decay 400ms, Sustain 70%, Release 1s.",
explain:`<strong class="env">ADSR = THE LIFE OF A NOTE</strong><br>Even with same pitch and filter, ADSR changes identity: percussive vs bowed vs pressed. Watch the envelope canvas — it's literally the volume contour.`,
params:{}
},
{
id:5, num:"06", title:"Filter Envelope — Punch",
unlocks:["osc1-type","osc1-oct","osc1-detune","osc2-type","osc2-detune","osc2-mix","noise-mix","filter-type","filter-cutoff","filter-res","filter-env","env-a","env-d","env-s","env-r"],
what:"use env->cut",
desc:`Your loudness envelope can also move the filter. ENV→CUT = classic synth brass trick: filter opens fast with each note, then decays. Makes every note bite.`,
tip:`Set CUTOFF low (200Hz), ENV→CUT high (+70%). Then with snappy Decay, each hit gets a bright twang that quickly gets mellow — super expressive.`,
goal:"Set CUTOFF 300Hz, ENV→CUT to +60%, make Decay short. Play staccato. That's the 'wah' punch heard on every funky synth bass.",
explain:`<strong class="filt">ENV → FILTER = ARTICULATION</strong><br>When filter follows envelope, filter acts like mouth shape: open at hit, close as note sustains. Amount sets how much it opens. Negative values close on hit (underwater plonk).`,
params:{}
},
{
id:6, num:"07", title:"LFO — Make It Move",
unlocks:["osc1-type","osc1-oct","osc1-detune","osc2-type","osc2-detune","osc2-mix","noise-mix","filter-type","filter-cutoff","filter-res","filter-env","env-a","env-d","env-s","env-r","lfo-target","lfo-shape","lfo-rate","lfo-amt"],
what:"add vibrato / wobble",
desc:`LFO = Low Frequency Oscillator. Too slow to hear as pitch (0.1-20Hz), so you feel it as movement. Route to pitch = vibrato. Route to filter = auto-wah wobble.`,
tip:`Vibrato: LFO target PITCH, rate 5-7Hz, amount tiny (10-20 cents). Dubstep wobble: target FILTER, rate sync to tempo (2-4Hz), amount huge.`,
goal:"Target FILTER, shape TRI, rate 3Hz, amount 50%. Hold a note. Now try target PITCH, rate 6Hz, amount small — instant human voice.",
explain:`<strong>LFO = ROBOT HAND</strong><br>Constantly turning a knob for you. <b>Rate</b>=how fast, <b>Amount</b>=how far. Slow sines = evolving pads. Fast squares = trills.`,
params:{}
},
{
id:7, num:"08", title:"Space — Glue It Together",
unlocks:["all"],
what:"play freely",
desc:`Delay repeats, drive saturates, glide slides. These don't make the sound, they place it in a room and make it playable. Now you have a full instrument. You've learned synthesis.`,
tip:`Short delay (150ms, 30% mix) = slapback 50s. Long (400ms, feedback) = space. Drive adds harmonics (like more oscillators for free). Glide only matters when you overlap notes — legato phrasing.`,
goal:"You've graduated. Set OSCs to SAW 40% mix, LP 1.2kHz + Res 20% + ENV→CUT 40%, plucky ADSR, slow LFO to filter, touch of delay. Play a riff. That's a classic house lead.",
explain:`<strong>FREE PLAY</strong><br>Recipe for most synths:<br>1. Choose raw material (OSC)<br>2. Carve (FILTER)<br>3. Give it time-behavior (ADSR + filter env)<br>4. Give it life (LFO)<br>5. Place in space (FX)`,
params:{}
},
];
// ── const: WAVE_OPTS ──
const WAVE_OPTS = [
{v:'sine', l:'SINE'},
{v:'triangle', l:'TRI'},
{v:'sawtooth', l:'SAW'},
{v:'square', l:'SQR'},
];
// ── const: FILTER_OPTS ──
const FILTER_OPTS = [
{v:'lowpass', l:'LP'},
{v:'highpass', l:'HP'},
{v:'bandpass', l:'BP'},
];
// ── const: LFO_TARGET_OPTS ──
const LFO_TARGET_OPTS = [
{v:'off', l:'OFF'},
{v:'pitch', l:'PITCH'},
{v:'filter', l:'FILTER'},
];
// ── const: LFO_SHAPE_OPTS ──
const LFO_SHAPE_OPTS = [
{v:'sine', l:'SINE'},
{v:'triangle', l:'TRI'},
{v:'sawtooth', l:'SAW'},
{v:'square', l:'SQR'},
];
// ── const: DEFAULT_PARAMS ──
const DEFAULT_PARAMS = {
'osc1-type':'sawtooth',
'osc1-oct':0,
'osc1-detune':0,
'osc2-type':'sawtooth',
'osc2-detune':5,
'osc2-mix':0,
'noise-mix':0,
'filter-type':'lowpass',
'filter-cutoff':0.75, // normalized 0-1 maps to 80-18000
'filter-res':0.1,
'filter-env':0,
'env-a':0.01,
'env-d':0.2,
'env-s':0.7,
'env-r':0.4,
'lfo-target':'off',
'lfo-shape':'sine',
'lfo-rate':0.3, // norm 0-1 => 0.1-20Hz
'lfo-amt':0.3,
'delay-mix':0,
'delay-time':0.35, // norm
'drive':0,
'glide':0,
};
// ── function: hzFromNormCutoff ──
function hzFromNormCutoff(norm){
// 40Hz to 18000 log
const minLog = Math.log(40), maxLog=Math.log(18000);
return Math.exp(minLog + norm*(maxLog-minLog));
}
// ── function: hzToNorm ──
function hzToNorm(hz){
const minLog=Math.log(40), maxLog=Math.log(18000);
return (Math.log(hz)-minLog)/(maxLog-minLog);
}
// ── class: Voice ──
class Voice {
constructor(ctx, dest, params, glideTime, prevFreq){
this.ctx=ctx; this.params=params;
this.out=ctx.createGain(); this.out.gain.value=0.0001; this.out.connect(dest);
this.filter=ctx.createBiquadFilter();
this.filter.type=params['filter-type']; this.filter.frequency.value=hzFromNormCutoff(params['filter-cutoff']); this.filter.Q.value=params['filter-res']*18+0.5;
this.filter.connect(this.out);
this.amp=params['env-s'];
// osc1
this.o1=ctx.createOscillator(); this.o1.type=params['osc1-type']; this.o1.detune.value=params['osc1-detune'];
this.o1Gain=ctx.createGain(); this.o1Gain.gain.value=1-params['osc2-mix'];
this.o1.connect(this.o1Gain); this.o1Gain.connect(this.filter);
// osc2
this.o2=ctx.createOscillator(); this.o2.type=params['osc2-type']; this.o2.detune.value=params['osc2-detune'];
this.o2Gain=ctx.createGain(); this.o2Gain.gain.value=params['osc2-mix'];
this.o2.connect(this.o2Gain); this.o2Gain.connect(this.filter);
// noise
this.noiseBuf=ctx.createBufferSource();
const buf=ctx.createBuffer(1, ctx.sampleRate*2, ctx.sampleRate);
const ch=buf.getChannelData(0);
for(let i=0;i<ch.length;i++) ch[i]=Math.random()*2-1;
this.noiseBuf.buffer=buf; this.noiseBuf.loop=true;
this.noiseGain=ctx.createGain(); this.noiseGain.gain.value=params['noise-mix'];
this.noiseBuf.connect(this.noiseGain); this.noiseGain.connect(this.filter);
// LFO
this.lfo=ctx.createOscillator(); this.lfo.type=params['lfo-shape']; this.lfo.frequency.value=0.1+params['lfo-rate']*19.9;
this.lfoGain=ctx.createGain(); this.lfoGain.gain.value=0;
this.lfo.connect(this.lfoGain);
// start
const now=ctx.currentTime;
this.o1.start(now); this.o2.start(now); this.noiseBuf.start(now); this.lfo.start(now);
this.baseFreq=0;
this.noteOn(0, now, 0, glideTime, prevFreq); // placeholder
}
updateLFORouting(){
const p=this.params;
const rate=0.1+p['lfo-rate']*19.9;
this.lfo.frequency.setTargetAtTime(rate, this.ctx.currentTime, 0.02);
this.lfo.type=p['lfo-shape'];
// disconnect all
try{ this.lfoGain.disconnect(); }catch{}
this.lfoGain.gain.value=0;
if(p['lfo-target']==='off'){ this.lfoGain.gain.value=0; return; }
if(p['lfo-target']==='pitch'){
const amt=p['lfo-amt']*80; // cents
this.lfoGain.gain.value=amt;
this.lfoGain.connect(this.o1.detune); this.lfoGain.connect(this.o2.detune);
} else if(p['lfo-target']==='filter'){
const amt=p['lfo-amt']*3000;
this.lfoGain.gain.value=amt;
this.lfoGain.connect(this.filter.detune);
}
}
applyParams(p, isLive=true){
this.params={...p};
const now=this.ctx.currentTime;
this.o1.type=p['osc1-type']; this.o2.type=p['osc2-type'];
this.o1.detune.setTargetAtTime(p['osc1-detune'], now, 0.02);
this.o2.detune.setTargetAtTime(p['osc2-detune'], now, 0.02);
this.o1Gain.gain.setTargetAtTime(1-p['osc2-mix'], now, 0.02);
this.o2Gain.gain.setTargetAtTime(p['osc2-mix'], now, 0.02);
this.noiseGain.gain.setTargetAtTime(p['noise-mix'], now, 0.02);
this.filter.type=p['filter-type'];
if(isLive){
// filter cutoff will be modulated by envelope, but set base
const base=hzFromNormCutoff(p['filter-cutoff']);
this.filter.frequency.setTargetAtTime(base, now, 0.02);
}
this.filter.Q.setTargetAtTime(p['filter-res']*18+0.5, now, 0.02);
this.updateLFORouting();
}
noteOn(freq, time, vel=1, glide=0, prevFreq=0){
const now=time||this.ctx.currentTime;
if(prevFreq && glide>0.001){
this.o1.frequency.setValueAtTime(prevFreq*Math.pow(2, this.params['osc1-oct']), now);
this.o2.frequency.setValueAtTime(prevFreq*Math.pow(2, this.params['osc1-oct']), now);
this.o1.frequency.linearRampToValueAtTime(freq*Math.pow(2, this.params['osc1-oct']), now+glide);
this.o2.frequency.linearRampToValueAtTime(freq*Math.pow(2, this.params['osc1-oct']), now+glide);
} else {
this.o1.frequency.setValueAtTime(freq*Math.pow(2, this.params['osc1-oct']), now);
this.o2.frequency.setValueAtTime(freq*Math.pow(2, this.params['osc1-oct']), now);
}
this.baseFreq=freq;
// amp ADSR
const a=this.params['env-a'], d=this.params['env-d'], s=this.params['env-s'];
const envAmt=this.params['filter-env'];
const baseF=hzFromNormCutoff(this.params['filter-cutoff']);
const peakF=baseF * Math.pow(2, envAmt*4); // up to +4 oct
// amp
this.out.gain.cancelScheduledValues(now);
this.out.gain.setValueAtTime(0.0001, now);
this.out.gain.linearRampToValueAtTime(vel, now+a*1.5+0.001);
this.out.gain.linearRampToValueAtTime(s*vel, now+a*1.5+d*1.2);
// filter env
this.filter.frequency.cancelScheduledValues(now);
this.filter.frequency.setValueAtTime(baseF, now);
if(envAmt!==0){
this.filter.frequency.linearRampToValueAtTime(peakF, now+a*1.2+0.001);
this.filter.frequency.linearRampToValueAtTime(baseF + (peakF-baseF)*s*0.5, now+a*1.2+d);
}
}
noteOff(time){
const now=time||this.ctx.currentTime;
const r=this.params['env-r'];
this.out.gain.cancelScheduledValues(now);
this.out.gain.setTargetAtTime(0.0001, now, r*0.4+0.02);
this.filter.frequency.cancelScheduledValues(now);
const baseF=hzFromNormCutoff(this.params['filter-cutoff']);
this.filter.frequency.setTargetAtTime(baseF, now, r*0.3+0.02);
setTimeout(()=>this.dispose(), (r*1000)+500);
}
dispose(){
try{this.o1.stop();}catch{}
try{this.o2.stop();}catch{}
try{this.noiseBuf.stop();}catch{}
try{this.lfo.stop();}catch{}
try{this.out.disconnect();}catch{}
}
}
// ── class: SynthEngine ──
class SynthEngine {
constructor(){
this.ctx=null; this.master=null; this.analyser=null; this.driveNode=null; this.delayNode=null; this.delayGain=null; this.delayFb=null;
this.voices=new Map();
this.params={...DEFAULT_PARAMS};
this.lastFreq=0;
this.octave=4;
}
ensure(){
if(this.ctx) return;
const AudioCtx=window.AudioContext||window.webkitAudioContext;
this.ctx=new AudioCtx();
this.master=this.ctx.createGain(); this.master.gain.value=0.7;
this.driveNode=this.ctx.createWaveShaper(); this.driveNode.curve=this.makeDrive(0);
this.delayNode=this.ctx.createDelay(2); this.delayNode.delayTime.value=0.35;
this.delayGain=this.ctx.createGain(); this.delayGain.gain.value=0;
this.delayFb=this.ctx.createGain(); this.delayFb.gain.value=0.3;
this.delayNode.connect(this.delayFb); this.delayFb.connect(this.delayNode);
this.delayNode.connect(this.delayGain);
this.analyser=this.ctx.createAnalyser(); this.analyser.fftSize=2048; this.analyser.smoothingTimeConstant=0.85;
// routing: voices -> drive -> master -> analyser -> dest ; plus delay parallel
this.driveNode.connect(this.master);
this.master.connect(this.analyser);
this.analyser.connect(this.ctx.destination);
this.master.connect(this.delayNode);
this.delayGain.connect(this.ctx.destination);
}
makeDrive(amount){
if(amount<0.001){ const c=new Float32Array(1); c[0]=0; // bypass-ish handled
// neutral curve
const curve=new Float32Array(44100); for(let i=0;i<44100;i++){ const x=(i-22050)/22050; curve[i]=x; } return curve;
}
const k=amount*80; const n=44100; const curve=new Float32Array(n);
for(let i=0;i<n;i++){ const x=(i-22050)/22050; curve[i]=(3+k)*x*20*Math.PI/180/(Math.PI+k*Math.abs(x)); }
return curve;
}
updateFX(){
if(!this.ctx) return;
const now=this.ctx.currentTime;
this.delayGain.gain.setTargetAtTime(this.params['delay-mix']*0.6, now, 0.05);
this.delayNode.delayTime.setTargetAtTime(0.05+this.params['delay-time']*0.65, now, 0.05);
this.driveNode.curve=this.makeDrive(this.params['drive']);
}
noteOn(midi, vel=0.9){
this.ensure();
if(this.ctx.state==='suspended') this.ctx.resume();
const freq=440*Math.pow(2,(midi-69)/12);
if(this.voices.has(midi)) this.voices.get(midi).noteOff();
const glide=this.params['glide'];
const v=new Voice(this.ctx, this.driveNode, this.params, glide, this.lastFreq);
v.noteOn(freq, this.ctx.currentTime, vel, glide, this.lastFreq);
this.voices.set(midi, v);
this.lastFreq=freq;
return freq;
}
noteOff(midi){
if(!this.voices.has(midi)) return;
const v=this.voices.get(midi);
v.noteOff(this.ctx.currentTime);
this.voices.delete(midi);
}
panic(){
this.voices.forEach(v=>v.dispose()); this.voices.clear();
}
setParam(k,v){
this.params[k]=v;
this.voices.forEach(vo=>vo.applyParams(this.params));
if(k.startsWith('delay')||k==='drive') this.updateFX();
}
getAnalyser(){ return this.analyser; }
}
// ── function: createKnob ──
function createKnob(containerId, paramKey, min, max, def, fmt, onChange){
const wrap=document.getElementById(containerId);
if(!wrap) return {set:()=>{}};
wrap.innerHTML=`<div class="knob" tabindex="0"><div class="pointer"></div><span class="val"></span></div>`;
const knob=wrap.querySelector('.knob');
const ptr=wrap.querySelector('.pointer');
const valEl=wrap.querySelector('.val');
let value=def;
function normToAng(n){ return -135 + n*270; }
function updateUI(){
const n = (value-min)/(max-min);
const ang=normToAng(Math.max(0,Math.min(1,n)));
ptr.style.transform=`translateX(-50%) rotate(${ang}deg)`;
knob.style.transformOrigin="50% 50%";
ptr.style.transformOrigin="50% 22px";
valEl.textContent=fmt?fmt(value): (value.toFixed(2));
}
function set(v, notify=true){
value=Math.max(min,Math.min(max,v));
updateUI();
if(notify) onChange(paramKey, value);
}
let dragging=false, startY=0, startVal=0;
function onPointerDown(e){
dragging=true; startY=e.touches?e.touches[0].clientY:e.clientY; startVal=value;
knob.setPointerCapture?.(e.pointerId);
e.preventDefault();
}
function onPointerMove(e){
if(!dragging) return;
const y=e.touches?e.touches[0].clientY:e.clientY;
const dy=startY-y;
const range=max-min;
const sensitivity = range/180;
set(startVal+dy*sensitivity);
}
function onPointerUp(){ dragging=false; }
knob.addEventListener('pointerdown', onPointerDown);
knob.addEventListener('mousedown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('touchmove', onPointerMove, {passive:false});
window.addEventListener('pointerup', onPointerUp);
knob.addEventListener('wheel', (e)=>{
e.preventDefault();
const delta = -Math.sign(e.deltaY)*(max-min)*0.03;
set(value+delta);
}, {passive:false});
updateUI();
return {set:(v)=>set(v,false), get:()=>value, root:wrap};
}
// ── function: createSelect ──
function createSelect(containerId, opts, def, onChange, paramKey){
const wrap=document.getElementById(containerId);
if(!wrap) return {set:()=>{}};
wrap.innerHTML=`<div class="select">${opts.map(o=>`<button data-v="${o.v}">${o.l}</button>`).join('')}</div>`;
const sel=wrap.querySelector('.select');
function set(v, notify=true){
sel.querySelectorAll('button').forEach(b=>{ b.classList.toggle('active', b.dataset.v===v); });
if(notify) onChange(paramKey, v);
}
sel.addEventListener('click', e=>{
const btn=e.target.closest('button'); if(!btn) return;
set(btn.dataset.v, true);
});
set(def,false);
return {set:(v)=>set(v,false)};
}
// ── function: midiToName ──
function midiToName(m){
const names=["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
const n=names[m%12]; const oct=Math.floor(m/12)-1; return n+oct;
}
// ── class: Visualizers ──
class Visualizers{
constructor(engine){
this.engine=engine;
this.scopeCanvas=document.getElementById('scopeCanvas');
this.sctx=this.scopeCanvas.getContext('2d');
this.filterCanvas=document.getElementById('filterCanvas');
this.fctx=this.filterCanvas.getContext('2d');
this.envCanvas=document.getElementById('envCanvas');
this.ectx=this.envCanvas.getContext('2d');
this.mode='wave';
this.raf=null;
}
start(){
const loop=()=>{
this.drawScope();
this.raf=requestAnimationFrame(loop);
};
loop();
}
stop(){ cancelAnimationFrame(this.raf); }
drawScope(){
const canvas=this.scopeCanvas; const ctx=this.sctx;
const analyser=this.engine.getAnalyser();
if(!analyser){
ctx.fillStyle="#08080c"; ctx.fillRect(0,0,canvas.width,canvas.height); return;
}
const w=canvas.width, h=canvas.height;
ctx.fillStyle="#08080c"; ctx.fillRect(0,0,w,h);
// grid
ctx.strokeStyle="rgba(255,255,255,0.06)"; ctx.lineWidth=1;
for(let i=1;i<4;i++){ ctx.beginPath(); ctx.moveTo(0,h*i/4); ctx.lineTo(w,h*i/4); ctx.stroke(); }
if(this.mode==='wave'){
const data=new Uint8Array(analyser.fftSize);
analyser.getByteTimeDomainData(data);
ctx.strokeStyle="#e8ff5a"; ctx.lineWidth=2; ctx.beginPath();
for(let i=0;i<data.length;i++){
const x=i/data.length*w;
const y=(data[i]/255)*h;
if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
}
ctx.stroke();
// glow
ctx.shadowColor="#e8ff5a"; ctx.shadowBlur=12; ctx.stroke(); ctx.shadowBlur=0;
} else {
const fData=new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(fData);
ctx.fillStyle="#8afff5";
const barW=w/fData.length*3;
for(let i=0;i<fData.length/3;i++){
const v=fData[i]/255; const bh=v*h*0.9;
ctx.globalAlpha=0.9 - i/fData.length;
ctx.fillRect(i*barW*1.2, h-bh, barW, bh);
}
ctx.globalAlpha=1;
}
}
drawFilter(params){
const canvas=this.filterCanvas; const ctx=this.fctx; if(!canvas) return;
const w=canvas.width, h=canvas.height;
ctx.fillStyle="#0a0a0f"; ctx.fillRect(0,0,w,h);
const type=params['filter-type'];
const cutoff=hzFromNormCutoff(params['filter-cutoff']);
const res=params['filter-res'];
const logMin=Math.log(20), logMax=Math.log(20000);
ctx.strokeStyle="rgba(255,255,255,0.07)"; ctx.lineWidth=1;
for(let i=0;i<=4;i++){ const x=w*i/4; ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
// response sketch
ctx.beginPath(); ctx.strokeStyle="#8afff5"; ctx.lineWidth=2;
for(let px=0; px<w; px++){
const f=Math.exp(logMin + (px/w)*(logMax-logMin));
let gain=1;
if(type==='lowpass'){
const ratio=f/cutoff;
gain = 1/Math.sqrt(1+Math.pow(ratio,4));
if(f>=cutoff*0.9 && f<=cutoff*1.1) gain += res*1.2*Math.exp(-Math.pow((f-cutoff)/(cutoff*0.1),2));
} else if(type==='highpass'){
const ratio=cutoff/f;
gain = 1/Math.sqrt(1+Math.pow(ratio,4));
if(f>=cutoff*0.9 && f<=cutoff*1.1) gain += res*1.2*Math.exp(-Math.pow((f-cutoff)/(cutoff*0.1),2));
} else {
gain = Math.exp(-Math.pow(Math.log(f/cutoff),2)*6)+res*0.6*Math.exp(-Math.pow(Math.log(f/cutoff),2)*18);
}
const db = 20*Math.log10(gain+0.001);
const y = h - ( (db+40)/50 * h + h*0.1 );
const yy=Math.max(4,Math.min(h-4,y));
if(px===0) ctx.moveTo(px,yy); else ctx.lineTo(px,yy);
}
ctx.stroke();
ctx.shadowColor="#8afff5"; ctx.shadowBlur=10; ctx.stroke(); ctx.shadowBlur=0;
// cutoff marker
const cx = ((Math.log(cutoff)-logMin)/(logMax-logMin))*w;
ctx.strokeStyle="rgba(232,255,90,0.6)"; ctx.setLineDash([4,4]); ctx.beginPath(); ctx.moveTo(cx,0); ctx.lineTo(cx,h); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle="#e8ff5a"; ctx.font="10px JetBrains Mono"; ctx.fillText(Math.round(cutoff)+"Hz", cx+4, 14);
}
drawEnv(params){
const canvas=this.envCanvas; const ctx=this.ectx; if(!canvas) return;
const w=canvas.width, h=canvas.height;
ctx.fillStyle="#0a0a0f"; ctx.fillRect(0,0,w,h);
const A=params['env-a']*w*0.35, D=params['env-d']*w*0.35, S=params['env-s'], R=params['env-r']*w*0.35;
const sustainY = h*0.2 + (1-S)*(h*0.6);
ctx.strokeStyle="rgba(255,255,255,0.07)"; for(let i=1;i<4;i++){ ctx.beginPath(); ctx.moveTo(0,h*i/4); ctx.lineTo(w,h*i/4); ctx.stroke(); }
ctx.beginPath(); ctx.strokeStyle="#ff8a3d"; ctx.lineWidth=2;
const ax=10;
ctx.moveTo(ax,h-10);
ctx.lineTo(ax+A,10);
ctx.lineTo(ax+A+D,sustainY);
ctx.lineTo(ax+A+D+100,sustainY);
ctx.lineTo(ax+A+D+100+R,h-10);
ctx.stroke();
ctx.shadowColor="#ff8a3d"; ctx.shadowBlur=8; ctx.stroke(); ctx.shadowBlur=0;
// labels
ctx.fillStyle="rgba(255,255,255,0.5)"; ctx.font="9px JetBrains Mono";
ctx.fillText("A", ax+A/2, h-14);
ctx.fillText("D", ax+A+D/2, sustainY-8);
ctx.fillText("S", ax+A+D+50, sustainY-8);
ctx.fillText("R", ax+A+D+100+R/2, h-16);
}
}
// ── main ──
const engine = new SynthEngine();
const viz = new Visualizers(engine);
let currentLesson = 0;
let unlockedUpTo = 0;
let paramState = {...DEFAULT_PARAMS};
let knobMap={}, selectMap={};
let activeMidi = new Set();
let baseOctave = 4;
let sustain = false;
let vizMode='wave';
function fmtCut(v){ const hz=hzFromNormCutoff(v); if(hz>=1000) return (hz/1000).toFixed(1)+'k'; return Math.round(hz)+''; }
function fmtOct(v){ return (v>0?'+':'')+Math.round(v); }
function fmtPct(v){ return Math.round(v*100)+'%'; }
function fmtDet(v){ return (v>=0?'+':'')+Math.round(v)+'c'; }
function fmtTime(v){ const norm=v; return Math.round((0.05+norm*0.65)*1000)+'ms'; }
function fmtRate(v){ return (0.1+v*19.9).toFixed(1)+'Hz'; }
function onParamChange(key, val){
paramState[key]=val;
engine.setParam(key,val);
viz.drawFilter(paramState);
viz.drawEnv(paramState);
updatePatchCode();
updateExplain();
// progress check
checkLessonGoal();
}
function buildControls(){
knobMap['osc1-oct']=createKnob('knob-osc1-oct','osc1-oct',-2,2,paramState['osc1-oct'],fmtOct,onParamChange);
knobMap['osc1-detune']=createKnob('knob-osc1-detune','osc1-detune',-50,50,paramState['osc1-detune'],fmtDet,onParamChange);
knobMap['osc2-detune']=createKnob('knob-osc2-detune','osc2-detune',-50,50,paramState['osc2-detune'],fmtDet,onParamChange);
knobMap['osc2-mix']=createKnob('knob-osc2-mix','osc2-mix',0,1,paramState['osc2-mix'],fmtPct,onParamChange);
knobMap['noise-mix']=createKnob('knob-noise-mix','noise-mix',0,1,paramState['noise-mix'],fmtPct,onParamChange);
knobMap['filter-cutoff']=createKnob('knob-filter-cutoff','filter-cutoff',0,1,paramState['filter-cutoff'],fmtCut,onParamChange);
knobMap['filter-res']=createKnob('knob-filter-res','filter-res',0,1,paramState['filter-res'],fmtPct,onParamChange);
knobMap['filter-env']=createKnob('knob-filter-env','filter-env',-1,1,paramState['filter-env'],fmtPct,onParamChange);
knobMap['env-a']=createKnob('knob-env-a','env-a',0,1,paramState['env-a'],v=> (v*1500).toFixed(0)+'ms',onParamChange);
knobMap['env-d']=createKnob('knob-env-d','env-d',0,1,paramState['env-d'],v=> (v*1500).toFixed(0)+'ms',onParamChange);
knobMap['env-s']=createKnob('knob-env-s','env-s',0,1,paramState['env-s'],fmtPct,onParamChange);
knobMap['env-r']=createKnob('knob-env-r','env-r',0,1,paramState['env-r'],v=> (v*2500).toFixed(0)+'ms',onParamChange);
knobMap['lfo-rate']=createKnob('knob-lfo-rate','lfo-rate',0,1,paramState['lfo-rate'],fmtRate,onParamChange);
knobMap['lfo-amt']=createKnob('knob-lfo-amt','lfo-amt',0,1,paramState['lfo-amt'],fmtPct,onParamChange);
knobMap['delay-mix']=createKnob('knob-delay-mix','delay-mix',0,1,paramState['delay-mix'],fmtPct,onParamChange);
knobMap['delay-time']=createKnob('knob-delay-time','delay-time',0,1,paramState['delay-time'],fmtTime,onParamChange);
knobMap['drive']=createKnob('knob-drive','drive',0,1,paramState['drive'],fmtPct,onParamChange);
knobMap['glide']=createKnob('knob-glide','glide',0,1,paramState['glide'],fmtPct,onParamChange);
selectMap['osc1-type']=createSelect('sel-osc1-type',WAVE_OPTS,paramState['osc1-type'], onParamChange,'osc1-type');
selectMap['osc2-type']=createSelect('sel-osc2-type',WAVE_OPTS,paramState['osc2-type'], onParamChange,'osc2-type');
selectMap['filter-type']=createSelect('sel-filter-type',FILTER_OPTS,paramState['filter-type'], onParamChange,'filter-type');
selectMap['lfo-target']=createSelect('sel-lfo-target',LFO_TARGET_OPTS,paramState['lfo-target'], onParamChange,'lfo-target');
selectMap['lfo-shape']=createSelect('sel-lfo-shape',LFO_SHAPE_OPTS,paramState['lfo-shape'], onParamChange,'lfo-shape');
}
function renderLessonList(){
const list=document.getElementById('lessonList');
list.innerHTML=LESSONS.map(l=>{
const done = l.id<=unlockedUpTo-1 ? ' done' : '';
const active = l.id===currentLesson ? ' active' : '';
const locked = l.id>unlockedUpTo ? ' locked' : '';
return `<div class="lesson-item${active}${done}${locked}" data-id="${l.id}"><div class="idx">${l.num}</div><div><div class="lt">${l.title}</div><div class="ld">${l.what}</div></div></div>`;
}).join('');
list.querySelectorAll('.lesson-item').forEach(el=>{
el.addEventListener('click',()=>{
const id=parseInt(el.dataset.id);
if(id<=unlockedUpTo) goToLesson(id);
});
});
}
function goToLesson(id){
currentLesson=id;
if(id>unlockedUpTo) unlockedUpTo=id;
const L=LESSONS[id];
document.getElementById('lessonTitle').textContent=L.title;
document.getElementById('lessonDesc').textContent=L.desc;
document.getElementById('lessonTip').textContent=L.tip;
document.getElementById('goalText').textContent=L.goal;
document.getElementById('explainBox').innerHTML=L.explain;
document.getElementById('lessonCounter').textContent=`${String(id+1).padStart(2,'0')} / ${String(LESSONS.length).padStart(2,'0')}`;
document.getElementById('progressBar').style.width=((id+1)/LESSONS.length*100)+'%';
// apply suggested params if any
Object.entries(L.params||{}).forEach(([k,v])=>{
setParamUI(k,v);
});
updateLocks();
renderLessonList();
viz.drawFilter(paramState);
viz.drawEnv(paramState);
}
function updateLocks(){
const L=LESSONS[currentLesson];
const allowed=L.unlocks.includes('all')?Object.keys(DEFAULT_PARAMS):L.unlocks;
document.querySelectorAll('.panel-section').forEach(sec=>{
const lock=sec.dataset.lock;
const shouldLock = !['osc','filter','env','lfo','fx'].every(()=>true); // placeholder
// mapping lock to keys
});
// actually per control
document.querySelectorAll('.control').forEach(c=>{
const key=c.dataset.ctrl;
const isAllowed = L.unlocks.includes('all') || L.unlocks.includes(key);
c.style.opacity=isAllowed?'1':'0.25';
c.style.pointerEvents=isAllowed?'auto':'none';
});
document.querySelectorAll('.panel-section').forEach(sec=>{
const secKey=sec.dataset.lock;
let anyAllowed=false;
sec.querySelectorAll('.control').forEach(cc=>{
if(cc.style.opacity==='1') anyAllowed=true;
});
sec.classList.toggle('locked', !anyAllowed);
});
// unlock groups visually
document.getElementById('g-osc2').classList.toggle('dimmed', !allowed.includes('osc2-mix'));
document.getElementById('g-noise').classList.toggle('dimmed', !allowed.includes('noise-mix'));
}
function setParamUI(k,v){
if(knobMap[k]) knobMap[k].set(v);
if(selectMap[k]) selectMap[k].set(v);
// force engine too
paramState[k]=v;
engine.setParam(k,v);
}
function updatePatchCode(){
const code=`// ${LESSONS[currentLesson].title}
OSC1: ${paramState['osc1-type'].toUpperCase()} oct${fmtOct(paramState['osc1-oct'])} det ${fmtDet(paramState['osc1-detune'])}
OSC2: ${paramState['osc2-type'].toUpperCase()} mix ${fmtPct(paramState['osc2-mix'])} det ${fmtDet(paramState['osc2-detune'])}
FILTER: ${paramState['filter-type'].toUpperCase()} @ ${fmtCut(paramState['filter-cutoff'])} res ${fmtPct(paramState['filter-res'])} env>${fmtPct(paramState['filter-env'])}
ENV: A${Math.round(paramState['env-a']*1000)} D${Math.round(paramState['env-d']*1000)} S${Math.round(paramState['env-s']*100)} R${Math.round(paramState['env-r']*1000)}
LFO: ${paramState['lfo-target']} ${paramState['lfo-shape']} ${fmtRate(paramState['lfo-rate'])} x ${fmtPct(paramState['lfo-amt'])}
FX: delay ${fmtPct(paramState['delay-mix'])} ${fmtTime(paramState['delay-time'])} drive ${fmtPct(paramState['drive'])} glide ${fmtPct(paramState['glide'])}`;
document.getElementById('patchCode').textContent=code;
}
function updateExplain(){
const freq=engine.lastFreq||0;
document.getElementById('freqRead').textContent= freq? Math.round(freq)+' Hz' : '-- Hz';
}
const lessonInteractionCount={};
function checkLessonGoal(){
const key=currentLesson;
lessonInteractionCount[key]=(lessonInteractionCount[key]||0)+1;
if(lessonInteractionCount[key]>6 && currentLesson < LESSONS.length-1 && unlockedUpTo===currentLesson){
unlockedUpTo=currentLesson+1;
renderLessonList();
// small toast? just update list
}
}
function buildKeyboard(){
const kb=document.getElementById('keyboard');
const whiteNotes=[]; const blackNotes=[];
const startMidi=48, endMidi=72; // C3 to C5
const whiteMap={0:true,2:true,4:true,5:true,7:true,9:true,11:true};
for(let m=startMidi;m<=endMidi;m++){
const isWhite=whiteMap[m%12];
if(isWhite) whiteNotes.push(m);
else blackNotes.push(m);
}
kb.innerHTML=`<div class="kb-white">${whiteNotes.map(m=>`<div class="kb-key white" data-midi="${m}"><span>${midiToName(m)}</span></div>`).join('')}</div><div class="kb-black"></div>`;
const blackContainer=kb.querySelector('.kb-black');
// position blacks
// calc approximate positions: each white key equal width, black at between
const whiteCount=whiteNotes.length;
// build mapping of midi to white index for black placement
let whiteIdx=0;
const whiteIdxByMidi={};
for(let m=startMidi;m<=endMidi;m++){
if(whiteMap[m%12]){ whiteIdxByMidi[m]=whiteIdx; whiteIdx++; }
}
blackNotes.forEach(m=>{
const prevWhite=m-1;
let posWhiteIdx=whiteIdxByMidi[prevWhite];
if(posWhiteIdx===undefined){
// find nearest white before
for(let k=m-1;k>=startMidi;k--) if(whiteIdxByMidi[k]!==undefined){ posWhiteIdx=whiteIdxByMidi[k]; break; }
}
const leftPct = ((posWhiteIdx+1)/whiteCount)*100;
const el=document.createElement('div');
el.className='kb-key black';
el.dataset.midi=m;
el.style.left=leftPct+'%';
blackContainer.appendChild(el);
});
function press(midi){
if(activeMidi.has(midi)) return;
activeMidi.add(midi);
const f=engine.noteOn(midi);
document.querySelectorAll(`.kb-key[data-midi="${midi}"]`).forEach(k=>k.classList.add('active'));
document.getElementById('noteRead').textContent=midiToName(midi);
document.getElementById('freqRead').textContent=Math.round(f)+' Hz';
engine.ensure();
}
function release(midi){
if(!activeMidi.has(midi)) return;
if(!sustain){
activeMidi.delete(midi);
engine.noteOff(midi);
document.querySelectorAll(`.kb-key[data-midi="${midi}"]`).forEach(k=>k.classList.remove('active'));
}
}
kb.addEventListener('pointerdown', e=>{
const key=e.target.closest('.kb-key'); if(!key) return;
const midi=parseInt(key.dataset.midi); press(midi);
const up=(ev)=>{
const m=midi; release(m);
window.removeEventListener('pointerup',up);
};
window.addEventListener('pointerup',up);
e.preventDefault();
});
// touch handled via pointer
kb.addEventListener('contextmenu', e=>e.preventDefault());
// computer keyboard map
const keyMap={'a':48,'w':49,'s':50,'e':51,'d':52,'f':53,'t':54,'g':55,'y':56,'h':57,'u':58,'j':59,'k':60,'o':61,'l':62,'p':63,';':64};
const held=new Set();
window.addEventListener('keydown', e=>{
if(e.repeat) return;
if(e.key==='Shift'){ sustain=true; e.preventDefault(); return; }
if(e.key==='['){ baseOctave=Math.max(1,baseOctave-1); return; }
if(e.key===']'){ baseOctave=Math.min(7,baseOctave+1); return; }
const low=e.key.toLowerCase();
if(keyMap[low]!==undefined){
const midi=keyMap[low]+(baseOctave-4)*12;
if(!held.has(midi)){ held.add(midi); press(midi); }
}
});
window.addEventListener('keyup', e=>{
if(e.key==='Shift'){
sustain=false;
activeMidi.forEach(m=>{ engine.noteOff(m); document.querySelectorAll(`.kb-key[data-midi="${m}"]`).forEach(k=>k.classList.remove('active')); });
activeMidi.clear();
held.clear();
return;
}
const low=e.key.toLowerCase();
if(keyMap[low]!==undefined){
const midi=keyMap[low]+(baseOctave-4)*12;
held.delete(midi);
release(midi);
activeMidi.delete(midi);
}
});
}
// buttons
document.getElementById('btnFree').addEventListener('click',()=>{
unlockedUpTo=LESSONS.length-1;
goToLesson(LESSONS.length-1);
renderLessonList();
});
document.getElementById('btnInit').addEventListener('click',()=>{ engine.panic(); activeMidi.clear(); document.querySelectorAll('.kb-key.active').forEach(k=>k.classList.remove('active')); });
document.getElementById('btnRandom').addEventListener('click',()=>{
const types=['sine','sawtooth','square','triangle'];
setParamUI('osc1-type', types[Math.floor(Math.random()*4)]);
setParamUI('osc2-type', types[Math.floor(Math.random()*4)]);
setParamUI('filter-type', ['lowpass','highpass','bandpass'][Math.floor(Math.random()*3)]);
setParamUI('osc1-detune', (Math.random()-0.5)*30);
setParamUI('osc2-detune', (Math.random()-0.5)*30);
setParamUI('osc2-mix', Math.random()*0.8);
setParamUI('filter-cutoff', 0.3+Math.random()*0.7);
setParamUI('filter-res', Math.random()*0.6);
setParamUI('filter-env', (Math.random()-0.5)*1);
setParamUI('env-a', Math.random()*0.5);
setParamUI('env-d', Math.random());
setParamUI('env-s', Math.random());
setParamUI('env-r', Math.random());
setParamUI('lfo-target', ['off','pitch','filter'][Math.floor(Math.random()*3)]);
setParamUI('lfo-rate', Math.random());
setParamUI('lfo-amt', Math.random()*0.6);
});
document.querySelectorAll('.viz-tabs button').forEach(b=>{
b.addEventListener('click',()=>{
document.querySelectorAll('.viz-tabs button').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
vizMode=b.dataset.viz;
viz.mode=vizMode;
});
});
// init
buildControls();
buildKeyboard();
renderLessonList();
goToLesson(0);
viz.start();
updatePatchCode();
engine.updateFX();
viz.drawFilter(paramState);
viz.drawEnv(paramState);
// resume on first interaction
window.addEventListener('pointerdown', ()=>{ engine.ensure(); if(engine.ctx.state==='suspended') engine.ctx.resume(); }, {once:true});
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.1","provider":"openrouter","persona":"composer","sessionId":"7b77f606-e8b7-4553-9782-ec6bb136ae34","tokensIn":224963,"tokensOut":21388,"tokensTotal":246351,"turns":13,"toolCalls":12,"failedToolCalls":1,"timestamp":"2026-07-17T04:39:22.848Z"} -->