Rebuild the Acid node after the acid voice of nodeseq (#421)
game-maker / gate (push) Successful in 4m23s
game-maker / suite (push) Failing after 18m20s

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:
adminandClaude Opus 5.5 committed 2026-10-13 12:39:01 +02:00
1 parent d844f2734a
commit f92d69d902
15 files changed
+645 -592

No files matched your search

+155 -166
View File
@@ -1,21 +1,27 @@
/**
* 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.
* The Acid of an instrument (#421): nodeseq's acid bass rendered sample by sample as its worklet
* does it – the oscillator, the resonant low-pass, the ADSR on volume and filter alike, the glide
* and the cut – 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 {
ACID_ROWS,
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,
acidQ,
acidResponseDb,
type AcidShape,
AcidSynth,
acidTopHz,
SUSTAIN_SWEEP,
} from '../../src/core/soundfx/acid';
import { fxChainOf } from '../../src/core/soundfx/nodes';
import { levelsOf } from '../../src/viewport/fxNodes';
@@ -23,49 +29,57 @@ 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),
};
}
type Knobs = Partial<Record<'cutoff' | 'resonance' | 'envMod' | 'pitch', number>>;
const note = (frequency: number, more: Partial<AcidNote> = {}): AcidNote => ({
wave: 'saw',
frequency,
accented: false,
slide: false,
from: null,
glide: 0,
attack: 0.003,
decay: 0.16,
sustain: 1,
release: 0.08,
velocity: 1,
...more,
});
/** `seconds` of a synth struck with `first`, in blocks; `then` strikes more notes at their second. */
/**
* `seconds` of a synth playing `played`, in blocks: the note ends at `endAt` and is cut at `cutAt`,
* each a second or never. Says what it played, its state after, and the second it stopped at.
*/
function render(
set: Partial<Record<keyof AcidKnobs, number>>,
first: AcidNote,
set: Knobs,
played: AcidNote,
seconds: number,
then: { at: number; note: AcidNote }[] = [],
shape: AcidShape = SAW,
{ endAt = Infinity, cutAt = Infinity }: { endAt?: number; cutAt?: number } = {},
) {
const synth = new AcidSynth(shape, RATE);
synth.strike(first);
const synth = new AcidSynth(played, RATE);
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);
const one = (value: number) => new Float32Array([value]);
let stopped = Infinity;
for (let done = 0; done < out.length; done += BLOCK) {
const block = out.subarray(done, done + BLOCK);
// Per sample, as an AudioParam hands over one that is set on the clock.
const switched = (at: number, before: number) =>
Float32Array.from(block, (_, i) => ((done + i) / RATE < at ? before : 1 - before));
const knobs: AcidKnobs = {
cutoff: one(set.cutoff ?? 500),
resonance: one(set.resonance ?? 0),
envMod: one(set.envMod ?? 0),
pitch: one(set.pitch ?? 0),
gate: switched(endAt, 1),
cut: switched(cutAt, 0),
};
if (!synth.render(block, knobs)) {
let last = block.length - 1;
while (last > 0 && block[last] === 0) last -= 1;
stopped = (done + last + 1) / RATE;
break;
}
}
return { out, synth };
return { out, synth, stopped };
}
/** How loud `hz` is in a stretch of the sound: the Goertzel magnitude through a Hann window, per sample. */
@@ -90,6 +104,8 @@ const peak = (out: Float32Array, from = 0, to = out.length) => {
return most;
};
const db = (value: number) => 20 * Math.log10(value);
describe('the Acid synth (#421)', () => {
it('plays a saw at the frequency of its note', () => {
const { out } = render({ cutoff: 8000 }, note(110), 0.5);
@@ -99,132 +115,94 @@ describe('the Acid synth (#421)', () => {
expect(peak(out)).toBeLessThan(1);
});
it('takes the highs away below its cutoff', () => {
it('takes the highs away above 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);
it('turns the resonance into a Q in dB as nodeseq does, 0.5 to 17.5', () => {
expect(db(acidQ(0))).toBeCloseTo(0.5, 6);
expect(db(acidQ(0.78))).toBeCloseTo(13.76, 6);
expect(db(acidQ(1))).toBeCloseTo(17.5, 6);
// A two-pole low-pass passes its cutoff Q times as loud.
expect(acidResponseDb(1000, 1000, 1)).toBeCloseTo(17.5, 6);
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);
expect(acidResponseDb(10000, 1000, 0)).toBeCloseTo(-40, 0);
});
});
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);
}
it('rings at its cutoff as high as the filter it draws', () => {
// The 18th harmonic of 55 Hz sits right at the cutoff; a saw's harmonics fall as 1/n.
for (const resonance of [0, 0.78, 1]) {
const { out } = render({ cutoff: 990, resonance }, note(55), 0.8);
const heard = db(energyAt(out, 990, 14400) / energyAt(out, 55, 14400));
const drawn = acidResponseDb(990, 990, resonance) - db(18) - acidResponseDb(55, 990, resonance);
expect(heard).toBeGreaterThan(drawn - 1);
expect(heard).toBeLessThan(drawn + 1);
}
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('shapes its volume as nodeseq automates it: a straight attack, then setTargetAtTime', () => {
const played = note(110, { attack: 0.01, decay: 0.3, sustain: 0.25, velocity: 0.5 });
// nodeseq's level, with Chrome's sawtooth 0.85 of a plain one, at velocity 0.5.
const top = 0.32 * 0.85 * 0.5;
const ampAt = (seconds: number) => render({ cutoff: 8000 }, played, seconds).synth.state.amp;
expect(ampAt(0.005)).toBeCloseTo(top / 2, 3);
// A decay's time constant is a third of it: after the whole decay e^-3 of the way is left.
const held = top * 0.25;
expect(ampAt(0.01 + 0.3)).toBeCloseTo(held + (top - held) * Math.exp(-3), 3);
expect(ampAt(2)).toBeCloseTo(held, 4);
});
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);
it('opens the filter in the attack, lets it fall to where the sustain holds it, and closes it in the release', () => {
const played = note(110, { attack: 0.005, decay: 0.2, sustain: 0.5, release: 0.2 });
const envAt = (seconds: number, endAt = Infinity) =>
render({ cutoff: 200, envMod: 0.6 }, played, seconds, { endAt }).synth.state.env;
expect(envAt(0.005)).toBeCloseTo(1, 2);
expect(envAt(1.5)).toBeCloseTo(0.5 * SUSTAIN_SWEEP, 3);
// A release's time constant is a quarter of it.
expect(envAt(1.5 + 0.05, 1.5)).toBeCloseTo(0.5 * SUSTAIN_SWEEP * Math.exp(-1), 2);
expect(acidTopHz(200, 0.6)).toBeCloseTo(200 * 2 ** 3, 6);
expect(acidTopHz(4000, 1)).toBe(18000);
// And it is heard: the highs are there at the top of the sweep and gone once it fell.
const { out } = render({ cutoff: 200, envMod: 1 }, note(110, { sustain: 0 }), 0.6);
expect(energyAt(out, 1100, 0, 1440)).toBeGreaterThan(energyAt(out, 1100, 21600, 24000) * 10);
});
it('plays out its release when the note ends and then stops by itself', () => {
const { out, synth, stopped } = render({ cutoff: 2000 }, note(110, { release: 0.1 }), 1, { endAt: 0.2 });
expect(synth.state.released).toBe(true);
expect(peak(out, 0.2 * RATE, 0.21 * RATE)).toBeGreaterThan(0.05);
// As nodeseq stops its oscillator: so many releases and 50 ms after the end.
expect(stopped).toBeCloseTo(0.2 + 0.1 * 1.6 + 0.05, 2);
expect(peak(out, Math.ceil(stopped * RATE))).toBe(0);
});
it('glides from the note before, evenly in octaves, and strikes anew', () => {
const gliding = note(220, { from: 110, glide: 0.05 });
const hzAt = (seconds: number, played = gliding) => render({}, played, seconds).synth.state.hz;
expect(hzAt(0.025)).toBeCloseTo(110 * Math.SQRT2, 0);
expect(hzAt(0.06)).toBeCloseTo(220, 6);
expect(hzAt(0.001, note(220))).toBeCloseTo(220, 6);
expect(hzAt(0.001, note(220, { from: 110, glide: 0 }))).toBeCloseTo(220, 6);
// The sweep starts at the top again: a glide is not a slide that runs on.
expect(render({ envMod: 1 }, gliding, 0.003).synth.state.env).toBeCloseTo(1, 1);
});
it('falls silent within 60 ms when the next note cuts it short', () => {
const { out, synth, stopped } = render({ cutoff: 2000 }, note(110), 0.5, { cutAt: 0.1 });
expect(synth.state.cut).toBe(true);
expect(stopped).toBeCloseTo(0.16, 2);
expect(peak(out, 0.15 * RATE, 0.16 * RATE)).toBeLessThan(peak(out, 0.09 * RATE, 0.1 * RATE) / 100);
});
it('stays finite at full resonance and sweep, high and low', () => {
for (const hz of [30, 55, 880, 4000]) {
const { out } = render({ cutoff: 18000, resonance: 1, envMod: 1 }, note(hz, { sustain: 0.5 }), 0.5);
expect(out.every(Number.isFinite)).toBe(true);
expect(peak(out)).toBeLessThan(4);
}
});
});
@@ -241,35 +219,46 @@ describe('the Acid node (#421)', () => {
],
});
it('plays the note, tuned, with its knobs, and is accented by a hard velocity', () => {
it("plays the note with nodeseq's acid by default, as loud as its velocity", () => {
const sources = instrumentSourcesOf(makeProject().project);
const build = compileInstrument(graph({ wave: 'square', tuning: 12, cutoff: 900 }), 60, sources);
const build = compileInstrument(graph({ wave: 'square', 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({
const chain = fxChainOf(layer!, { '@velocity': 0.7 });
if ('mix' in chain) throw new Error('one way');
expect(chain.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,
resonance: 0.78,
envMod: 0.62,
attack: 0.003,
decay: 0.16,
sustain: 0.15,
release: 0.08,
glide: 0.05,
velocity: 0.7,
});
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]);
expect(chain.acid!.frequency).toBeCloseTo(noteFrequency(60), 6);
// What moves as the note plays: the cutoff, the resonance and the env mod.
expect(levelsOf(chain)).toEqual([900, 0.78, 0.62]);
});
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 [layer] = compileInstrument(
graph({ cutoff: 99999, resonance: 3, attack: 0, sustain: 2, glide: -1 }),
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 });
expect(chain.acid).toMatchObject({
cutoff: ACID_ROWS.cutoff.max,
resonance: 1,
attack: ACID_ROWS.attack.min,
sustain: 1,
glide: 0,
});
});
});
+1 -1
View File
@@ -249,7 +249,7 @@ describe('an instrument preset', () => {
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]!.acid).toMatchObject({ wave: 'saw', cutoff: 250, resonance: 0.78 });
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]!;