TutSynth
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · 018e5c8320
I want to understand how synthesizers work, what the various controls do. Build an interactive synth playground that adds features progressively while teaching me how to take advantage of each
Inspect original source 31,077 bytes · SHA-256 62866118ccb8
<!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 playing</title>
<style>
:root{--bg:#0d1015;--panel:#161b23;--panel2:#1c232d;--ink:#e6edf3;--mute:#8b98a8;--acc:#ffb547;--acc2:#4fd1c5;--line:#273142;--red:#ff6b6b}
*{box-sizing:border-box}
body{margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;background:var(--bg);color:var(--ink)}
header{display:flex;align-items:center;gap:14px;padding:10px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap;position:sticky;top:0;background:var(--bg);z-index:5}
h1{font-size:18px;margin:0;letter-spacing:.3px}
.muted{color:var(--mute)}
.spacer{flex:1}
header label{display:flex;align-items:center;gap:6px;color:var(--mute);font-size:13px}
button{font:inherit;color:var(--ink);background:var(--panel2);border:1px solid var(--line);border-radius:7px;padding:5px 11px;cursor:pointer}
button:hover:not(:disabled){border-color:var(--acc)}
button:disabled{opacity:.4;cursor:default}
button.primary{background:var(--acc);color:#1a1200;border-color:var(--acc);font-weight:600}
.grid{display:grid;grid-template-columns:360px 1fr;gap:14px;padding:14px;align-items:start}
@media(max-width:920px){.grid{grid-template-columns:1fr}}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px}
.side{position:sticky;top:64px}
.lessonlist{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}
.lessonlist button{text-align:left;border-color:transparent;background:transparent;color:var(--mute);padding:4px 8px}
.lessonlist button.active{color:var(--ink);background:var(--panel2);border-color:var(--line)}
.lessonlist button.done{color:#b8c4d2}
.lessonlist button.done::before{content:"\2713 ";color:var(--acc2)}
#lessonBody h2{font-size:18px;margin:4px 0 8px}
#lessonBody p{margin:8px 0;color:#c9d3de}
.task{background:#1d2a24;border:1px solid #2c4a3d;border-radius:8px;padding:9px 11px;margin:10px 0;color:#c9f0e6}
.task b{color:var(--acc2)}
.lnav{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.lnav .spacer{flex:1}
.counter{color:var(--mute);font-size:12px}
.viz{padding:10px;margin-bottom:14px}
.viz .cap{font-size:12px;color:var(--mute);margin-bottom:4px;display:flex;justify-content:space-between}
canvas{display:block;width:100%;border-radius:6px;background:#0a0d12}
.hidden{display:none!important}
#keysWrap{margin-bottom:14px}
#piano{position:relative;height:130px;user-select:none;touch-action:none}
.wk{position:absolute;top:0;bottom:0;background:linear-gradient(#fbfbfb,#e4e7ec);border:1px solid #556;border-radius:0 0 6px 6px;box-sizing:border-box;display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;color:#334;font-size:11px;font-weight:700;cursor:pointer}
.bk{position:absolute;top:0;height:62%;background:linear-gradient(#333,#0a0a0a);border-radius:0 0 4px 4px;z-index:2;display:flex;align-items:flex-end;justify-content:center;padding-bottom:5px;color:#ccd;font-size:11px;font-weight:700;cursor:pointer;box-sizing:border-box;border:1px solid #000}
.wk.on{background:linear-gradient(#ffe2a8,#ffb547)}
.bk.on{background:linear-gradient(#b87d1f,#6b4700)}
.hint{font-size:12px;color:var(--mute);margin-top:8px}
.rack{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:12px}
.mod{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 13px;transition:opacity .3s,filter .3s;position:relative}
.mod.locked{opacity:.3;filter:grayscale(1);pointer-events:none}
.mod.locked .lock{display:block}
.mod.fresh{animation:flash 1.4s ease-out}
@keyframes flash{0%{box-shadow:0 0 0 2px var(--acc)}100%{box-shadow:0 0 0 0 transparent}}
.mh{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
.mh h3{margin:0;font-size:15px}
.tag{font-size:11px;color:var(--mute);border:1px solid var(--line);border-radius:10px;padding:0 7px}
.mdesc{margin:2px 0 8px;color:var(--mute);font-size:12px}
.row{margin:9px 0}
.lbl{display:flex;justify-content:space-between;font-size:12.5px;color:#b9c4d1}
.val{font-variant-numeric:tabular-nums;color:var(--acc);font-weight:600}
input[type=range]{width:100%;accent-color:var(--acc);margin:3px 0 0}
.seg{display:flex;gap:4px;margin-top:5px;flex-wrap:wrap}
.seg button{flex:1;padding:4px 6px;font-size:12px}
.seg button.active{background:var(--acc);color:#1a1200;border-color:var(--acc);font-weight:600}
.lock{display:none;margin-top:6px;font-size:12px;color:var(--mute);font-style:italic}
.banner{position:fixed;inset:auto 16px 16px auto;background:var(--panel2);border:1px solid var(--acc);padding:10px 14px;border-radius:10px;max-width:320px;font-size:13px;z-index:9}
</style>
</head>
<body>
<header>
<h1>Synth Lab</h1><span class="muted">learn synthesis by playing</span>
<div class="spacer"></div>
<label><input type="checkbox" id="freeplay"> Free play (all modules)</label>
<label>Master <input type="range" id="master" min="0" max="1" step="0.01" value="0.6" style="width:120px"></label>
<span class="muted">Keyboard octave: <b id="octLbl" style="color:var(--ink)"></b> (Z/X)</span>
<button id="resetBtn">Reset</button>
<button id="audioBtn" class="primary">Enable audio</button>
</header>
<main class="grid">
<aside class="panel side" id="lessonPanel">
<div class="lessonlist" id="lessonList"></div>
<div id="lessonBody"></div>
</aside>
<section>
<div class="panel viz" id="scopeWrap">
<div class="cap"><span>Waveform (oscilloscope)</span><span>Output after all effects</span></div>
<canvas id="scope" style="height:110px"></canvas>
<div class="cap" style="margin-top:8px"><span>Spectrum (what frequencies are present)</span><span>20 Hz to 8 kHz, log scale</span></div>
<canvas id="spec" style="height:110px"></canvas>
</div>
<div class="panel viz" id="envWrap">
<div class="cap"><span>Envelopes over time</span><span><span style="color:#fff">white</span> = amp, <span style="color:var(--acc)">orange</span> = filter; the line is your playhead</span></div>
<canvas id="envc" style="height:120px"></canvas>
</div>
<div class="panel viz" id="filtWrap">
<div class="cap"><span>Filter frequency response</span><span>how loud each frequency passes through</span></div>
<canvas id="filtc" style="height:130px"></canvas>
</div>
<div class="panel" id="keysWrap">
<div id="piano"></div>
<div class="hint">Keys: A W S E D F T G Y H U J K O L P ; play notes. Z / X shift the keyboard octave. Mouse and touch work too.</div>
</div>
<div class="rack" id="rack"></div>
</section>
</main>
<script>
/* ---------- helpers ---------- */
const $=s=>document.querySelector(s);
const NOTE=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const fT=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 fQ=v=>v.toFixed(1);
const fOct=v=>v.toFixed(1)+' oct';
const fOctAbs=v=>(v>0?'+':'')+v+' oct';
const fCt=v=>(v>0?'+':'')+Math.round(v)+' ¢';
const fRate=v=>v.toFixed(2)+' Hz';
const rng=(k,label,lo,hi,v,fmt,o={})=>Object.assign({t:'range',k,label,lo,hi,v,fmt},o);
const seg=(k,label,opts,v)=>({t:'seg',k,label,opts,v});
const WAVES=[['sine','Sine'],['triangle','Triangle'],['sawtooth','Saw'],['square','Square']];
/* ---------- module definitions (lesson = lesson that unlocks it) ---------- */
const MODS=[
{id:'osc',name:'Oscillator',lesson:1,desc:'The sound source. Waveform sets timbre; octave sets pitch.',controls:[
seg('wave','Waveform',WAVES,'sawtooth'),
rng('oct','Octave',-2,2,0,fOctAbs,{round:1})
]},
{id:'mix',name:'Osc 2 & Noise',lesson:2,desc:'A second, slightly detuned oscillator plus unpitched noise.',controls:[
seg('wave2','Osc 2 waveform',WAVES,'square'),
rng('osc2lvl','Osc 2 level',0,1,0,fPct),
rng('detune','Osc 2 detune',-50,50,8,fCt),
rng('noise','Noise level',0,1,0,fPct)
]},
{id:'amp',name:'Amp Envelope',lesson:3,desc:'Shapes loudness over the life of a note (ADSR).',controls:[
rng('aA','Attack',0.001,3,0.01,fT,{log:1}),
rng('aD','Decay',0.001,3,0.3,fT,{log:1}),
rng('aS','Sustain',0,1,0.7,fPct),
rng('aR','Release',0.01,4,0.5,fT,{log:1})
]},
{id:'flt',name:'Filter',lesson:4,desc:'Removes frequencies. Cutoff = where it bites; resonance = a peak there.',controls:[
seg('fType','Type',[['lowpass','Low-pass'],['highpass','High-pass'],['bandpass','Band-pass']],'lowpass'),
rng('cutoff','Cutoff',20,20000,1500,fHz,{log:1}),
rng('res','Resonance (Q)',0.1,20,1,fQ,{log:1})
]},
{id:'fenv',name:'Filter Envelope',lesson:5,desc:'An ADSR that sweeps the cutoff over the life of each note.',controls:[
rng('fAmt','Amount',0,5,0,fOct),
rng('fA','Attack',0.001,3,0.01,fT,{log:1}),
rng('fD','Decay',0.001,3,0.4,fT,{log:1}),
rng('fS','Sustain',0,1,0.2,fPct),
rng('fR','Release',0.01,4,0.5,fT,{log:1})
]},
{id:'lfo',name:'LFO',lesson:6,desc:'A slow wave (well below 20 Hz) that wobbles a target.',controls:[
seg('lWave','Shape',WAVES,'sine'),
rng('lRate','Rate',0.1,20,5,fRate,{log:1}),
rng('lDepth','Depth',0,1,0,fPct),
seg('lTarget','Target',[['pitch','Pitch'],['filter','Filter'],['amp','Amp']],'pitch')
]},
{id:'fx',name:'Effects',lesson:7,desc:'Delay repeats the sound; reverb places it in a room.',controls:[
rng('dT','Delay time',0.02,1,0.35,fT,{log:1}),
rng('dFb','Delay feedback',0,0.9,0.4,fPct),
rng('dMix','Delay mix',0,1,0,fPct),
rng('rSize','Reverb size',0.3,6,2.5,fT,{log:1}),
rng('rMix','Reverb mix',0,1,0,fPct)
]}
];
const CTRL={}, P={};
MODS.forEach(m=>m.controls.forEach(c=>{CTRL[c.k]=c;P[c.k]=c.v;}));
const DEF=Object.assign({},P);
/* ---------- lessons ---------- */
const LESSONS=[
{t:'The signal chain',
b:`<p>Sound is air pressure rising and falling. A synth makes that motion electronically in four stages: an <b>oscillator</b> generates a repeating wave, a <b>filter</b> shapes its tone, an <b>amplifier</b> shapes its loudness over time, and <b>effects</b> add echo and space. Modulators (envelopes and LFOs) move these knobs automatically.</p>
<p>Pitch is frequency in Hz: A4 is 440 Hz, and every octave doubles it. The <b>waveform</b> sets timbre (tone colour). The scope shows its shape; the spectrum shows which harmonics it contains.</p><p><i>Unlocks: Oscillator</i></p>`,
task:'Play a few keys (click the piano, or use A–; on your keyboard). Switch the waveform between Sine and Saw.',
hear:'Sine sounds pure and round; saw sounds buzzy. The spectrum shows the saw has many more harmonics.'},
{t:'Harmonics and layering',
b:`<p>Every repeating wave is a stack of sine waves: a fundamental (the pitch) plus harmonics at 2×, 3×, 4× that frequency. Each waveform has a different recipe. A square wave has only odd harmonics, so it sounds hollow. A saw has all of them, so it sounds bright.</p>
<p>Two slightly detuned oscillators beat against each other, giving a fat, chorus-like sound. <b>Noise</b> contains every frequency at once, which gives breath, hiss and percussion.</p><p><i>Unlocks: Osc 2 & Noise</i></p>`,
task:'Set Osc 2 level to about 50%, detune to 12 ¢ and Osc 2 waveform to Saw. Then bring up Noise a little.',
hear:'A slow shimmer from the detuning. Noise adds grit.'},
{t:'Amp envelope',
b:`<p>An <b>envelope</b> is a shape that changes over a note's life. It has four stages. <b>Attack</b>: time to reach full loudness. <b>Decay</b>: time to fall to the <b>Sustain</b> level, which is held while the key is down. <b>Release</b>: the fade after you let go.</p>
<p>The white curve in the envelope panel is the amp envelope. It is the biggest factor in whether a sound behaves like a pad, a pluck or an organ.</p><p><i>Unlocks: Amp Envelope</i></p>`,
task:'Make a pad: Attack about 400 ms, Sustain high, Release 1.5 s. Then make a pluck: Attack 0, Decay about 200 ms, Sustain 0.',
hear:'A slow attack swells in; a short release cuts off abruptly.'},
{t:'The filter (subtractive synthesis)',
b:`<p>Subtractive synthesis starts with a rich sound and carves frequencies away. A <b>low-pass</b> filter lets lows through and cuts highs above the <b>cutoff</b>. A <b>high-pass</b> does the reverse, and a <b>band-pass</b> keeps only a band around the cutoff.</p>
<p><b>Resonance</b> (Q) boosts the frequencies right at the cutoff. Turn it up and you get the squelchy, vocal "acid" tone. The orange curve in the filter panel shows exactly what passes through.</p><p><i>Unlocks: Filter</i></p>`,
task:'Use a saw, set cutoff to about 400 Hz, then sweep it upward. Raise resonance to about 8 and sweep again.',
hear:'Sweeping the cutoff sounds like "wah". High resonance whistles at the cutoff.'},
{t:'Filter envelope',
b:`<p>The filter envelope is an ADSR that automatically sweeps the cutoff for every note, so tone changes over time. <b>Amount</b> sets how far (in octaves) the cutoff travels. It is the source of most classic bass and pluck sounds: a bright attack that closes down into a warm body.</p><p><i>Unlocks: Filter Envelope</i></p>`,
task:'Set Amount to 3 oct, Filter Attack 0, Decay about 250 ms, Sustain 0. Keep cutoff low (around 300 Hz). Play a melody.',
hear:'A "wow" at the start of each note that closes down.'},
{t:'LFO (modulation)',
b:`<p>An <b>LFO</b> (low-frequency oscillator) is a wave too slow to hear as pitch, usually 0.1–20 Hz. Routing it to a target creates movement. <b>Pitch</b> gives vibrato, <b>Filter</b> gives wah, <b>Amp</b> gives tremolo. <b>Rate</b> sets speed; <b>Depth</b> sets how far it pushes.</p><p><i>Unlocks: LFO</i></p>`,
task:'Target = Pitch, Rate 5 Hz, Depth about 25% for vibrato. Then Target = Filter, Rate 0.5 Hz, Depth 60% for a slow wah.',
hear:'Vibrato wobbles pitch; the filter LFO sweeps brightness.'},
{t:'Effects: delay and reverb',
b:`<p><b>Delay</b> replays the sound: time sets the gap, feedback sets how many repeats, and mix sets echo loudness. <b>Reverb</b> simulates a room, and size sets how long its tail lasts. Both are wet/dry blends, so mix 0% means bypassed.</p>
<p>That completes the core signal chain. Turn on <b>Free play</b> in the header to use everything at once.</p><p><i>Unlocks: Effects</i></p>`,
task:'Set Delay mix 35%, feedback 50%, time about 0.35 s. Then add Reverb mix 30%.',
hear:'Repeating echoes and a sense of space.'}
];
let maxIdx=0, cur=0, freeOn=false;
function unlockedMod(L){return freeOn || (L-1)<=maxIdx;}
function renderLessons(){
const list=$('#lessonList'); list.innerHTML='';
LESSONS.forEach((L,i)=>{
const b=document.createElement('button');
b.textContent=`${i+1}. ${L.t}`;
b.className=(i===cur?'active ':'')+(i<maxIdx?'done':'');
b.disabled=!(freeOn||i<=maxIdx);
b.onclick=()=>{cur=i;renderLessons();};
list.appendChild(b);
});
const L=LESSONS[cur], last=cur===LESSONS.length-1;
$('#lessonBody').innerHTML=`<h2>${cur+1}. ${L.t}</h2>${L.b}
<div class="task"><b>Try this:</b> ${L.task}</div>
<div class="hint">You'll hear: ${L.hear}</div>
<div class="lnav"><button id="prevL" ${cur===0?'disabled':''}>← Prev</button><span class="spacer"></span>
<span class="counter">${cur+1} / ${LESSONS.length}</span><span class="spacer"></span>
<button id="nextL" class="primary" ${last?'disabled':''}>Next →</button></div>`;
$('#prevL').onclick=()=>{cur=Math.max(0,cur-1);renderLessons();};
$('#nextL').onclick=()=>{
if(cur===maxIdx && maxIdx<LESSONS.length-1) maxIdx++;
cur++; renderLessons(); renderLocks();
};
}
/* ---------- rack UI ---------- */
function ctrlHTML(c){
if(c.t==='seg') return `<div class="row"><div class="lbl"><span>${c.label}</span></div>
<div class="seg" data-k="${c.k}">${c.opts.map(([v,l])=>`<button data-v="${v}" class="${P[c.k]===v?'active':''}">${l}</button>`).join('')}</div></div>`;
return `<div class="row"><div class="lbl"><span>${c.label}</span><span class="val" data-v="${c.k}"></span></div>
<input type="range" min="0" max="1000" value="${rawOf(c,P[c.k])}" data-k="${c.k}"></div>`;
}
function rawOf(c,v){return c.log?Math.log(v/c.lo)/Math.log(c.hi/c.lo)*1000:(v-c.lo)/(c.hi-c.lo)*1000;}
function valOf(c,r){let x=c.log?c.lo*Math.pow(c.hi/c.lo,r/1000):c.lo+(c.hi-c.lo)*r/1000; if(c.round) x=Math.round(x); return x;}
function updateVals(){
Object.values(CTRL).forEach(c=>{ if(c.t!=='range') return;
const el=document.querySelector(`[data-v="${c.k}"]`); if(el) el.textContent=c.fmt(P[c.k]); });
}
function buildRack(){
const r=$('#rack'); r.innerHTML='';
MODS.forEach(m=>{
const el=document.createElement('div');
el.className='mod'; el.id='mod_'+m.id;
el.innerHTML=`<div class="mh"><h3>${m.name}</h3><span class="tag">Lesson ${m.lesson}</span></div>
<div class="mdesc">${m.desc}</div>${m.controls.map(ctrlHTML).join('')}
<div class="lock">Locked: unlocks in lesson ${m.lesson}</div>`;
r.appendChild(el);
});
updateVals();
}
function renderLocks(){
MODS.forEach(m=>{
const el=document.getElementById('mod_'+m.id); if(!el) return;
const u=unlockedMod(m.lesson);
if(u && !el.dataset.u){ el.classList.remove('fresh'); void el.offsetWidth; el.classList.add('fresh'); }
el.dataset.u=u?'1':'';
el.classList.toggle('locked',!u);
el.querySelector('.lock').textContent=`Locked: unlocks in lesson ${m.lesson}`;
});
}
$('#rack').addEventListener('input',e=>{
const k=e.target.dataset.k; if(!k||!CTRL[k]) return;
const c=CTRL[k]; P[k]=valOf(c,+e.target.value); updateVals(); onParam(k);
});
$('#rack').addEventListener('click',e=>{
const b=e.target.closest('.seg button'); if(!b) return;
const k=b.parentElement.dataset.k; P[k]=b.dataset.v;
b.parentElement.querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b));
onParam(k);
});
/* ---------- audio graph ---------- */
let ctx=null, G={}, voices=[], irT=null;
const MAXV=8;
const mtof=m=>440*Math.pow(2,(m-69)/12);
function makeIR(sec){
const n=Math.max(1,Math.floor(ctx.sampleRate*sec));
const b=ctx.createBuffer(2,n,ctx.sampleRate);
for(let c=0;c<2;c++){const d=b.getChannelData(c);
for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*Math.pow(1-i/n,3);}
return b;
}
function initAudio(){
const AC=window.AudioContext||window.webkitAudioContext;
if(!AC){alert('Web Audio is not supported in this browser.');return;}
ctx=new AC(); const c=ctx;
G.bus=c.createGain();
G.dry=c.createGain();
G.delay=c.createDelay(2); G.delay.delayTime.value=P.dT;
G.fb=c.createGain(); G.fb.gain.value=P.dFb;
G.dWet=c.createGain(); G.dWet.gain.value=P.dMix;
G.conv=c.createConvolver(); G.conv.buffer=makeIR(P.rSize);
G.rWet=c.createGain(); G.rWet.gain.value=P.rMix;
G.master=c.createGain(); G.master.gain.value=+$('#master').value;
G.comp=c.createDynamicsCompressor();
G.an=c.createAnalyser(); G.an.fftSize=2048;
// dry + effects into master
G.bus.connect(G.dry).connect(G.master);
G.bus.connect(G.delay); G.delay.connect(G.fb); G.fb.connect(G.delay);
G.delay.connect(G.dWet); G.dWet.connect(G.master);
G.bus.connect(G.conv); G.conv.connect(G.rWet); G.rWet.connect(G.master);
G.master.connect(G.comp); G.comp.connect(G.an); G.an.connect(c.destination);
// global LFO with three routable outputs
G.lfo=c.createOscillator(); G.lfo.type=P.lWave; G.lfo.frequency.value=P.lRate;
G.lP=c.createGain(); G.lF=c.createGain(); G.lA=c.createGain();
G.lfo.connect(G.lP); G.lfo.connect(G.lF); G.lfo.connect(G.lA);
G.lfo.start();
// shared white noise buffer
G.noise=c.createBuffer(1,c.sampleRate*2,c.sampleRate);
const nd=G.noise.getChannelData(0); for(let i=0;i<nd.length;i++) nd[i]=Math.random()*2-1;
// a filter used only to draw the response curve
G.dummy=c.createBiquadFilter();
applyGlobal(); applyLFORouting(); applyLive();
}
function applyGlobal(){
if(!ctx) return; const t=ctx.currentTime;
const s=(p,v,tc=0.03)=>p.setTargetAtTime(v,t,tc);
s(G.delay.delayTime,P.dT); s(G.fb.gain,P.dFb); s(G.dWet.gain,P.dMix);
s(G.rWet.gain,P.rMix); s(G.lfo.frequency,P.lRate); G.lfo.type=P.lWave;
}
function applyLFORouting(){
if(!ctx) return; const t=ctx.currentTime, d=P.lDepth;
G.lP.gain.setTargetAtTime(P.lTarget==='pitch'?d*100:0,t,0.02); // cents
G.lF.gain.setTargetAtTime(P.lTarget==='filter'?d*1200:0,t,0.02); // cents (1 octave at full)
G.lA.gain.setTargetAtTime(P.lTarget==='amp'?d*0.5:0,t,0.02); // tremolo depth
}
// update sounding voices with continuous params
function applyLive(){
if(!ctx) return; const t=ctx.currentTime;
G.dummy.type=P.fType; G.dummy.frequency.value=P.cutoff; G.dummy.Q.value=P.res;
voices.forEach(v=>{
if(v.rel) return;
v.o1.type=P.wave; v.o2.type=P.wave2;
const f=mtof(v.m)*Math.pow(2,P.oct);
v.o1.frequency.value=f; v.o2.frequency.value=f; v.o2.detune.value=P.detune;
v.g2.gain.setTargetAtTime(P.osc2lvl,t,0.02);
v.gn.gain.setTargetAtTime(P.noise*0.6,t,0.02);
v.filt.type=P.fType; v.filt.frequency.value=P.cutoff; v.filt.Q.value=P.res;
});
}
let irDebounce=null;
function onParam(k){
if(k==='rSize'){
clearTimeout(irDebounce);
irDebounce=setTimeout(()=>{ if(ctx) G.conv.buffer=makeIR(P.rSize); },150);
}
applyGlobal(); applyLFORouting(); applyLive();
}
/* ---------- voices ---------- */
function adsr(p,t,A,D,S,peak,base){
p.cancelScheduledValues(t);
p.setValueAtTime(base,t);
p.linearRampToValueAtTime(peak,t+A);
p.linearRampToValueAtTime(base+(peak-base)*S,t+A+D);
}
function noteOn(m){
if(!ctx) initAudio();
if(ctx.state!=='running') ctx.resume();
const same=voices.find(v=>v.m===m&&!v.rel); if(same) release(same);
const live=voices.filter(v=>!v.rel); if(live.length>=MAXV) release(live[0]);
const t=ctx.currentTime+0.005;
const f=mtof(m)*Math.pow(2,P.oct);
const o1=ctx.createOscillator(), o2=ctx.createOscillator(), n=ctx.createBufferSource();
o1.type=P.wave; o1.frequency.value=f;
o2.type=P.wave2; o2.frequency.value=f; o2.detune.value=P.detune;
n.buffer=G.noise; n.loop=true;
const g2=ctx.createGain(); g2.gain.value=P.osc2lvl;
const gn=ctx.createGain(); gn.gain.value=P.noise*0.6;
const mix=ctx.createGain(); mix.gain.value=0.5;
o1.connect(mix); o2.connect(g2).connect(mix); n.connect(gn).connect(mix);
const filt=ctx.createBiquadFilter();
filt.type=P.fType; filt.frequency.value=P.cutoff; filt.Q.value=P.res;
const amp=ctx.createGain(); amp.gain.value=0;
const trem=ctx.createGain(); trem.gain.value=1;
mix.connect(filt).connect(amp).connect(trem).connect(G.bus);
adsr(amp.gain,t,P.aA,P.aD,P.aS,0.35,0);
adsr(filt.detune,t,P.fA,P.fD,P.fS,P.fAmt*1200,0);
G.lP.connect(o1.detune); G.lP.connect(o2.detune);
G.lF.connect(filt.detune); G.lA.connect(trem.gain);
o1.start(t); o2.start(t); n.start(t);
voices.push({m,o1,o2,n,g2,gn,filt,amp,trem,rel:false});
setKey(m,true);
}
function unroute(v){
try{G.lP.disconnect(v.o1.detune);}catch(e){}
try{G.lP.disconnect(v.o2.detune);}catch(e){}
try{G.lF.disconnect(v.filt.detune);}catch(e){}
try{G.lA.disconnect(v.trem.gain);}catch(e){}
}
function release(v){
if(v.rel) return; v.rel=true;
const t=ctx.currentTime, R=Math.max(0.01,P.aR), end=t+R*1.6+0.05;
v.amp.gain.cancelScheduledValues(t);
v.amp.gain.setValueAtTime(v.amp.gain.value,t);
v.amp.gain.setTargetAtTime(0,t,R/4);
v.filt.detune.cancelScheduledValues(t);
v.filt.detune.setValueAtTime(v.filt.detune.value,t);
v.filt.detune.setTargetAtTime(0,t,Math.max(0.01,P.fR)/4);
[v.o1,v.o2,v.n].forEach(x=>{try{x.stop(end);}catch(e){}});
setTimeout(()=>{
unroute(v);
[v.o1,v.o2,v.n,v.g2,v.gn,v.filt,v.amp,v.trem].forEach(x=>{try{x.disconnect();}catch(e){}});
voices=voices.filter(x=>x!==v);
},(end-t)*1000+100);
}
function noteOff(m){
const v=voices.find(x=>x.m===m&&!x.rel);
if(v) release(v);
setKey(m,false);
}
/* ---------- piano ---------- */
let octShift=0;
const baseMidi=()=>60+octShift*12;
function setKey(m,on){
const el=document.querySelector(`#piano [data-m="${m}"]`);
if(el) el.classList.toggle('on',on);
}
function buildPiano(){
const el=$('#piano'); el.innerHTML='';
const base=baseMidi(); const keys=[];
for(let i=0;i<25;i++){const m=base+i; keys.push({m,black:[1,3,6,8,10].includes(m%12)});}
const whites=keys.filter(k=>!k.black), wp=100/whites.length;
let wi=0;
keys.forEach(k=>{
const d=document.createElement('div'); d.dataset.m=k.m;
if(k.black){
d.className='bk';
d.style.left=`calc(${wi*wp}% - ${wp*0.3}%)`; d.style.width=`${wp*0.6}%`;
} else {
d.className='wk';
d.style.left=`${wi*wp}%`; d.style.width=`${wp}%`; wi++;
if(k.m%12===0) d.textContent=NOTE[0]+(Math.floor(k.m/12)-1);
}
el.appendChild(d);
});
$('#octLbl').textContent=`C${Math.floor(base/12)-1}`;
}
const pianoEl=$('#piano'); let mouseM=null;
pianoEl.addEventListener('pointerdown',e=>{
const k=e.target.closest('[data-m]'); if(!k) return;
mouseM=+k.dataset.m; noteOn(mouseM); e.preventDefault();
});
pianoEl.addEventListener('pointerover',e=>{
if(mouseM===null) return;
const k=e.target.closest('[data-m]'); if(!k) return;
const m=+k.dataset.m;
if(m!==mouseM){ noteOff(mouseM); mouseM=m; noteOn(m); }
});
window.addEventListener('pointerup',()=>{ if(mouseM!==null){ noteOff(mouseM); mouseM=null; } });
const KEYMAP={a:0,w:1,s:2,e:3,d:4,f:5,t:6,g:7,y:8,h:9,u:10,j:11,k:12,o:13,l:14,p:15,';':16};
const heldKeys=new Map();
window.addEventListener('keydown',e=>{
if(e.target.matches('input[type=text],textarea')) return;
if(e.repeat) return;
const k=e.key.toLowerCase();
if(k==='z'||k==='x'){ octShift=Math.max(-2,Math.min(2,octShift+(k==='x'?1:-1))); buildPiano(); return; }
if(k in KEYMAP && !heldKeys.has(k)){
const m=baseMidi()+KEYMAP[k]; heldKeys.set(k,m); noteOn(m);
}
});
window.addEventListener('keyup',e=>{
const k=e.key.toLowerCase();
if(heldKeys.has(k)){ noteOff(heldKeys.get(k)); heldKeys.delete(k); }
});
/* ---------- visualisers ---------- */
const cs={scope:$('#scope'),spec:$('#spec'),envc:$('#envc'),filtc:$('#filtc')};
function prep(c){
const d=window.devicePixelRatio||1, w=c.clientWidth, h=c.clientHeight;
if(c.width!==Math.round(w*d)||c.height!==Math.round(h*d)){c.width=Math.round(w*d);c.height=Math.round(h*d);}
const g=c.getContext('2d'); g.setTransform(d,0,0,d,0,0); g.clearRect(0,0,w,h);
return {g,w,h};
}
const tBuf=new Uint8Array(2048), fBuf=new Uint8Array(1024);
function drawScope(){
const {g,w,h}=prep(cs.scope);
g.strokeStyle='#1e2733'; g.lineWidth=1; g.beginPath(); g.moveTo(0,h/2); g.lineTo(w,h/2); g.stroke();
if(!G.an) return;
G.an.getByteTimeDomainData(tBuf);
let s=0; for(let i=1;i<1000;i++){ if(tBuf[i-1]<128&&tBuf[i]>=128){s=i;break;} }
const span=600;
g.strokeStyle='#4fd1c5'; g.lineWidth=1.8; g.beginPath();
for(let i=0;i<span;i++){
const x=i/span*w, y=h/2-(tBuf[s+i]-128)/128*h*0.45;
i?g.lineTo(x,y):g.moveTo(x,y);
}
g.stroke();
}
function drawSpec(){
const {g,w,h}=prep(cs.spec);
if(!G.an) return;
G.an.getByteFrequencyData(fBuf);
const bin=ctx.sampleRate/2048;
const labels=[100,1000,5000];
g.fillStyle='#1e2733';
labels.forEach(f=>{const x=Math.log(f/20)/Math.log(400)*w; g.fillRect(x,0,1,h);});
g.fillStyle='#ffb547';
const bars=Math.floor(w/3);
for(let i=0;i<bars;i++){
const f=20*Math.pow(400,i/bars), b=Math.round(f/bin);
const bh=(fBuf[b]||0)/255*h;
g.fillRect(i*3,h-bh,2,bh);
}
g.fillStyle='#6b7a8c'; g.font='11px system-ui';
labels.forEach(f=>{const x=Math.log(f/20)/Math.log(400)*w; g.fillText(f>=1000?(f/1000)+'k':f+'',x+3,h-4);});
}
// envelope value at time t for a note held for `gate` seconds
function envVal(t,A,D,S,R,gate){
if(t<gate){
if(t<A) return A>0?t/A:1;
if(t<A+D) return 1-(1-S)*(t-A)/D;
return S;
}
return envVal(gate-1e-6,A,D,S,R,gate)*Math.max(0,1-(t-gate)/R);
}
function drawEnv(){
const {g,w,h}=prep(cs.envc);
const gate=1.5, T=Math.max(3,gate+P.aR+0.4,gate+P.fR+0.4);
const X=t=>t/T*w, Y=v=>h-8-v*(h-18);
g.strokeStyle='#1e2733'; g.lineWidth=1;
g.beginPath(); g.moveTo(0,Y(0)); g.lineTo(w,Y(0)); g.moveTo(0,Y(1)); g.lineTo(w,Y(1)); g.stroke();
g.setLineDash([4,4]); g.strokeStyle='#3a4658'; g.beginPath(); g.moveTo(X(gate),0); g.lineTo(X(gate),h); g.stroke(); g.setLineDash([]);
g.font='11px system-ui'; g.fillStyle='#6b7a8c';
g.fillText('key down',X(0)+4,h-4); g.fillText('key up',X(gate)+4,h-4);
const curve=(A,D,S,R,color)=>{
g.beginPath();
for(let i=0;i<=300;i++){
const t=i/300*T, v=envVal(t,A,D,S,R,gate);
i?g.lineTo(X(t),Y(v)):g.moveTo(X(t),Y(v));
}
g.strokeStyle=color; g.lineWidth=2; g.stroke();
};
curve(P.fA,P.fD,P.fS,P.fR,'#ffb547');
curve(P.aA,P.aD,P.aS,P.aR,'#f0f4f8');
const ph=(performance.now()/1000)%T;
g.strokeStyle='#4fd1c5'; g.lineWidth=1.2; g.beginPath(); g.moveTo(X(ph),0); g.lineTo(X(ph),h); g.stroke();
}
function drawFilt(){
const {g,w,h}=prep(cs.filtc);
if(!G.dummy) return;
const N=240, fr=new Float32Array(N), mg=new Float32Array(N), ph=new Float32Array(N);
for(let i=0;i<N;i++) fr[i]=20*Math.pow(1000,i/(N-1));
G.dummy.getFrequencyResponse(fr,mg,ph);
const X=f=>Math.log10(f/20)/3*w;
const Y=db=>{const c=Math.max(-40,Math.min(20,db)); return (20-c)/60*(h-14)+6;};
g.strokeStyle='#1e2733'; g.lineWidth=1; g.font='11px system-ui'; g.fillStyle='#6b7a8c';
[0,-20,-40].forEach(db=>{g.beginPath(); g.moveTo(0,Y(db)); g.lineTo(w,Y(db)); g.stroke(); g.fillText(db+' dB',4,Y(db)-3);});
[100,1000,10000].forEach(f=>{g.beginPath(); g.moveTo(X(f),0); g.lineTo(X(f),h); g.stroke(); g.fillText(f>=1000?(f/1000)+'k':f+'',X(f)+3,h-4);});
g.beginPath();
for(let i=0;i<N;i++){
const db=20*Math.log10(Math.max(1e-6,mg[i]));
i?g.lineTo(X(fr[i]),Y(db)):g.moveTo(X(fr[i]),Y(db));
}
g.strokeStyle='#ffb547'; g.lineWidth=2; g.stroke();
g.strokeStyle='rgba(79,209,197,.6)'; g.setLineDash([3,3]); g.beginPath(); g.moveTo(X(P.cutoff),0); g.lineTo(X(P.cutoff),h); g.stroke(); g.setLineDash([]);
}
function frame(){ drawScope(); drawSpec(); drawEnv(); drawFilt(); requestAnimationFrame(frame); }
/* ---------- header controls ---------- */
$('#master').addEventListener('input',e=>{ if(G.master) G.master.gain.setTargetAtTime(+e.target.value,ctx.currentTime,0.03); });
$('#audioBtn').addEventListener('click',()=>{
if(!ctx) initAudio();
ctx.resume().then(()=>{ $('#audioBtn').textContent='Audio on ✓'; });
});
$('#freeplay').addEventListener('change',e=>{ freeOn=e.target.checked; renderLessons(); renderLocks(); });
$('#resetBtn').addEventListener('click',()=>{
Object.assign(P,DEF); buildRack(); renderLocks(); applyGlobal(); applyLFORouting(); applyLive();
if(ctx) G.conv.buffer=makeIR(P.rSize);
});
/* ---------- boot ---------- */
buildRack();
initAudio();
buildPiano();
renderLessons();
renderLocks();
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"1489e56c-ec43-44eb-b050-e39117198b5e","tokensIn":105807,"tokensOut":166547,"tokensTotal":272354,"cost":0.08778388,"turns":7,"toolCalls":7,"failedToolCalls":1,"timestamp":"2026-10-08T01:06:57.720Z"} -->