Files
feuerwerk/tests/core/instrument.test.ts
T
adminandClaude Opus 5.5 687377682c Draw the EQ's curve in its node and a spectrum behind it (#375, #374)
Both were asked for in #367 "for later". The node of a Parametric EQ
now shows its curve in small, and its tab shows what passes the node
while it is seen.

#375, the curve in the node:

- NodeDef has an optional `picture`: a height, and shapes made from
  the settings as plain point lists (guide, area, line). It is
  general, the Parametric EQ is its first user.
- NodeView draws it in a box below the rows, again only when the box
  or the settings change. A drag in the EQ tab changes them, so the
  node follows it live.
- Without a band the box stays, flat on its 0 dB line, so the node
  does not grow at the first band.
- The axes of the curve, `eqCurvePoints` and `fillShapes` move from
  editor/soundfx/eqView to core/soundfx/parametricEq. Tab and node
  draw the sum from the same function.

#374, the spectrum:

- A node marked `tapped` gets a `tap` stage in front of its own
  stages and one behind them when compiled, in a sound FX and in an
  instrument alike. Its id is asset and node, since a copied asset
  keeps its node ids.
- A tap is a gain of 1. SpectrumTaps (new, viewport/spectrum.ts)
  holds the tap of every voice and bus while it plays.
- While the EQ tab is seen, in the tab row or in a window, the taps
  of that node feed one analyser per side (8192 points). Nothing is
  made before a tap of the node plays, so no AudioContext is opened
  early.
- The tab draws both behind the curve, on a scale of 0 to -96 dBFS:
  the sound coming in faint and grey, the sound going on in the
  colour of a sound port. The analyser smooths it, and once the
  sound has gone the sum still hands it silence, so it falls away.
- Once the tab is not seen, Editor.tick lets the analysers go, like
  the meters of the Song tab.
- A tap does nothing to the sound, so `stats().last.stages` leaves it
  out, and a bypassed EQ keeps its taps.

Decided on my own:

- Every sound passing the node counts: Play of the sound FX tab, an
  instrument's keys, a song, a run. The issue proposed that.
- The spectrum has no labels of its own. Its scale is in the wiki.

Tests:

  Gate (melektaus-atx)     275 files, 4247 tests (11 new), EXIT 0
  e2e 34 (melektaus-atx)   ALL PASSED, 98 checks (2 new), twice

Counter-checks, each put back by copy and compared with cmp:

- The node's curve drawn without its bands: 2 unit tests fail.
- The spectrum without the loudest bin between two points: 1 fails.
- SpectrumTaps.stop leaving the taps linked: 1 fails.
- Taps that never feed the analyser: e2e 34 fails in exactly the
  spectrum check (frames 0). Run in the container only.

The screenshots of the second run were looked at: the blip as one
peak behind the curve, and the dip at 4 kHz in the node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:58:53 +02:00

