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:
1 parent
3443368855
commit
d844f2734a
18 files changed
+1615
-15
No files matched your search
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -638,6 +638,7 @@ describe('the sound library', () => {
|
||||
layers: 1,
|
||||
pitches: [0],
|
||||
stages: 0,
|
||||
slid: 0,
|
||||
duration: 1.5,
|
||||
at: 0,
|
||||
envelopes: 0,
|
||||
|
||||
Reference in new issue
Block a user