Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367// fx.mjs — buffer post-effects for the dance lane. Wobble (LFO mod) and// bitcrush (depth + sample-rate reduction), both modulated by envelopes// so a section can ramp the effect in or out.//// All effects mutate the buffer in place over [startSec, endSec]. If// you want a dry/wet blend, render to a temp buffer first and mix.//// Envelopes are { time: secondsFromStart, ...params }[] breakpoints,// linearly interpolated. A single static value also works.
const DEFAULT_SAMPLE_RATE = 48_000;
function resolveEnv(envOrValue, key, t) { if (typeof envOrValue === "number") return envOrValue; if (!Array.isArray(envOrValue) || envOrValue.length === 0) return undefined; if (t <= envOrValue[0].time) return envOrValue[0][key]; if (t >= envOrValue[envOrValue.length - 1].time) return envOrValue[envOrValue.length - 1][key]; for (let i = 0; i < envOrValue.length - 1; i++) { const a = envOrValue[i], b = envOrValue[i + 1]; if (t >= a.time && t <= b.time) { const f = (t - a.time) / Math.max(1e-9, b.time - a.time); return a[key] + (b[key] - a[key]) * f; } } return envOrValue[envOrValue.length - 1][key];}
// ── wobble ────────────────────────────────────────────────────────────// LFO that modulates either amplitude ("amp") or one-pole lowpass cutoff// ("filter"). Trance + dubstep crossover effect — slow sine LFO at// ~0.5–8 Hz creates the "wub wub" feel. Both rate (Hz) and depth (0..1)// can be enveloped over the section.//// opts: {// target: "amp" | "filter" (default "filter")// rate: number | env breakpoints (Hz, default 2)// depth: number | env breakpoints (0..1, default 0.6)// baseCutoffHz: number (filter mode only; default 2000)// waveform: "sine" | "tri" | "square" (default "sine")// sampleRate: number (default 48000)// startSec, endSec: optional region (default whole buffer)// }//// Envelope rows: { time, rate, depth }export function applyWobble(buf, opts = {}) { if (!(buf instanceof Float32Array)) return; const sr = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; const target = opts.target ?? "filter"; const waveform = opts.waveform ?? "sine"; const startSec = opts.startSec ?? 0; const endSec = opts.endSec ?? (buf.length / sr); const startIdx = Math.max(0, Math.floor(startSec * sr)); const endIdx = Math.min(buf.length, Math.floor(endSec * sr)); if (endIdx <= startIdx) return;
const baseCutoff = opts.baseCutoffHz ?? 2000; const dt = 1 / sr;
// One-pole LP state for filter mode. let lp = 0; let phase = 0;
for (let i = startIdx; i < endIdx; i++) { const t = (i - startIdx) / sr; const rate = resolveEnv(opts.rate ?? 2.0, "rate", t); const depth = resolveEnv(opts.depth ?? 0.6, "depth", t);
phase += rate * dt; if (phase >= 1) phase -= Math.floor(phase); let lfo; switch (waveform) { case "tri": lfo = Math.abs(phase * 2 - 1) * 2 - 1; break; case "square": lfo = phase < 0.5 ? 1 : -1; break; default: lfo = Math.sin(phase * 2 * Math.PI); } // Map LFO from [-1,1] → [0,1] for depth (so depth=1 fully closes the gate) const modUnit = (lfo + 1) * 0.5;
if (target === "amp") { // amp gain swings from (1-depth) to 1. const gain = 1 - depth + depth * modUnit; buf[i] *= gain; } else { // filter cutoff swings exponentially across baseCutoff*[1-depth..1] // — closing the filter when depth is high. const minCut = baseCutoff * (1 - depth); const cutoff = minCut + (baseCutoff - minCut) * modUnit; const rc = 1 / (2 * Math.PI * Math.max(20, cutoff)); const alpha = dt / (rc + dt); lp += alpha * (buf[i] - lp); buf[i] = lp; } }}
// ── bitcrush ──────────────────────────────────────────────────────────// Quantize to `bits` and hold each sample for `downsample` samples// (sample-rate reduction). Both can be enveloped — e.g. start clean,// crush hard at the build climax, return to clean on the drop.//// opts: {// bits: int | env breakpoints (1..16, default 8)// downsample: int | env breakpoints (1..N, default 1)// mix: 0..1 (dry/wet, default 1)// sampleRate: number (default 48000)// startSec, endSec: optional region// }//// Envelope rows: { time, bits, downsample }export function applyBitcrush(buf, opts = {}) { if (!(buf instanceof Float32Array)) return; const sr = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; const startSec = opts.startSec ?? 0; const endSec = opts.endSec ?? (buf.length / sr); const startIdx = Math.max(0, Math.floor(startSec * sr)); const endIdx = Math.min(buf.length, Math.floor(endSec * sr)); if (endIdx <= startIdx) return; const mix = Math.max(0, Math.min(1, opts.mix ?? 1));
let held = 0; let holdCount = 0;
for (let i = startIdx; i < endIdx; i++) { const t = (i - startIdx) / sr; const bits = Math.max(1, Math.min(16, Math.round(resolveEnv(opts.bits ?? 8, "bits", t)))); const ds = Math.max(1, Math.floor(resolveEnv(opts.downsample ?? 1, "downsample", t)));
if (holdCount <= 0) { const levels = Math.pow(2, bits - 1); const q = Math.round(buf[i] * levels) / levels; held = Math.max(-1, Math.min(1, q)); holdCount = ds; } holdCount--; buf[i] = buf[i] * (1 - mix) + held * mix; }}
// ── flange ────────────────────────────────────────────────────────────// Short delay-line modulated by an LFO, mixed back with the dry signal.// Fast flange = LFO rate ~4-8 Hz with depth swinging ±3 ms; classic// trance/jet sweep on supersaw + lead chords.//// opts: {// rate: Hz (default 5)// depthMs: ms delay swing (peak) (default 3)// baseDelayMs: ms center delay (default 4)// feedback: -0.95..0.95 (default 0.45)// mix: 0..1 (dry/wet) (default 0.55)// sampleRate: number (default 48000)// startSec, endSec: optional region// }export function applyFlange(buf, opts = {}) { if (!(buf instanceof Float32Array)) return; const sr = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; const rate = opts.rate ?? 5; const depthMs = opts.depthMs ?? 3; const baseDelayMs = opts.baseDelayMs ?? 4; const feedback = Math.max(-0.95, Math.min(0.95, opts.feedback ?? 0.45)); const mix = Math.max(0, Math.min(1, opts.mix ?? 0.55)); const startIdx = Math.max(0, Math.floor((opts.startSec ?? 0) * sr)); const endIdx = Math.min(buf.length, Math.floor((opts.endSec ?? (buf.length / sr)) * sr)); if (endIdx <= startIdx) return;
// Delay-line large enough for base + depth + slack. const maxDelaySamples = Math.ceil((baseDelayMs + depthMs + 1) * 0.001 * sr) + 4; const delayLine = new Float32Array(maxDelaySamples); let writeIdx = 0; let phase = 0; const dt = 1 / sr;
for (let i = startIdx; i < endIdx; i++) { phase += rate * dt; if (phase >= 1) phase -= Math.floor(phase); const lfo = Math.sin(phase * 2 * Math.PI); // -1..1 const delaySamples = ((baseDelayMs + lfo * depthMs) * 0.001 * sr);
// Linear interpolation read from the delay line. const readPos = writeIdx - delaySamples; const readPosWrapped = (readPos + maxDelaySamples * 2) % maxDelaySamples; const r0 = Math.floor(readPosWrapped); const r1 = (r0 + 1) % maxDelaySamples; const frac = readPosWrapped - r0; const delayed = delayLine[r0] * (1 - frac) + delayLine[r1] * frac;
// Write input + feedback to the delay line, then output dry+wet mix. const input = buf[i]; delayLine[writeIdx] = input + delayed * feedback; writeIdx = (writeIdx + 1) % maxDelaySamples;
buf[i] = input * (1 - mix) + delayed * mix; }}
// ── ring modulation ───────────────────────────────────────────────────// Multiplies the signal by a carrier — a synthesized sine/tri/square at// `freq`, OR any in-program audio buffer passed as `carrier`. Sub-audio// freqs give tremolo; audio-rate freqs give clangorous inharmonic// sidebands. freq + mix can be enveloped over the section.//// opts: {// freq: Hz | env breakpoints (carrier osc; default 220.// ignored when a carrier buffer is given)// carrier: Float32Array (optional — any audio as the modulator)// waveform: "sine" | "tri" | "square" (default "sine")// mix: 0..1 | env breakpoints (dry/wet, default 1)// sampleRate: number (default 48000)// startSec, endSec: optional region// }//// Envelope rows: { time, freq, mix }export function applyRingMod(buf, opts = {}) { if (!(buf instanceof Float32Array)) return; const sr = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; const waveform = opts.waveform ?? "sine"; const carrier = opts.carrier instanceof Float32Array ? opts.carrier : null; const startIdx = Math.max(0, Math.floor((opts.startSec ?? 0) * sr)); const endIdx = Math.min(buf.length, Math.floor((opts.endSec ?? (buf.length / sr)) * sr)); if (endIdx <= startIdx) return; const dt = 1 / sr; let phase = 0;
for (let i = startIdx; i < endIdx; i++) { const t = (i - startIdx) / sr; const mix = Math.max(0, Math.min(1, resolveEnv(opts.mix ?? 1, "mix", t)));
let c; if (carrier) { c = carrier[(i - startIdx) % carrier.length]; // loop the modulator } else { const freq = resolveEnv(opts.freq ?? 220, "freq", t); phase += freq * dt; if (phase >= 1) phase -= Math.floor(phase); switch (waveform) { case "tri": c = Math.abs(phase * 2 - 1) * 2 - 1; break; case "square": c = phase < 0.5 ? 1 : -1; break; default: c = Math.sin(phase * 2 * Math.PI); } } buf[i] = buf[i] * (1 - mix) + buf[i] * c * mix; }}
// ── vocoder ───────────────────────────────────────────────────────────// Classic channel vocoder. The buffer is the CARRIER (a synth pad,// supersaw, noise…); `modulator` is any other audio (a voice, a drum// loop). The modulator is split into `bands` log-spaced frequency// channels; each channel's amplitude envelope is imprinted onto the// matching band of the carrier — so the carrier "speaks" the modulator.//// Uses a TPT state-variable bandpass per band (stable, cheap). Mutates// the carrier buffer in place.//// opts: {// modulator: Float32Array (required — the audio that does the talking)// bands: int (default 16)// loFreq, hiFreq: Hz band spread (default 120 .. 8000)// q: band resonance (default 6)// attackMs, releaseMs: per-band envelope smoothing (default 3 / 18)// makeup: wet output gain (default 1.4/sqrt(bands))// mix: 0..1 dry/wet (default 1)// sampleRate: number (default 48000)// }// A noise carrier vocodes loud — follow with softClip() if it peaks.export function applyVocoder(buf, opts = {}) { if (!(buf instanceof Float32Array)) return; const mod = opts.modulator; if (!(mod instanceof Float32Array) || mod.length === 0) return; const sr = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; const bands = Math.max(2, Math.floor(opts.bands ?? 16)); const loF = opts.loFreq ?? 120; const hiF = Math.min(opts.hiFreq ?? 8000, sr * 0.45); const q = opts.q ?? 6; const mix = Math.max(0, Math.min(1, opts.mix ?? 1)); const aA = Math.exp(-1 / (Math.max(0.01, opts.attackMs ?? 3) * 0.001 * sr)); const aR = Math.exp(-1 / (Math.max(0.01, opts.releaseMs ?? 18) * 0.001 * sr)); const makeup = opts.makeup ?? 1.4 / Math.sqrt(bands);
// Per-band SVF coefficients (log-spaced centers) + filter state. const k = 1 / q; const a1 = [], a2 = [], a3 = []; for (let b = 0; b < bands; b++) { const f = loF * Math.pow(hiF / loF, b / (bands - 1)); const g = Math.tan(Math.PI * Math.min(f, sr * 0.49) / sr); const d = 1 / (1 + g * (g + k)); a1.push(d); a2.push(g * d); a3.push(g * g * d); } // state: carrier ic1/ic2, modulator ic1/ic2, modulator envelope const c1 = new Float32Array(bands), c2 = new Float32Array(bands); const m1 = new Float32Array(bands), m2 = new Float32Array(bands); const env = new Float32Array(bands);
const n = buf.length; for (let i = 0; i < n; i++) { const cx = buf[i]; const mx = i < mod.length ? mod[i] : 0; let wet = 0; for (let b = 0; b < bands; b++) { // carrier bandpass let v3 = cx - c2[b]; let v1 = a1[b] * c1[b] + a2[b] * v3; let v2 = c2[b] + a2[b] * c1[b] + a3[b] * v3; c1[b] = 2 * v1 - c1[b]; c2[b] = 2 * v2 - c2[b]; const cbp = v1; // modulator bandpass v3 = mx - m2[b]; v1 = a1[b] * m1[b] + a2[b] * v3; v2 = m2[b] + a2[b] * m1[b] + a3[b] * v3; m1[b] = 2 * v1 - m1[b]; m2[b] = 2 * v2 - m2[b]; // modulator band envelope const r = Math.abs(v1); const a = r > env[b] ? aA : aR; env[b] = a * env[b] + (1 - a) * r; wet += cbp * env[b]; } buf[i] = cx * (1 - mix) + wet * makeup * mix; }}
// ── envelope follower ─────────────────────────────────────────────────// Tracks the amplitude contour of a signal into a 0..1 control curve,// one value per sample. Separate attack/release smoothing (ms) sets how// fast it chases rising vs falling level. The returned curve can drive// any fx/synth param — auto-wah, ducking, audio-reactive visuals.//// This is a control-signal primitive, not an in-place buffer fx, so it// RETURNS a Float32Array rather than mutating `buf`.//// opts: { attackMs (default 5), releaseMs (default 80), sampleRate }export function envelopeFollower(buf, opts = {}) { const sr = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; const out = new Float32Array(buf instanceof Float32Array ? buf.length : 0); if (!(buf instanceof Float32Array)) return out; const aA = Math.exp(-1 / (Math.max(0.01, opts.attackMs ?? 5) * 0.001 * sr)); const aR = Math.exp(-1 / (Math.max(0.01, opts.releaseMs ?? 80) * 0.001 * sr)); let env = 0; 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; out[i] = env > 1 ? 1 : env; } return out;}
// Invert a 0..1 control curve — the "opposite amplitude" signal Abe// asked for: loud input → low control, silence → full control.export function invertControl(ctrl) { const out = new Float32Array(ctrl.length); for (let i = 0; i < ctrl.length; i++) { out[i] = 1 - Math.min(1, Math.max(0, ctrl[i])); } return out;}
// ── tape-style soft clip (gentle saturation) ──────────────────────────// Bonus — useful for taming peaks after sidechain + wobble + bitcrush// without harsh digital clipping.export function softClip(buf, drive = 1.0) { if (!(buf instanceof Float32Array)) return; for (let i = 0; i < buf.length; i++) { buf[i] = Math.tanh(buf[i] * drive); }}