Add an Acid instrument node for TB-303 style bass lines (#421)

The instruments could only come near a 303 with a Chip, a Filter and
two envelopes. The biquad filter does not saturate, every note strikes
anew, and there was no slide and no accent.

- Acid is a new instrument node that plays a saw or a square through a
  four-pole ladder low-pass with tanh saturation, oversampled twice.
  Knobs: cutoff, resonance, env mod, decay, accent, tuning and slide
  time.
- An accent comes from the velocity, from 0.9 on. It is louder, opens
  the filter further with a shorter decay, and accents close behind
  each other build up.
- Two notes that touch or overlap slide. Each note stays a voice of
  its own, so the scheduler is unchanged. The notes of a line share
  one synth in the worklet, and the new voice takes it over from its
  first sample on.
- The node sets its own volume, so it needs no envelope behind it.
- The graph shows it as a device with the filter curve at rest and at
  the top of the sweep.
- A preset Acid Bass puts an Acid in front of a short echo.

Tests:
- tsc, eslint, Prettier clean
- Unit: 14 new tests in acid.test.ts for pitch, low-pass, resonance,
  sweep, accent, slide and the handover between two voices.
- e2e 34 on melektaus-atx: ALL PASSED, 148 checks (3 new). The
  handover sits on sample 4800 and jumps by 0.0012, of three notes
  only the second slides.
- Without the handover, or with a slide that strikes anew, the unit
  tests are red.
- Gate on melektaus-atx: tsc, eslint, Prettier clean, 4753 of 4754
  tests green. The red one was sound-library.test.ts, which did not
  know the new slid count in the stats. Fixed, and the file alone
  is green again with 128 tests. No second full gate.
- Not checked: the sound by ear, slide at the keyboard, a bake with
  Acid, the CPU load of many notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 11:08:57 +02:00
1 parent 3443368855
commit d844f2734a
18 files changed
+1615 -15

No files matched your search

+15 -1
View File
@@ -21,8 +21,10 @@ import {
SoundFxPitch,
} from '../soundfx/nodes';
import {
ACID_ROWS,
CHIP_ROWS,
ENVELOPE_ROWS,
INSTRUMENT_ACID,
INSTRUMENT_CHIP,
INSTRUMENT_ENVELOPE,
INSTRUMENT_NOTES,
@@ -58,6 +60,7 @@ export type TonePreset =
| 'pluck'
| 'triangleBass'
| 'pluckBass'
| 'acidBass'
| 'drumKit';
export type ChipPreset = EffectPreset | TonePreset;
@@ -76,6 +79,7 @@ export const TONE_PRESETS: readonly { value: TonePreset; label: string }[] = [
{ value: 'pluck', label: 'Pluck' },
{ value: 'triangleBass', label: 'Triangle Bass' },
{ value: 'pluckBass', label: 'Pluck Bass' },
{ value: 'acidBass', label: 'Acid Bass' },
{ value: 'drumKit', label: 'Drum Kit' },
];
@@ -396,7 +400,7 @@ const ROW = 340;
/**
* Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide,
* a Chip's device (#386) wider, an Envelope's (#388) a little less, a Pitch's and a Mix's (#390) two,
* a Delay's (#393), a Phaser's, a Flanger's (#398) and a Bitcrusher's (#399) four.
* a Delay's (#393), a Phaser's, a Flanger's (#398) and a Bitcrusher's (#399) four, an Acid's (#421) three.
*/
const COLUMNS_OF: Readonly<Record<string, number>> = {
[SoundFxFilter.type]: 4,
@@ -408,6 +412,7 @@ const COLUMNS_OF: Readonly<Record<string, number>> = {
[SoundFxMix.type]: 2,
[INSTRUMENT_CHIP]: 5,
[INSTRUMENT_ENVELOPE]: 3,
[INSTRUMENT_ACID]: 3,
};
/** The column right of a node, past all it takes. */
@@ -711,6 +716,15 @@ 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.
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),
});
s.wire(notes, 'any', acid, 'trigger');
return s.line(acid, echo(0.75, 0.25, 0.1));
},
};
/** An instrument preset: every note at its own pitch, the same graph each time it is picked. */
+27
View File
@@ -21,6 +21,7 @@ import { resolveAssetProps } from '../properties/resolve';
import { volumeOf } from '../properties/sounds';
import { soundFxBuildFor, soundFxKey } from '../soundfx/compile';
import {
type FxAcidPlan,
type FxAmount,
type FxChipPlan,
type FxEnvelopePlan,
@@ -41,9 +42,12 @@ import {
withTaps,
} from '../soundfx/nodes';
import {
ACID_ROWS,
acidWave,
CHIP_ROWS,
chipWave,
ENVELOPE_ROWS,
INSTRUMENT_ACID,
INSTRUMENT_CHIP,
INSTRUMENT_ENVELOPE,
INSTRUMENT_NOTES,
@@ -210,6 +214,11 @@ export function compileInstrument(
if (!trigger || !('on' in trigger) || !trigger.on) return { out: SILENT };
return { out: { plans: [{ pitch: [], stages: [], chip: chipOf(node, note, amounts) }] } };
}
case INSTRUMENT_ACID: {
const trigger = inputs.trigger;
if (!trigger || !('on' in trigger) || !trigger.on) return { out: SILENT };
return { out: { plans: [{ pitch: [], stages: [], acid: acidOf(node, note, amounts) }] } };
}
case INSTRUMENT_SOUND_FX: {
const fx = sources.soundFx(textOf(node.params.fx ?? ''));
const arriving = plansAt('in');
@@ -293,6 +302,24 @@ export function compileInstrument(
};
}
/** An Acid's numbers, held to its rows (#421): it always plays the note, `tuning` off it. */
function acidOf(node: GraphNode, played: number, amounts: Readonly<Record<string, FxAmount>>): FxAcidPlan {
const rows = ACID_ROWS;
return {
id: node.id,
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),
decay: held(amounts.decay, rows.decay),
accent: held(amounts.accent, rows.accent),
};
}
const output = graph.nodes.find((n) => n.type === INSTRUMENT_OUTPUT);
if (!output) return { layers: [], problems: walk.problems, reason: 'No Output node' };
// Saved while the Output still took any number of sounds: only the first would be heard.
+52
View File
@@ -11,6 +11,7 @@ import type { NodeDef, PortDef } from '../graph/NodeRegistry';
import { asNumber } from '../graph/values';
import { type PropertyBag, textOf } from '../model/Properties';
import type { FlatDef, PropertyDef } from '../properties/PropertySchema';
import type { AcidWave } from '../soundfx/acid';
import type { ChipWave } from '../soundfx/chip';
import { type FxChipPlan, soundFxParamPort } from '../soundfx/nodes';
import { HIGHEST_NOTE, heldNote, LOWEST_NOTE, MIDDLE_C, NOTE_OPTIONS, noteName } from './notes';
@@ -344,6 +345,56 @@ export const InstrumentChip: InstrumentNodeDef = {
},
};
export const INSTRUMENT_ACID = 'InstrumentAcid';
export const ACID_WAVES: { value: AcidWave; label: string }[] = [
{ value: 'saw', label: 'Saw' },
{ value: 'square', label: 'Square' },
];
const ACID_WAVE: PropertyDef = {
key: 'wave',
label: 'Wave',
kind: 'enum',
default: 'saw',
options: ACID_WAVES,
};
export function acidWave(params: PropertyBag): AcidWave {
return ACID_WAVES.find((option) => option.value === params[ACID_WAVE.key])?.value ?? 'saw';
}
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),
} satisfies Record<string, FlatDef<'number'>>;
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.',
inputs: [
triggerIn,
...Object.values(ACID_ROWS).map((row): PortDef =>
row.key === ACID_ROWS.cutoff.key ? { ...numberIn(row), followsEnvelope: true } : numberIn(row),
),
],
outputs: [audioOut],
params: [ACID_WAVE],
// Nothing sweeps the filter: there is nothing for the decay to let fall.
idleParams: (params, fed = () => false) =>
!fed('envMod') && !fed('decay') && asNumber(params.envMod ?? ACID_ROWS.envMod.default) <= 0
? [ACID_ROWS.decay.key]
: [],
};
export const InstrumentOutput: InstrumentNodeDef = {
type: INSTRUMENT_OUTPUT,
label: 'Output',
@@ -360,6 +411,7 @@ export const INSTRUMENT_NODES: readonly InstrumentNodeDef[] = [
InstrumentNotes,
InstrumentSample,
InstrumentChip,
InstrumentAcid,
InstrumentParameters,
InstrumentSoundFx,
InstrumentEnvelope,
+299
View File
@@ -0,0 +1,299 @@
/**
* 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 knobs come as an AudioParam hands them over: one value for the block or one per sample.
*/
export type AcidWave = 'saw' | 'square';
export interface AcidShape {
wave: AcidWave;
/** Seconds a slide takes to all but reach its note: 95 % of the way. */
slideTime: number;
}
/** Cutoff in Hz, resonance, env mod and accent 0 to 1, decay in seconds, pitch in semitones. */
export interface AcidKnobs {
cutoff: Float32Array;
resonance: Float32Array;
envMod: Float32Array;
decay: Float32Array;
accent: Float32Array;
pitch: 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 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;
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 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;
constructor(
private readonly shape: AcidShape,
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);
}
/** 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 };
}
/** 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;
}
out[i] = (sum / OVERSAMPLE) * OUTPUT;
}
}
/** 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;
}
/** One value of the wave, its corners rounded off by PolyBLEP so it does not alias. */
private oscillator(step: number): number {
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);
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.
*/
private ladder(x: number, fc: number, k: 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;
}
}
/** The correction PolyBLEP puts on a jump of the wave at phase 0, `step` the phase moved per sample. */
function blep(p: number, step: number): number {
if (p < step) {
const t = p / step;
return t + t - t * t - 1;
}
if (p > 1 - step) {
const t = (p - 1) / step;
return t * t + t + t + 1;
}
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).
*/
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 at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
const finite = (value: number | undefined, fallback: number) =>
value !== undefined && Number.isFinite(value) ? value : fallback;
const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
+51 -1
View File
@@ -4,6 +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 { ChipShape, ChipWave } from './chip';
import { COMPRESSOR_LATENCY, compressorMakeup, limiterLatency } from './dynamics';
import { LFO_SHAPES, type LfoShape, vibratoDelay } from './lfo';
@@ -253,6 +254,29 @@ export type FxChipPlan = { wave: ChipWave } & Record<
FxAmount
>;
/**
* 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.
*/
export interface FxAcid extends AcidShape {
id: string;
frequency: number;
accented: boolean;
cutoff: number;
resonance: number;
envMod: number;
decay: number;
accent: number;
cutoffMod?: FxMod;
}
/** An Acid with its numbers still open; `velocity` is the note's, which says whether it is accented. */
export type FxAcidPlan = { id: string; wave: AcidWave } & Record<
'frequency' | 'tuning' | 'velocity' | 'slideTime' | 'cutoff' | 'resonance' | 'envMod' | 'decay' | 'accent',
FxAmount
>;
/**
* A sound on its way through the graph: how many semitones it is played up or down, and what it
* passes, in order. `gain` is a factor, a filter's `gain` is in dB as Web Audio takes it. `shifted`
@@ -269,6 +293,7 @@ export type FxChain =
stages: readonly FxStage[];
source?: FxSource;
chip?: FxChip;
acid?: FxAcid;
pitchMods?: readonly FxMod[];
}
| { mix: number; curve?: MixCurve; a: FxChain; b: FxChain; stages: readonly FxStage[]; mixMod?: FxMod };
@@ -330,7 +355,13 @@ export type FxStagePlan =
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
export type FxPlan =
| { pitch: readonly FxAmount[]; stages: readonly FxStagePlan[]; source?: FxSource; chip?: FxChipPlan }
| {
pitch: readonly FxAmount[];
stages: readonly FxStagePlan[];
source?: FxSource;
chip?: FxChipPlan;
acid?: FxAcidPlan;
}
| { mix: FxAmount; curve?: MixCurve; a: FxPlan; b: FxPlan; stages: readonly FxStagePlan[] };
export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] };
@@ -1535,6 +1566,7 @@ export function fxChainOf(plan: FxPlan, params: FxParams = {}, tempo = DEFAULT_T
stages,
...(pitchMods.length > 0 && { pitchMods }),
...(plan.chip && { chip: chipOf(plan.chip, params) }),
...(plan.acid && { acid: acidOf(plan.acid, params) }),
};
return plan.source ? { ...chain, source: plan.source } : chain;
}
@@ -1571,6 +1603,24 @@ function chipOf(plan: FxChipPlan, params: FxParams): FxChip {
};
}
function acidOf(plan: FxAcidPlan, params: FxParams): FxAcid {
const value = (amount: FxAmount) => amountOf(amount, params);
const cutoffMod = modOf(plan.cutoff, params);
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),
cutoff: value(plan.cutoff),
resonance: value(plan.resonance),
envMod: value(plan.envMod),
decay: value(plan.decay),
accent: value(plan.accent),
...(cutoffMod && { cutoffMod }),
};
}
/** The stages that sound on after their input ends: an echo, and what holds the sound back on the worklet. */
const RINGING: ReadonlySet<FxStagePlan['kind']> = new Set(['delay', 'limit', 'vibrato', 'reverb']);