Rebuild the Acid node after the acid voice of nodeseq (#421)
The first Acid hardly squelched. Its tanh ate the resonance, and its sweep fell evenly in octaves. The user pointed at the acid tone of nodeseq.com/studio, so the node now follows how that voice is built from Web Audio parts. - The filter is a two-pole 12 dB low-pass with Q in dB up to 17.5, like a BiquadFilterNode. The ladder and its tanh are gone. - One ADSR drives both the volume and the filter. The sweep falls back in Hz, fast from the top and long at the bottom. - Every note glides from the one before. A new note cuts the last one off, and the node plays its release itself. - Knobs: cutoff, reso, env, attack, decay, sustain, release and glide. The defaults are the acid preset of nodeseq. - Accent, slide, drive and tuning are dropped. Old projects were not kept, the user had only placed the node for a try. - A hook __gm.acid.compare renders a line offline through our worklet and through the nodeseq graph, for e2e 34. Found on the way: in Chrome an oscillator that starts inside a render block runs its first samples at 440 Hz. The comparison evens this out. Tests: - tsc, eslint, Prettier clean - Unit: acid.test.ts rewritten with 12 tests. chip-presets and sound-library follow the new node. - e2e 34 on melektaus-atx: ALL PASSED, 148 checks. Against nodeseq the loudness per 10 ms window is at most 0.14 dB apart, and 95 to 97 % of the windows are equally bright. - With a linear Q instead of Q in dB the comparison is red. - Gate on melektaus-atx: green, 306 files, 4752 tests. That is 4754 less the 14 old and plus the 12 new Acid tests. - Not checked: the sound by ear, the master chain of nodeseq, the keyboard and a bake with the new node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d844f2734a
commit
f92d69d902
15 files changed
+645
-592
No files matched your search
@@ -716,11 +716,11 @@ const TONES: Record<Exclude<TonePreset, 'drumKit'>, (s: Sketch, notes: GraphNode
|
||||
sustain: 0.15,
|
||||
release: 0.08,
|
||||
}),
|
||||
// The TB-303 (#421): a saw through a filter that rings and snaps shut, with a short echo.
|
||||
// nodeseq's acid (#421): a saw through a filter that rings and snaps shut, with a short echo.
|
||||
acidBass: (s, notes) => {
|
||||
const acid = s.add(INSTRUMENT_ACID, 1, 0, {
|
||||
wave: 'saw',
|
||||
...held({ cutoff: 300, resonance: 0.8, envMod: 0.6, decay: 0.4, accent: 0.7 }, ACID_ROWS),
|
||||
...held({ cutoff: 250, resonance: 0.78, envMod: 0.62 }, ACID_ROWS),
|
||||
});
|
||||
s.wire(notes, 'any', acid, 'trigger');
|
||||
return s.line(acid, echo(0.75, 0.25, 0.1));
|
||||
|
||||
@@ -302,7 +302,7 @@ export function compileInstrument(
|
||||
};
|
||||
}
|
||||
|
||||
/** An Acid's numbers, held to its rows (#421): it always plays the note, `tuning` off it. */
|
||||
/** An Acid's numbers, held to its rows (#421): it always plays the note, as loud as its velocity. */
|
||||
function acidOf(node: GraphNode, played: number, amounts: Readonly<Record<string, FxAmount>>): FxAcidPlan {
|
||||
const rows = ACID_ROWS;
|
||||
return {
|
||||
@@ -310,13 +310,14 @@ export function compileInstrument(
|
||||
wave: acidWave(node.params),
|
||||
frequency: { value: noteFrequency(played) },
|
||||
velocity: { value: 1, param: VELOCITY_PARAM, min: 0, max: 1 },
|
||||
tuning: held(amounts.tuning, rows.tuning),
|
||||
slideTime: held(amounts.slideTime, rows.slideTime),
|
||||
cutoff: held(amounts.cutoff, rows.cutoff),
|
||||
resonance: held(amounts.resonance, rows.resonance),
|
||||
envMod: held(amounts.envMod, rows.envMod),
|
||||
attack: held(amounts.attack, rows.attack),
|
||||
decay: held(amounts.decay, rows.decay),
|
||||
accent: held(amounts.accent, rows.accent),
|
||||
sustain: held(amounts.sustain, rows.sustain),
|
||||
release: held(amounts.release, rows.release),
|
||||
glide: held(amounts.glide, rows.glide),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -363,23 +363,28 @@ export function acidWave(params: PropertyBag): AcidWave {
|
||||
return ACID_WAVES.find((option) => option.value === params[ACID_WAVE.key])?.value ?? 'saw';
|
||||
}
|
||||
|
||||
/** The defaults are nodeseq's acid bass: Cutoff 30, Reso 78, Env 62 of its 100, its ADSR and its glide. */
|
||||
export const ACID_ROWS = {
|
||||
tuning: chipRow('tuning', 'Tuning (st)', -12, 12, 0.01),
|
||||
cutoff: chipRow('cutoff', 'Cutoff (Hz)', 30, 12000, 1, 400),
|
||||
resonance: chipRow('resonance', 'Resonance', 0, 1, 0.01, 0.7),
|
||||
envMod: chipRow('envMod', 'Env mod', 0, 1, 0.01, 0.5),
|
||||
decay: chipRow('decay', 'Decay (s)', 0.2, 2, 0.01, 0.5),
|
||||
accent: chipRow('accent', 'Accent', 0, 1, 0.01, 0.6),
|
||||
slideTime: chipRow('slideTime', 'Slide time (s)', 0, 0.5, 0.001, 0.06),
|
||||
cutoff: chipRow('cutoff', 'Cutoff (Hz)', 40, 18000, 1, 250),
|
||||
resonance: chipRow('resonance', 'Resonance', 0, 1, 0.01, 0.78),
|
||||
envMod: chipRow('envMod', 'Env mod', 0, 1, 0.01, 0.62),
|
||||
attack: chipRow('attack', 'Attack (s)', 0.001, 2, 0.001, 0.003),
|
||||
decay: chipRow('decay', 'Decay (s)', 0.02, 3, 0.01, 0.16),
|
||||
sustain: chipRow('sustain', 'Sustain', 0, 1, 0.01, 0.15),
|
||||
release: chipRow('release', 'Release (s)', 0.02, 4, 0.01, 0.08),
|
||||
glide: chipRow('glide', 'Glide (s)', 0, 0.5, 0.001, 0.05),
|
||||
} satisfies Record<string, FlatDef<'number'>>;
|
||||
|
||||
/** The numbers an Acid takes as its note plays: the rest it fixes when the note starts. */
|
||||
export const ACID_MOVING = ['cutoff', 'resonance', 'envMod'] as const;
|
||||
|
||||
export const InstrumentAcid: InstrumentNodeDef = {
|
||||
type: INSTRUMENT_ACID,
|
||||
label: 'Acid',
|
||||
category: 'sound',
|
||||
instrument: true,
|
||||
description:
|
||||
'A bass line in the way of the Roland TB-303, one note at a time. "Wave" is a Saw or a Square, played at the pitch of the note and "tuning" semitones off it, through a resonant low-pass that saturates. "cutoff" is where the filter rests, "resonance" how sharply it rings there, up to just short of a whistle. Every note opens the filter by up to four octaves with "env mod" and lets it fall back in "decay" seconds. A note struck at a velocity of 0.9 or more is accented: louder, its sweep shorter and higher by "accent", and accents close behind each other build up. A note that starts while the one before still plays, or right where it ends, slides: the pitch glides there in "slide time" seconds and nothing is struck anew, so the sweep and the volume run on. Notes that start together all play. The Acid shapes its own volume and ends with its note, faded out in a few milliseconds; an Envelope behind it starts over with every note, also on a slide. An Envelope can move "cutoff", a Pitch moves its frequency. In the graph the node is a device: it draws the filter where it rests and at the top of its sweep.',
|
||||
'An acid bass, one note at a time, as nodeseq.com plays it; its defaults are nodeseq\'s acid. "Wave" is a Saw or a Square at the pitch of the note, through a low-pass with a sharp resonance. "cutoff" is where the filter rests, "resonance" how sharply it rings there. One ADSR shapes the volume and the filter alike: in "attack" seconds the filter opens by up to five octaves with "env mod", in "decay" it falls back to where "sustain" holds it, and when the note ends it closes in "release". Every note strikes anew and glides from the pitch of the note before in "glide" seconds; a note that starts while the one before still sounds cuts it short. Notes that start together all play. The velocity makes it louder or softer. The Acid shapes its own volume and plays out its release, so it needs no Envelope behind it. An Envelope can move "cutoff", a Pitch moves its frequency. In the graph the node is a device: it draws the filter where it rests, at the top of its sweep and where the sustain holds it.',
|
||||
inputs: [
|
||||
triggerIn,
|
||||
...Object.values(ACID_ROWS).map((row): PortDef =>
|
||||
@@ -388,9 +393,12 @@ export const InstrumentAcid: InstrumentNodeDef = {
|
||||
],
|
||||
outputs: [audioOut],
|
||||
params: [ACID_WAVE],
|
||||
// Nothing sweeps the filter: there is nothing for the decay to let fall.
|
||||
// At full sustain and without a sweep nothing falls: the decay has nothing to do.
|
||||
idleParams: (params, fed = () => false) =>
|
||||
!fed('envMod') && !fed('decay') && asNumber(params.envMod ?? ACID_ROWS.envMod.default) <= 0
|
||||
!fed('sustain') &&
|
||||
!fed('envMod') &&
|
||||
asNumber(params.sustain ?? ACID_ROWS.sustain.default) >= 1 &&
|
||||
asNumber(params.envMod ?? ACID_ROWS.envMod.default) <= 0
|
||||
? [ACID_ROWS.decay.key]
|
||||
: [],
|
||||
};
|
||||
|
||||
+160
-228
@@ -1,161 +1,177 @@
|
||||
/**
|
||||
* The Acid of an instrument (#421) sample by sample, as its worklet runs it: a bass line in the way
|
||||
* of the Roland TB-303. A saw or a square, band-limited, goes through a four-pole ladder low-pass
|
||||
* that saturates in its feedback. An envelope opens the filter at once and lets it fall in "decay",
|
||||
* the volume rises in a few milliseconds and dies away slowly. An accented note is louder, its sweep
|
||||
* shorter and higher, and accents close behind each other build up, as the 303's accent circuit
|
||||
* charges a capacitor. A note that slides glides to its pitch and strikes nothing anew.
|
||||
* The Acid of an instrument (#421) sample by sample, as its worklet runs it: the acid bass of
|
||||
* nodeseq.com, built as its studio builds it from Web Audio nodes and worked here in one. A saw or a
|
||||
* square, band-limited, goes through a two-pole low-pass with a sharp resonance, the one Chrome's
|
||||
* `BiquadFilterNode` is, its Q in dB. One ADSR shapes the volume and the filter alike: in the attack
|
||||
* the cutoff climbs straight to the top of the sweep, in the decay it falls back to where the sustain
|
||||
* holds it, and in the release down to where it rests. It falls in Hz, not in octaves, so it drops
|
||||
* fast from the top and lingers low – what makes it squelch. Every note strikes anew and glides from
|
||||
* the pitch of the note before; a note that starts while the one before still sounds cuts it short,
|
||||
* one at a time.
|
||||
*
|
||||
* The knobs come as an AudioParam hands them over: one value for the block or one per sample.
|
||||
* Cutoff, resonance, env mod and pitch come as an AudioParam hands them over: one value for the
|
||||
* block or one per sample. So do the note's end (`gate` falls to 0) and the cut (`cut` rises to 1),
|
||||
* which the page sets on the audio clock to the sample.
|
||||
*/
|
||||
export type AcidWave = 'saw' | 'square';
|
||||
|
||||
export interface AcidShape {
|
||||
/** What a note is struck with: all of it fixed for the note, as nodeseq fixes it. */
|
||||
export interface AcidNote {
|
||||
wave: AcidWave;
|
||||
/** Seconds a slide takes to all but reach its note: 95 % of the way. */
|
||||
slideTime: number;
|
||||
/** Hz, and the Hz of the note before it glides from: null where there was none. */
|
||||
frequency: number;
|
||||
from: number | null;
|
||||
/** Seconds the glide takes, and the ADSR's, the sustain 0 to 1. */
|
||||
glide: number;
|
||||
attack: number;
|
||||
decay: number;
|
||||
sustain: number;
|
||||
release: number;
|
||||
/** 0 to 1: how loud. */
|
||||
velocity: number;
|
||||
}
|
||||
|
||||
/** Cutoff in Hz, resonance, env mod and accent 0 to 1, decay in seconds, pitch in semitones. */
|
||||
/** Cutoff in Hz, resonance and env mod 0 to 1, pitch in semitones; `gate` 1 while held, `cut` 1 once cut. */
|
||||
export interface AcidKnobs {
|
||||
cutoff: Float32Array;
|
||||
resonance: Float32Array;
|
||||
envMod: Float32Array;
|
||||
decay: Float32Array;
|
||||
accent: Float32Array;
|
||||
pitch: Float32Array;
|
||||
gate: Float32Array;
|
||||
cut: Float32Array;
|
||||
}
|
||||
|
||||
export interface AcidNote {
|
||||
/** Hz, with the tuning in it. */
|
||||
frequency: number;
|
||||
accented: boolean;
|
||||
/** Glides from the note before rather than striking: the envelopes run on. */
|
||||
slide: boolean;
|
||||
/** The resonance in dB at none and at full, as nodeseq turns its Reso 0 to 100 into the filter's Q. */
|
||||
const LEAST_Q_DB = 0.5;
|
||||
const MOST_Q_DB = 17.5;
|
||||
/** Octaves the envelope opens the filter by at full env mod, and the highest it opens it to. */
|
||||
export const ENV_MOD_OCTAVES = 5;
|
||||
export const TOP_HZ = 18000;
|
||||
/** How much of the sweep the sustain holds at full: at sustain 1 the cutoff stays 60 % of the way up. */
|
||||
export const SUSTAIN_SWEEP = 0.6;
|
||||
/**
|
||||
* How loud a note at velocity 1 is: nodeseq's bass at its volume 0.8, a saw into it at 0.8, and
|
||||
* Chrome's sawtooth peaks at 1 with its ripple, so it is 0.85 of a plain one.
|
||||
*/
|
||||
const OUTPUT = 0.32 * 0.85;
|
||||
/** Time constant of the fall where the next note cuts a note short, and when it stops then. */
|
||||
const CUT_FALL = 0.006;
|
||||
const CUT_STOP = 0.06;
|
||||
/** When a note stops after its release starts: so many releases and a little more. */
|
||||
const RELEASE_STOPS = 1.6;
|
||||
const RELEASE_MORE = 0.05;
|
||||
const LOWEST_HZ = 10;
|
||||
|
||||
/** The filter's Q, as a plain number, at `resonance` 0 to 1. */
|
||||
export function acidQ(resonance: number): number {
|
||||
return 10 ** ((LEAST_Q_DB + (MOST_Q_DB - LEAST_Q_DB) * clamp01(resonance)) / 20);
|
||||
}
|
||||
|
||||
/** The velocity from which a note is accented: the piano roll strikes at 0.8 unless told otherwise. */
|
||||
export const ACCENT_VELOCITY = 0.9;
|
||||
const OVERSAMPLE = 2;
|
||||
/** Octaves the envelope opens the filter by at full env mod, and an accent at full on top. */
|
||||
export const ENV_MOD_OCTAVES = 4;
|
||||
export const ACCENT_OCTAVES = 2;
|
||||
/** The decay of an accented note, whatever the knob says: the 303's shortest. */
|
||||
export const ACCENT_DECAY = 0.2;
|
||||
/** The feedback of the ladder at full resonance: just short of where it would whistle by itself. */
|
||||
export const MOST_FEEDBACK = 3.9;
|
||||
/** How hard the oscillator drives the filter, and how much of the bass the resonance takes is given back. */
|
||||
const DRIVE = 1.4;
|
||||
const BASS_KEPT = 0.3;
|
||||
/** Seconds of the volume's rise, and its time constant as it dies away while the note is held. */
|
||||
const AMP_ATTACK = 0.003;
|
||||
const AMP_FALL = 1.6;
|
||||
/** How much louder an accent makes the top of a note: twice, 6 dB, at full. */
|
||||
const ACCENT_LOUDER = 1;
|
||||
/** The accent's capacitor: time constants as it charges and as it lets go. */
|
||||
const ACCENT_CHARGE = 0.02;
|
||||
const ACCENT_LEAK = 0.25;
|
||||
/** Where a decay counts as over: 1 % of the sweep. */
|
||||
const DECAY_FLOOR = Math.log(100);
|
||||
const OUTPUT = 0.5;
|
||||
const LOWEST_HZ = 20;
|
||||
/** The top of a note's sweep, in Hz, from the cutoff at rest. */
|
||||
export function acidTopHz(cutoff: number, envMod: number): number {
|
||||
return Math.min(TOP_HZ, Math.max(LOWEST_HZ, cutoff) * 2 ** (ENV_MOD_OCTAVES * clamp01(envMod)));
|
||||
}
|
||||
|
||||
export class AcidSynth {
|
||||
private readonly rate: number;
|
||||
private phase = 0;
|
||||
/** log2 of the frequency played now, and of the note it glides to. */
|
||||
private octave = 0;
|
||||
private target = 0;
|
||||
private sweep = 0;
|
||||
private phase = 0.5;
|
||||
/** Samples since the note started, when its glide and attack are over, and when it stops. */
|
||||
private t = 0;
|
||||
private readonly glideFor: number;
|
||||
private readonly attackFor: number;
|
||||
private stopAt = Infinity;
|
||||
/** log2 of the frequency it glides from and to. */
|
||||
private readonly fromOctave: number;
|
||||
private readonly toOctave: number;
|
||||
private readonly peak: number;
|
||||
private readonly decayStep: number;
|
||||
private readonly releaseStep: number;
|
||||
private readonly cutStep: number;
|
||||
private amp = 0;
|
||||
private rising = false;
|
||||
private accented = false;
|
||||
private charge = 0;
|
||||
private struck = false;
|
||||
private readonly stages = new Float64Array(4);
|
||||
private readonly glide: number;
|
||||
private readonly rise: number;
|
||||
private readonly fall: number;
|
||||
private readonly chargeStep: number;
|
||||
private readonly leakStep: number;
|
||||
private decayFor = -1;
|
||||
private decayStep = 0;
|
||||
/** How far up the sweep the cutoff stands, 0 at rest and 1 at the top. */
|
||||
private env = 0;
|
||||
private released = false;
|
||||
private cutting = false;
|
||||
/** The filter's two states. */
|
||||
private ic1 = 0;
|
||||
private ic2 = 0;
|
||||
|
||||
constructor(
|
||||
private readonly shape: AcidShape,
|
||||
private readonly note: AcidNote,
|
||||
sampleRate: number,
|
||||
) {
|
||||
this.rate = sampleRate * OVERSAMPLE;
|
||||
const step = (seconds: number) => (seconds > 0 ? 1 - Math.exp(-1 / (seconds * this.rate)) : 1);
|
||||
// 95 % of the way in the slide time is three time constants.
|
||||
this.glide = step(Math.max(0, shape.slideTime) / 3);
|
||||
this.rise = step(AMP_ATTACK / 3);
|
||||
this.fall = Math.exp(-1 / (AMP_FALL * this.rate));
|
||||
this.chargeStep = step(ACCENT_CHARGE);
|
||||
this.leakStep = step(ACCENT_LEAK);
|
||||
this.rate = sampleRate;
|
||||
const step = (tau: number) => (tau > 0 ? 1 - Math.exp(-1 / (tau * sampleRate)) : 1);
|
||||
this.toOctave = Math.log2(Math.max(LOWEST_HZ, note.frequency));
|
||||
this.fromOctave =
|
||||
note.from !== null && note.glide > 0 ? Math.log2(Math.max(LOWEST_HZ, note.from)) : this.toOctave;
|
||||
this.glideFor = Math.max(1, Math.round(Math.max(0, note.glide) * sampleRate));
|
||||
this.attackFor = Math.max(1, Math.round(Math.max(0.001, note.attack) * sampleRate));
|
||||
this.peak = OUTPUT * clamp01(note.velocity);
|
||||
// nodeseq's decay and release run as `setTargetAtTime` with a third and a quarter of the time.
|
||||
this.decayStep = step(Math.max(0.02, note.decay) / 3);
|
||||
this.releaseStep = step(Math.max(0.02, note.release) / 4);
|
||||
this.cutStep = step(CUT_FALL);
|
||||
}
|
||||
|
||||
/** Where it stands: the frequency played, the sweep from 1 down, the accent's charge and the volume. */
|
||||
get state(): { hz: number; sweep: number; charge: number; amp: number } {
|
||||
return { hz: 2 ** this.octave, sweep: this.sweep, charge: this.charge, amp: this.amp };
|
||||
/** Where it stands: the frequency played, the volume, and how far up its sweep the filter is. */
|
||||
get state(): { hz: number; amp: number; env: number; released: boolean; cut: boolean } {
|
||||
return {
|
||||
hz: 2 ** this.octave(),
|
||||
amp: this.amp,
|
||||
env: this.env,
|
||||
released: this.released,
|
||||
cut: this.cutting,
|
||||
};
|
||||
}
|
||||
|
||||
/** A note from the next sample on. The first a synth plays never slides: there is nothing to glide from. */
|
||||
strike(note: AcidNote): void {
|
||||
this.target = Math.log2(Math.max(LOWEST_HZ, note.frequency));
|
||||
this.accented = note.accented;
|
||||
if (note.slide && this.struck) return;
|
||||
this.struck = true;
|
||||
this.octave = this.target;
|
||||
this.sweep = 1;
|
||||
this.rising = true;
|
||||
}
|
||||
|
||||
/** Fills `out` from `from` up to `to`. */
|
||||
render(out: Float32Array, from: number, to: number, knobs: AcidKnobs): void {
|
||||
const { rate } = this;
|
||||
for (let i = from; i < to; i++) {
|
||||
const cutoff = finite(at(knobs.cutoff, i), 500);
|
||||
const resonance = clamp01(finite(at(knobs.resonance, i), 0));
|
||||
const envMod = clamp01(finite(at(knobs.envMod, i), 0));
|
||||
const accent = clamp01(finite(at(knobs.accent, i), 0));
|
||||
const fallStep = this.decayStepOf(this.accented ? ACCENT_DECAY : finite(at(knobs.decay, i), 0.5));
|
||||
const pitch = finite(at(knobs.pitch, i), 0);
|
||||
const k = MOST_FEEDBACK * resonance;
|
||||
let sum = 0;
|
||||
for (let n = 0; n < OVERSAMPLE; n++) {
|
||||
this.octave += (this.target - this.octave) * this.glide;
|
||||
const hz = Math.min(rate / 2, 2 ** (this.octave + pitch / 12));
|
||||
const wave = this.oscillator(hz / rate);
|
||||
const lift = accent * (this.accented ? 1 : 0);
|
||||
this.charge +=
|
||||
(lift * this.sweep - this.charge) *
|
||||
(lift * this.sweep > this.charge ? this.chargeStep : this.leakStep);
|
||||
const octaves =
|
||||
envMod * ENV_MOD_OCTAVES * this.sweep +
|
||||
accent * ACCENT_OCTAVES * this.charge * (0.5 + 0.5 * resonance);
|
||||
const fc = Math.min(0.45 * rate, Math.max(LOWEST_HZ, cutoff * 2 ** octaves));
|
||||
const filtered = this.ladder(wave * DRIVE * (1 + BASS_KEPT * k), fc, k);
|
||||
if (this.rising) {
|
||||
this.amp += (1 - this.amp) * this.rise;
|
||||
if (this.amp >= 0.999) this.rising = false;
|
||||
} else this.amp *= this.fall;
|
||||
const level = this.amp * (1 + ACCENT_LOUDER * lift * this.sweep);
|
||||
sum += filtered * level;
|
||||
this.sweep *= 1 - fallStep;
|
||||
/** Fills `out`; false once the note has stopped, the rest of `out` silent. */
|
||||
render(out: Float32Array, knobs: AcidKnobs): boolean {
|
||||
const { rate, note } = this;
|
||||
const sustain = clamp01(note.sustain);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (!this.released && at(knobs.gate, i) < 0.5) {
|
||||
this.released = true;
|
||||
this.stopAt = Math.min(
|
||||
this.stopAt,
|
||||
this.t + Math.round((Math.max(0.02, note.release) * RELEASE_STOPS + RELEASE_MORE) * rate),
|
||||
);
|
||||
}
|
||||
out[i] = (sum / OVERSAMPLE) * OUTPUT;
|
||||
if (!this.cutting && at(knobs.cut, i) >= 0.5) {
|
||||
this.cutting = true;
|
||||
this.stopAt = Math.min(this.stopAt, this.t + Math.round(CUT_STOP * rate));
|
||||
}
|
||||
if (this.t >= this.stopAt) {
|
||||
out.fill(0, i);
|
||||
return false;
|
||||
}
|
||||
if (this.cutting) this.amp -= this.amp * this.cutStep;
|
||||
if (this.released) {
|
||||
if (!this.cutting) this.amp -= this.amp * this.releaseStep;
|
||||
this.env -= this.env * this.releaseStep;
|
||||
} else if (this.t < this.attackFor) {
|
||||
const share = this.t / this.attackFor;
|
||||
if (!this.cutting) this.amp = this.peak * share;
|
||||
this.env = share;
|
||||
} else {
|
||||
if (!this.cutting) this.amp += (this.peak * sustain - this.amp) * this.decayStep;
|
||||
this.env += (sustain * SUSTAIN_SWEEP - this.env) * this.decayStep;
|
||||
}
|
||||
const cutoff = Math.max(LOWEST_HZ, finite(at(knobs.cutoff, i), 250));
|
||||
const top = acidTopHz(cutoff, finite(at(knobs.envMod, i), 0));
|
||||
const fc = Math.min(0.49 * rate, cutoff + (top - cutoff) * this.env);
|
||||
const hz = Math.min(0.49 * rate, 2 ** (this.octave() + finite(at(knobs.pitch, i), 0) / 12));
|
||||
const wave = this.oscillator(hz / rate);
|
||||
out[i] = this.lowPass(wave, fc, acidQ(finite(at(knobs.resonance, i), 0))) * this.amp;
|
||||
this.t += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The step per sample a sweep of `seconds` falls by, worked out again only when it changes. */
|
||||
private decayStepOf(seconds: number): number {
|
||||
if (seconds !== this.decayFor) {
|
||||
this.decayFor = seconds;
|
||||
const tau = Math.max(1e-3, seconds) / DECAY_FLOOR;
|
||||
this.decayStep = 1 - Math.exp(-1 / (tau * this.rate));
|
||||
}
|
||||
return this.decayStep;
|
||||
/** log2 of the frequency now: an even glide in octaves, as `exponentialRampToValueAtTime` makes it. */
|
||||
private octave(): number {
|
||||
if (this.t >= this.glideFor) return this.toOctave;
|
||||
return this.fromOctave + (this.toOctave - this.fromOctave) * (this.t / this.glideFor);
|
||||
}
|
||||
|
||||
/** One value of the wave, its corners rounded off by PolyBLEP so it does not alias. */
|
||||
@@ -163,30 +179,27 @@ export class AcidSynth {
|
||||
this.phase += step;
|
||||
if (this.phase >= 1) this.phase -= Math.floor(this.phase);
|
||||
const p = this.phase;
|
||||
if (this.shape.wave === 'saw') return 2 * p - 1 - blep(p, step);
|
||||
if (this.note.wave === 'saw') return 2 * p - 1 - blep(p, step);
|
||||
const half = (p + 0.5) % 1;
|
||||
return (p < 0.5 ? 1 : -1) + blep(p, step) - blep(half, step);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ladder: four one-pole low-passes in a row, solved without a delay in the loop (Zavalishin's
|
||||
* topology-preserving transform), the signal into it saturated by `tanh` after the feedback.
|
||||
* The low-pass: a state-variable filter solved without a delay in the loop (Simper's), the same
|
||||
* curve as the cookbook biquad Chrome runs, and steady however fast the cutoff moves.
|
||||
*/
|
||||
private ladder(x: number, fc: number, k: number): number {
|
||||
private lowPass(x: number, fc: number, q: number): number {
|
||||
const g = Math.tan((Math.PI * fc) / this.rate);
|
||||
const G = g / (1 + g);
|
||||
const s = this.stages;
|
||||
const rest = 1 - G;
|
||||
const S = G * G * G * s[0]! * rest + G * G * s[1]! * rest + G * s[2]! * rest + s[3]! * rest;
|
||||
const G4 = G * G * G * G;
|
||||
let y = Math.tanh((x - k * S) / (1 + k * G4));
|
||||
for (let n = 0; n < 4; n++) {
|
||||
const v = (y - s[n]!) * G;
|
||||
const out = v + s[n]!;
|
||||
s[n] = out + v;
|
||||
y = out;
|
||||
}
|
||||
return y;
|
||||
const k = 1 / q;
|
||||
const a1 = 1 / (1 + g * (g + k));
|
||||
const a2 = g * a1;
|
||||
const a3 = g * a2;
|
||||
const v3 = x - this.ic2;
|
||||
const v1 = a1 * this.ic1 + a2 * v3;
|
||||
const v2 = this.ic2 + a2 * this.ic1 + a3 * v3;
|
||||
this.ic1 = 2 * v1 - this.ic1;
|
||||
this.ic2 = 2 * v2 - this.ic2;
|
||||
return v2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,94 +216,13 @@ function blep(p: number, step: number): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How loud the ladder passes `hz` with its cutoff at `fc` and its resonance at `resonance`, in dB:
|
||||
* the four poles and their feedback as an analogue filter has them, without the saturation. What a
|
||||
* device draws (#421).
|
||||
*/
|
||||
/** How loud the low-pass passes `hz` with its cutoff at `fc` and its resonance at `resonance`, in dB: what a device draws. */
|
||||
export function acidResponseDb(hz: number, fc: number, resonance: number): number {
|
||||
const k = MOST_FEEDBACK * clamp01(resonance);
|
||||
const w = hz / Math.max(LOWEST_HZ, fc);
|
||||
// One pole: 1 / (1 + jw). Four of them: (1 + jw)^-4, as a complex number.
|
||||
let re = 1;
|
||||
let im = 0;
|
||||
for (let n = 0; n < 4; n++) {
|
||||
const r = re - im * w;
|
||||
im = re * w + im;
|
||||
re = r;
|
||||
}
|
||||
const mag2 = re * re + im * im;
|
||||
const pr = re / mag2;
|
||||
const pi = -im / mag2;
|
||||
const dr = 1 + k * pr;
|
||||
const di = k * pi;
|
||||
const h = Math.sqrt((pr * pr + pi * pi) / (dr * dr + di * di));
|
||||
return 20 * Math.log10(Math.max(1e-9, h * (1 + BASS_KEPT * k)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The notes of one line of an Acid (#421) on the audio thread, each a voice of its own with its
|
||||
* processor. A note that slides out of the one before plays on the same synth: from its first sample
|
||||
* on it is the one that hears it, and the note before is silent. The synth runs once per block for
|
||||
* all of them, whichever asks first, so it makes no difference in which order they are run.
|
||||
*/
|
||||
export class AcidLine<Owner> {
|
||||
private readonly strikes: { frame: number; owner: Owner; note: AcidNote }[] = [];
|
||||
private readonly members = new Set<Owner>();
|
||||
private owner: Owner | null = null;
|
||||
private block = new Float32Array(0);
|
||||
private blockAt = -1;
|
||||
/** Who hears the block from where on. */
|
||||
private turns: { from: number; owner: Owner | null }[] = [];
|
||||
|
||||
constructor(private readonly synth: AcidSynth) {}
|
||||
|
||||
/** A note of `owner` from the sample `frame` of the audio clock on; one already past is played at once. */
|
||||
strike(owner: Owner, frame: number, note: AcidNote): void {
|
||||
this.members.add(owner);
|
||||
this.strikes.push({ frame, owner, note });
|
||||
this.strikes.sort((a, b) => a.frame - b.frame);
|
||||
}
|
||||
|
||||
/** A note gone; says whether the line has none left. */
|
||||
leave(owner: Owner): boolean {
|
||||
this.members.delete(owner);
|
||||
return this.members.size === 0;
|
||||
}
|
||||
|
||||
/** Fills `out` with the block from `frame` on where `who` is the one heard, and silence elsewhere. */
|
||||
play(who: Owner, frame: number, out: Float32Array, knobs: AcidKnobs): void {
|
||||
if (this.blockAt !== frame || this.block.length !== out.length) this.render(frame, out.length, knobs);
|
||||
out.fill(0);
|
||||
this.turns.forEach((turn, i) => {
|
||||
if (turn.owner !== who) return;
|
||||
const to = this.turns[i + 1]?.from ?? out.length;
|
||||
out.set(this.block.subarray(turn.from, to), turn.from);
|
||||
});
|
||||
}
|
||||
|
||||
private render(frame: number, length: number, knobs: AcidKnobs): void {
|
||||
if (this.block.length !== length) this.block = new Float32Array(length);
|
||||
this.blockAt = frame;
|
||||
this.turns = [{ from: 0, owner: this.owner }];
|
||||
let done = 0;
|
||||
while (this.strikes.length > 0 && this.strikes[0]!.frame < frame + length) {
|
||||
const { frame: due, owner, note } = this.strikes.shift()!;
|
||||
const at = Math.max(done, Math.min(length, due - frame));
|
||||
this.run(done, at, knobs);
|
||||
done = at;
|
||||
this.synth.strike(note);
|
||||
this.owner = owner;
|
||||
this.turns.push({ from: at, owner });
|
||||
}
|
||||
this.run(done, length, knobs);
|
||||
}
|
||||
|
||||
private run(from: number, to: number, knobs: AcidKnobs): void {
|
||||
if (to <= from) return;
|
||||
if (this.owner === null) this.block.fill(0, from, to);
|
||||
else this.synth.render(this.block, from, to, knobs);
|
||||
}
|
||||
const q = acidQ(resonance);
|
||||
const re = 1 - w * w;
|
||||
const im = w / q;
|
||||
return -10 * Math.log10(Math.max(1e-18, re * re + im * im));
|
||||
}
|
||||
|
||||
const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
|
||||
|
||||
+19
-13
@@ -4,7 +4,7 @@ import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||||
import { booleanOf, type PropertyBag, textOf } from '../model/Properties';
|
||||
import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time';
|
||||
import type { FlatDef } from '../properties/PropertySchema';
|
||||
import { ACCENT_VELOCITY, type AcidShape, type AcidWave } from './acid';
|
||||
import type { AcidWave } from './acid';
|
||||
import type { ChipShape, ChipWave } from './chip';
|
||||
import { COMPRESSOR_LATENCY, compressorMakeup, limiterLatency } from './dynamics';
|
||||
import { LFO_SHAPES, type LfoShape, vibratoDelay } from './lfo';
|
||||
@@ -255,25 +255,29 @@ export type FxChipPlan = { wave: ChipWave } & Record<
|
||||
>;
|
||||
|
||||
/**
|
||||
* An Acid as a way of a note plays it (#421): the note's frequency with the tuning in it, whether it
|
||||
* is accented, and its knobs, the cutoff of which an Envelope can move. `id` is its node: the notes
|
||||
* of one Acid slide on a line of their own.
|
||||
* An Acid as a way of a note plays it (#421): the note's frequency and velocity, its ADSR and glide,
|
||||
* and its knobs, the cutoff of which an Envelope can move. `id` is its node: a note glides from the
|
||||
* one before of the same Acid and cuts it short.
|
||||
*/
|
||||
export interface FxAcid extends AcidShape {
|
||||
export interface FxAcid {
|
||||
id: string;
|
||||
wave: AcidWave;
|
||||
frequency: number;
|
||||
accented: boolean;
|
||||
velocity: number;
|
||||
cutoff: number;
|
||||
resonance: number;
|
||||
envMod: number;
|
||||
attack: number;
|
||||
decay: number;
|
||||
accent: number;
|
||||
sustain: number;
|
||||
release: number;
|
||||
glide: number;
|
||||
cutoffMod?: FxMod;
|
||||
}
|
||||
|
||||
/** An Acid with its numbers still open; `velocity` is the note's, which says whether it is accented. */
|
||||
/** An Acid with its numbers still open. */
|
||||
export type FxAcidPlan = { id: string; wave: AcidWave } & Record<
|
||||
'frequency' | 'tuning' | 'velocity' | 'slideTime' | 'cutoff' | 'resonance' | 'envMod' | 'decay' | 'accent',
|
||||
Exclude<keyof FxAcid, 'id' | 'wave' | 'cutoffMod'>,
|
||||
FxAmount
|
||||
>;
|
||||
|
||||
@@ -1609,14 +1613,16 @@ function acidOf(plan: FxAcidPlan, params: FxParams): FxAcid {
|
||||
return {
|
||||
id: plan.id,
|
||||
wave: plan.wave,
|
||||
frequency: value(plan.frequency) * 2 ** (value(plan.tuning) / 12),
|
||||
accented: value(plan.velocity) >= ACCENT_VELOCITY,
|
||||
slideTime: value(plan.slideTime),
|
||||
frequency: value(plan.frequency),
|
||||
velocity: value(plan.velocity),
|
||||
cutoff: value(plan.cutoff),
|
||||
resonance: value(plan.resonance),
|
||||
envMod: value(plan.envMod),
|
||||
attack: value(plan.attack),
|
||||
decay: value(plan.decay),
|
||||
accent: value(plan.accent),
|
||||
sustain: value(plan.sustain),
|
||||
release: value(plan.release),
|
||||
glide: value(plan.glide),
|
||||
...(cutoffMod && { cutoffMod }),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user