A Mix crossfaded: at 0.5 each sound was at half. Layering two sounds in an instrument now goes through a Mix (076c564), so the user asked for a second curve where each side stays full up to the middle and only fades on the other half. At 0.25 a is full and b half, at 0.5 both are full, at 0.75 a is half and b full. - The Mix has a setting Curve with Crossfade (default) and Both at center. A plan and a chain only say `curve` where it is `both`, so old projects and their compiled plans stay as they were. - A share is not a straight line of `mix` with that curve. Two ramps of their own would dip to half each when `mix` glides across the middle. So a ConstantSourceNode carries `mix` on the audio thread, and two WaveShaperNodes make the shares from it. The knob of such a Mix is that one source. - The source is started with the voice and stopped when it is cut or ends by itself, so it does not run on after the voice. - A sound FX node in an instrument keeps the curve of a Mix inside it. - At 0.5 both sounds are added in full, up to +6 dB where both are loud. That is what several edges into the Output did before. The wiki says so. Found on the way: the suite of23bdc3f(task 529) failed the Step check in e2e 34. It timed the jump from the start of the voice, but a timed change runs from when it arrives. In CI that was 80 ms later, and the pitch rightly held to 2.08 s. - stats() says `tunedAt`, the second of the audio clock the last change moved a voice. A change that moves nothing, as On update sends every frame, leaves it. - The Step check times its readings from there. Tests: - sound-library.test.ts: both shares shaped from one source, a glide moves only the source, the source stops with a cut and with an end by itself (50 tests, 2 new) - soundfx.test.ts: curve only where set, one sound at 0 (41 tests) - instrument.test.ts: the curve through a sound FX node (34 tests) - Counter-checks: dropping the curve in throughFx and not stopping the source in done() each turn their new test red - e2e 34 on melektaus-atx: ALL PASSED, 48 checks (+1). Rendered offline in Chrome, a and b come to 1/0, 1/0.5, 1/1, 0.5/1 and 0/1 at mix 0 to 1, and a glide from 0 to 1 reads the same at 0.25, 0.5 and 0.75 s. Step jumped 2.04 s after the change. - Gate on melektaus-atx: CHECK EXIT 0, 244 test files, 3793 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
589 lines
26 KiB
TypeScript
589 lines
26 KiB
TypeScript
/**
|
||
* 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 } 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('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);
|
||
});
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|