diff --git a/viz/index.html b/viz/index.html
index 58f401d..f79ea61 100644
--- a/viz/index.html
+++ b/viz/index.html
@@ -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{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=vo.len)vo.idx=0;}}for(let c=1;c{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=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{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=vo.len)vo.idx=0;}}for(let c=1;c{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=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 };
})();
// ---------------------------------------------------------------------------