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

+275
View File
@@ -0,0 +1,275 @@
/**
* The Acid of an instrument (#421): a TB-303 line rendered sample by sample as its worklet does it,
* the line that lets a note slide out of the one before, and the node that makes it.
*/
import { describe, expect, it } from 'vitest';
import { compileInstrument, instrumentSourcesOf } from '../../src/core/instrument/compile';
import { INSTRUMENT_ACID, INSTRUMENT_NOTES, INSTRUMENT_OUTPUT } from '../../src/core/instrument/nodes';
import { noteFrequency } from '../../src/core/instrument/notes';
import type { Graph } from '../../src/core/graph/Graph';
import type { PropertyBag } from '../../src/core/model/Properties';
import {
ACCENT_VELOCITY,
type AcidKnobs,
AcidLine,
type AcidNote,
acidResponseDb,
type AcidShape,
AcidSynth,
} from '../../src/core/soundfx/acid';
import { fxChainOf } from '../../src/core/soundfx/nodes';
import { levelsOf } from '../../src/viewport/fxNodes';
import { makeProject } from '../helpers';
const RATE = 48000;
const BLOCK = 128;
const SAW: AcidShape = { wave: 'saw', slideTime: 0.06 };
function knobs(set: Partial<Record<keyof AcidKnobs, number>> = {}): AcidKnobs {
const one = (value: number) => new Float32Array([value]);
return {
cutoff: one(set.cutoff ?? 500),
resonance: one(set.resonance ?? 0),
envMod: one(set.envMod ?? 0),
decay: one(set.decay ?? 0.5),
accent: one(set.accent ?? 0),
pitch: one(set.pitch ?? 0),
};
}
const note = (frequency: number, more: Partial<AcidNote> = {}): AcidNote => ({
frequency,
accented: false,
slide: false,
...more,
});
/** `seconds` of a synth struck with `first`, in blocks; `then` strikes more notes at their second. */
function render(
set: Partial<Record<keyof AcidKnobs, number>>,
first: AcidNote,
seconds: number,
then: { at: number; note: AcidNote }[] = [],
shape: AcidShape = SAW,
) {
const synth = new AcidSynth(shape, RATE);
synth.strike(first);
const out = new Float32Array(Math.round(seconds * RATE));
const k = knobs(set);
const due = [...then];
let done = 0;
while (done < out.length) {
const next = due[0] ? Math.round(due[0].at * RATE) : Infinity;
const to = Math.min(out.length, done + BLOCK, next);
synth.render(out, done, to, k);
done = to;
if (done === next) synth.strike(due.shift()!.note);
}
return { out, synth };
}
/** How loud `hz` is in a stretch of the sound: the Goertzel magnitude through a Hann window, per sample. */
function energyAt(out: Float32Array, hz: number, from = 0, to = out.length): number {
const w = (2 * Math.PI * hz) / RATE;
const c = 2 * Math.cos(w);
let s1 = 0;
let s2 = 0;
const n = to - from;
for (let i = from; i < to; i++) {
const hann = 0.5 - 0.5 * Math.cos((2 * Math.PI * (i - from)) / (n - 1));
const s0 = out[i]! * hann + c * s1 - s2;
s2 = s1;
s1 = s0;
}
return Math.sqrt(s1 * s1 + s2 * s2 - c * s1 * s2) / (to - from);
}
const peak = (out: Float32Array, from = 0, to = out.length) => {
let most = 0;
for (let i = from; i < to; i++) most = Math.max(most, Math.abs(out[i]!));
return most;
};
describe('the Acid synth (#421)', () => {
it('plays a saw at the frequency of its note', () => {
const { out } = render({ cutoff: 8000 }, note(110), 0.5);
const at = energyAt(out, 110, 4800);
expect(at).toBeGreaterThan(energyAt(out, 117, 4800) * 10);
expect(energyAt(out, 220, 4800)).toBeGreaterThan(at * 0.2);
expect(peak(out)).toBeLessThan(1);
});
it('takes the highs away below its cutoff', () => {
const open = render({ cutoff: 6000 }, note(110), 0.3).out;
const shut = render({ cutoff: 200 }, note(110), 0.3).out;
const high = (out: Float32Array) => energyAt(out, 1100, 4800);
expect(high(shut)).toBeLessThan(high(open) / 30);
});
it('rings at its cutoff the more, the higher the resonance', () => {
// The 8th harmonic of 110 Hz sits right at the cutoff.
const flat = render({ cutoff: 880, resonance: 0 }, note(110), 0.4).out;
const ringing = render({ cutoff: 880, resonance: 0.9 }, note(110), 0.4).out;
const ratio = (out: Float32Array) => energyAt(out, 880, 4800) / energyAt(out, 110, 4800);
expect(ratio(ringing)).toBeGreaterThan(ratio(flat) * 4);
});
it('stays finite and in range at full resonance, sweep and accent', () => {
const { out } = render(
{ cutoff: 12000, resonance: 1, envMod: 1, accent: 1, decay: 2 },
note(55, { accented: true }),
3,
[0.5, 1, 1.5, 2].map((at, i) => ({ at, note: note(55 * 2 ** (i / 3), { accented: true }) })),
);
expect(out.every(Number.isFinite)).toBe(true);
expect(peak(out)).toBeLessThan(1.5);
});
it('sweeps its filter open on every note and lets it fall in the decay', () => {
const { out } = render({ cutoff: 200, envMod: 1, decay: 0.4 }, note(110), 0.6);
const early = energyAt(out, 1100, 480, 2880);
const late = energyAt(out, 1100, 21600, 24000);
expect(early).toBeGreaterThan(late * 10);
});
it('makes an accented note louder and builds up on accents close behind each other', () => {
const plain = render({ envMod: 0.5, accent: 1 }, note(110), 0.1).out;
const accented = render({ envMod: 0.5, accent: 1 }, note(110, { accented: true }), 0.1).out;
expect(peak(accented)).toBeGreaterThan(peak(plain) * 1.5);
// A sixteenth at 120 BPM apart: the second accent starts with the first one's charge in it.
const step = 0.125;
const top = 0.04;
const one = render({ accent: 1 }, note(110, { accented: true }), top).synth.state.charge;
const two = render({ accent: 1 }, note(110, { accented: true }), step + top, [
{ at: step, note: note(110, { accented: true }) },
]).synth.state.charge;
expect(two).toBeGreaterThan(one * 1.2);
});
it('glides to a note it slides to, without striking anew', () => {
const shape = { wave: 'saw' as const, slideTime: 0.06 };
const { synth } = render({}, note(110), 0.2, [{ at: 0.1, note: note(220, { slide: true }) }], shape);
expect(synth.state.hz).toBeGreaterThan(215);
const midway = render({}, note(110), 0.12, [{ at: 0.1, note: note(220, { slide: true }) }], shape);
expect(midway.synth.state.hz).toBeGreaterThan(130);
expect(midway.synth.state.hz).toBeLessThan(200);
// The sweep runs on: it fell for 0.12 s and was not set back to the top.
expect(midway.synth.state.sweep).toBeLessThan(0.5);
const struck = render({}, note(110), 0.1005, [{ at: 0.1, note: note(220) }], shape);
expect(struck.synth.state.hz).toBeCloseTo(220, 3);
expect(struck.synth.state.sweep).toBeGreaterThan(0.9);
});
it('never slides on its first note', () => {
const { synth } = render({}, note(220, { slide: true }), 0.01);
expect(synth.state.hz).toBeCloseTo(220, 3);
expect(synth.state.sweep).toBeGreaterThan(0.9);
});
it('draws a low-pass that loses 12 dB at its cutoff and peaks there with resonance', () => {
expect(acidResponseDb(1000, 1000, 0)).toBeCloseTo(-12.04, 1);
expect(acidResponseDb(50, 1000, 0)).toBeCloseTo(0, 1);
expect(acidResponseDb(1000, 1000, 1)).toBeGreaterThan(acidResponseDb(50, 1000, 1) + 12);
expect(acidResponseDb(8000, 1000, 0)).toBeLessThan(-60);
});
});
describe('the line of an Acid (#421)', () => {
/** Two notes on a line, the second sliding in at `slideAt`, played block by block in the order given. */
function played(slideAt: number, blocks: number, order: 'first' | 'second') {
const line = new AcidLine<'a' | 'b'>(new AcidSynth(SAW, RATE));
line.strike('a', 0, note(110));
line.strike('b', slideAt, note(165, { slide: true }));
const a = new Float32Array(blocks * BLOCK);
const b = new Float32Array(blocks * BLOCK);
const k = knobs({ cutoff: 2000 });
for (let n = 0; n < blocks; n++) {
const frame = n * BLOCK;
const into = (out: Float32Array) => out.subarray(frame, frame + BLOCK);
if (order === 'first') {
line.play('a', frame, into(a), k);
line.play('b', frame, into(b), k);
} else {
line.play('b', frame, into(b), k);
line.play('a', frame, into(a), k);
}
}
return { a, b };
}
it('hands the synth to the note that slides in, from its first sample on', () => {
const { a, b } = played(200, 4, 'first');
expect(peak(a, 0, 200)).toBeGreaterThan(0.05);
expect(peak(a, 200)).toBe(0);
expect(peak(b, 0, 200)).toBe(0);
expect(peak(b, 200)).toBeGreaterThan(0.05);
// One synth: what both hear is what it plays with both notes struck in turn.
const reference = render({ cutoff: 2000 }, note(110), (4 * BLOCK) / RATE, [
{ at: 200 / RATE, note: note(165, { slide: true }) },
]).out;
const heard = a.map((value, i) => value + b[i]!);
expect(Array.from(heard)).toEqual(Array.from(reference));
});
it('plays the same whichever note is run first in a block', () => {
const one = played(300, 5, 'first');
const two = played(300, 5, 'second');
expect(Array.from(two.a)).toEqual(Array.from(one.a));
expect(Array.from(two.b)).toEqual(Array.from(one.b));
});
it('is silent before its first note and says when its last one has gone', () => {
const line = new AcidLine<'a'>(new AcidSynth(SAW, RATE));
line.strike('a', 1000, note(110));
const out = new Float32Array(BLOCK);
line.play('a', 0, out, knobs());
expect(peak(out)).toBe(0);
expect(line.leave('a')).toBe(true);
});
});
describe('the Acid node (#421)', () => {
const graph = (params: PropertyBag = {}): Graph => ({
nodes: [
{ id: 'notes', type: INSTRUMENT_NOTES, x: 0, y: 0, params: {} },
{ id: 'acid', type: INSTRUMENT_ACID, x: 200, y: 0, params },
{ id: 'out', type: INSTRUMENT_OUTPUT, x: 900, y: 0, params: {} },
],
edges: [
{ id: 'e1', from: { node: 'notes', port: 'any' }, to: { node: 'acid', port: 'trigger' } },
{ id: 'e2', from: { node: 'acid', port: 'out' }, to: { node: 'out', port: 'in' } },
],
});
it('plays the note, tuned, with its knobs, and is accented by a hard velocity', () => {
const sources = instrumentSourcesOf(makeProject().project);
const build = compileInstrument(graph({ wave: 'square', tuning: 12, cutoff: 900 }), 60, sources);
expect(build.reason).toBe('');
const [layer] = build.layers;
const soft = fxChainOf(layer!, { '@velocity': 0.8 });
const hard = fxChainOf(layer!, { '@velocity': ACCENT_VELOCITY });
if ('mix' in soft || 'mix' in hard) throw new Error('one way');
expect(soft.acid).toMatchObject({
id: 'acid',
wave: 'square',
cutoff: 900,
resonance: 0.7,
envMod: 0.5,
decay: 0.5,
accent: 0.6,
slideTime: 0.06,
accented: false,
});
expect(soft.acid!.frequency).toBeCloseTo(noteFrequency(72), 6);
expect(hard.acid!.accented).toBe(true);
expect(levelsOf(soft)).toEqual([900, 0.7, 0.5, 0.5, 0.6]);
});
it('holds its knobs to their rows', () => {
const sources = instrumentSourcesOf(makeProject().project);
const [layer] = compileInstrument(graph({ cutoff: 99999, resonance: 3, decay: 0 }), 60, sources).layers;
const chain = fxChainOf(layer!, {});
if ('mix' in chain) throw new Error('one way');
expect(chain.acid).toMatchObject({ cutoff: 12000, resonance: 1, decay: 0.2 });
});
});
+13 -2
View File
@@ -66,7 +66,7 @@ describe('a preset (#329)', () => {
...TONE_PRESETS.map((p) => p.value),
...EFFECT_PRESETS.map((p) => p.value),
]);
expect(TONE_PRESETS).toHaveLength(15);
expect(TONE_PRESETS).toHaveLength(16);
});
it('of sfxr is a chain from every note through Chip, Envelope, Filter and Bitcrusher, ending by itself', () => {
@@ -205,7 +205,8 @@ describe('an instrument preset', () => {
const STRUCK = new Set(['bell', 'pluck', 'drumKit']);
it('plays every key at its own pitch, without a problem, and holds it unless it is struck', () => {
for (const { value } of TONE_PRESETS.filter((p) => p.value !== 'drumKit')) {
// The Acid Bass plays no Chip (#421): it has a check of its own below.
for (const { value } of TONE_PRESETS.filter((p) => p.value !== 'drumKit' && p.value !== 'acidBass')) {
const graph = chipPresetGraph(value);
const [c4, c5] = [60, 72].map((note) => build(graph, note));
expect(c4!.reason, value).toBe('');
@@ -240,6 +241,16 @@ describe('an instrument preset', () => {
arpeggio: 12,
repeat: 0.08,
});
// Acid Bass (#421): an Acid at the pitch of the note, into a Delay.
const acid = [60, 72].map((note) => {
const built = compileInstrument(chipPresetGraph('acidBass'), note, sources());
expect(built.problems).toEqual({});
const chain = fxChainOf(built.layers[0]!);
return 'mix' in chain ? null : chain;
});
expect(acid[1]!.acid!.frequency / acid[0]!.acid!.frequency).toBeCloseTo(2, 6);
expect(acid[0]!.acid).toMatchObject({ wave: 'saw', cutoff: 300, resonance: 0.8 });
expect(acid[0]!.stages.map((stage) => stage.kind)).toEqual(['delay']);
// Phased Pad: a saw through a Phaser of six stages (#341).
const pad = build(chipPresetGraph('phasedPad'), 60).layers[0]!;
expect(fxChainOf(pad).stages.at(-1)).toMatchObject({ kind: 'phaser', stages: 6, frequency: 600, mix: 1 });
+1
View File
@@ -638,6 +638,7 @@ describe('the sound library', () => {
layers: 1,
pitches: [0],
stages: 0,
slid: 0,
duration: 1.5,
at: 0,
envelopes: 0,