Files
adminandClaude Opus 5.5 f92d69d902
game-maker / gate (push) Successful in 4m23s
game-maker / suite (push) Failing after 18m20s
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>
2026-10-13 12:39:01 +02:00

265 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 {
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 {
type AcidKnobs,
type AcidNote,
acidQ,
acidResponseDb,
AcidSynth,
acidTopHz,
SUSTAIN_SWEEP,
} 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;
type Knobs = Partial<Record<'cutoff' | 'resonance' | 'envMod' | 'pitch', number>>;
const note = (frequency: number, more: Partial<AcidNote> = {}): AcidNote => ({
wave: 'saw',
frequency,
from: null,
glide: 0,
attack: 0.003,
decay: 0.16,
sustain: 1,
release: 0.08,
velocity: 1,
...more,
});
/**
* `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: Knobs,
played: AcidNote,
seconds: number,
{ endAt = Infinity, cutAt = Infinity }: { endAt?: number; cutAt?: number } = {},
) {
const synth = new AcidSynth(played, RATE);
const out = new Float32Array(Math.round(seconds * RATE));
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, stopped };
}
/** 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;
};
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);
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 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('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(10000, 1000, 0)).toBeCloseTo(-40, 0);
});
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);
}
});
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('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);
}
});
});
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 with nodeseq's acid by default, as loud as its velocity", () => {
const sources = instrumentSourcesOf(makeProject().project);
const build = compileInstrument(graph({ wave: 'square', cutoff: 900 }), 60, sources);
expect(build.reason).toBe('');
const [layer] = build.layers;
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.78,
envMod: 0.62,
attack: 0.003,
decay: 0.16,
sustain: 0.15,
release: 0.08,
glide: 0.05,
velocity: 0.7,
});
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, 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: ACID_ROWS.cutoff.max,
resonance: 1,
attack: ACID_ROWS.attack.min,
sustain: 1,
glide: 0,
});
});
});