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>
This commit is contained in:
1 parent
d844f2734a
commit
f92d69d902
15 files changed
+645
-592
No files matched your search
+155
-166
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]!;
|
||||
|
||||
Reference in new issue
Block a user