Files
feuerwerk/tests/core/swing.test.ts
T
adminandClaude Opus 5.5 b5ea7524e6
feuerwerk / gate (push) Failing after 12m20s
feuerwerk / suite (push) Failing after 25m17s
Let a pattern swing its eighths or sixteenths (#372)
A pattern played its notes strictly on the grid, so a shuffle or a
groove needed every second note moved by hand in the piano roll. Now
a pattern carries its own swing, as agreed in comment 3994.

- Two new rows in the inspector: Swing (%) from 0 to 100, default 0,
  and Swing grid with 1/8 (default) or 1/16.
- Swing is only heard. Model, piano roll, ghost notes and song items
  keep the notes on the grid. notesIn adds a separate late offset at
  planning time, so loops and automation still go by the grid.
- It applies in the pattern tab, as a pattern item of a song, to
  Play pattern and Play song, in compositions and in bakes. A
  composition applies it exactly once. Sound items stay straight.
- A changed swing makes a bake stale. Bakes without swing stay valid.
- Pairs count from each bar start. A pair cut by the bar end stays
  straight, like the last eighth of a 7/8 bar.
- A note that swing would push past a loop end starts one tick
  before it.

Tests:
- Unit: 10 new (swing 9, composition-audio 1). At 1/8 and 120 BPM
  the second eighth comes 1/12 s late at 100 %, 1/24 s at 50 %.
- 13 counter-checks, all red.
- e2e 64 on atx: ALL PASSED, 38 checks. New 64k' measures gaps of
  0.333333 and 0.166667 s in turn. Without late it sees 0.25 s.
- Gate on atx: exit 0, 338 files, 5232 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:34:21 +01:00

207 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Swing (#372): the second eighth or sixteenth of each pair is heard later, the notes stay where they
* are written. Counted on 120 BPM, where an eighth is 0.25 s.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
import { SetSongCommand } from '../../src/core/commands/songCommands';
import type { PatternAsset, SongAsset } from '../../src/core/model/Asset';
import { findAssetOf } from '../../src/core/model/Project';
import { patternSequence, patternSwing, type PatternNote } from '../../src/core/music/pattern';
import {
type ScheduledNote,
Scheduler,
type SchedulerHost,
LEAD,
INTERVAL,
} from '../../src/core/music/Scheduler';
import { notesIn, type Sequence } from '../../src/core/music/sequence';
import { itemNotes, newItem, songSequence } from '../../src/core/music/song';
import { swungNote, swungTick } from '../../src/core/music/swing';
import { secondsOfTicks } from '../../src/core/music/time';
import { makeProject } from '../helpers';
const Q = 480;
const E = Q / 2;
const eighths = { pair: Q, bar: 4 * Q };
const note = (id: string, tick: number, length = E, n = 60): PatternNote => ({
id,
tick,
length,
note: n,
velocity: 1,
});
/** A pattern "Groove" of one bar with an instrument, its notes as given, swing as given. */
function makeGroove(notes: PatternNote[], swing?: number, grid?: string) {
const f = makeProject();
const ins = new CreateAssetCommand('instrument', 'Lead');
const pattern = new CreateAssetCommand('pattern', 'Groove');
f.stack.execute(ins);
f.stack.execute(pattern);
const set = (key: string, value: string | number) =>
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, key, value));
set('instrument', ins.asset.id);
set('bars', 1);
if (swing !== undefined) set('swing', swing);
if (grid !== undefined) set('swingGrid', grid);
f.stack.execute(new SetPatternNotesCommand(pattern.asset.id, notes));
const groove = () => findAssetOf(f.project, pattern.asset.id, 'pattern') as PatternAsset;
return { ...f, groove, set };
}
/** Plays a sequence on a scheduler at 120 BPM for `seconds`, and gives when each note was planned, from the start. */
function heard(sequence: Sequence, seconds: number, loop: { start: number; end: number } | null = null) {
const played: ScheduledNote[] = [];
const clock = { time: 10, pump: null as (() => void) | null };
const sink: SchedulerHost = {
now: () => clock.time,
latency: () => 0,
play: (scheduled) => played.push(scheduled),
playing: () => true,
stop: () => {},
wake: (pump) => {
clock.pump = pump;
return () => (clock.pump = null);
},
};
const scheduler = new Scheduler(sink, () => sequence, { tempo: 120, loop });
scheduler.start();
for (let t = 0; t < seconds; t += INTERVAL) {
clock.time += INTERVAL;
clock.pump?.();
}
return played.map((n) => ({ at: n.at - 10 - LEAD, duration: n.duration, note: n.note }));
}
describe('swing of a pattern (#372)', () => {
it('plays the second eighth of a pair 1/12 s late at 100 %, 1/24 s at 50 %, straight at 0 %', () => {
const notes = [note('a', 0), note('b', E), note('c', Q), note('d', Q + E)];
const at = (swing: number) =>
heard(patternSequence(makeGroove(notes, swing).groove()), 1).map((n) => n.at);
const straight = at(0);
expect(straight).toHaveLength(4);
[0, 0.25, 0.5, 0.75].forEach((s, i) => expect(straight[i]).toBeCloseTo(s, 9));
const full = at(100);
[0, 1 / 3, 0.5, 0.5 + 1 / 3].forEach((s, i) => expect(full[i]).toBeCloseTo(s, 9));
expect(full[1]! - straight[1]!).toBeCloseTo(1 / 12, 9);
const half = at(50);
expect(half[1]! - straight[1]!).toBeCloseTo(1 / 24, 9);
// Long – short: the first of a pair now lasts 1/3 s until the next, the second 1/6 s.
expect(full[1]! - full[0]!).toBeCloseTo(1 / 3, 9);
expect(full[2]! - full[1]!).toBeCloseTo(1 / 6, 9);
});
it('holds each note from its swung start to its swung end', () => {
const played = heard(patternSequence(makeGroove([note('a', 0), note('b', E)], 100).groove()), 1);
expect(played.map((n) => n.duration)).toEqual([secondsOfTicks(320, 120), secondsOfTicks(160, 120)]);
});
it('pairs sixteenths on a 1/16 swing grid', () => {
const sixteenth = Q / 4;
const g = makeGroove([note('a', sixteenth, sixteenth), note('b', E, sixteenth)], 100, '1/16');
const [first, second] = heard(patternSequence(g.groove()), 1).map((n) => n.at);
expect(first! - secondsOfTicks(sixteenth, 120)).toBeCloseTo(1 / 24, 9);
// The eighth starts a new pair of sixteenths: it stays.
expect(second).toBeCloseTo(0.25, 9);
});
it('moves notes inside a pair in proportion, and leaves the start of every pair', () => {
const swing = { amount: 1, ...eighths };
// First half stretched by 4/3, second squeezed by 2/3.
expect([0, 120, 240, 360, 480, 600].map((t) => swungTick(t, swing))).toEqual([
0, 160, 320, 400, 480, 640,
]);
expect(swungNote(120, 240, swing)).toEqual({ late: 40, length: 240 });
// A note over a whole pair keeps its length.
expect(swungNote(0, Q, swing)).toEqual({ late: 0, length: Q });
expect(swungTick(240, { ...swing, amount: 0 })).toBe(240);
});
it('counts pairs from each bar and plays a pair the bar cuts short straight: the last eighth of 7/8', () => {
const swing = { amount: 1, pair: Q, bar: 7 * E };
expect([E, 3 * E, 5 * E, 6 * E, 6 * E + 120, 7 * E, 8 * E].map((t) => swungTick(t, swing))).toEqual([
320,
3 * E + 80,
5 * E + 80,
6 * E,
6 * E + 120,
7 * E,
8 * E + 80,
]);
const g = makeGroove([], 100);
g.set('sign', '7/8');
expect(patternSwing(g.groove().props)).toEqual(swing);
});
it('holds the amount to 0 to 100 % and an unknown grid to 1/8; none plays as before, without `late`', () => {
const g = makeGroove([note('a', E)], 250, '1/3');
expect(patternSwing(g.groove().props)).toEqual({ amount: 1, ...eighths });
// As a file may hold it, past what the inspector lets through.
expect(patternSwing({ swing: 250, swingGrid: '1/16' })).toEqual({ amount: 1, pair: E, bar: 4 * Q });
g.set('swing', -20);
expect(patternSwing(g.groove().props)).toBeNull();
g.set('swing', 0);
// What a bake is printed from stays as it was before swing.
expect(patternSequence(g.groove()).notes).toEqual([
{ tick: E, length: E, note: 60, velocity: 1, instrument: expect.any(String) as string },
]);
});
it('keeps the note in its place in the sequence and its loop: only where it is heard moves', () => {
const g = makeGroove([note('a', 0), note('b', E)], 100);
const sequence = patternSequence(g.groove());
expect(sequence.notes.map((n) => [n.tick, n.late])).toEqual([
[0, undefined],
[E, 80],
]);
// Round a one-bar loop: every round the same, nothing given on as `late`.
const due = notesIn(sequence, 0, { start: 0, end: 4 * Q }, 0, 8 * Q);
expect(due.map((n) => [n.tick, n.at])).toEqual([
[0, 0],
[E, 320],
[0, 4 * Q],
[E, 4 * Q + 320],
]);
expect(due.some((n) => 'late' in n)).toBe(false);
// A window ending between a note's place and where it is heard still gives it, once.
expect(notesIn(sequence, 0, null, 0, E + 1).map((n) => n.at)).toEqual([0, 320]);
expect(notesIn(sequence, 0, null, E + 1, Q).map((n) => n.at)).toEqual([]);
});
it('starts a note a tick before the end of a loop cut in the middle of a pair, not past it', () => {
const g = makeGroove([note('a', E + 100, 40)], 100);
const sequence = patternSequence(g.groove());
// 340 is heard at 320 + 100 · 2/3 ≈ 386.7, behind a loop ending at 360.
const [first] = notesIn(sequence, 0, { start: 0, end: 360 }, 0, 360);
expect([first!.at, first!.length]).toEqual([359, 1]);
});
});
describe('swing of a pattern in a song (#372)', () => {
it('plays a pattern item with its pattern’s swing, round after round, drawn without', () => {
const g = makeGroove([note('a', 0), note('b', E)], 100);
const song = new CreateAssetCommand('song', 'Tune');
g.stack.execute(song);
const tune = () => findAssetOf(g.project, song.asset.id, 'song') as SongAsset;
const track = tune().tracks[0]!.id;
const item = newItem({ track, kind: 'pattern', asset: g.groove().id, tick: 0, length: 8 * Q });
g.stack.execute(new SetSongCommand(song.asset.id, { items: [item] }));
const notes = songSequence(g.project, tune()).notes;
expect(notes.map((n) => [n.tick, n.late ?? 0, n.length])).toEqual([
[0, 0, 320],
[E, 80, 160],
[4 * Q, 0, 320],
[4 * Q + E, 80, 160],
]);
expect(itemNotes(g.groove(), tune().items[0]!).map((n) => [n.tick, n.length, n.late])).toEqual([
[0, E, undefined],
[E, E, undefined],
[4 * Q, E, undefined],
[4 * Q + E, E, undefined],
]);
});
});