Files
feuerwerk/tests/core/pattern.test.ts
T
adminandClaude Opus 5.5 0e73fd85b9
game-maker / gate (push) Successful in 4m20s
game-maker / suite (push) Failing after 15m17s
Route sounds, songs and buses in one Master routing graph (#351)
A project bus could only go into one other bus, and a sound, an
instrument, a pattern or a song into exactly one bus. A shared reverb
needs a sound to go there in part and on dry at the same time. Instead
of a list of sends on every asset, the project gets one routing graph
like the routing of a song (#350), one level up.

- Menu -> Master routing opens a tab with one graph per project. It has
  a node per sound, instrument, pattern, song and bus, named after its
  asset, and one Master. Embedded assets have none, their song routes
  them.
- The nodes follow the assets. Every edit settles the routing in the
  same undo step (KeepRoutingCommand), so an undone delete brings the
  links of its asset back. A new asset gets a node and a link to the
  master. What went only into a deleted bus goes to the master.
- New nodes start in three columns: what plays, the buses, and the
  master a row above, so no link into it runs behind a bus.
- Links are sends: -60 to +12 dB, and a Mode on links out of a bus or a
  song. Out of a sound, an instrument or a pattern a link always takes
  the sound after its volume and FX. Loops are refused.
- A node without a link is silent and says "Goes nowhere".
- The rows Bus and Into are gone. Play nodes with a picked bus still
  overrule the routing.
- A click on a node shows its asset in the inspector, the Master shows
  the master volume and sound FX. A double click opens the asset's tab.
- The graph lives in project.routing and is held to the assets when a
  project is read, but nothing is added there. The editor adds the
  nodes when it opens a project. An asset without a node plays on the
  master, so old projects sound as before without a migration.
- In the mix an asset with one link at 0 dB goes straight into that bus.
  A sound or instrument with more gets a bus of its own with the sends.
  A pattern or song sends from the master of its playing, which is
  where its Mode taps. Buses of the mix can have sends now too.

Found:

- The check "the scheduler plans a looped bar" in e2e 34 wobbles. It
  failed in 2 of 5 runs here and in CI task 580 at 490fcba, before
  this change. HEAD passed it 2 of 2. Not fixed here.

Tests:

- Unit master-routing (new, 10), sound-library (2 new), buses
  rewritten onto the routing, 5 lists without the Bus row. 5 counter
  checks red.
- e2e 34 (melektaus-atx): ALL PASSED, 93 checks (3 new, 2 rewritten).
  Counter check with the Sound tab ignoring the routing: the routing
  check red.
- Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4225 tests.

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

176 lines
6.7 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 { SIGN_OPTIONS } from '../../src/core/music/time';
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',
'sign',
'tempo',
'fx',
]);
});
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('sign', '6/8');
expect(patternLength(f.pattern())).toBe(3 * 6 * 240);
});
it('picks its meter from the signs a song has too, 4/4 for any other (#364)', () => {
const f = makePattern();
f.set('sign', '9/8');
expect(patternMeter(f.pattern().props)).toEqual({ beats: 9, unit: 8 });
f.set('sign', '12/8');
expect(patternMeter(f.pattern().props)).toEqual({ beats: 4, unit: 4 });
expect(schemaForAsset(f.project, f.pattern()).find((d) => d.key === 'sign')).toMatchObject({
kind: 'enum',
options: SIGN_OPTIONS,
});
});
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);
});
});