With "Mode" and "Loop" as two fields, One-shot plus Loop ran until Stop was pressed: letting go of the key did nothing. The two fields are now one, "Play", with four entries that each say what happens while the note is held and when it is let go. - Gate and One-shot play the sound once, as before. Loop repeats it and fades out with the note. - "Loop, then release" is new: the sampler's sustain loop. Let go, it finishes the round it is in and plays what comes after "Loop end" to the end of the sound. - A buffer source stops looping and plays on by itself. The pitch worklet gets a "leave" message. The phase vocoder then lets the round finish that its newest frame has read into, so nothing read already changes under the frames still sounding. - Samples saved with "Mode" and "Loop" are turned into "Play" on load. Gate and One-shot stay, either with Loop becomes Loop, since playing until stopped is gone. - The keys of the Instrument tab stay half lit while a note let go is still heard. Esc on the keys stops everything, like Stop. - Striking a ringing key again plays a new note beside it rather than stopping the old one, so a drum can be hit quickly. This differs from what was proposed in the issue discussion. Tests: - instrument + pitch-player + sound-library + node-catalog: 477 tests, green (12 new). - e2e 34 (melektaus-atx): ALL PASSED, 45 checks (+1). Let go after a loop of 0.05 s, the note played out for 0.31 s on the audio clock, the key half lit meanwhile. - Counter-check: a release that does not leave the loop fails the 2 new player tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
521 lines
23 KiB
TypeScript
521 lines
23 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 them and the Output sums them. 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, both 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 output = f.edit.addNode('InstrumentOutput');
|
||
f.edit.connect(notes, 'n36', kick, 'trigger');
|
||
f.edit.connect(notes, 'n38', snare, 'trigger');
|
||
f.edit.connect(kick, 'out', output, 'in');
|
||
f.edit.connect(snare, 'out', output, 'in');
|
||
// The Output takes both: a second edge into it does not replace the first.
|
||
expect(f.instrument.graph.edges.filter((e) => e.to.node === output.id)).toHaveLength(2);
|
||
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('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('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();
|
||
});
|
||
});
|