647 lines
28 KiB
TypeScript
Raw 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.
/**
* An instrument (#331) is a graph that makes a note a sound: Notes nodes bring the note in, Samples
* play sounds for it, the effects of a sound FX shape and mix them, and the Output takes one. It is compiled
* once per note; a trigger is on only for the notes it stands for.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, CreateSoundCommand } from '../../src/core/commands/assetCommands';
import { ConnectCommand } from '../../src/core/commands/graphCommands';
import {
AddPortCommand,
RenamePortCommand,
SetPortDefaultCommand,
} from '../../src/core/commands/portCommands';
import { callProblem, resolveDef, soundFxParamsMismatch } from '../../src/core/graph';
import { createEmptyGraph, portsCompatible } from '../../src/core/graph/Graph';
import {
compileInstrument,
instrumentBuildFor,
instrumentSourcesOf,
} from '../../src/core/instrument/compile';
import { VELOCITY_PARAM } from '../../src/core/instrument/nodes';
import { heldNote, noteFrequency, noteName } from '../../src/core/instrument/notes';
import type { BehaviorAsset, InstrumentAsset, SoundFxAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { graphLookup } from '../../src/core/model/graphLookup';
import { fxChainOf, type FxPlan, tapId } from '../../src/core/soundfx/nodes';
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
import { keyboardLayout, noteAt, trackerNote } from '../../src/editor/instrument/keys';
import type { AudioSink } from '../../src/runtime/systems/Audio';
import { playNote } from '../../src/runtime/systems/behaviorContext';
import { graphEditor, makeProject, type ProjectFixture, roundTrip } from '../helpers';
const KICK = `${'a1'.repeat(32)}.wav`;
const SNARE = `${'b2'.repeat(32)}.wav`;
function makeInstrument(f: ProjectFixture = makeProject()) {
const command = new CreateAssetCommand('instrument', 'Piano');
f.stack.execute(command);
const instrument = command.asset as InstrumentAsset;
const sound = (name: string, file: string) => {
const made = new CreateSoundCommand(name, file);
f.stack.execute(made);
return made.asset;
};
return { ...f, instrument, sound, edit: graphEditor(f, instrument as unknown as BehaviorAsset) };
}
/** An instrument with nothing in it yet, for a test that builds its own graph. */
function emptyInstrument() {
const f = makeInstrument();
f.instrument.graph = createEmptyGraph();
return f;
}
const nodeOf = (f: { instrument: InstrumentAsset }, type: string) =>
f.instrument.graph.nodes.find((n) => n.type === type)!;
const compile = (f: ProjectFixture & { instrument: InstrumentAsset }, note: number) =>
compileInstrument(f.instrument.graph, note, instrumentSourcesOf(f.project));
const way = (plan: FxPlan | undefined) => (plan && 'pitch' in plan ? plan : undefined);
describe('notes', () => {
it('count as MIDI does: C4 is 60, A4 is 440 Hz, the lowest is C-1, sharps only', () => {
expect(noteName(60)).toBe('C4');
expect(noteName(61)).toBe('C#4');
expect(noteName(0)).toBe('C-1');
expect(noteName(127)).toBe('G9');
expect(noteFrequency(69)).toBe(440);
expect(noteFrequency(81)).toBeCloseTo(880, 9);
expect(heldNote(200)).toBe(127);
expect(heldNote(Number.NaN)).toBe(60);
});
});
describe('the instrument asset', () => {
it('is not placeable and starts as Notes into a Sample into the Output', () => {
expect(isPlaceableType('instrument')).toBe(false);
const f = makeInstrument();
expect(f.instrument.graph.nodes.map((n) => n.type)).toEqual([
'InstrumentNotes',
'InstrumentSample',
'InstrumentOutput',
]);
expect(f.instrument.graph.edges.map((e) => `${e.from.port}>${e.to.port}`)).toEqual([
'any>trigger',
'out>in',
]);
// Its Sample has no sound yet, and says so.
expect(compile(f, 60)).toMatchObject({ layers: [], reason: 'No sound picked' });
});
it('survives a round trip, keeping its own nodes and the effects it shares with a sound FX', () => {
const f = makeInstrument();
f.edit.addNode('SoundFxGain', { gain: -6 });
const back = roundTrip(f.project).assets.find((a) => a.id === f.instrument.id) as InstrumentAsset;
expect(back.graph.nodes.map((n) => n.type)).toEqual([
'InstrumentNotes',
'InstrumentSample',
'InstrumentOutput',
'SoundFxGain',
]);
expect(back.graph.edges).toHaveLength(2);
});
it('offers its own nodes and the effects, but no Input and no second Output', () => {
const f = makeInstrument();
const offered = paletteEntries(f.project, f.instrument).map((e) => e.id);
expect(offered).toEqual(
expect.arrayContaining(['InstrumentNotes', 'InstrumentSample', 'InstrumentParameters', 'SoundFxDelay']),
);
expect(offered).not.toContain('InstrumentOutput');
expect(offered).not.toContain('SoundFxInput');
expect(offered).not.toContain('SoundFxOutput');
});
});
describe('compiling a note', () => {
it('plays a Sample at its root as it is, and other notes shifted by the distance', () => {
const f = makeInstrument();
const piano = f.sound('Piano C4', KICK);
nodeOf(f, 'InstrumentSample').params.sound = piano.id;
const root = compile(f, 60);
expect(root.reason).toBe('');
expect(root.layers).toHaveLength(1);
expect(way(root.layers[0])).toMatchObject({
pitch: [],
source: { file: KICK, volume: 1, loop: false, gate: true },
});
expect(way(compile(f, 64).layers[0])?.pitch).toEqual([{ value: 4 }]);
expect(way(compile(f, 67).layers[0])?.pitch).toEqual([{ value: 7 }]);
});
it('plays nothing for a note outside the range of its Notes node – silence, not a problem', () => {
const f = makeInstrument();
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
expect(compile(f, 72)).toEqual({ layers: [], problems: {}, reason: '' });
expect(compile(f, 59).layers).toEqual([]);
expect(compile(f, 71).layers).toHaveLength(1);
});
it.each([
['gate', { loop: false, gate: true }],
['oneshot', { loop: false, gate: false }],
['loop', { loop: true, gate: true, loopStart: 0.25, loopEnd: 1 }],
// Looping, not ending with the note: the player leaves the loop on a release.
['release', { loop: true, gate: false, loopStart: 0.25, loopEnd: 1 }],
])('plays a Sample set to %s as such', (play, source) => {
const f = makeInstrument();
Object.assign(nodeOf(f, 'InstrumentSample').params, {
sound: f.sound('Pad', KICK).id,
play,
loopStart: 0.25,
loopEnd: 1,
});
expect(way(compile(f, 60).layers[0])?.source).toMatchObject(source);
});
it.each([
[{ mode: 'gate' }, 'gate'],
[{ mode: 'oneshot' }, 'oneshot'],
[{ mode: 'gate', loop: true }, 'loop'],
// It ran until it was stopped; that is gone, and Loop is the nearest.
[{ mode: 'oneshot', loop: true }, 'loop'],
])('loads a Sample saved with %o before "Play" as %s', (old, play) => {
const f = makeInstrument();
const sample = nodeOf(f, 'InstrumentSample');
delete sample.params.play;
Object.assign(sample.params, old);
const back = roundTrip(f.project).assets.find((a) => a.id === f.instrument.id) as InstrumentAsset;
const params = back.graph.nodes.find((n) => n.type === 'InstrumentSample')!.params;
expect(params.play).toBe(play);
expect(params).not.toHaveProperty('mode');
expect(params).not.toHaveProperty('loop');
});
it('plays a Sample that does not follow the note the same for every note', () => {
const f = makeInstrument();
Object.assign(nodeOf(f, 'InstrumentSample').params, {
sound: f.sound('Kick', KICK).id,
followNote: false,
});
expect(way(compile(f, 67).layers[0])?.pitch).toEqual([]);
});
it('a drum kit: one Notes node with single notes, a Sample on each, mixed into the Output', () => {
const f = emptyInstrument();
const notes = f.edit.addNode('InstrumentNotes', {
from: '36',
to: '47',
singleNotes: true,
name36: 'Kick',
});
const kick = f.edit.addNode('InstrumentSample', { sound: f.sound('Kick', KICK).id, followNote: false });
const snare = f.edit.addNode('InstrumentSample', { sound: f.sound('Snare', SNARE).id, play: 'oneshot' });
const mix = f.edit.addNode('SoundFxMix', { mix: 0.5 });
const output = f.edit.addNode('InstrumentOutput');
f.edit.wire([
[notes, 'n36', kick, 'trigger'],
[notes, 'n38', snare, 'trigger'],
[kick, 'out', mix, 'a'],
[snare, 'out', mix, 'b'],
[mix, 'out', output, 'in'],
]);
// Only one drum sounds for a note, so the Mix is the other side and plays it at its own volume.
const files = (note: number) => compile(f, note).layers.map((plan) => way(plan)?.source?.file);
expect(files(36)).toEqual([KICK]);
expect(files(38)).toEqual([SNARE]);
expect(files(37)).toEqual([]);
expect(way(compile(f, 38).layers[0])?.source?.gate).toBe(false);
// The node names its output after the note's own name, the others after the note.
const def = resolveDef(notes, f.instrument.graph, graphLookup(f.project))!;
expect(def.outputs.map((p) => p.label).slice(0, 7)).toEqual([
'any',
'note',
'frequency',
'velocity',
'Kick',
'C#2',
'D2',
]);
expect(def.outputs).toHaveLength(4 + 12);
});
it('takes one sound at the Output: a second edge replaces the first', () => {
const f = emptyInstrument();
const notes = f.edit.addNode('InstrumentNotes');
const a = f.edit.addNode('InstrumentSample', { sound: f.sound('A', KICK).id });
const b = f.edit.addNode('InstrumentSample', { sound: f.sound('B', SNARE).id });
const output = f.edit.addNode('InstrumentOutput');
f.edit.wire([
[notes, 'any', a, 'trigger'],
[notes, 'any', b, 'trigger'],
[a, 'out', output, 'in'],
[b, 'out', output, 'in'],
]);
const into = f.instrument.graph.edges.filter((e) => e.to.node === output.id);
expect(into.map((e) => e.from.node)).toEqual([b.id]);
expect(compile(f, 60).layers.map((plan) => way(plan)?.source?.file)).toEqual([SNARE]);
});
it('says so of an Output saved with more than one sound, which it played together then', () => {
const f = makeInstrument();
const sample = nodeOf(f, 'InstrumentSample');
sample.params.sound = f.sound('A', KICK).id;
const output = nodeOf(f, 'InstrumentOutput');
const edge = f.instrument.graph.edges.find((e) => e.to.node === output.id)!;
f.instrument.graph.edges.push({ ...edge, id: 'second' });
expect(compile(f, 60)).toMatchObject({
layers: [],
problems: { [output.id]: 'Takes one sound: put a Mix in front of it to play several' },
});
});
it('an Output with nothing in it says so', () => {
const f = makeInstrument();
const output = nodeOf(f, 'InstrumentOutput');
f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id);
expect(compile(f, 60)).toMatchObject({ layers: [], reason: 'Nothing comes in: connect it to a Sample' });
});
it('two Notes nodes over one Sample: a trigger takes more than one edge, either one plays it', () => {
const f = emptyInstrument();
const low = f.edit.addNode('InstrumentNotes', { from: '48', to: '52' });
const high = f.edit.addNode('InstrumentNotes', { from: '70', to: '72' });
const sample = f.edit.addNode('InstrumentSample', { sound: f.sound('Piano', KICK).id });
const output = f.edit.addNode('InstrumentOutput');
f.edit.wire([
[low, 'any', sample, 'trigger'],
[high, 'any', sample, 'trigger'],
[sample, 'out', output, 'in'],
]);
expect(compile(f, 50).layers).toHaveLength(1);
expect(compile(f, 71).layers).toHaveLength(1);
expect(compile(f, 60).layers).toHaveLength(0);
});
it('a Mix with one silent side is its other side', () => {
const f = emptyInstrument();
const notes = f.edit.addNode('InstrumentNotes', { from: '60', to: '62', singleNotes: true });
const a = f.edit.addNode('InstrumentSample', { sound: f.sound('A', KICK).id });
const b = f.edit.addNode('InstrumentSample', { sound: f.sound('B', SNARE).id });
const mix = f.edit.addNode('SoundFxMix', { mix: 0.5 });
const output = f.edit.addNode('InstrumentOutput');
f.edit.wire([
[notes, 'n60', a, 'trigger'],
[notes, 'any', b, 'trigger'],
[a, 'out', mix, 'a'],
[b, 'out', mix, 'b'],
[mix, 'out', output, 'in'],
]);
const both = compile(f, 60).layers[0]!;
expect('mix' in both).toBe(true);
const only = compile(f, 61).layers[0];
expect(way(only)?.source?.file).toBe(SNARE);
});
it('leaves velocity and the parameters open for the voice, and fills in note and frequency', () => {
const f = makeInstrument();
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
const params = f.edit.addNode('InstrumentParameters');
f.stack.execute(new AddPortCommand(f.instrument.id, params.id, { key: 'level', kind: 'number' }));
f.stack.execute(new SetPortDefaultCommand(f.instrument.id, params.id, 'level', -3));
const sample = nodeOf(f, 'InstrumentSample');
const output = nodeOf(f, 'InstrumentOutput');
const notes = nodeOf(f, 'InstrumentNotes');
f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id);
const byVelocity = f.edit.addNode('SoundFxGain');
const byParam = f.edit.addNode('SoundFxGain');
const byNote = f.edit.addNode('SoundFxEq');
f.stack.execute(
new ConnectCommand(
f.instrument.id,
{ node: sample.id, port: 'out' },
{ node: byVelocity.id, port: 'in' },
),
);
f.edit.wire([
[notes, 'velocity', byVelocity, 'gain'],
[byVelocity, 'out', byParam, 'in'],
[params, 'level', byParam, 'gain'],
[byParam, 'out', byNote, 'in'],
[notes, 'note', byNote, 'high'],
[byNote, 'out', output, 'in'],
]);
const plan = compile(f, 62).layers[0]!;
expect(plan.stages).toEqual([
{ kind: 'gain', db: { value: 1, param: VELOCITY_PARAM, min: -120, max: 24 } },
{ kind: 'gain', db: { value: -3, param: 'level', min: -120, max: 24 } },
// The note is a number by now: 62, held to the band's row.
{ kind: 'filter', filter: 'highshelf', frequency: 12000, gain: { value: 24 }, q: 1 },
]);
const chain = fxChainOf(plan, { [VELOCITY_PARAM]: -6, level: -9 });
expect(chain.stages.map((s) => ('gain' in s ? s.gain : 0))).toEqual([
10 ** (-6 / 20),
10 ** (-9 / 20),
24,
]);
});
it('sends a sound through a sound FX, its parameters bound to the inputs of the node', () => {
const f = makeInstrument();
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
// The FX: Input, a Gain fed by its parameter "depth" (default -6), a Pitch of 12, Output.
const made = new CreateAssetCommand('soundfx', 'Cave');
f.stack.execute(made);
const fx = made.asset as SoundFxAsset;
const fxEdit = graphEditor(f, fx as unknown as BehaviorAsset);
const input = fxEdit.addNode('SoundFxInput');
f.stack.execute(new AddPortCommand(fx.id, input.id, { key: 'depth', kind: 'number' }));
f.stack.execute(new SetPortDefaultCommand(fx.id, input.id, 'depth', -6));
const gain = fxEdit.addNode('SoundFxGain');
const pitch = fxEdit.addNode('SoundFxPitch', { semitones: 12 });
const fxOut = fxEdit.addNode('SoundFxOutput');
fxEdit.wire([
[input, 'out', gain, 'in'],
[input, 'depth', gain, 'gain'],
[gain, 'out', pitch, 'in'],
[pitch, 'out', fxOut, 'in'],
]);
// In the instrument: Sample → Sound FX → Output.
const sample = nodeOf(f, 'InstrumentSample');
const output = nodeOf(f, 'InstrumentOutput');
f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id);
const through = f.edit.addNode('InstrumentSoundFx', { fx: fx.id });
f.edit.wire([
[sample, 'out', through, 'in'],
[through, 'out', output, 'in'],
]);
const lookup = graphLookup(f.project);
const def = resolveDef(through, f.instrument.graph, lookup)!;
expect(def.label).toBe('Cave');
expect(def.inputs.map((p) => [p.key, p.default])).toEqual([
['in', undefined],
['depth', -6],
]);
// Left open, the parameter is the FX's default.
const open = way(compile(f, 64).layers[0])!;
expect(open.pitch).toEqual([{ value: 4 }, { value: 12 }]);
expect(open.stages).toEqual([{ kind: 'gain', db: { value: -6 } }]);
expect(open.source?.file).toBe(KICK);
// Typed in at the node, it is a number – held to the FX's row.
through.params.depth = -500;
expect(way(compile(f, 64).layers[0])!.stages).toEqual([{ kind: 'gain', db: { value: -120 } }]);
// Fed from the instrument's own parameter, it is that one.
const params = f.edit.addNode('InstrumentParameters');
f.stack.execute(new AddPortCommand(f.instrument.id, params.id, { key: 'dark', kind: 'number' }));
f.edit.connect(params, 'dark', through, 'depth');
expect(way(compile(f, 64).layers[0])!.stages).toEqual([
{ kind: 'gain', db: { value: 0, param: 'dark', min: -120, max: 24 } },
]);
// Renaming the FX's parameter takes the edge into the node along.
f.stack.execute(new RenamePortCommand(fx.id, input.id, 'depth', 'floor'));
expect(f.instrument.graph.edges.some((e) => e.to.node === through.id && e.to.port === 'floor')).toBe(
true,
);
expect(callProblem(through, lookup, f.instrument.id)).toBeNull();
through.params.fx = '';
expect(callProblem(through, lookup, f.instrument.id)).toBe('No sound FX picked');
});
it('binds the wet of a sound FX it sends a sound through, of a blend and of a Pitch (#354)', () => {
const f = makeInstrument();
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
// The FX: Input, a Gain and a Pitch, both at the wet of its parameter "blend", Output.
const made = new CreateAssetCommand('soundfx', 'Half');
f.stack.execute(made);
const fx = made.asset as SoundFxAsset;
const fxEdit = graphEditor(f, fx as unknown as BehaviorAsset);
const input = fxEdit.addNode('SoundFxInput');
f.stack.execute(new AddPortCommand(fx.id, input.id, { key: 'blend', kind: 'number' }));
f.stack.execute(new SetPortDefaultCommand(fx.id, input.id, 'blend', 0.5));
const gain = fxEdit.addNode('SoundFxGain', { gain: -6 });
const pitch = fxEdit.addNode('SoundFxPitch', { semitones: 12 });
const fxOut = fxEdit.addNode('SoundFxOutput');
fxEdit.wire([
[input, 'out', gain, 'in'],
[input, 'blend', gain, 'wet'],
[gain, 'out', pitch, 'in'],
[input, 'blend', pitch, 'wet'],
[pitch, 'out', fxOut, 'in'],
]);
const sample = nodeOf(f, 'InstrumentSample');
const output = nodeOf(f, 'InstrumentOutput');
f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id);
const through = f.edit.addNode('InstrumentSoundFx', { fx: fx.id });
f.edit.wire([
[sample, 'out', through, 'in'],
[through, 'out', output, 'in'],
]);
const params = f.edit.addNode('InstrumentParameters');
f.stack.execute(new AddPortCommand(f.instrument.id, params.id, { key: 'dry', kind: 'number' }));
f.edit.connect(params, 'dry', through, 'blend');
const bound = { value: 0, param: 'dry', min: 0, max: 1 };
const played = way(compile(f, 60).layers[0])!;
expect(played.stages).toEqual([
{ kind: 'wet', wet: bound, stages: [{ kind: 'gain', db: { value: -6 } }] },
]);
expect(played.pitch).toEqual([{ value: 12, times: bound }]);
});
it('keeps the curve of a Mix inside a sound FX it sends a sound through', () => {
const f = makeInstrument();
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
// The FX: its Input on both sides of a Mix "Both at center", then the Output.
const made = new CreateAssetCommand('soundfx', 'Wide');
f.stack.execute(made);
const fx = made.asset as SoundFxAsset;
const fxEdit = graphEditor(f, fx as unknown as BehaviorAsset);
const input = fxEdit.addNode('SoundFxInput');
const mix = fxEdit.addNode('SoundFxMix', { curve: 'both' });
const fxOut = fxEdit.addNode('SoundFxOutput');
fxEdit.wire([
[input, 'out', mix, 'a'],
[input, 'out', mix, 'b'],
[mix, 'out', fxOut, 'in'],
]);
const sample = nodeOf(f, 'InstrumentSample');
const output = nodeOf(f, 'InstrumentOutput');
f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id);
const through = f.edit.addNode('InstrumentSoundFx', { fx: fx.id });
f.edit.wire([
[sample, 'out', through, 'in'],
[through, 'out', output, 'in'],
]);
const [layer] = compile(f, 60).layers;
expect(layer).toMatchObject({ mix: { value: 0.5 }, curve: 'both', a: { source: { file: KICK } } });
});
it('says what keeps it from compiling: a Sample nothing triggers, a sound that is gone', () => {
const f = makeInstrument();
const sample = nodeOf(f, 'InstrumentSample');
sample.params.sound = 'gone';
expect(compile(f, 60).problems[sample.id]).toBe('That sound is gone');
sample.params.sound = f.sound('Piano', KICK).id;
f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== sample.id);
expect(compile(f, 60).problems[sample.id]).toBe('Nothing triggers it: connect it to a Notes node');
});
it('is compiled again when a sound it plays changes, not on every note', () => {
const f = makeInstrument();
const piano = f.sound('Piano', KICK);
nodeOf(f, 'InstrumentSample').params.sound = piano.id;
const sources = instrumentSourcesOf(f.project);
const first = instrumentBuildFor(f.instrument, 60, sources);
expect(instrumentBuildFor(f.instrument, 60, sources)).toBe(first);
piano.file = SNARE;
const again = instrumentBuildFor(f.instrument, 60, instrumentSourcesOf(f.project));
expect(way(again.layers[0])?.source?.file).toBe(SNARE);
});
});
describe('ports of an instrument', () => {
it('a trigger fits only a trigger', () => {
expect(portsCompatible('trigger', 'trigger')).toBe(true);
expect(portsCompatible('trigger', 'boolean')).toBe(false);
expect(portsCompatible('trigger', 'any')).toBe(false);
expect(portsCompatible('audio', 'trigger')).toBe(false);
});
it('a Sample keeps every trigger connected to it, a Gain still only one sound', () => {
const f = makeInstrument();
const notes = nodeOf(f, 'InstrumentNotes');
notes.params.singleNotes = true;
const sample = nodeOf(f, 'InstrumentSample');
f.edit.connect(notes, 'n61', sample, 'trigger');
expect(f.instrument.graph.edges.filter((e) => e.to.node === sample.id)).toHaveLength(2);
const gain = f.edit.addNode('SoundFxGain');
f.edit.connect(sample, 'out', gain, 'in');
const other = f.edit.addNode('InstrumentSample');
f.edit.connect(other, 'out', gain, 'in');
expect(f.instrument.graph.edges.filter((e) => e.to.node === gain.id)).toHaveLength(1);
});
it('taps a Parametric EQ in front and behind, named by the instrument and the node (#374)', () => {
const f = makeInstrument();
const sample = nodeOf(f, 'InstrumentSample');
sample.params.sound = f.sound('Piano', KICK).id;
const eq = f.edit.addNode('SoundFxParametricEq', { bands: [{ type: 'peaking', gain: 6 }] });
f.edit.connect(sample, 'out', eq, 'in');
f.edit.connect(eq, 'out', nodeOf(f, 'InstrumentOutput'), 'in');
const id = tapId(f.instrument.id, eq.id);
const stages = way(
instrumentBuildFor(f.instrument, 60, instrumentSourcesOf(f.project)).layers[0],
)?.stages;
expect(stages?.map((s) => (s.kind === 'tap' ? `${s.at}:${s.id}` : s.kind))).toEqual([
`in:${id}`,
'pass',
`out:${id}`,
]);
});
});
describe('Play note', () => {
function sink() {
const notes: {
name: string;
layers: readonly FxPlan[];
params: object;
volume: number;
duration: number;
}[] = [];
const audio = {
playNote: (
name: string,
layers: readonly FxPlan[],
params: object,
volume: number,
_o: string,
duration: number,
) => notes.push({ name, layers, params, volume, duration }),
} as unknown as AudioSink;
return { audio, notes };
}
it('hands the sink the layers of the note, its velocity as loudness and as a value, held to 0..1', () => {
const f = makeInstrument();
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
const { audio, notes } = sink();
const struck = { note: 64, velocity: 0.5, volume: 0.8, duration: 0.25 };
expect(playNote({ project: f.project, audio }, f.instrument.id, struck, '', { cut: 1 })).toBe(1);
expect(notes[0]).toMatchObject({
name: `instrument:${f.instrument.id}`,
params: { cut: 1, [VELOCITY_PARAM]: 0.5 },
volume: 0.4,
duration: 0.25,
});
expect(way(notes[0]!.layers[0])?.pitch).toEqual([{ value: 4 }]);
playNote({ project: f.project, audio }, f.instrument.id, { ...struck, velocity: 3, volume: 2 }, '', {});
expect(notes[1]).toMatchObject({ volume: 1, params: { [VELOCITY_PARAM]: 1 } });
});
it('plays nothing for a note the instrument has nothing for, or an instrument that is gone', () => {
const f = makeInstrument();
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
const { audio, notes } = sink();
const struck = { note: 30, velocity: 1, volume: 1, duration: 0 };
expect(playNote({ project: f.project, audio }, f.instrument.id, struck, '', {})).toBe(0);
expect(playNote({ project: f.project, audio }, 'gone', { ...struck, note: 60 }, '', {})).toBe(0);
expect(notes).toEqual([]);
});
it('warns where its parameters come from a node naming another instrument', () => {
const f = makeInstrument();
const behavior = new CreateAssetCommand('behavior', 'B');
f.stack.execute(behavior);
const asset = behavior.asset as BehaviorAsset;
const edit = graphEditor(f, asset);
const play = edit.addNode('PlayNote', { instrument: f.instrument.id });
const values = edit.addNode('InstrumentParameterValues', { soundFxAsset: 'other' });
edit.connect(values, 'params', play, 'fxParams');
expect(soundFxParamsMismatch(play, asset.graph)).toBe('The parameters are for another instrument');
values.params.soundFxAsset = f.instrument.id;
expect(soundFxParamsMismatch(play, asset.graph)).toBeNull();
expect(callProblem(values, graphLookup(f.project))).toBeNull();
});
it('an Instrument parameters node offers what the Parameters node declares, and follows a rename', () => {
const f = makeInstrument();
const params = f.edit.addNode('InstrumentParameters');
f.stack.execute(new AddPortCommand(f.instrument.id, params.id, { key: 'cut', kind: 'number' }));
f.stack.execute(new SetPortDefaultCommand(f.instrument.id, params.id, 'cut', 0.7));
expect(graphLookup(f.project).soundFx(f.instrument.id)?.params).toEqual([
{ key: 'cut', kind: 'number', default: 0.7 },
]);
const behavior = new CreateAssetCommand('behavior', 'B');
f.stack.execute(behavior);
const asset = behavior.asset as BehaviorAsset;
const values = graphEditor(f, asset).addNode('InstrumentParameterValues', {
soundFxAsset: f.instrument.id,
});
f.stack.execute(new AddPortCommand(asset.id, values.id, { key: 'cut', kind: 'number' }));
const def = resolveDef(values, asset.graph, graphLookup(f.project))!;
expect(def.inputs.map((p) => [p.key, p.default])).toEqual([['cut', 0.7]]);
f.stack.execute(new RenamePortCommand(f.instrument.id, params.id, 'cut', 'bright'));
expect(values.ports?.map((p) => p.key)).toEqual(['bright']);
});
});
describe('the keys of the tab', () => {
it('lays out all 128 notes, black over white, and finds the note under a point', () => {
const keys = keyboardLayout(750, 60);
expect(keys).toHaveLength(128);
expect(keys.filter((k) => k.black)).toHaveLength(53);
const c4 = keys.find((k) => k.note === 60)!;
const cSharp4 = keys.find((k) => k.note === 61)!;
expect(noteAt(keys, c4.x + 1, 55)).toBe(60);
// Up top, the black key lies over the edge of the white one.
expect(noteAt(keys, cSharp4.x + cSharp4.w / 2, 5)).toBe(61);
expect(noteAt(keys, 800, 10)).toBeNull();
});
it('plays the computer keyboard by where a key is, as a tracker does', () => {
expect(trackerNote('KeyZ', 3)).toBe(48);
expect(trackerNote('KeyS', 3)).toBe(49);
expect(trackerNote('KeyQ', 3)).toBe(60);
expect(trackerNote('Digit2', 3)).toBe(61);
expect(trackerNote('KeyP', 3)).toBe(76);
expect(trackerNote('KeyA', 3)).toBeNull();
expect(trackerNote('KeyP', 8)).toBeNull();
});
});