Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122// analysis.mjs — audio → control-signal primitives for /pop. Turns a// rendered buffer into something musical you can act on: a pitch curve,// or a list of onset triggers. Pure DSP, no Node imports — safe to// import in a browser. WAV file reading lives in lib/wav.mjs.//// Companion to the envelope follower in dance/synths/fx.mjs — together// these are the `analysis` category of the pop menu.//// pitchTrack(buf, opts) -> [{ time, hz, midi, clarity }]// audioGate(buf, opts) -> [{ time, level }]
const DEFAULT_SAMPLE_RATE = 48_000;
// ── pitch tracking ────────────────────────────────────────────────────// Frame-by-frame fundamental-frequency detection by normalized// autocorrelation. Returns one row per hop with the detected pitch in// Hz, the nearest (fractional) MIDI note, and a 0..1 clarity score —// silent / unpitched frames come back with hz = null.//// opts: {// sampleRate (default 48000)// fmin, fmax Hz search range (default 65 .. 1200)// hopMs frame stride (default 10)// winMs analysis window (default 40)// rmsGate silence floor (default 0.01)// clarityGate min peak to call (default 0.5)// }export function pitchTrack(buf, opts = {}) { if (!(buf instanceof Float32Array)) return []; const sr = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; const fmin = opts.fmin ?? 65; const fmax = opts.fmax ?? 1200; const hop = Math.max(1, Math.floor((opts.hopMs ?? 10) * 0.001 * sr)); const win = Math.max(64, Math.floor((opts.winMs ?? 40) * 0.001 * sr)); const rmsGate = opts.rmsGate ?? 0.01; const clarityGate = opts.clarityGate ?? 0.5; const lagMin = Math.floor(sr / fmax); const lagMax = Math.min(Math.floor(sr / fmin), Math.floor(win / 2));
const frames = []; for (let start = 0; start + win <= buf.length; start += hop) { const time = start / sr;
let rms = 0; for (let j = 0; j < win; j++) rms += buf[start + j] * buf[start + j]; rms = Math.sqrt(rms / win); if (rms < rmsGate) { frames.push({ time, hz: null, midi: null, clarity: 0 }); continue; }
// energy at zero lag, for normalization const ac = (lag) => { let s = 0; for (let j = 0; j < win - lag; j++) s += buf[start + j] * buf[start + j + lag]; return s; }; const ac0 = ac(0) || 1e-9;
let bestLag = lagMin, bestScore = -Infinity; for (let lag = lagMin; lag <= lagMax; lag++) { const score = ac(lag) / ac0; if (score > bestScore) { bestScore = score; bestLag = lag; } }
// parabolic interpolation for sub-sample lag precision let lagF = bestLag; if (bestLag > lagMin && bestLag < lagMax) { const a = ac(bestLag - 1), b = ac(bestLag), c = ac(bestLag + 1); const denom = a - 2 * b + c; if (Math.abs(denom) > 1e-9) lagF = bestLag - 0.5 * (c - a) / denom; }
if (bestScore < clarityGate) { frames.push({ time, hz: null, midi: null, clarity: bestScore }); } else { const hz = sr / lagF; frames.push({ time, hz, midi: 69 + 12 * Math.log2(hz / 440), clarity: bestScore }); } } return frames;}
// ── audio gate / onset trigger ────────────────────────────────────────// Follows the signal's amplitude and emits a trigger every time it// crosses up through `threshold` after dipping below it — a beatbox /// percussion onset detector. `minGapMs` debounces fast retriggers.// Feed the trigger list to a sampler to fire drum hits, or to// audio-to-rhythm to build a .np score.//// opts: {// sampleRate (default 48000)// threshold open level 0..1 (default 0.06)// attackMs follower attack (default 2)// releaseMs follower release (default 40)// minGapMs retrigger lockout (default 60)// }export function audioGate(buf, opts = {}) { if (!(buf instanceof Float32Array)) return []; const sr = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; const threshold = opts.threshold ?? 0.06; const minGap = Math.floor((opts.minGapMs ?? 60) * 0.001 * sr); const aA = Math.exp(-1 / (Math.max(0.01, opts.attackMs ?? 2) * 0.001 * sr)); const aR = Math.exp(-1 / (Math.max(0.01, opts.releaseMs ?? 40) * 0.001 * sr)); // hysteresis: must fall below this before a new trigger can fire const releaseLevel = threshold * 0.6;
const triggers = []; let env = 0, armed = true, lastTrig = -Infinity; for (let i = 0; i < buf.length; i++) { const x = Math.abs(buf[i]); const a = x > env ? aA : aR; env = a * env + (1 - a) * x;
if (armed && env >= threshold && i - lastTrig >= minGap) { triggers.push({ time: i / sr, level: Math.min(1, env) }); lastTrig = i; armed = false; } else if (!armed && env < releaseLevel) { armed = true; } } return triggers;}