TutSynth
Deepseek v4.1 Flash · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 56,156 bytes · SHA-256 a266ff91eecb
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Synth Lab — Learn Synthesis by Building It</title>
<style>
:root{
--bg:#07080f;
--panel:rgba(255,255,255,.035);
--line:rgba(255,255,255,.09);
--text:#e9edfb;
--muted:#8e97b5;
--cy:#6ee7ff;
--pu:#b07cff;
--pk:#ff7ab8;
--gr:#6dfbc0;
--am:#ffcc66;
--acc:var(--cy);
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;color:var(--text);min-height:100%;
font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Inter,sans-serif;
-webkit-font-smoothing:antialiased;
background:
radial-gradient(1100px 700px at 12% -12%, rgba(110,231,255,.10), transparent 60%),
radial-gradient(900px 620px at 96% -4%, rgba(176,124,255,.13), transparent 58%),
radial-gradient(800px 700px at 60% 120%, rgba(255,122,184,.07), transparent 60%),
#07080f;
background-attachment:fixed;
}
.app{
display:grid;gap:14px;padding:16px;max-width:1580px;margin:0 auto;
grid-template-columns:minmax(0,1fr) 372px;
align-items:start;
}
.card{
background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.012));
border:1px solid var(--line);border-radius:18px;
box-shadow:0 22px 50px -30px #000, inset 0 1px 0 rgba(255,255,255,.04);
}
/* ---------- header ---------- */
header{grid-column:1/-1;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:12px;min-width:0}
.logo{
width:38px;height:38px;border-radius:12px;flex:none;position:relative;
background:conic-gradient(from 200deg,var(--cy),var(--pu),var(--pk),var(--cy));
box-shadow:0 0 26px rgba(110,231,255,.35);
}
.logo::after{content:"";position:absolute;inset:5px;border-radius:8px;background:#0a0c16}
.logo::before{content:"";position:absolute;inset:0;margin:auto;width:14px;height:14px;border-radius:50%;
border:2px solid var(--cy);box-shadow:0 0 12px var(--cy);z-index:2}
h1{margin:0;font-size:19px;letter-spacing:.2px;font-weight:650}
h1 span{background:linear-gradient(90deg,var(--cy),var(--pu) 55%,var(--pk));-webkit-background-clip:text;background-clip:text;color:transparent}
.sub{margin:2px 0 0;font-size:12px;color:var(--muted)}
.head-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.pill{
display:flex;align-items:center;gap:8px;padding:7px 12px;border-radius:99px;
border:1px solid var(--line);background:rgba(255,255,255,.03);font-size:12px;color:var(--muted)
}
.pill b{color:var(--text);font-variant-numeric:tabular-nums}
.status-dot{width:7px;height:7px;border-radius:50%;background:#4a5170;box-shadow:none;transition:.3s}
.status-dot.on{background:var(--gr);box-shadow:0 0 10px var(--gr)}
#master{width:110px}
/* ---------- stage ---------- */
.stage{grid-column:1;position:relative;overflow:hidden;height:174px}
.stage canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
.stage-label{
position:absolute;top:12px;left:14px;font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;
color:var(--muted);pointer-events:none;z-index:2
}
.stage-legend{
position:absolute;top:12px;right:14px;display:flex;gap:14px;font-size:10.5px;color:var(--muted);
letter-spacing:.08em;pointer-events:none;z-index:2
}
.stage-legend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px;vertical-align:middle}
/* ---------- sidebar ---------- */
.sidebar{grid-column:2;grid-row:2/span 2;display:flex;flex-direction:column;gap:12px;position:sticky;top:16px}
.progress{display:flex;gap:6px;padding:12px 14px 4px;align-items:center}
.pdot{
flex:1;height:6px;border-radius:99px;background:rgba(255,255,255,.08);position:relative;overflow:hidden;transition:.4s
}
.pdot.done{background:linear-gradient(90deg,var(--cy),var(--pu))}
.pdot.cur{background:rgba(255,255,255,.14);box-shadow:0 0 0 1px rgba(110,231,255,.5)}
.pcount{font-size:11px;color:var(--muted);white-space:nowrap;padding-left:6px}
.lesson{padding:16px 18px 18px}
.kicker{font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--acc);font-weight:700}
.lesson h2{margin:8px 0 8px;font-size:19px;line-height:1.25;font-weight:650}
.lesson p{margin:0 0 10px;font-size:13.5px;line-height:1.62;color:#c3cbe4}
.lesson p em{color:var(--text);font-style:normal;border-bottom:1px dashed rgba(255,255,255,.35)}
.task{
margin-top:12px;border-radius:12px;padding:11px 13px;font-size:12.5px;line-height:1.5;
border:1px solid rgba(110,231,255,.28);background:rgba(110,231,255,.07);display:flex;gap:10px
}
.task .box{
width:16px;height:16px;flex:none;border-radius:5px;border:1.5px solid rgba(110,231,255,.6);margin-top:1px;
position:relative;transition:.25s
}
.task.met{border-color:rgba(109,251,192,.45);background:rgba(109,251,192,.08)}
.task.met .box{border-color:var(--gr);background:var(--gr);box-shadow:0 0 12px rgba(109,251,192,.5)}
.task.met .box::after{content:"";position:absolute;left:4.5px;top:1px;width:4px;height:8px;border:solid #05231a;border-width:0 2px 2px 0;transform:rotate(42deg)}
.lesson-actions{display:flex;gap:9px;margin-top:14px;align-items:center}
button{font-family:inherit}
.btn{
border:1px solid var(--line);background:rgba(255,255,255,.05);color:var(--text);
padding:9px 14px;border-radius:11px;font-size:12.5px;cursor:pointer;transition:.18s;font-weight:560
}
.btn:hover{background:rgba(255,255,255,.1);transform:translateY(-1px)}
.btn:active{transform:translateY(0)}
.btn.primary{
border:none;color:#04121a;background:linear-gradient(100deg,var(--cy),#8ad4ff 60%,var(--pu));
box-shadow:0 8px 26px -10px rgba(110,231,255,.7);font-weight:700
}
.btn.primary.ready{animation:pulse 1.6s infinite}
@keyframes pulse{0%,100%{box-shadow:0 8px 26px -10px rgba(110,231,255,.7)}50%{box-shadow:0 8px 34px -6px rgba(176,124,255,.95)}}
.btn.ghost{border:none;background:none;color:var(--muted);padding:9px 6px;font-size:11.5px;text-decoration:underline;text-underline-offset:3px}
.btn.ghost:hover{color:var(--text);background:none;transform:none}
.btn[disabled]{opacity:.35;pointer-events:none}
.hintbox{font-size:12px;line-height:1.55;color:var(--muted);padding:12px 16px}
.hintbox b{color:#cfd7ee;font-weight:600}
/* ---------- keyboard ---------- */
.keyboard-wrap{grid-column:1;padding:14px 14px 16px}
.kb-top{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap}
.kb-title{font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)}
.kb-keys{margin-left:auto;font-size:11px;color:var(--muted)}
.kb-keys kbd{
display:inline-block;padding:1px 6px;border-radius:5px;border:1px solid var(--line);
background:rgba(255,255,255,.05);font-family:ui-monospace,monospace;font-size:10.5px;margin:0 1px;color:#b9c2de
}
.keyboard{position:relative;height:126px;user-select:none;touch-action:none}
.wkey{
position:absolute;top:0;bottom:0;border-radius:0 0 8px 8px;
background:linear-gradient(180deg,#f3f6ff,#c9d3ee);
border:1px solid rgba(0,0,0,.35);border-top:none;
box-shadow:inset 0 -7px 10px -6px rgba(0,0,0,.45);
transition:background .06s, transform .06s;
}
.wkey.active{background:linear-gradient(180deg,#bff3ff,#6ee7ff);box-shadow:0 0 24px rgba(110,231,255,.55),inset 0 -4px 10px -4px rgba(0,0,0,.35)}
.bkey{
position:absolute;top:0;height:62%;border-radius:0 0 7px 7px;z-index:3;
background:linear-gradient(180deg,#2a3048,#0e1120);
border:1px solid #05070e;box-shadow:0 6px 10px -4px rgba(0,0,0,.8),inset 0 -6px 8px -6px rgba(255,255,255,.25);
transition:background .06s;
}
.bkey.active{background:linear-gradient(180deg,#8a5cff,#b07cff);box-shadow:0 0 22px rgba(176,124,255,.6)}
.key-lbl{
position:absolute;bottom:6px;left:0;right:0;text-align:center;font-size:9.5px;color:#5b6685;
pointer-events:none;letter-spacing:.04em
}
.wkey.active .key-lbl{color:#05293a}
.key-tip{display:flex;gap:16px;margin-top:10px;font-size:11.5px;color:var(--muted);flex-wrap:wrap}
/* ---------- modules ---------- */
.modules{grid-column:1/-1;display:flex;gap:14px;overflow-x:auto;padding:4px 2px 14px;scroll-snap-type:x proximity}
.modules::-webkit-scrollbar{height:9px}
.modules::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:9px}
.modules::-webkit-scrollbar-track{background:rgba(255,255,255,.04);border-radius:9px}
.panel{
flex:0 0 300px;scroll-snap-align:start;padding:14px 15px 16px;position:relative;
transition:opacity .5s, filter .5s, transform .5s;
}
.panel.locked{opacity:.32;filter:grayscale(.85) blur(.4px);pointer-events:none;transform:scale(.98)}
.panel.locked::after{
content:"🔒 locked";position:absolute;top:13px;right:14px;font-size:10px;letter-spacing:.1em;
text-transform:uppercase;color:var(--muted)
}
.panel.unlocking{animation:unlock .8s cubic-bezier(.2,.9,.2,1)}
@keyframes unlock{0%{opacity:.3;filter:grayscale(1);transform:scale(.95) translateY(6px)}100%{opacity:1;filter:none;transform:none}}
.panel-head{display:flex;align-items:center;gap:9px;margin-bottom:4px}
.panel-head .dot{width:9px;height:9px;border-radius:50%;background:var(--acc);box-shadow:0 0 12px var(--acc)}
.panel-head h3{margin:0;font-size:13.5px;font-weight:650;letter-spacing:.01em}
.panel-step{margin-left:auto;font-size:10px;color:var(--muted);letter-spacing:.1em}
.panel-note{font-size:11.5px;line-height:1.5;color:var(--muted);margin:2px 0 12px}
.ctrl{display:block;margin-bottom:11px}
.ctrl:last-child{margin-bottom:0}
.ctrl-label{display:flex;justify-content:space-between;align-items:baseline;font-size:11.5px;color:#b7c0dc;margin-bottom:3px}
.ctrl-label em{font-style:normal;color:var(--acc);font-variant-numeric:tabular-nums;font-size:11px;font-family:ui-monospace,monospace}
.seg{display:flex;gap:5px;background:rgba(0,0,0,.28);padding:4px;border-radius:11px;border:1px solid var(--line)}
.seg button{
flex:1;border:none;background:transparent;color:var(--muted);font-size:11px;padding:7px 4px;border-radius:8px;
cursor:pointer;transition:.16s;font-weight:560
}
.seg button:hover{color:var(--text);background:rgba(255,255,255,.05)}
.seg button.on{background:linear-gradient(180deg,rgba(255,255,255,.16),rgba(255,255,255,.07));color:#fff;box-shadow:0 2px 8px -3px #000}
.seg button svg{display:block;margin:0 auto}
input[type=range]{
-webkit-appearance:none;appearance:none;width:100%;height:20px;background:transparent;cursor:pointer;display:block;margin:0
}
input[type=range]::-webkit-slider-runnable-track{
height:4px;border-radius:99px;
background:linear-gradient(90deg,rgba(255,255,255,.42),rgba(255,255,255,.1))
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;width:14px;height:14px;margin-top:-5px;border-radius:50%;background:#eef6ff;
box-shadow:0 0 0 3px rgba(110,231,255,.16),0 2px 6px rgba(0,0,0,.7);transition:transform .12s,box-shadow .12s
}
input[type=range]:hover::-webkit-slider-thumb{box-shadow:0 0 0 4px rgba(110,231,255,.28),0 2px 8px #000}
input[type=range]:active::-webkit-slider-thumb{transform:scale(1.12)}
input[type=range]::-moz-range-track{height:4px;border-radius:99px;background:rgba(255,255,255,.2)}
input[type=range]::-moz-range-thumb{width:13px;height:13px;border:none;border-radius:50%;background:#eef6ff;box-shadow:0 0 0 3px rgba(110,231,255,.2)}
.mini-canvas{width:100%;height:64px;display:block;border-radius:10px;background:rgba(0,0,0,.3);border:1px solid var(--line)}
.grid-seq{display:grid;gap:3px;margin-top:2px}
.cell{aspect-ratio:1/1;border-radius:5px;background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.05);cursor:pointer;transition:.1s}
.cell:hover{background:rgba(255,255,255,.14)}
.cell.on{background:linear-gradient(180deg,var(--pk),#ff4f9a);border-color:transparent;box-shadow:0 0 12px rgba(255,122,184,.5)}
.cell.playing{outline:1.5px solid var(--cy);outline-offset:1px}
.row-lab{font-size:9.5px;color:var(--muted);display:flex;align-items:center;justify-content:flex-end;padding-right:5px;font-family:ui-monospace,monospace}
.seq-head{display:flex;gap:8px;align-items:center;margin-top:12px}
.toast{
position:fixed;left:50%;bottom:28px;transform:translate(-50%,140%);z-index:99;
padding:12px 18px;border-radius:14px;font-size:13px;font-weight:600;color:#04121a;
background:linear-gradient(100deg,var(--cy),var(--pu));box-shadow:0 18px 40px -14px rgba(110,231,255,.75);
transition:transform .5s cubic-bezier(.2,.9,.2,1);display:flex;gap:10px;align-items:center;max-width:88vw
}
.toast.show{transform:translate(-50%,0)}
.scrollx{overflow-x:auto}
.hot{animation:hot 1.1s ease-out}
@keyframes hot{0%{box-shadow:0 0 0 0 rgba(110,231,255,.6)}100%{box-shadow:0 0 0 14px rgba(110,231,255,0)}}
@media (max-width:1000px){
.app{grid-template-columns:minmax(0,1fr)}
.sidebar{grid-column:1;grid-row:auto;position:static}
.stage{grid-column:1}
.keyboard-wrap{grid-column:1}
}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand">
<div class="logo"></div>
<div>
<h1>Synth <span>Lab</span></h1>
<p class="sub">Build a synthesizer one block at a time — and hear what each one does.</p>
</div>
</div>
<div class="head-right">
<div class="pill"><span class="status-dot" id="statusDot"></span><span id="statusText">click to power on</span></div>
<div class="pill">Master <input type="range" id="master" min="0" max="100" value="70"></div>
<button class="btn" id="panic">All notes off</button>
</div>
</header>
<section class="stage card">
<div class="stage-label">Oscilloscope</div>
<div class="stage-legend">
<span><i style="background:rgba(110,231,255,.35)"></i>spectrum</span>
<span><i style="background:#b07cff"></i>waveform</span>
</div>
<canvas id="scope"></canvas>
</section>
<aside class="sidebar">
<div class="card">
<div class="progress" id="progress">
<span class="pcount" id="pcount">Step 1 / 6</span>
</div>
<div class="lesson">
<div class="kicker" id="kicker">Module 1</div>
<h2 id="lessonTitle">The Oscillator</h2>
<div id="lessonBody"></div>
<div class="task" id="task"><span class="box"></span><span id="taskText"></span></div>
<div class="lesson-actions">
<button class="btn primary" id="nextBtn" disabled>Continue ▸</button>
<button class="btn ghost" id="skipBtn">skip ahead</button>
</div>
</div>
</div>
<div class="card hintbox" id="hintbox"></div>
</aside>
<section class="keyboard-wrap card">
<div class="kb-top">
<span class="kb-title">Keyboard · C4 – B5</span>
<span class="kb-keys">play with <kbd>A</kbd><kbd>W</kbd><kbd>S</kbd><kbd>E</kbd><kbd>D</kbd><kbd>F</kbd><kbd>T</kbd><kbd>G</kbd><kbd>Y</kbd><kbd>H</kbd><kbd>U</kbd><kbd>J</kbd><kbd>K</kbd> … or click the keys</span>
</div>
<div class="keyboard" id="keyboard"></div>
<div class="key-tip">
<span>💡 Hold a key with the mouse and slide across the keyboard to glide between notes.</span>
</div>
</section>
<section class="modules" id="modules"></section>
</div>
<div class="toast" id="toast"></div>
<script>
(function(){
"use strict";
const $ = s => document.querySelector(s);
const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
const mtof = m => 440*Math.pow(2,(m-69)/12);
/* ==========================================================================
PARAMETERS — the single source of truth for the whole synth
========================================================================== */
const P = {
wave:'sawtooth', detune:0,
attack:0.01, decay:0.30, sustain:0.65, release:0.35,
filterType:'lowpass', cutoff:2600, resonance:2.5, envAmt:0,
lfoTarget:'pitch', lfoRate:5.5, lfoDepth:0,
drive:0, delayMix:0, delayTime:0.30, feedback:0.35,
bpm:112, volume:0.72
};
/* ==========================================================================
AUDIO ENGINE
========================================================================== */
let AC=null, mixBus, shaper, dryIn, delayNode, fbGain, wetGain, postGain, analyser, master;
let lfo, lfoPitchGain, lfoFilterGain, lfoAmpGain;
let waveData, freqData;
const voices = new Map(); // midi -> voice (held notes only)
function initAudio(){
if(AC) return;
AC = new (window.AudioContext||window.webkitAudioContext)();
const t = AC.currentTime;
mixBus = AC.createGain(); mixBus.gain.value = 1;
shaper = AC.createWaveShaper();
dryIn = AC.createGain(); dryIn.gain.value = 1;
delayNode= AC.createDelay(2.0); delayNode.delayTime.value = P.delayTime;
fbGain = AC.createGain(); fbGain.gain.value = P.feedback;
wetGain = AC.createGain(); wetGain.gain.value = P.delayMix;
postGain = AC.createGain(); postGain.gain.value = 1;
analyser = AC.createAnalyser();
analyser.fftSize = 2048; analyser.smoothingTimeConstant = 0.78;
master = AC.createGain(); master.gain.value = P.volume * 0.9;
mixBus.connect(shaper);
shaper.connect(dryIn); dryIn.connect(postGain);
shaper.connect(delayNode);
delayNode.connect(fbGain); fbGain.connect(delayNode);
delayNode.connect(wetGain); wetGain.connect(postGain);
postGain.connect(analyser); analyser.connect(master); master.connect(AC.destination);
waveData = new Uint8Array(analyser.fftSize);
freqData = new Uint8Array(analyser.frequencyBinCount);
makeDriveCurve(P.drive);
/* ---- global LFO routed into buses that every voice hooks onto ---- */
lfo = AC.createOscillator(); lfo.type='sine'; lfo.frequency.value = P.lfoRate;
lfoPitchGain = AC.createGain(); lfoPitchGain.gain.value = 0; // cents
lfoFilterGain = AC.createGain(); lfoFilterGain.gain.value = 0; // Hz
lfoAmpGain = AC.createGain(); lfoAmpGain.gain.value = 0; // gain units
lfo.connect(lfoPitchGain); lfo.connect(lfoFilterGain); lfo.connect(lfoAmpGain);
lfo.start();
applyLFO();
$("#statusDot").classList.add('on');
$("#statusText").textContent = 'engine live';
}
function makeDriveCurve(amount){
if(!shaper) return;
const n = 1024, curve = new Float32Array(n), k = amount*18;
for(let i=0;i<n;i++){
const x = (i/(n-1))*2-1;
curve[i] = k<0.001 ? x : (1+k) * x / (1 + k*Math.abs(x));
}
shaper.curve = curve;
shaper.oversample = '2x';
}
function applyLFO(){
if(!AC) return;
const t = AC.currentTime;
lfo.frequency.setTargetAtTime(P.lfoRate, t, 0.02);
const d = P.lfoDepth;
lfoPitchGain.gain.setTargetAtTime(P.lfoTarget==='pitch' ? d*600 : 0, t, 0.02);
lfoFilterGain.gain.setTargetAtTime(P.lfoTarget==='filter'? d*4000 : 0, t, 0.02);
lfoAmpGain.gain.setTargetAtTime(P.lfoTarget==='amp' ? d*0.5 : 0, t, 0.02);
// tremolo needs a base offset so gain doesn't go negative
voices.forEach(v => { if(v.trem) v.trem.gain.setTargetAtTime(1 - (P.lfoTarget==='amp'?d*0.5:0), t, 0.02); });
}
/* ---------------- voices ---------------- */
function makeVoice(freq, at){
const osc = AC.createOscillator();
osc.type = P.wave;
osc.frequency.setValueAtTime(freq, at);
osc.detune.setValueAtTime(P.detune, at);
const filt = AC.createBiquadFilter();
filt.type = P.filterType;
filt.frequency.setValueAtTime(P.cutoff, at);
filt.Q.setValueAtTime(P.resonance, at);
const amp = AC.createGain(); amp.gain.setValueAtTime(0.0001, at);
const trem = AC.createGain(); trem.gain.setValueAtTime(1 - (P.lfoTarget==='amp'?P.lfoDepth*0.5:0), at);
osc.connect(filt); filt.connect(amp); amp.connect(trem); trem.connect(mixBus);
lfoPitchGain.connect(osc.detune);
lfoFilterGain.connect(filt.frequency);
lfoAmpGain.connect(trem.gain);
osc.start(at);
return {osc, filt, amp, trem, freq};
}
const PEAK = 0.30;
function scheduleEnvelope(v, at, gate){
const A=P.attack, D=P.decay, S=P.sustain, R=P.release;
const g = v.amp.gain;
g.cancelScheduledValues(at);
g.setValueAtTime(0.0001, at);
g.linearRampToValueAtTime(PEAK, at + Math.max(0.001,A));
const holdStart = at + Math.max(0.001,A) + D;
g.linearRampToValueAtTime(Math.max(0.0001, PEAK*S), holdStart);
const off = gate==null ? null : at + gate;
if(off != null){
const rel = Math.max(off, holdStart + 0.005);
g.setValueAtTime(Math.max(0.0001, PEAK*S), rel);
g.linearRampToValueAtTime(0.0001, rel + Math.max(0.01,R));
stopVoice(v, rel + Math.max(0.01,R) + 0.06);
}
// filter envelope
if(P.envAmt > 0.001){
const f = v.filt.frequency, base = P.cutoff;
const peak = clamp(base * Math.pow(2, P.envAmt*5), 30, 19000);
f.cancelScheduledValues(at);
f.setValueAtTime(base, at);
f.linearRampToValueAtTime(peak, at + Math.max(0.003,A));
f.exponentialRampToValueAtTime(Math.max(30,base), at + Math.max(0.003,A) + D + 0.15);
}
}
function stopVoice(v, when){
try{ v.osc.stop(when); }catch(e){}
v.osc.onended = () => {
try{ v.osc.disconnect(); v.filt.disconnect(); v.amp.disconnect(); v.trem.disconnect(); }catch(e){}
};
}
function releaseVoice(v, now){
const R = Math.max(0.02, P.release);
const g = v.amp.gain;
if(g.cancelAndHoldAtTime) g.cancelAndHoldAtTime(now);
else { g.cancelScheduledValues(now); g.setValueAtTime(Math.max(0.0001,g.value), now); }
g.linearRampToValueAtTime(0.0001, now + R);
stopVoice(v, now + R + 0.06);
}
/* ---------------- note events ---------------- */
const keyEls = new Map();
function noteOn(midi, src){
initAudio();
if(AC.state === 'suspended') AC.resume();
if(voices.has(midi)) noteOff(midi);
const now = AC.currentTime;
const v = makeVoice(mtof(midi), now);
v.midi = midi; voices.set(midi, v);
scheduleEnvelope(v, now, null); // gate unknown → held until noteOff
flags.played = true;
flags.waves.add(P.wave);
const el = keyEls.get(midi); if(el) el.classList.add('active');
setTimeout(()=>focusLesson(), 0);
}
function noteOff(midi){
const v = voices.get(midi);
if(!v || !AC) return;
voices.delete(midi);
releaseVoice(v, AC.currentTime);
const el = keyEls.get(midi); if(el) el.classList.remove('active');
}
function allOff(){
[...voices.keys()].forEach(noteOff);
}
/* ---------------- sequencer playback ---------------- */
let seqOn=false, seqStep=0, seqNext=0, seqTimer=null;
const flashQueue=[];
function scheduleStep(step, t){
for(let row=0; row<SEQ_NOTES.length; row++){
if(pattern[row][step]){
const v = makeVoice(mtof(SEQ_NOTES[row]), t);
scheduleEnvelope(v, t, 0.30);
flashQueue.push({midi:SEQ_NOTES[row], t});
}
}
}
function seqTick(){
if(!AC) return;
const spb = 60/P.bpm/4;
while(seqNext < AC.currentTime + 0.12){
scheduleStep(seqStep, Math.max(seqNext, AC.currentTime + 0.01));
seqNext += spb;
uiStep = seqStep;
seqStep = (seqStep+1) % 16;
}
}
/* ==========================================================================
STATE FLAGS + LESSON PLAN
========================================================================== */
const flags = { played:false, waves:new Set(), env:false, sweep:false, lfo:false, fx:false, seq:false };
let current = 0;
const seqFmt = v => v>=1000 ? (v/1000).toFixed(1)+'k' : Math.round(v)+'';
const LESSONS = [
{ key:'osc', acc:'var(--cy)', title:'The Oscillator', kicker:'Module 1 of 6',
body:`<p>The oscillator is the <em>sound source</em> — it vibrates back and forth, and that vibration
becomes sound. Two things matter: <em>how fast</em> it vibrates (that's pitch, and the keyboard controls it)
and <em>the shape</em> of the vibration, which decides the tone.</p>
<p>A <em>sine</em> is one pure frequency. <em>Sawtooth</em> and <em>square</em> add stacked harmonics
(a bright, buzzy character); <em>triangle</em> sits in between. Detune nudges the tuning a few cents —
it's how you make two notes "beat" against each other.</p>`,
task:'Play a note, then listen to all four waveforms.',
hint:'<b>Pitch comes from frequency, tone colour comes from the waveform.</b> Hold a note and switch shapes — notice how the oscilloscope changes. Detune is measured in cents: 100 cents = one semitone.',
recipes:[] },
{ key:'env', acc:'var(--pu)', title:'The Amplitude Envelope', kicker:'Module 2 of 6',
body:`<p>An envelope draws the <em>life story of a note</em>. The ADSR shape sets it: <em>Attack</em> is how long it
fades in, <em>Decay</em> how long it falls to the <em>Sustain</em> level it holds while you press, and
<em>Release</em> how long it fades out after you let go.</p>
<p>This is the single biggest difference between a plucked string, an organ and a swelling pad.</p>`,
task:'Shape a sound: long attack for a pad, or a snappy pluck. Play while you drag.',
hint:'<b>Attack + Release define the feel.</b> Short attack & short release = percussive (pluck, stab, kick). Long attack & long release = pad. Instant attack + long release = bell or organ.',
recipes:[['Pluck',{attack:0.005,decay:0.22,sustain:0.0,release:0.18}],
['Pad',{attack:1.2,decay:1.0,sustain:0.8,release:2.4}],
['Organ',{attack:0.02,decay:0.1,sustain:1.0,release:0.12}],
['Bell',{attack:0.002,decay:1.6,sustain:0.0,release:1.8}]] },
{ key:'filter', acc:'var(--am)', title:'The Filter', kicker:'Module 3 of 6',
body:`<p>A filter is a <em>tone control on steroids</em>. A <em>low-pass</em> filter lets low frequencies through and
removes everything above the <em>cutoff</em> — dark and muffled when low, bright and open when high.</p>
<p><em>Resonance</em> rings the frequencies right at the cutoff, giving that whistling, "juicy" character.
The <em>env amount</em> makes the filter open and close on its own with every note, which is where
classic synth plucks and acid basslines come from.</p>`,
task:'Hold a note and sweep the cutoff from dark to bright. Add resonance. Add some env amount.',
hint:'<b>Cutoff is the most expressive knob on a synth.</b> Try high-pass to make things thin and airy, band-pass for a telephone effect. Give the envelope a big amount and a short decay for that "pew" bass sound.',
recipes:[['Dark',{filterType:'lowpass',cutoff:420,resonance:2,envAmt:0}],
['Acid',{filterType:'lowpass',cutoff:300,resonance:16,envAmt:0.65,decay:0.3,sustain:0.1}],
['Airy',{filterType:'highpass',cutoff:1200,resonance:1,envAmt:0}],
['Phone',{filterType:'bandpass',cutoff:1600,resonance:6,envAmt:0}]] },
{ key:'lfo', acc:'var(--pk)', title:'The LFO', kicker:'Module 4 of 6',
body:`<p>An LFO (low-frequency oscillator) is an oscillator running <em>slower than hearing range</em>, so instead of
making a note it <em>moves another control for you</em>, over and over.</p>
<p>Point it at the pitch and you get <em>vibrato</em>. Point it at the filter and you get a <em>wobble</em>.
Point it at the volume and you get <em>tremolo</em>. Rate = how fast it moves, depth = how far.</p>`,
task:'Turn up the depth, then try each of the three targets.',
hint:'<b>Modulation is what makes a synth sound alive.</b> Pitch -> vibrato (keep the depth small and the rate near 5 Hz). Filter -> wobble (rate ~2–8 Hz, big depth). Amplitude -> tremolo (slow rate, medium depth).',
recipes:[['Vibrato',{lfoTarget:'pitch',lfoRate:5.5,lfoDepth:0.09}],
['Wobble',{lfoTarget:'filter',lfoRate:3,lfoDepth:0.7}],
['Tremolo',{lfoTarget:'amp',lfoRate:6.5,lfoDepth:0.8}],
['Siren',{lfoTarget:'pitch',lfoRate:0.6,lfoDepth:0.5}]] },
{ key:'fx', acc:'var(--gr)', title:'Drive & Delay', kicker:'Module 5 of 6',
body:`<p>Effects happen <em>after</em> the note. <em>Drive</em> pushes the signal past its limit, squashing the tops
of the wave and adding gritty harmonics — subtle warmth to full destruction.</p>
<p><em>Delay</em> records the sound and plays it back a moment later, over and over. <em>Time</em> sets the gap
(short = slapback, long = dub echoes), <em>feedback</em> how many repeats, <em>mix</em> how loud they sit.</p>`,
task:'Add some drive, then turn the delay mix up until you hear repeats.',
hint:'<b>Time sets the groove, feedback sets the atmosphere.</b> 80–140 ms is slapback, 300–450 ms is rhythmic echo, and past 0.7 feedback it starts to build into a runaway wall of sound.',
recipes:[['Warm',{drive:0.18,delayMix:0.10,delayTime:0.28,feedback:0.25}],
['Dub',{drive:0.1,delayMix:0.45,delayTime:0.42,feedback:0.68}],
['Slap',{drive:0.25,delayMix:0.28,delayTime:0.11,feedback:0.15}],
['Destroy',{drive:0.95,delayMix:0.35,delayTime:0.22,feedback:0.6}]] },
{ key:'seq', acc:'var(--cy)', title:'The Sequencer', kicker:'Module 6 of 6',
body:`<p>Now put it together. A sequencer plays notes for you on a grid — this one is a mini piano roll where each
row is a pitch and each column is a step in time. Hit <em>Play</em> and the whole patch you built starts looping.</p>
<p>Change the tempo, add or remove steps, and hear how every control you've learned shapes the same pattern differently.</p>`,
task:'Draw a pattern, press Play, then tweak the filter and LFO while it loops.',
hint:'<b>You built a synthesizer.</b> The signal path, in order, was: oscillator → filter → amplifier (with its envelope) → effects → speakers. Almost every synth ever made is some version of exactly that chain.',
recipes:[] }
];
/* ==========================================================================
PANEL MARKUP
========================================================================== */
let SEQ_NOTES = [72,70,67,65,63,62,60,58];
SEQ_NOTES = [77,75,72,70,67,65,63,60];
let pattern = Array.from({length:8},()=>Array(16).fill(false));
let uiStep = -1, drawnStep = -1;
const WAVE_ICONS = {
sine:'M2 10 C6 3 8 3 12 10 C16 17 18 17 22 10',
square:'M2 15 L2 5 L12 5 L12 15 L22 15 L22 5',
sawtooth:'M2 15 L12 5 L12 15 L22 5',
triangle:'M2 15 L7 5 L12 15 L17 5 L22 15'
};
function waveBtn(type, extra){
const path = WAVE_ICONS[type] || WAVE_ICONS.sine;
return `<button data-wave="${type}" class="${P.wave===type?'on':''}">
<svg width="26" height="18" viewBox="0 0 24 20"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>
<span style="display:block;font-size:9.5px;margin-top:2px">${extra||type}</span></button>`;
}
function ctrl(id,label,min,max,step,val,fmt,unit){
return `<label class="ctrl"><span class="ctrl-label"><span>${label}</span><em id="${id}v">${fmt(val)}</em></span>
<input type="range" id="${id}" min="${min}" max="${max}" step="${step}" value="${val}"></label>`;
}
const fms = v => v<1 ? Math.round(v*1000)+' ms' : v.toFixed(2)+' s';
const fhz = v => v>=1000 ? (v/1000).toFixed(2)+' kHz' : Math.round(v)+' Hz';
const fpct= v => Math.round(v*100)+'%';
const fcent=v => (v>0?'+':'')+Math.round(v)+' cents';
function buildPanels(){
$("#modules").innerHTML = `
<section class="panel card locked" data-m="osc" style="--acc:var(--cy)">
<div class="panel-head"><span class="dot"></span><h3>1 · Oscillator</h3><span class="panel-step">source</span></div>
<p class="panel-note">The raw vibration. Shape = tone, frequency = pitch.</p>
<div class="seg" id="waveSeg" style="margin-bottom:12px">
${['sine','triangle','square','sawtooth'].map(w=>waveBtn(w)).join('')}
</div>
${ctrl('detune','Detune',-50,50,1,P.detune,fcent)}
</section>
<section class="panel card locked" data-m="env" style="--acc:var(--pu)">
<div class="panel-head"><span class="dot"></span><h3>2 · Amplitude Envelope</h3><span class="panel-step">shape</span></div>
<p class="panel-note">How the volume of a single note rises, holds and falls.</p>
<canvas class="mini-canvas" id="envCanvas" style="height:74px;margin-bottom:12px"></canvas>
${ctrl('attack','Attack',0.001,3,0.001,P.attack,fms)}
${ctrl('decay','Decay',0.005,3,0.005,P.decay,fms)}
${ctrl('sustain','Sustain',0,1,0.01,P.sustain,fpct)}
${ctrl('release','Release',0.01,4,0.01,P.release,fms)}
</section>
<section class="panel card locked" data-m="filter" style="--acc:var(--am)">
<div class="panel-head"><span class="dot"></span><h3>3 · Filter</h3><span class="panel-step">tone</span></div>
<p class="panel-note">Removes frequencies above (or below) the cutoff point.</p>
<div class="seg" id="filtSeg" style="margin-bottom:12px">
<button data-ft="lowpass" class="${P.filterType==='lowpass'?'on':''}">Low-pass</button>
<button data-ft="highpass" class="${P.filterType==='highpass'?'on':''}">High-pass</button>
<button data-ft="bandpass" class="${P.filterType==='bandpass'?'on':''}">Band-pass</button>
</div>
<canvas class="mini-canvas" id="filtCanvas" style="height:70px;margin-bottom:12px"></canvas>
<div id="cutWrap"></div>
${ctrl('reso','Resonance',0.1,24,0.1,P.resonance,f=>f.toFixed(1))}
${ctrl('envAmt','Filter envelope',0,1,0.01,P.envAmt,fpct)}
</section>
<section class="panel card locked" data-m="lfo" style="--acc:var(--pk)">
<div class="panel-head"><span class="dot"></span><h3>4 · LFO</h3><span class="panel-step">motion</span></div>
<p class="panel-note">A slow oscillator that automates another control.</p>
<div class="seg" id="lfoSeg" style="margin-bottom:12px">
<button data-lt="pitch" class="${P.lfoTarget==='pitch'?'on':''}">→ Pitch</button>
<button data-lt="filter" class="${P.lfoTarget==='filter'?'on':''}">→ Filter</button>
<button data-lt="amp" class="${P.lfoTarget==='amp'?'on':''}">→ Volume</button>
</div>
${ctrl('lfoRate','Rate',0.1,20,0.1,P.lfoRate,v=>v.toFixed(1)+' Hz')}
${ctrl('lfoDepth','Depth',0,1,0.01,P.lfoDepth,fpct)}
</section>
<section class="panel card locked" data-m="fx" style="--acc:var(--gr)">
<div class="panel-head"><span class="dot"></span><h3>5 · Drive & Delay</h3><span class="panel-step">space</span></div>
<p class="panel-note">After the note: distortion and echoes.</p>
${ctrl('drive','Drive',0,1,0.01,P.drive,fpct)}
${ctrl('delayTime','Delay time',0.02,0.9,0.01,P.delayTime,fms)}
${ctrl('feedback','Feedback',0,0.85,0.01,P.feedback,fpct)}
${ctrl('delayMix','Delay mix',0,1,0.01,P.delayMix,fpct)}
</section>
<section class="panel card locked" data-m="seq" style="--acc:var(--cy)">
<div class="panel-head"><span class="dot"></span><h3>6 · Sequencer</h3><span class="panel-step">loop</span></div>
<p class="panel-note">Click cells to build a 16-step pattern. Rows = pitches (C minor-ish pentatonic).</p>
<div class="grid-seq" id="seqGrid"></div>
<div class="seq-head">
<button class="btn primary" id="seqPlay" style="padding:7px 14px">▶ Play</button>
<button class="btn" id="seqRandom" style="padding:7px 12px;font-size:11.5px">Random</button>
<button class="btn" id="seqClear" style="padding:7px 12px;font-size:11.5px">Clear</button>
</div>
<div style="margin-top:10px">${ctrl('bpm','Tempo',60,180,1,P.bpm,v=>Math.round(v)+' BPM')}</div>
</section>`;
// cutoff uses an exponential slider
const cutX = Math.log(P.cutoff/60)/Math.log(19000/60)*100;
$("#cutWrap").innerHTML = ctrl('cutoff','Cutoff',0,100,0.1,cutX.toFixed(1),
()=>fhz(P.cutoff));
$("#cutoff").addEventListener('input', e => {
const x = +e.target.value/100;
P.cutoff = 60*Math.pow(19000/60, x);
$("#cutoffv").textContent = fhz(P.cutoff);
liveFilter(); drawFilterCurve(); flags.sweep = flags.sweep || voices.size>0; focusLesson();
});
// seq grid
const g = $("#seqGrid");
g.style.gridTemplateColumns = `24px repeat(16, minmax(0,1fr))`;
for(let r=0;r<8;r++){
const lab = document.createElement('div');
lab.className='row-lab'; lab.textContent = seqFmt(SEQ_NOTES[r]);
g.appendChild(lab);
for(let c=0;c<16;c++){
const cell=document.createElement('div');
cell.className='cell'; cell.dataset.r=r; cell.dataset.c=c;
cell.addEventListener('click',()=>{
pattern[r][c]=!pattern[r][c];
cell.classList.toggle('on',pattern[r][c]);
flags.seq = flags.seq || pattern.flat().some(Boolean);
focusLesson();
});
g.appendChild(cell);
}
}
bindPanelControls();
}
/* ---------------- binding ---------------- */
function bindPanelControls(){
// waveform
$("#waveSeg").addEventListener('click', e=>{
const b = e.target.closest('button[data-wave]'); if(!b) return;
P.wave = b.dataset.wave;
[...$("#waveSeg").children].forEach(x=>x.classList.toggle('on', x===b));
liveOsc();
if(voices.size===0) flags.waves.add(P.wave); // count even when silent
focusLesson();
});
// filter type
$("#filtSeg").addEventListener('click', e=>{
const b = e.target.closest('button[data-ft]'); if(!b) return;
P.filterType = b.dataset.ft;
[...$("#filtSeg").children].forEach(x=>x.classList.toggle('on', x===b));
liveFilter(); drawFilterCurve(); focusLesson();
});
// lfo target
$("#lfoSeg").addEventListener('click', e=>{
const b = e.target.closest('button[data-lt]'); if(!b) return;
P.lfoTarget = b.dataset.lt;
[...$("#lfoSeg").children].forEach(x=>x.classList.toggle('on', x===b));
applyLFO(); flags.lfo = flags.lfo || P.lfoDepth>0.02; refreshFlagUI(); focusLesson();
});
const bind = (id, key, fmt, after, flag) => {
const el = $("#"+id); if(!el) return;
el.addEventListener('input', ()=>{
const v = +el.value; P[key] = v;
$("#"+id+"v").textContent = fmt(v);
if(after) after();
if(flag) flags[flag] = true;
focusLesson();
});
};
bind('detune','detune', fcent, ()=>{ liveOsc(); flags.env=flags.env||false; });
bind('attack','attack', fms, ()=>{ liveEnv(); drawEnv(); }, 'env');
bind('decay','decay', fms, ()=>{ liveEnv(); drawEnv(); }, 'env');
bind('sustain','sustain', fpct, ()=>{ liveEnv(); drawEnv(); }, 'env');
bind('release','release', fms, ()=>drawEnv(), 'env');
bind('reso','resonance', v=>v.toFixed(1), ()=>{ liveFilter(); drawFilterCurve(); });
bind('envAmt','envAmt', fpct, null);
bind('lfoRate','lfoRate', v=>v.toFixed(1)+' Hz', applyLFO);
bind('lfoDepth','lfoDepth', fpct, ()=>{ applyLFO(); flags.lfo = P.lfoDepth>0.02; refreshFlagUI(); });
bind('drive','drive', fpct, ()=>makeDriveCurve(P.drive), 'fx');
bind('delayTime','delayTime', fms, ()=>{ if(delayNode) delayNode.delayTime.setTargetAtTime(P.delayTime, AC.currentTime, .05); }, 'fx');
bind('feedback','feedback', fpct, ()=>{ if(fbGain) fbGain.gain.value = P.feedback; }, 'fx');
bind('delayMix','delayMix', fpct, ()=>{ if(wetGain) wetGain.gain.value = P.delayMix; }, 'fx');
bind('bpm','bpm', v=>Math.round(v)+' BPM');
// sequencer transport
$("#seqPlay").addEventListener('click', ()=>{
initAudio();
if(AC.state==='suspended') AC.resume();
seqOn = !seqOn;
$("#seqPlay").textContent = seqOn ? '❚❚ Stop' : '▶ Play';
if(seqOn){ seqNext = AC.currentTime + 0.08; seqStep = 0; }
else {
flashQueue.length = 0;
document.querySelectorAll('.grid-seq .cell.playing').forEach(c=>c.classList.remove('playing'));
drawnStep = -1;
}
flags.seq = flags.seq || pattern.flat().some(Boolean);
focusLesson();
});
$("#seqRandom").addEventListener('click', ()=>{
pattern = pattern.map(()=>Array(16).fill(false));
SEQ_NOTES.forEach((n,r)=>{ for(let c=0;c<16;c++) pattern[r][c] = Math.random() < (r<3?0.14:0.2); });
pattern[7][0]=true; pattern[4][0]=true;
paintSeq(); flags.seq=true; focusLesson();
});
$("#seqClear").addEventListener('click', ()=>{
pattern = pattern.map(()=>Array(16).fill(false));
paintSeq();
});
}
function paintSeq(){
document.querySelectorAll('.grid-seq .cell').forEach(c=>{
c.classList.toggle('on', pattern[+c.dataset.r][+c.dataset.c]);
});
}
/* live parameter updates on sounding voices */
function liveOsc(){ if(!AC) return; voices.forEach(v=>{ v.osc.type = P.wave; v.osc.detune.setTargetAtTime(P.detune, AC.currentTime, .01); }); }
function liveFilter(){ if(!AC) return; voices.forEach(v=>{ v.filt.type=P.filterType; v.filt.frequency.setTargetAtTime(P.cutoff, AC.currentTime, .02); v.filt.Q.setTargetAtTime(P.resonance, AC.currentTime, .02); }); }
function liveEnv(){ /* envelopes are per-note; new notes pick up changes */ }
function syncUI(){
const set=(id,val,fmt)=>{ const el=$("#"+id); if(!el) return; el.value=val; const l=$("#"+id+"v"); if(l) l.textContent = fmt(+val); };
set('detune',P.detune,fcent);
set('attack',P.attack,fms); set('decay',P.decay,fms);
set('sustain',P.sustain,fpct); set('release',P.release,fms);
set('reso',P.resonance,v=>v.toFixed(1)); set('envAmt',P.envAmt,fpct);
set('lfoRate',P.lfoRate,v=>v.toFixed(1)+' Hz'); set('lfoDepth',P.lfoDepth,fpct);
set('drive',P.drive,fpct); set('delayTime',P.delayTime,fms);
set('feedback',P.feedback,fpct); set('delayMix',P.delayMix,fpct); set('bpm',P.bpm,v=>Math.round(v)+' BPM');
const cutX = Math.log(P.cutoff/60)/Math.log(19000/60)*100;
const cEl=$('#cutoff'); if(cEl){ cEl.value=cutX; $('#cutoffv').textContent=fhz(P.cutoff); }
document.querySelectorAll('#waveSeg button').forEach(b=>b.classList.toggle('on', b.dataset.wave===P.wave));
document.querySelectorAll('#filtSeg button').forEach(b=>b.classList.toggle('on', b.dataset.ft===P.filterType));
document.querySelectorAll('#lfoSeg button').forEach(b=>b.classList.toggle('on', b.dataset.lt===P.lfoTarget));
drawEnv(); drawFilterCurve();
}
function applyPreset(obj){
initAudio();
Object.assign(P, obj);
if(obj.delayTime!=null && delayNode) delayNode.delayTime.setTargetAtTime(P.delayTime, AC.currentTime, .05);
if(obj.feedback!=null && fbGain) fbGain.gain.value = P.feedback;
if(obj.delayMix!=null && wetGain) wetGain.gain.value = P.delayMix;
if(obj.drive!=null) makeDriveCurve(P.drive);
if(master) master.gain.setTargetAtTime(P.volume*0.9, AC.currentTime, .05);
applyLFO(); liveOsc(); liveFilter(); syncUI();
}
/* ==========================================================================
VISUALISERS
========================================================================== */
const scope = $("#scope");
function sizeCanvas(c){
const dpr = window.devicePixelRatio||1;
const r = c.getBoundingClientRect();
if(!r.width) return null;
if(c.width !== Math.round(r.width*dpr) || c.height !== Math.round(r.height*dpr)){
c.width = Math.round(r.width*dpr); c.height = Math.round(r.height*dpr);
}
const x = c.getContext('2d');
x.setTransform(dpr,0,0,dpr,0,0);
return {x, w:r.width, h:r.height};
}
function drawScope(){
const s = sizeCanvas(scope); if(!s) return;
const {x,w,h} = s;
x.clearRect(0,0,w,h);
x.strokeStyle='rgba(255,255,255,.05)'; x.lineWidth=1;
for(let i=1;i<6;i++){ x.beginPath(); x.moveTo(w*i/6,0); x.lineTo(w*i/6,h); x.stroke(); }
for(let i=1;i<3;i++){ x.beginPath(); x.moveTo(0,h*i/3); x.lineTo(w,h*i/3); x.stroke(); }
if(!analyser){
x.strokeStyle='rgba(150,160,200,.35)'; x.lineWidth=1.5;
x.beginPath(); x.moveTo(0,h/2); x.lineTo(w,h/2); x.stroke();
x.fillStyle='rgba(142,151,181,.9)'; x.font='500 11px ui-sans-serif,sans-serif';
x.fillText('play a note to wake the engine up', 14, h-14);
return;
}
analyser.getByteFrequencyData(freqData);
analyser.getByteTimeDomainData(waveData);
const bars = 56, bw = w/bars;
for(let i=0;i<bars;i++){
const idx = Math.floor(Math.pow(i/bars, 1.8) * (freqData.length*0.6));
const v = freqData[idx]/255;
const bh = Math.pow(v,1.25) * h * 0.9;
const g = x.createLinearGradient(0,h,0,h-bh);
g.addColorStop(0,'rgba(110,231,255,.02)');
g.addColorStop(1,'rgba(110,231,255,.34)');
x.fillStyle = g;
x.fillRect(i*bw + bw*0.18, h-bh, bw*0.64, bh);
}
const n = waveData.length;
x.beginPath();
for(let i=0;i<n;i++){
const px = (i/(n-1))*w;
const py = h/2 + (waveData[i]-128)/128 * h*0.40;
i ? x.lineTo(px,py) : x.moveTo(px,py);
}
x.strokeStyle='#b98cff'; x.lineWidth=2; x.lineJoin='round';
x.shadowColor='rgba(176,124,255,.9)'; x.shadowBlur=12;
x.stroke(); x.shadowBlur=0;
}
function drawEnv(){
const c = $("#envCanvas"); if(!c) return;
const s = sizeCanvas(c); if(!s) return;
const {x,w,h} = s;
x.clearRect(0,0,w,h);
const pad=9, A=P.attack, D=P.decay, S=P.sustain, R=P.release, HOLD=0.35;
const total = Math.max(0.05, A+D+HOLD+R);
const sx = t => pad + (t/total)*(w-pad*2);
const y = v => (h-pad) - v*(h-pad*2);
x.strokeStyle='rgba(255,255,255,.06)'; x.lineWidth=1;
for(let i=1;i<4;i++){ x.beginPath(); x.moveTo(0,h*i/4); x.lineTo(w,h*i/4); x.stroke(); }
const pts=[[0,0.02],[A,1],[A+D,S],[A+D+HOLD,S],[A+D+HOLD+R,0.02]];
x.beginPath(); x.moveTo(sx(0),y(0.02));
const segs=[[A,1],[A+D,S],[A+D+HOLD,S],[A+D+HOLD+R,0.02]];
for(const [t,v] of segs) x.lineTo(sx(t),y(v));
x.lineTo(sx(A+D+HOLD+R),h-pad); x.lineTo(sx(0),h-pad); x.closePath();
const g = x.createLinearGradient(0,0,0,h);
g.addColorStop(0,'rgba(176,124,255,.34)'); g.addColorStop(1,'rgba(176,124,255,0)');
x.fillStyle=g; x.fill();
x.beginPath(); x.moveTo(sx(0),y(0.02));
for(const [t,v] of segs) x.lineTo(sx(t),y(v));
x.strokeStyle='#c39cff'; x.lineWidth=2; x.lineJoin='round'; x.stroke();
x.fillStyle='rgba(200,210,240,.75)'; x.font='600 9px ui-monospace,monospace';
x.fillText('A', sx(A/2)-3, h-2);
x.fillText('D', sx(A+D/2)-3, h-2);
x.fillText('S', sx(A+D+HOLD/2)-3, h-2);
x.fillText('R', sx(A+D+HOLD+R/2)-3, h-2);
}
let curveFilter=null;
function drawFilterCurve(){
const c = $("#filtCanvas"); if(!c) return;
const s = sizeCanvas(c); if(!s) return;
const {x,w,h} = s;
x.clearRect(0,0,w,h);
x.strokeStyle='rgba(255,255,255,.06)'; x.lineWidth=1;
for(let i=1;i<4;i++){ x.beginPath(); x.moveTo(w*i/4,0); x.lineTo(w*i/4,h); x.stroke(); }
if(!AC){ x.fillStyle='rgba(142,151,181,.8)'; x.font='500 10px ui-sans-serif'; x.fillText('power on to see response', 12, h/2+4); return; }
if(!curveFilter) curveFilter = AC.createBiquadFilter();
curveFilter.type = P.filterType; curveFilter.frequency.value = P.cutoff; curveFilter.Q.value = P.resonance;
const N=220, freqs=new Float32Array(N), mag=new Float32Array(N), ph=new Float32Array(N);
for(let i=0;i<N;i++) freqs[i] = 20*Math.pow(1000, i/(N-1));
try{ curveFilter.getFrequencyResponse(freqs,mag,ph); }catch(e){ return; }
const dbToY = db => h - ((clamp(db,-54,18)+54)/72)*h;
x.beginPath();
for(let i=0;i<N;i++){
const px = (Math.log(freqs[i]/20)/Math.log(1000))*w;
const db = 20*Math.log10(Math.max(1e-5,mag[i]));
const py = dbToY(db);
i?x.lineTo(px,py):x.moveTo(px,py);
}
x.lineTo(w,h); x.lineTo(0,h); x.closePath();
const g = x.createLinearGradient(0,0,0,h);
g.addColorStop(0,'rgba(255,204,102,.30)'); g.addColorStop(1,'rgba(255,204,102,0)');
x.fillStyle=g; x.fill();
x.beginPath();
for(let i=0;i<N;i++){
const px = (Math.log(freqs[i]/20)/Math.log(1000))*w;
const db = 20*Math.log10(Math.max(1e-5,mag[i]));
i?x.lineTo(px,dbToY(db)):x.moveTo(px,dbToY(db));
}
x.strokeStyle='#ffcc66'; x.lineWidth=2; x.stroke();
const cx = (Math.log(P.cutoff/20)/Math.log(1000))*w;
x.strokeStyle='rgba(255,204,102,.45)'; x.setLineDash([3,3]); x.lineWidth=1;
x.beginPath(); x.moveTo(cx,0); x.lineTo(cx,h); x.stroke(); x.setLineDash([]);
x.fillStyle='rgba(255,220,160,.85)'; x.font='600 9px ui-monospace,monospace';
x.fillText('cutoff '+fhz(P.cutoff), Math.min(cx+4, w-70), 11);
}
function updateSeqUI(){
if(!seqOn){
if(drawnStep!==-1){
document.querySelectorAll('.grid-seq .cell.playing').forEach(c=>c.classList.remove('playing'));
drawnStep=-1;
}
return;
}
if(uiStep===drawnStep) return;
drawnStep=uiStep;
document.querySelectorAll('.grid-seq .cell').forEach(c=>{
c.classList.toggle('playing', +c.dataset.c === uiStep);
});
}
function loop(){
requestAnimationFrame(loop);
drawScope();
if(seqOn && AC){
seqTick();
const now = AC.currentTime;
while(flashQueue.length && flashQueue[0].t <= now){
const f = flashQueue.shift();
const el = keyEls.get(f.midi);
if(el){ el.classList.add('active'); setTimeout(()=>{ if(!voices.has(f.midi)) el.classList.remove('active'); }, 170); }
}
if(flashQueue.length > 96) flashQueue.length = 0;
}
updateSeqUI();
}
/* ==========================================================================
KEYBOARD
========================================================================== */
const KBLOW=60, NKEYS=24, WHITES=14, wIdx=[0,1,1,2,2,3,4,4,5,5,6,6];
const KEYMAP={};
"awsedftgyhujkolp;".split('').forEach((k,i)=>{ KEYMAP[k]=KBLOW+i; });
function buildKeyboard(){
const kb = $("#keyboard"); kb.innerHTML='';
const ww = 100/WHITES, bw = ww*0.62;
for(let i=0;i<NKEYS;i++){
const midi=KBLOW+i, s=i%12, oct=Math.floor(i/12);
const black=[1,3,6,8,10].indexOf(s)>-1;
const el=document.createElement('div');
el.className = black?'bkey':'wkey';
el.dataset.midi=midi;
if(black){
el.style.left = ((oct*7 + wIdx[s] + 1)*ww - bw/2) + '%';
el.style.width = bw + '%';
}else{
el.style.left = ((oct*7 + wIdx[s])*ww) + '%';
el.style.width = ww + '%';
}
if(midi%12===0){
const l=document.createElement('div'); l.className='key-lbl';
l.textContent='C'+(Math.floor(midi/12)-1);
el.appendChild(l);
}
kb.appendChild(el); keyEls.set(midi, el);
}
const kbEl = kb;
let pressed=false, cur=null;
const at = e => {
const t = document.elementFromPoint(e.clientX, e.clientY);
const k = t && t.closest ? t.closest('.wkey,.bkey') : null;
return k ? +k.dataset.midi : null;
};
kbEl.addEventListener('pointerdown', e=>{
e.preventDefault();
try{ kbEl.setPointerCapture(e.pointerId); }catch(err){}
pressed=true;
const m = at(e); if(m!=null){ cur=m; noteOn(m); }
});
kbEl.addEventListener('pointermove', e=>{
if(!pressed) return;
const m = at(e);
if(m!==cur){
if(cur!=null) noteOff(cur);
cur=m;
if(m!=null) noteOn(m);
}
});
const up = ()=>{ if(!pressed) return; pressed=false; if(cur!=null) noteOff(cur); cur=null; };
window.addEventListener('pointerup', up);
window.addEventListener('pointercancel', up);
kbEl.addEventListener('pointerleave', ()=>{ if(pressed && cur!=null){ noteOff(cur); cur=null; } });
}
window.addEventListener('keydown', e=>{
if(e.repeat || e.metaKey || e.ctrlKey || e.altKey) return;
const tag = (e.target.tagName||'').toLowerCase();
if(tag==='input'||tag==='textarea') return;
const m = KEYMAP[(e.key||'').toLowerCase()];
if(m!=null){ e.preventDefault(); noteOn(m); }
});
window.addEventListener('keyup', e=>{
const m = KEYMAP[(e.key||'').toLowerCase()];
if(m!=null) noteOff(m);
});
/* ==========================================================================
LESSON CONTROLLER
========================================================================== */
const CHECKS = {
osc: ()=> flags.played && flags.waves.size>=4,
env: ()=> flags.env,
filter: ()=> flags.sweep,
lfo: ()=> flags.lfo,
fx: ()=> flags.fx,
seq: ()=> flags.seq
};
let toastTimer=null;
function toast(msg){
const t=$("#toast"); t.textContent=msg; t.classList.add('show');
clearTimeout(toastTimer);
toastTimer=setTimeout(()=>t.classList.remove('show'), 3600);
}
function refreshFlagUI(){
if(!LESSONS[current]) return;
const done = CHECKS[LESSONS[current].key]();
$("#task").classList.toggle('met', done);
const next = $("#nextBtn");
next.disabled = false;
next.classList.toggle('ready', done && current < LESSONS.length-1);
next.textContent = current >= LESSONS.length-1 ? 'Loop is running ✓' : 'Continue ▸';
}
function focusLesson(){ refreshFlagUI(); }
function renderLesson(){
const L = LESSONS[current];
$("#kicker").textContent = L.kicker;
$("#lessonTitle").innerHTML = L.title;
$("#lessonBody").innerHTML = L.body;
$("#taskText").textContent = L.task;
document.documentElement.style.setProperty('--acc', L.acc);
// recipe buttons
let html = `<b>${L.key==='seq'?'🎛️':'💡'} ${L.key==='seq'?'Where you ended up':'Try this'}</b><br>${L.hint}`;
if(L.recipes.length){
html += `<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">` +
L.recipes.map((r,i)=>`<button class="btn" data-recipe="${L.key}:${i}" style="padding:6px 11px;font-size:11.5px">${r[0]}</button>`).join('') + `</div>`;
}
$("#hintbox").innerHTML = html;
$("#hintbox").querySelectorAll('[data-recipe]').forEach(b=>{
b.addEventListener('click', ()=>{
const [k,i] = b.dataset.recipe.split(':');
const L2 = LESSONS.find(l=>l.key===k);
applyPreset(L2.recipes[+i][1]);
b.classList.add('hot'); setTimeout(()=>b.classList.remove('hot'), 1200);
toast('Applied "'+L2.recipes[+i][0]+'" — now play a note');
});
});
// progress dots
const bar = $("#progress");
bar.innerHTML = LESSONS.map((l,i)=>{
const cls = i<current ? 'pdot done' : (i===current ? 'pdot cur' : 'pdot');
return `<span class="${cls}"></span>`;
}).join('') + `<span class="pcount" id="pcount">${current+1} / ${LESSONS.length}</span>`;
refreshFlagUI();
}
function unlockNext(){
const nx = document.querySelector(`.panel[data-m="${LESSONS[current].key}"]`);
if(nx){ nx.classList.remove('locked'); nx.classList.add('unlocking'); setTimeout(()=>nx.classList.remove('unlocking'),900); }
if(current < LESSONS.length-1){
const key = LESSONS[current+1].key;
const p = document.querySelector(`.panel[data-m="${key}"]`);
if(p){ p.classList.remove('locked'); p.classList.add('unlocking'); setTimeout(()=>p.classList.remove('unlocking'),900);
p.scrollIntoView({behavior:'smooth', block:'nearest', inline:'center'}); }
}
}
/* ==========================================================================
INIT
========================================================================== */
function init(){
buildKeyboard();
buildPanels();
renderLesson();
drawEnv(); drawFilterCurve();
// first panel is available from the start
const first = document.querySelector('.panel[data-m="osc"]');
if(first) first.classList.remove('locked');
$("#nextBtn").addEventListener('click', ()=>{
unlockNext();
if(current < LESSONS.length-1){ current++; renderLesson(); }
else toast('You finished the chain. Now go make some noise.');
});
$("#skipBtn").addEventListener('click', ()=>{
unlockNext();
if(current < LESSONS.length-1){ current++; renderLesson(); toast('Skipped ahead — the explanation is in the panel.'); }
});
$("#master").addEventListener('input', e=>{
P.volume = (+e.target.value)/100;
if(master) master.gain.setTargetAtTime(P.volume*0.9, AC.currentTime, .03);
});
$("#panic").addEventListener('click', ()=>{ allOff(); });
const ro = ()=>{
drawEnv(); drawFilterCurve(); sizeCanvas(scope);
};
window.addEventListener('resize', ro);
if(window.ResizeObserver) new ResizeObserver(ro).observe(document.body);
requestAnimationFrame(loop);
}
init();
/* small public handle (handy for tinkering in the console) */
window.SynthLab = {
P, noteOn, noteOff, allOff, applyPreset,
get ctx(){ return AC; },
get voices(){ return voices; },
get analyser(){ return analyser; },
get nodes(){ return {mixBus, shaper, dryIn, postGain, master, delayNode, wetGain, lfo}; },
get step(){ return uiStep; },
flags
};
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"d70067e1-1bfd-41dc-8e19-690fcee0baf6","tokensIn":2617271,"tokensOut":53684,"tokensTotal":2670955,"cost":0.046147866,"turns":42,"toolCalls":41,"failedToolCalls":0,"timestamp":"2026-09-10T23:46:00.111Z"} -->