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 }; })(); // ---------------------------------------------------------------------------