A sound FX could only be picked where something was played: on a Play
node, a track or an item of a song. A sound that should always sound
through the same effect needed that pick everywhere it was used. Now
the asset says it once, and every place that plays it follows.
- New row Sound FX on a sound, an instrument, a pattern and a song,
right before Bus. A sound goes through it on every voice, an
instrument on every note after its graph, a pattern on all its
notes together and a song on its output.
- The fx input of Play sound, Play note, Play pattern and Play song
and the Sound FX of a song item now read "From the asset" while
empty. None is a pick of its own, stored as `#none` like `#master`.
A pick replaces the FX of the asset, it does not come on top.
- A pattern item's Instrument reads "From the asset" instead of None.
The meaning is unchanged.
- A double-click on a sound item opens the sound, as one on a pattern
item opens the pattern.
- The tabs play through the FX of their asset. The Pattern and Song
tab put it on a bus of their own between the output and the bus of
the asset. The keys of the Pattern tab play there too.
- Notes of a pattern or song go through the FX of their instrument.
Its chain is loaded with the other needs of the sequence.
Decided without asking:
- Old items and Play nodes with an empty fx now mean "From the
asset". No asset has an FX yet, so nothing sounds different.
- The label is "From the asset" as on Loop and Bus, not "From asset".
- A sound used as a Sample, as the impulse of a Reverb or as the test
sound of the Sound FX tab plays without its own FX.
Findings:
- In e2e, `__gm.inspectorField('Sound FX')` and `('Instrument')` find
the group of the Assets panel first. The check asks for the last
match.
Tests:
- Unit asset-fx (new): 8 green. Six counter-checks each turned red.
- Field lists of sound, pattern and song: Sound FX added.
- e2e 65 (melektaus-atx): ALL PASSED, 46 checks (3 new). Counter-check
without the FX bus of the tab: that check red.
- Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4142 tests.
- Not checked: how it sounds, the Sound, Instrument and Pattern tab
in the browser.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
6.2 KiB
TypeScript
166 lines
6.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
|
import { SetPatternLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
|
|
import { namedNotes } from '../../src/core/instrument/compile';
|
|
import { starterInstrumentGraph } from '../../src/core/instrument/nodes';
|
|
import type { PatternAsset } from '../../src/core/model/Asset';
|
|
import {
|
|
DEFAULT_GRID,
|
|
gridTicks,
|
|
heldPatternNotes,
|
|
patternGrid,
|
|
patternLength,
|
|
patternLoop,
|
|
patternMeter,
|
|
type PatternNote,
|
|
patternSequence,
|
|
patternTempo,
|
|
} from '../../src/core/music/pattern';
|
|
import { schemaForAsset } from '../../src/core/properties/PropertySchema';
|
|
import { makeProject, roundTrip } from '../helpers';
|
|
|
|
function makePattern() {
|
|
const f = makeProject();
|
|
const create = new CreateAssetCommand('pattern', 'Beat');
|
|
f.stack.execute(create);
|
|
const asset = create.asset;
|
|
const pattern = () => f.project.assets.find((a) => a.id === asset.id) as PatternAsset;
|
|
const set = (key: string, value: string | number) =>
|
|
f.stack.execute(new SetAssetPropertyCommand(asset.id, key, value));
|
|
return { ...f, id: asset.id, pattern, set };
|
|
}
|
|
|
|
const note = (id: string, tick: number, length: number, n = 60, velocity = 0.8): PatternNote => ({
|
|
id,
|
|
tick,
|
|
length,
|
|
note: n,
|
|
velocity,
|
|
});
|
|
|
|
describe('pattern asset (#333)', () => {
|
|
it('starts empty: four bars of 4/4 at 120 BPM on a 1/16 grid, no instrument', () => {
|
|
const f = makePattern();
|
|
const p = f.pattern();
|
|
expect(p.notes).toEqual([]);
|
|
expect(p.loop).toBeUndefined();
|
|
expect(patternMeter(p.props)).toEqual({ beats: 4, unit: 4 });
|
|
expect(patternTempo(p.props)).toBe(120);
|
|
expect(patternGrid(p.props)).toBe(120);
|
|
expect(patternLength(p)).toBe(4 * 1920);
|
|
expect(schemaForAsset(f.project, p).map((d) => d.key)).toEqual([
|
|
'instrument',
|
|
'bars',
|
|
'grid',
|
|
'beats',
|
|
'beat',
|
|
'tempo',
|
|
'fx',
|
|
'bus',
|
|
]);
|
|
});
|
|
|
|
it('counts its grids in whole ticks, triplets too', () => {
|
|
expect(['1/1', '1/4', '1/16', '1/32', '1/4T', '1/8T', '1/16T', '1/32T'].map(gridTicks)).toEqual([
|
|
1920, 480, 120, 60, 320, 160, 80, 40,
|
|
]);
|
|
expect(gridTicks('nonsense')).toBe(gridTicks(DEFAULT_GRID));
|
|
});
|
|
|
|
it('measures its length in bars of its meter: 3 bars of 6/8 are 4320 ticks', () => {
|
|
const f = makePattern();
|
|
f.set('bars', 3);
|
|
f.set('beats', 6);
|
|
f.set('beat', '8');
|
|
expect(patternLength(f.pattern())).toBe(3 * 6 * 240);
|
|
});
|
|
|
|
it('holds notes to whole ticks, a tick at least, the keyboard and 0 to 1, sorted, ids unique', () => {
|
|
const held = heldPatternNotes([
|
|
{ id: 'b', tick: 480.4, length: 0.2, note: 64, velocity: 2 },
|
|
{ id: 'a', tick: 0, length: 240, note: 60, velocity: 0.5 },
|
|
{ id: 'a', tick: 0, length: 240, note: 67, velocity: 0.5 },
|
|
{ id: 'c', tick: -10, length: 240, note: 60 },
|
|
{ id: 'd', tick: 0, length: 240, note: 128 },
|
|
'junk',
|
|
]);
|
|
expect(held.map((n) => [n.tick, n.length, n.note, n.velocity])).toEqual([
|
|
[0, 240, 60, 0.5],
|
|
[0, 240, 67, 0.5],
|
|
[480, 1, 64, 1],
|
|
]);
|
|
expect(new Set(held.map((n) => n.id)).size).toBe(3);
|
|
expect(held[0]!.id).toBe('a');
|
|
});
|
|
|
|
it('plays nothing without an instrument; with one, the notes before its end, cut off there', () => {
|
|
const f = makePattern();
|
|
f.set('bars', 1);
|
|
f.stack.execute(
|
|
new SetPatternNotesCommand(f.id, [
|
|
note('a', 0, 480),
|
|
note('b', 1800, 480, 62),
|
|
note('c', 1920, 240, 64),
|
|
]),
|
|
);
|
|
expect(patternSequence(f.pattern()).notes).toEqual([]);
|
|
const ins = new CreateAssetCommand('instrument', 'Piano');
|
|
f.stack.execute(ins);
|
|
f.set('instrument', ins.asset.id);
|
|
expect(patternSequence(f.pattern()).notes).toEqual([
|
|
{ tick: 0, length: 480, note: 60, velocity: 0.8, instrument: ins.asset.id },
|
|
{ tick: 1800, length: 120, note: 62, velocity: 0.8, instrument: ins.asset.id },
|
|
]);
|
|
});
|
|
|
|
it('writes the notes in one undo step, and none when they read the same', () => {
|
|
const f = makePattern();
|
|
const notes = [note('a', 0, 120)];
|
|
f.stack.execute(new SetPatternNotesCommand(f.id, notes, 'Draw note'));
|
|
expect(f.pattern().notes).toEqual(notes);
|
|
expect(SetPatternNotesCommand.changes(f.pattern(), notes)).toBe(false);
|
|
expect(SetPatternNotesCommand.changes(f.pattern(), [note('a', 120, 120)])).toBe(true);
|
|
f.stack.undo();
|
|
expect(f.pattern().notes).toEqual([]);
|
|
});
|
|
|
|
it('loops the whole pattern until a loop is set, and a loop is cut at its end', () => {
|
|
const f = makePattern();
|
|
expect(patternLoop(f.pattern())).toEqual({ start: 0, end: 7680 });
|
|
f.stack.execute(new SetPatternLoopCommand(f.id, { start: 1920, end: 9600 }));
|
|
expect(f.pattern().loop).toEqual({ start: 1920, end: 9600 });
|
|
expect(patternLoop(f.pattern())).toEqual({ start: 1920, end: 7680 });
|
|
f.set('bars', 1);
|
|
expect(patternLoop(f.pattern())).toEqual({ start: 0, end: 1920 });
|
|
f.stack.execute(new SetPatternLoopCommand(f.id, null));
|
|
expect(f.pattern().loop).toBeUndefined();
|
|
f.stack.undo();
|
|
expect(f.pattern().loop).toEqual({ start: 1920, end: 9600 });
|
|
});
|
|
|
|
it('survives a round trip with its notes and its loop', () => {
|
|
const f = makePattern();
|
|
f.stack.execute(
|
|
new SetPatternNotesCommand(f.id, [note('a', 0, 120, 36, 1), note('b', 240, 60, 38, 0.25)]),
|
|
);
|
|
f.stack.execute(new SetPatternLoopCommand(f.id, { start: 0, end: 960 }));
|
|
f.set('grid', '1/8T');
|
|
const back = roundTrip(f.project).assets.find((a) => a.id === f.id) as PatternAsset;
|
|
expect(back.notes).toEqual(f.pattern().notes);
|
|
expect(back.loop).toEqual({ start: 0, end: 960 });
|
|
expect(patternGrid(back.props)).toBe(160);
|
|
});
|
|
|
|
it('reads the names a drum kit gives its notes, for the keys of the roll', () => {
|
|
const graph = starterInstrumentGraph();
|
|
const notes = graph.nodes.find((n) => n.type === 'InstrumentNotes')!;
|
|
Object.assign(notes.params, { from: 36, to: 38, singleNotes: true, name36: 'Kick', name38: ' Snare ' });
|
|
expect([...namedNotes(graph)]).toEqual([
|
|
[36, 'Kick'],
|
|
[38, 'Snare'],
|
|
]);
|
|
notes.params.singleNotes = false;
|
|
expect(namedNotes(graph).size).toBe(0);
|
|
});
|
|
});
|