🌾 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:
parent
b0fad08556
commit
d2ddec1521
235
viz/index.html
235
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<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 };
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user