/** * 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(); }); });