🌾 godrum: AMPLER — chop the captured world onto the drum voices (D)

The SP-404 move, Godstrument-style. GODSONIQ already captures ~3s of the
live master; AMPLER routes its own capture into a separate amplerBuf
(recTarget-tagged per-capture, so GODSONIQ's keys-replay stays byte-
identical), chops it into 8 or 16 equal slices, reverses any slice (lazy
cached copy), and seats a slice onto any drum voice. An assigned voice
PLAYS its slice through that voice's level→pan→drumBus chain, so the kit
knobs and patch persistence keep acting; a slice-kick still ducks and a
slice-hat/ohat still obeys the TR choke (both factored to be source-
agnostic). Eight frozen ampler* methods + an amplerPeaks waveform helper.

UI: a right-side #ampler panel (capture · waveform · chop · slice grid
with per-slice reverse · per-voice assignment strip), opened by one sky-
menu ctxItem after GODSONIQ. Engine lives in the Synth IIFE; the panel
sits after openBeat's brace — E's territory (openBeat body, seqTick,
keydown, exportMid) untouched. AMPLER state is session-only in v1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
monsterrobotparty 2026-07-13 01:05:55 +10:00
parent b0fad08556
commit d2ddec1521

View File

@ -296,6 +296,43 @@
#beatbtn.on { color: #cfe0ff; border-color: rgba(150,200,255,0.7); background: rgba(30,42,66,0.9); }
#beatbtn.hidden { display: none; }
/* 🌾 AMPLER — right-side sample-slicing panel (the bottom is the drawers' turf) */
#ampler {
position: fixed; right: 12px; top: 64px; width: 300px; z-index: 13;
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
max-height: 82vh; overflow-y: auto;
}
#ampler.open { display: flex; flex-direction: column; gap: 6px; }
#ampler .atop { display: flex; align-items: center; gap: 8px; }
#ampler .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 1; }
#ampler .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
#ampler .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
#ampler .albl { color: rgba(150,170,200,0.7); font-size: 10px; }
#ampler .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
border-radius: 7px; padding: 3px 9px; cursor: pointer; font-size: 11px; }
#ampler .midibtn.on { background: rgba(230,120,120,0.22); color: #ffc9c9; border-color: rgba(230,130,130,0.55); }
#ampler .midibtn:disabled { opacity: 0.6; cursor: default; }
#ampler .bmini { padding: 2px 7px; }
#ampler .asel { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3);
border-radius: 6px; padding: 2px 5px; font-size: 10px; }
#ampler canvas { width: 100%; height: 54px; background: rgba(20,26,40,0.7); border-radius: 6px; display: block; }
#ampler .sgrid { display: grid; gap: 3px; }
#ampler .scell { position: relative; height: 30px; border-radius: 4px; background: rgba(40,52,74,0.7);
border: 1px solid rgba(120,150,200,0.25); cursor: pointer; font-size: 9px; color: #9fb6dc;
display: flex; align-items: center; justify-content: center; }
#ampler .scell:hover { border-color: rgba(150,180,230,0.6); }
#ampler .scell.sel { border-color: rgba(150,200,255,0.85); background: rgba(50,66,96,0.85); }
#ampler .scell.rev { background: rgba(120,150,230,0.32); }
#ampler .scell .rv { position: absolute; top: 0; right: 2px; font-size: 9px; color: #bcd; }
#ampler .scell .rv:hover { color: #eaf2ff; }
#ampler .amap { display: flex; flex-direction: column; gap: 3px; }
#ampler .maprow { display: flex; align-items: center; gap: 5px; }
#ampler .mvn { width: 40px; color: #aebfda; font-size: 10px; }
#ampler .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
#ampler .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
/* right-click context menu — every setting, on everything */
#ctxmenu {
position: fixed; z-index: 30; display: none; width: 240px; max-height: 72vh;
@ -2739,6 +2776,107 @@
beatEl.classList.add("open");
}
// ---- 🌾 AMPLER — sample the world into the kit ---------------------------
// GODSONIQ captures ~3s of the live master; AMPLER chops that capture into
// slices and seats them onto the drum voices, so the beat's lanes play the
// world instead of synthesis (the SP-404 move). A right-side panel — the
// bottom belongs to the beat/arranger drawers. All AMPLER state is session-only.
let amplerEl = null;
const ampler = { open: false, capLen: 3, chop: 16, sel: -1, capturing: false, rev: [] };
function amplerEnsure() {
if (!amplerEl) { amplerEl = document.createElement("div"); amplerEl.id = "ampler"; document.body.appendChild(amplerEl); }
return amplerEl;
}
function closeAmpler() { ampler.open = false; if (amplerEl) amplerEl.classList.remove("open"); }
function openAmpler() { ampler.open = true; amplerEnsure().classList.add("open"); amplerRender(); }
function amplerCapture() {
const ok = Synth.amplerArm ? Synth.amplerArm(ampler.capLen) : false;
if (!ok) { eventTicker.unshift({ text: "🌾 AMPLER needs the audio worklet — press ♪ first, then ⏺ capture", tAdded: now(), src: "sys" }); return; }
ampler.capturing = true; ampler.sel = -1; ampler.rev = [];
eventTicker.unshift({ text: "🌾 AMPLER — sampling " + ampler.capLen + "s of the world… then ✂ chop it into a kit", tAdded: now(), src: "sys" });
amplerRender();
setTimeout(() => { ampler.capturing = false; if (ampler.open) amplerRender(); }, ampler.capLen * 1000 + 250); // capture runs real-time; re-render once it has landed
}
function amplerDrawWave(cv) {
const g = cv.getContext("2d"); if (!g) return;
g.clearRect(0, 0, cv.width, cv.height);
const peaks = Synth.amplerPeaks ? Synth.amplerPeaks(cv.width) : [], mid = cv.height / 2;
g.strokeStyle = "rgba(140,175,255,0.75)"; g.beginPath();
for (let px = 0; px < peaks.length; px++) { const p = peaks[px]; g.moveTo(px + 0.5, mid - p[1] * mid); g.lineTo(px + 0.5, mid - p[0] * mid); }
g.stroke();
const st = Synth.amplerStatus ? Synth.amplerStatus() : { slices: 0 };
if (st.slices) { g.strokeStyle = "rgba(150,200,255,0.35)";
for (let i = 1; i < st.slices; i++) { const xx = Math.floor(cv.width * i / st.slices) + 0.5; g.beginPath(); g.moveTo(xx, 0); g.lineTo(xx, cv.height); g.stroke(); } }
}
function amplerRender() {
if (!amplerEl) return;
const st = Synth.amplerStatus ? Synth.amplerStatus() : { has: false, secs: 0, slices: 0 };
amplerEl.innerHTML = "";
const top = document.createElement("div"); top.className = "atop";
const title = document.createElement("span"); title.className = "btitle"; title.textContent = "🌾 AMPLER";
const x = document.createElement("span"); x.className = "aclose"; x.textContent = "×"; x.title = "close"; x.onclick = closeAmpler;
top.append(title, x); amplerEl.appendChild(top);
const cap = document.createElement("div"); cap.className = "row";
const rec = document.createElement("button"); rec.className = "midibtn"; rec.textContent = ampler.capturing ? "…capturing" : "⏺ capture";
rec.classList.toggle("on", ampler.capturing); rec.disabled = ampler.capturing; rec.onclick = amplerCapture;
const lenSel = document.createElement("select"); lenSel.className = "asel"; lenSel.title = "capture length";
for (const s of [1.5, 3, 6]) { const o = document.createElement("option"); o.value = String(s); o.textContent = s + "s"; if (s === ampler.capLen) o.selected = true; lenSel.appendChild(o); }
lenSel.onchange = () => { ampler.capLen = parseFloat(lenSel.value) || 3; };
cap.append(rec, lenSel); amplerEl.appendChild(cap);
if (!st.has) {
const h = document.createElement("div"); h.className = "hint";
h.textContent = ampler.capturing ? "listening to the world…" : "press ♪, let the world play, then ⏺ capture — you'll chop it into a kit.";
amplerEl.appendChild(h); return;
}
const cv = document.createElement("canvas"); cv.width = 280; cv.height = 54; amplerEl.appendChild(cv); amplerDrawWave(cv);
const chopRow = document.createElement("div"); chopRow.className = "row";
const chopL = document.createElement("span"); chopL.className = "albl"; chopL.textContent = "chop";
const chopSel = document.createElement("select"); chopSel.className = "asel";
for (const n of [8, 16]) { const o = document.createElement("option"); o.value = String(n); o.textContent = n + " slices"; if (n === ampler.chop) o.selected = true; chopSel.appendChild(o); }
chopSel.onchange = () => { ampler.chop = parseInt(chopSel.value) || 16; };
const chopBtn = document.createElement("button"); chopBtn.className = "midibtn bmini"; chopBtn.textContent = "✂ chop";
chopBtn.onclick = () => { if (Synth.amplerChop(ampler.chop)) { ampler.sel = -1; ampler.rev = []; amplerRender(); } };
chopRow.append(chopL, chopSel, chopBtn); amplerEl.appendChild(chopRow);
if (!st.slices) { const h = document.createElement("div"); h.className = "hint"; h.textContent = "✂ chop the capture into slices, then tap to audition and assign."; amplerEl.appendChild(h); return; }
const grid = document.createElement("div"); grid.className = "sgrid";
grid.style.gridTemplateColumns = "repeat(" + (st.slices > 8 ? 8 : st.slices) + ", 1fr)";
for (let i = 0; i < st.slices; i++) {
const cell = document.createElement("div"); cell.className = "scell"; cell.textContent = String(i);
if (i === ampler.sel) cell.classList.add("sel");
if (ampler.rev[i]) cell.classList.add("rev");
cell.title = "tap to audition slice " + i;
cell.onclick = () => { ampler.sel = i; Synth.amplerPlay(i, 0.95); amplerRender(); };
const rv = document.createElement("span"); rv.className = "rv"; rv.textContent = "⇄"; rv.title = "reverse this slice";
rv.onclick = (ev) => { ev.stopPropagation(); ampler.rev[i] = Synth.amplerReverse(i); amplerRender(); };
cell.appendChild(rv); grid.appendChild(cell);
}
amplerEl.appendChild(grid);
const map = Synth.amplerMap ? Synth.amplerMap() : {};
const strip = document.createElement("div"); strip.className = "amap";
for (const v of DRUM_VOICES) {
const row = document.createElement("div"); row.className = "maprow";
const nm = document.createElement("span"); nm.className = "mvn"; nm.textContent = v;
const sel = document.createElement("select"); sel.className = "asel"; sel.title = "assign a slice to " + v;
const syn = document.createElement("option"); syn.value = "-1"; syn.textContent = "synth"; sel.appendChild(syn);
for (let i = 0; i < st.slices; i++) { const o = document.createElement("option"); o.value = String(i); o.textContent = "s" + i + (ampler.rev[i] ? "ʳ" : ""); sel.appendChild(o); }
sel.value = map[v] ? String(map[v].slice) : "-1";
sel.onchange = () => { const idx = parseInt(sel.value); if (idx < 0) Synth.amplerClear(v); else Synth.amplerAssign(v, idx); amplerRender(); };
const cur = document.createElement("span"); cur.className = "albl"; cur.textContent = map[v] ? ("← s" + map[v].slice + (map[v].reverse ? "ʳ" : "")) : "← synth";
const clr = document.createElement("span"); clr.className = "x"; clr.textContent = "✕"; clr.title = "back to synth"; clr.onclick = () => { Synth.amplerClear(v); amplerRender(); };
row.append(nm, sel, cur, clr); strip.appendChild(row);
}
amplerEl.appendChild(strip);
const h = document.createElement("div"); h.className = "hint"; h.textContent = "tap a slice to audition · ⇄ reverses it · assign a slice to a voice and the beat plays the world";
amplerEl.appendChild(h);
}
function seqTick() {
for (const k in seqs) {
const sq = seqs[k];
@ -6977,6 +7115,7 @@
() => { const ok = Synth.godsoniq(3);
eventTicker.unshift({ text: ok ? "🎛 GODSONIQ — sampling the world for 3s… then every pluck replays it, pitched across the keys (🎸 string voice to exit)"
: "🎛 GODSONIQ needs the audio worklet — press ♪ first, then try again", tAdded: now(), src: "sys" }); });
ctxItem("🌾 AMPLER — there is ample (sample the world into the kit)", () => openAmpler());
ctxItem("🌐 " + (Synth.feeding() ? "tab feed: live — click to stop" : "feed a tab (YouTube, a stream…) into the world"),
async () => { const r = await Synth.feedTab();
const msg = r.stopped ? "🌐 tab feed stopped"
@ -8396,6 +8535,10 @@
else { p.cancelScheduledValues(t); p.setValueAtTime(p.value, t); } };
let ohatEnv = null; // the currently-ringing open hat, for the TR choke
function choke(t) { if (ohatEnv) { cancelHold(ohatEnv.gain, t); ohatEnv.gain.setTargetAtTime(0, t, 0.012); ohatEnv = null; } }
function kickDuck(t) { // the kick parts the sea — dip pad+drone via duckG, then let them flood back (shared by synth- and slice-kick)
if (KIT.kick.duck > 0 && N.duckG) { const dg = N.duckG.gain; cancelHold(dg, t);
dg.linearRampToValueAtTime(1 - KIT.kick.duck * 0.85, t + 0.012); dg.setTargetAtTime(1, t + 0.05, 0.15); }
}
function fireKick(vel) {
const t = ctx.currentTime, K = KIT.kick, dst = N.drums.kick.g;
@ -8413,10 +8556,7 @@
const cg = ctx.createGain(); cg.gain.setValueAtTime(vel * K.punch * 0.3, t); cg.gain.setTargetAtTime(0, t, 0.002);
cs.connect(hp); hp.connect(cg); cg.connect(dst); cs.start(t); cs.stop(t + 0.05);
}
if (K.duck > 0 && N.duckG) { // the kick parts the sea — dip pad+drone, then let them flood back
const dg = N.duckG.gain; cancelHold(dg, t); // same cancel-and-hold idiom as choke() (robust when two kicks land close)
dg.linearRampToValueAtTime(1 - K.duck * 0.85, t + 0.012); dg.setTargetAtTime(1, t + 0.05, 0.15);
}
kickDuck(t); // dip pad+drone (factored — the slice-kick path shares this exact block)
}
function fireSnare(vel) {
const t = ctx.currentTime, K = KIT.snare, dst = N.drums.snare.g;
@ -8463,6 +8603,8 @@
ctx.resume();
if (!on) N.out.gain.setTargetAtTime(0.85, ctx.currentTime, 0.1);
const v = vel == null ? 0.8 : c01(vel);
const slb = own(aMap, name) ? amplerSliceBuf(aMap[name]) : null; // 🌾 AMPLER — an assigned slice PLAYS the world for this voice
if (slb) { fireSlice(name, slb, v); return; }
if (name === "kick") fireKick(v);
else if (name === "snare") fireSnare(v);
else if (name === "clap") fireClap(v);
@ -8552,9 +8694,12 @@
// drivable) and a sample-accurate Karplus-Strong string. Both need a worklet
// (native feedback + real sample-rate reduction are unstable/impossible with
// stock nodes — the spectral harness proved it). Loaded async from a Blob URL.
const WORKLET_SRC = "class Bitcrush extends AudioWorkletProcessor{static get parameterDescriptors(){return[{name:'bits',defaultValue:16,minValue:1,maxValue:16,automationRate:'k-rate'},{name:'reduction',defaultValue:1,minValue:1,maxValue:64,automationRate:'k-rate'}];}constructor(){super();this.phase=0;this.hold=null;}process(inputs,outputs,p){const inp=inputs[0],out=outputs[0];if(!inp||!inp.length)return true;const b=p.bits[0],red=Math.max(1,Math.round(p.reduction[0])),lv=Math.pow(2,b),nC=out.length,nS=out[0].length;if(!this.hold)this.hold=new Float32Array(8);for(let i=0;i<nS;i++){if(this.phase<=0){for(let c=0;c<nC;c++){const xi=(inp[c]||inp[0])[i];this.hold[c]=Math.round(xi*lv)/lv;}this.phase=red;}this.phase--;for(let c=0;c<nC;c++)out[c][i]=this.hold[c];}return true;}}registerProcessor('bitcrush',Bitcrush);class Karplus extends AudioWorkletProcessor{constructor(){super();this.v=[];for(let i=0;i<8;i++)this.v.push({buf:new Float32Array(4096),len:0,idx:0,active:false,fb:0.995,damp:0.5,last:0,amp:0});this.n=0;this.port.onmessage=(e)=>{const vo=this.v[this.n];this.n=(this.n+1)%8;const L=Math.max(2,Math.min(4096,Math.round(sampleRate/e.data.freq)));vo.len=L;vo.idx=0;vo.active=true;vo.amp=e.data.vel;vo.last=0;for(let i=0;i<L;i++)vo.buf[i]=Math.random()*2-1;};}process(inputs,outputs){const out=outputs[0],y=out[0],nS=y.length;for(let i=0;i<nS;i++)y[i]=0;for(const vo of this.v){if(!vo.active)continue;for(let i=0;i<nS;i++){const cur=vo.buf[vo.idx];const f=vo.damp*cur+(1-vo.damp)*vo.last;vo.last=f;vo.buf[vo.idx]=f*vo.fb;y[i]+=cur*vo.amp*0.4;vo.idx++;if(vo.idx>=vo.len)vo.idx=0;}}for(let c=1;c<out.length;c++)out[c].set(y);return true;}}registerProcessor('karplus',Karplus);class Recorder extends AudioWorkletProcessor{constructor(){super();this.rec=false;this.buf=null;this.pos=0;this.port.onmessage=(e)=>{if(e.data.rec){this.buf=new Float32Array(e.data.len);this.pos=0;this.rec=true;}};}process(inputs){const inp=inputs[0];if(this.rec&&inp&&inp[0]){const x=inp[0];for(let i=0;i<x.length&&this.pos<this.buf.length;i++)this.buf[this.pos++]=x[i];if(this.pos>=this.buf.length){this.rec=false;const b=this.buf;this.buf=null;this.port.postMessage({done:true,buf:b},[b.buffer]);}}return true;}}registerProcessor('recorder',Recorder);";
const WORKLET_SRC = "class Bitcrush extends AudioWorkletProcessor{static get parameterDescriptors(){return[{name:'bits',defaultValue:16,minValue:1,maxValue:16,automationRate:'k-rate'},{name:'reduction',defaultValue:1,minValue:1,maxValue:64,automationRate:'k-rate'}];}constructor(){super();this.phase=0;this.hold=null;}process(inputs,outputs,p){const inp=inputs[0],out=outputs[0];if(!inp||!inp.length)return true;const b=p.bits[0],red=Math.max(1,Math.round(p.reduction[0])),lv=Math.pow(2,b),nC=out.length,nS=out[0].length;if(!this.hold)this.hold=new Float32Array(8);for(let i=0;i<nS;i++){if(this.phase<=0){for(let c=0;c<nC;c++){const xi=(inp[c]||inp[0])[i];this.hold[c]=Math.round(xi*lv)/lv;}this.phase=red;}this.phase--;for(let c=0;c<nC;c++)out[c][i]=this.hold[c];}return true;}}registerProcessor('bitcrush',Bitcrush);class Karplus extends AudioWorkletProcessor{constructor(){super();this.v=[];for(let i=0;i<8;i++)this.v.push({buf:new Float32Array(4096),len:0,idx:0,active:false,fb:0.995,damp:0.5,last:0,amp:0});this.n=0;this.port.onmessage=(e)=>{const vo=this.v[this.n];this.n=(this.n+1)%8;const L=Math.max(2,Math.min(4096,Math.round(sampleRate/e.data.freq)));vo.len=L;vo.idx=0;vo.active=true;vo.amp=e.data.vel;vo.last=0;for(let i=0;i<L;i++)vo.buf[i]=Math.random()*2-1;};}process(inputs,outputs){const out=outputs[0],y=out[0],nS=y.length;for(let i=0;i<nS;i++)y[i]=0;for(const vo of this.v){if(!vo.active)continue;for(let i=0;i<nS;i++){const cur=vo.buf[vo.idx];const f=vo.damp*cur+(1-vo.damp)*vo.last;vo.last=f;vo.buf[vo.idx]=f*vo.fb;y[i]+=cur*vo.amp*0.4;vo.idx++;if(vo.idx>=vo.len)vo.idx=0;}}for(let c=1;c<out.length;c++)out[c].set(y);return true;}}registerProcessor('karplus',Karplus);class Recorder extends AudioWorkletProcessor{constructor(){super();this.rec=false;this.buf=null;this.pos=0;this.port.onmessage=(e)=>{if(e.data.rec){this.buf=new Float32Array(e.data.len);this.pos=0;this.rec=true;this.tag=e.data.tag;}};}process(inputs){const inp=inputs[0];if(this.rec&&inp&&inp[0]){const x=inp[0];for(let i=0;i<x.length&&this.pos<this.buf.length;i++)this.buf[this.pos++]=x[i];if(this.pos>=this.buf.length){this.rec=false;const b=this.buf;this.buf=null;this.port.postMessage({done:true,buf:b,tag:this.tag},[b.buffer]);}}return true;}}registerProcessor('recorder',Recorder);";
let crusher = null, ksNode = null, ksMode = false, workletTried = false;
let recNode = null, sampleBuf = null, sampleMode = false; // GODSONIQ (cheeky Ensoniq nod): resample the world onto the keys
let recTarget = "godsoniq"; // routes a finished Recorder capture: "godsoniq" → keys-replay, "ampler" → amplerBuf
let amplerBuf = null, slN = 0, sliceFwd = [], sliceRevBuf = [], sliceRev = []; // 🌾 AMPLER: capture + equal slices (fwd + lazily-cached reversed) + per-slice reverse flags
const aMap = {}; // 🌾 AMPLER assignment map: voice → slice idx (session-only, not persisted in v1)
function loadWorklets() {
if (workletTried || !ctx || !ctx.audioWorklet || !N) return;
workletTried = true;
@ -8570,17 +8715,88 @@
const rsink = ctx.createGain(); rsink.gain.value = 0; recNode.connect(rsink); rsink.connect(ctx.destination);
recNode.port.onmessage = (e) => { if (!e.data.done) return;
const f = e.data.buf, ab = ctx.createBuffer(1, f.length, ctx.sampleRate); ab.copyToChannel(f, 0);
sampleBuf = ab; sampleMode = true; ksMode = false; // captured — plucks now replay it, pitched
if (e.data.tag === "ampler") { amplerBuf = ab; slN = 0; sliceFwd = []; sliceRev = []; sliceRevBuf = []; } // 🌾 AMPLER — its own buffer; must (re)chop. Routed by the capture's own tag (race-proof), so GODSONIQ's sampleBuf/sampleMode stay untouched.
else { sampleBuf = ab; sampleMode = true; ksMode = false; } // GODSONIQ — plucks now replay it, pitched
};
}).catch(() => { workletTried = false; });
}
function godsoniq(secs) { // arm the resampler for `secs` of the live world
function armRec(secs) { // shared: arm the Recorder worklet for `secs` of the live master
if (!ctx) start();
if (!recNode) return false; // worklet not ready
if (!on) start(); // world must be sounding to sample it
recNode.port.postMessage({ rec: true, len: Math.floor((secs || 3) * ctx.sampleRate) });
recNode.port.postMessage({ rec: true, len: Math.floor((secs || 3) * ctx.sampleRate), tag: recTarget }); // tag travels with the capture → the finished buffer routes to its own sink, no shared-state race
return true;
}
function godsoniq(secs) { recTarget = "godsoniq"; return armRec(secs); } // GODSONIQ — capture lands on the keys-replay path (unchanged)
// ---- 🌾 AMPLER — chop the captured world into slices seated on the drum voices.
// Captures land in amplerBuf (recTarget routes them, so GODSONIQ stays byte-identical);
// slices are equal divisions; reverse is a lazily-built, cached per-slice copy; an
// assigned voice PLAYS its slice through that voice's level→pan→drumBus chain, so the
// kit knobs and their patch-persistence keep working, and duck/choke stay source-agnostic. ----
function revBuf(fwd) { const n = fwd.length, r = ctx.createBuffer(1, n, fwd.sampleRate), s = fwd.getChannelData(0), o = r.getChannelData(0);
for (let i = 0; i < n; i++) o[i] = s[n - 1 - i]; return r; }
function amplerSliceBuf(idx) { // the buffer to play for slice idx (reversed copy if flagged, built lazily)
if (idx == null || idx < 0 || idx >= slN || !sliceFwd[idx]) return null;
if (sliceRev[idx]) { if (!sliceRevBuf[idx]) sliceRevBuf[idx] = revBuf(sliceFwd[idx]); return sliceRevBuf[idx]; }
return sliceFwd[idx];
}
function playBuf(buf, dst, vel, t) { // one-shot slice playback with a short anti-click fade; returns the gain (for choke)
const src = ctx.createBufferSource(); src.buffer = buf;
const g = ctx.createGain(), pk = vel * vel, dur = buf.duration;
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(pk, t + 0.003);
if (dur > 0.008) { const fo = t + dur - Math.min(0.006, dur / 3); g.gain.setValueAtTime(pk, fo); g.gain.linearRampToValueAtTime(0, t + dur); } // anti-click tail, fade clamped for short slices
src.connect(g); g.connect(dst); src.start(t); src.stop(t + dur + 0.02);
return g;
}
function fireSlice(voice, buf, vel) { // an AMPLER slice seated on a drum voice (playbackRate 1.0 — no pitching in v1)
const t = ctx.currentTime;
if (voice === "hat" || voice === "ohat") choke(t); // the TR choke is source-agnostic
const g = playBuf(buf, N.drums[voice].g, vel, t); // → level → pan → drumBus (kit knobs & persistence still act)
if (voice === "kick") kickDuck(t); // a slice-kick still parts the sea
if (voice === "ohat") ohatEnv = g; // a slice-ohat can be choked
}
function amplerArm(secs) { recTarget = "ampler"; return armRec(secs); } // capture `secs` of the live master into amplerBuf
function amplerStatus() { return { has: !!amplerBuf, secs: amplerBuf ? amplerBuf.duration : 0, slices: slN }; }
function amplerChop(n) { // slice the capture into n equal slices (8 or 16)
if (!amplerBuf) return false;
n = n === 8 ? 8 : n === 16 ? 16 : 0; if (!n) return false;
const total = amplerBuf.length, data = amplerBuf.getChannelData(0), per = Math.floor(total / n);
if (per < 1) return false;
sliceFwd = []; sliceRev = []; sliceRevBuf = [];
for (let i = 0; i < n; i++) { const s0 = i * per, len = (i === n - 1) ? (total - s0) : per;
const b = ctx.createBuffer(1, len, amplerBuf.sampleRate); b.copyToChannel(data.subarray(s0, s0 + len), 0);
sliceFwd.push(b); sliceRev.push(false); sliceRevBuf.push(null); }
slN = n;
for (const v in aMap) if (own(aMap, v) && aMap[v] >= n) delete aMap[v]; // drop assignments that fell out of range so map/UI match playback
return true;
}
function amplerPlay(idx, vel) { // audition a slice through the full FX chain (default vel 0.9)
const b = amplerSliceBuf(idx); if (!b || !ctx) return false;
ctx.resume(); const t = ctx.currentTime; if (!on) N.out.gain.setTargetAtTime(0.85, t, 0.1);
playBuf(b, N.preSat, vel == null ? 0.9 : c01(vel), t); return true;
}
function amplerReverse(idx, on2) { // toggle (on omitted) or set the slice's reverse; → new boolean
if (idx == null || idx < 0 || idx >= slN) return false;
const nv = on2 === undefined ? !sliceRev[idx] : !!on2;
if (nv && !sliceRevBuf[idx]) sliceRevBuf[idx] = revBuf(sliceFwd[idx]);
sliceRev[idx] = nv; return nv;
}
function amplerAssign(voice, idx) { // voice now PLAYS this slice (own-key voice only — proto-safe)
if (!own(DRUM_DEFS, voice) || idx == null || idx < 0 || idx >= slN || !sliceFwd[idx]) return false;
aMap[voice] = idx; return true;
}
function amplerClear(voice) { if (own(aMap, voice)) delete aMap[voice]; return true; } // back to the synth recipe
function amplerMap() { const m = {}; for (const v in aMap) if (own(aMap, v)) m[v] = { slice: aMap[v], reverse: !!sliceRev[aMap[v]] }; return m; }
function amplerPeaks(cols) { // min/max per column for the panel waveform (a D helper beyond the frozen 8)
cols = Math.max(1, Math.min(2048, Math.floor(cols || 200))); const out = [];
if (!amplerBuf) return out;
const data = amplerBuf.getChannelData(0), n = data.length, per = n / cols;
for (let c = 0; c < cols; c++) { let mn = 1, mx = -1; const a = Math.floor(c * per), b = Math.min(n, Math.floor((c + 1) * per));
for (let i = a; i < b; i++) { const x = data[i]; if (x < mn) mn = x; if (x > mx) mx = x; }
if (b <= a) { mn = 0; mx = 0; } out.push([mn, mx]); }
return out;
}
// 🌐 external tab feed — pull another tab's audio (a YouTube tab, a live stream) into the
// world via getDisplayMedia. Wired into preSat, so it flows the whole FX chain to the master:
// audible live AND resampleable by GODSONIQ (which taps N.out). Chrome/Edge only; on a Mac the
@ -8690,7 +8906,8 @@
stringVoice(v) { if (v === undefined) return ksMode; if (!ctx) start(); ksMode = !!v; sampleMode = false; return ksMode; },
godsoniq(secs) { return godsoniq(secs); }, sampling() { return sampleMode && !!sampleBuf; },
feedTab() { return feedTab(); }, feeding() { return !!extStream; },
drum, drumParam, drumParamList, drumKit, drumKitLoad };
drum, drumParam, drumParamList, drumKit, drumKitLoad,
amplerArm, amplerStatus, amplerChop, amplerPlay, amplerReverse, amplerAssign, amplerClear, amplerMap, amplerPeaks };
})();
// ---------------------------------------------------------------------------