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>
207 lines
8.9 KiB
TypeScript
207 lines
8.9 KiB
TypeScript
/**
|
||
* 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],
|
||
]);
|
||
});
|
||
});
|