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>
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
/**
|
||
* 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,
|
||
});
|
||
});
|
||
});
|