From b5ea7524e6b1c010c577141dde588d013411f077 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 30 Oct 2026 21:34:21 +0100 Subject: [PATCH] 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 --- src/core/music/pattern.ts | 33 ++++- src/core/music/sequence.ts | 22 ++- src/core/music/song.ts | 23 ++- src/core/music/swing.ts | 43 ++++++ src/core/properties/PropertySchema.ts | 22 +++ tests/core/composition-audio.test.ts | 14 ++ tests/core/pattern.test.ts | 2 + tests/core/swing.test.ts | 206 ++++++++++++++++++++++++++ tools/e2e/64-pattern.mjs | 32 ++++ 9 files changed, 383 insertions(+), 14 deletions(-) create mode 100644 src/core/music/swing.ts create mode 100644 tests/core/swing.test.ts diff --git a/src/core/music/pattern.ts b/src/core/music/pattern.ts index 698cb172..7ff3a18e 100644 --- a/src/core/music/pattern.ts +++ b/src/core/music/pattern.ts @@ -8,6 +8,7 @@ import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes'; import type { PatternAsset } from '../model/Asset'; import { type PropertyBag, textOf } from '../model/Properties'; import { heldLoop, type LoopRange, type Sequence } from './sequence'; +import { type Swing, swungNote } from './swing'; import { DEFAULT_SIGN, heldTempo, type Meter, signMeter, ticksPerBar } from './time'; export interface PatternNote { @@ -26,6 +27,8 @@ export const PATTERN_BARS_KEY = 'bars'; export const PATTERN_GRID_KEY = 'grid'; export const PATTERN_SIGN_KEY = 'sign'; export const PATTERN_TEMPO_KEY = 'tempo'; +export const PATTERN_SWING_KEY = 'swing'; +export const PATTERN_SWING_GRID_KEY = 'swingGrid'; export const DEFAULT_BARS = 4; export const MAX_BARS = 256; @@ -49,6 +52,10 @@ const GRIDS: readonly (readonly [string, number])[] = [ export const GRID_OPTIONS = GRIDS.map(([value]) => ({ value, label: value })); +/** What swing (#372) pairs up: eighths or sixteenths. */ +export const SWING_GRID_OPTIONS = ['1/8', '1/16'].map((value) => ({ value, label: value })); +export const DEFAULT_SWING_GRID = '1/8'; + export function gridTicks(grid: string): number { return (GRIDS.find(([value]) => value === grid) ?? GRIDS[4]!)[1]; } @@ -84,6 +91,29 @@ export function patternInstrument(props: PropertyBag): string { return textOf(props[PATTERN_INSTRUMENT_KEY] ?? ''); } +/** The swing it is played with (#372); null without, so a pattern with none plays as it always did. */ +export function patternSwing(props: PropertyBag): Swing | null { + const percent = props[PATTERN_SWING_KEY]; + const amount = typeof percent === 'number' && Number.isFinite(percent) ? Math.min(1, percent / 100) : 0; + if (!(amount > 0)) return null; + const grid = textOf(props[PATTERN_SWING_GRID_KEY] ?? DEFAULT_SWING_GRID) === '1/16' ? '1/16' : '1/8'; + return { amount, pair: 2 * gridTicks(grid), bar: ticksPerBar(patternMeter(props)) }; +} + +/** + * A note of the pattern as the scheduler plays it: with swing heard `late` ticks after its place and + * held as long as its swung end says; without, as it is. + */ +export function playedNote( + tick: number, + length: number, + swing: Swing | null, +): { length: number; late?: number } { + if (!swing) return { length }; + const swung = swungNote(tick, length, swing); + return swung.late > 0 ? swung : { length: swung.length }; +} + /** Where the pattern ends, in ticks: a note starting there or later is kept, but not played. */ export function patternLength(asset: PatternAsset): number { return patternBars(asset.props) * ticksPerBar(patternMeter(asset.props)); @@ -102,12 +132,13 @@ export function patternSequence(asset: PatternAsset): Sequence { const instrument = patternInstrument(asset.props); if (!instrument) return { notes: [] }; const length = patternLength(asset); + const swing = patternSwing(asset.props); return { notes: asset.notes .filter((n) => n.tick < length) .map((n) => ({ tick: n.tick, - length: Math.min(n.length, length - n.tick), + ...playedNote(n.tick, Math.min(n.length, length - n.tick), swing), note: n.note, velocity: n.velocity, instrument, diff --git a/src/core/music/sequence.ts b/src/core/music/sequence.ts index 18d1da7b..6350ed08 100644 --- a/src/core/music/sequence.ts +++ b/src/core/music/sequence.ts @@ -23,6 +23,11 @@ export interface SequenceNote { bus?: string; /** What a Delay synced to beats counts by, where it is not the sequence's: a pattern in a composition (#418). */ tempo?: number; + /** + * How many ticks after `tick` it is heard: the swing of its pattern (#372). Where it lies in the + * sequence and its loop is still `tick`. + */ + late?: number; } /** @@ -88,8 +93,8 @@ export function playheadAt(elapsed: number, from: number, loop: LoopRange | null } /** A note of the sequence where it falls in playing time, held no longer than the loop lets it. */ -export interface DueNote extends SequenceNote { - /** Ticks of playing time from the start. */ +export interface DueNote extends Omit { + /** Ticks of playing time from the start, with its swing. */ at: number; } @@ -97,7 +102,8 @@ export interface DueNote extends SequenceNote { * The notes starting in the playing time from `start` up to `end`, both whole ticks. Across the end * of a loop the window goes on at its start, so the next round is planned ahead like any other * stretch and the seam has no gap. A note reaching past the end of a loop ends there: the playhead - * has jumped back. + * has jumped back. One its swing would push past that end – a loop cut in the middle of a pair – + * starts a tick before it instead. */ export function notesIn( sequence: Sequence, @@ -113,13 +119,15 @@ export function notesIn( while (elapsed < end) { const span = Math.min(end - elapsed, stretchEnd - head); if (!(span > 0)) break; - for (const note of sequence.notes) - if (note.tick >= head && note.tick < head + span) + for (const { late: swing = 0, ...note } of sequence.notes) + if (note.tick >= head && note.tick < head + span) { + const late = Math.min(swing, Math.max(0, stretchEnd - note.tick - 1)); due.push({ ...note, - at: elapsed + (note.tick - head), - length: Math.min(note.length, stretchEnd - note.tick), + at: elapsed + (note.tick - head) + late, + length: Math.min(note.length, stretchEnd - note.tick - late), }); + } elapsed += span; head = head + span >= stretchEnd ? loop!.start : head + span; } diff --git a/src/core/music/song.ts b/src/core/music/song.ts index 734fc465..97596a96 100644 --- a/src/core/music/song.ts +++ b/src/core/music/song.ts @@ -15,7 +15,14 @@ import { findSound } from '../model/sound'; import { pickedFx } from '../properties/assetFx'; import { type AutomationLane, heldLanes, sequenceLaneOf } from './automation'; import { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB } from './level'; -import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern'; +import { + gridTicks, + GRID_OPTIONS, + patternInstrument, + patternLength, + patternSwing, + playedNote, +} from './pattern'; import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence'; import { DEFAULT_SIGN, heldTempo, type Meter, secondsOfTicks, signMeter, ticksPerBar } from './time'; @@ -179,22 +186,26 @@ export function firstRound(item: SongItem, span: number): number { /** * The notes a pattern item plays, in song ticks: its pattern over and over for as long as the item - * is, moved by its transpose, cut at the item's ends and at the end of each round. + * is, moved by its transpose, cut at the item's ends and at the end of each round. `swung` gives + * them the pattern's swing (#372) as they are heard; where they are drawn they go without. */ export function itemNotes( pattern: PatternAsset, item: SongItem, -): { tick: number; length: number; note: number; velocity: number }[] { + swung = false, +): { tick: number; length: number; late?: number; note: number; velocity: number }[] { const span = patternLength(pattern); const end = itemEnd(item); - const out: { tick: number; length: number; note: number; velocity: number }[] = []; + const swing = swung ? patternSwing(pattern.props) : null; + const out: { tick: number; length: number; late?: number; note: number; velocity: number }[] = []; for (let round = firstRound(item, span); round < end; round += span) for (const n of pattern.notes) { const tick = round + n.tick; const note = n.note + item.transpose; if (n.tick >= span || tick < item.tick || tick >= end || note < LOWEST_NOTE || note > HIGHEST_NOTE) continue; - out.push({ tick, length: Math.min(n.length, span - n.tick, end - tick), note, velocity: n.velocity }); + const length = Math.min(n.length, span - n.tick, end - tick); + out.push({ tick, ...playedNote(n.tick, length, swing), note, velocity: n.velocity }); } return out; } @@ -224,7 +235,7 @@ export function songSequence(project: Project, song: SongAsset): Sequence { const pattern = findAssetOf(project, item.asset, 'pattern'); const instrument = pattern && (item.instrument || patternInstrument(pattern.props)); if (!pattern || !instrument) continue; - for (const n of itemNotes(pattern, item)) notes.push({ ...n, instrument, volume, bus }); + for (const n of itemNotes(pattern, item, true)) notes.push({ ...n, instrument, volume, bus }); } notes.sort((a, b) => a.tick - b.tick || a.note - b.note); clips.sort((a, b) => a.tick - b.tick); diff --git a/src/core/music/swing.ts b/src/core/music/swing.ts new file mode 100644 index 00000000..5ac4c11c --- /dev/null +++ b/src/core/music/swing.ts @@ -0,0 +1,43 @@ +/** + * Swing (#372): every second eighth or sixteenth a little later, as it is heard and not as it is + * written. The notes stay on the grid in the model and the piano roll; only the scheduler plays them + * late. The time of each pair is bent, not just the note on its second half: the first half is + * stretched up to where the second now starts and the second squeezed into what is left, so a note + * set off the grid and the end of every note go along with it. + */ + +export interface Swing { + /** 0 to 1: at 1 the second half of a pair starts on the triplet, at 2/3 of it instead of 1/2. */ + amount: number; + /** How long a pair is, in ticks: two eighths or two sixteenths. */ + pair: number; + /** How long a bar is, in ticks: pairs are counted from the start of each. */ + bar: number; +} + +/** + * Where a tick of the pattern is heard with swing. A pair cut short by the end of a bar – the last + * eighth of a 7/8 bar – is played straight, as there is no second half to push. + */ +export function swungTick(tick: number, swing: Swing): number { + const { amount, pair, bar } = swing; + if (!(amount > 0) || !(pair > 0) || !(bar > 0)) return tick; + const barStart = Math.floor(tick / bar) * bar; + const pairStart = barStart + Math.floor((tick - barStart) / pair) * pair; + if (pairStart + pair > barStart + bar) return tick; + const half = pair / 2; + const middle = half + (amount * pair) / 6; + const into = tick - pairStart; + return into < half + ? pairStart + (into * middle) / half + : pairStart + middle + ((into - half) * (pair - middle)) / half; +} + +/** + * A note of the pattern with swing: how many ticks after its place it is heard (`late`), and how + * long it is held then, which is where its end is heard less where its start is. + */ +export function swungNote(tick: number, length: number, swing: Swing): { late: number; length: number } { + const start = swungTick(tick, swing); + return { late: start - tick, length: swungTick(tick + length, swing) - start }; +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 334deda3..c9f01eca 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -112,13 +112,17 @@ import { import { DEFAULT_BARS, DEFAULT_GRID, + DEFAULT_SWING_GRID, GRID_OPTIONS, MAX_BARS, PATTERN_BARS_KEY, PATTERN_GRID_KEY, PATTERN_INSTRUMENT_KEY, PATTERN_SIGN_KEY, + PATTERN_SWING_GRID_KEY, + PATTERN_SWING_KEY, PATTERN_TEMPO_KEY, + SWING_GRID_OPTIONS, } from '../music/pattern'; import { DEFAULT_SONG_GRID, @@ -770,6 +774,24 @@ const ASSET_SCHEMAS: Record = { max: MAX_TEMPO, step: 1, }, + // Heard, not drawn (#372): the notes stay on the grid. + { + key: PATTERN_SWING_KEY, + label: 'Swing (%)', + kind: 'number', + default: 0, + min: 0, + max: 100, + step: 5, + decimals: 0, + }, + { + key: PATTERN_SWING_GRID_KEY, + label: 'Swing grid', + kind: 'enum', + default: DEFAULT_SWING_GRID, + options: SWING_GRID_OPTIONS, + }, FX_ROW, ], // The tracks, items and loop are intrinsic data on the asset (see SongAsset). diff --git a/tests/core/composition-audio.test.ts b/tests/core/composition-audio.test.ts index 82c3bded..03b87bf3 100644 --- a/tests/core/composition-audio.test.ts +++ b/tests/core/composition-audio.test.ts @@ -258,6 +258,20 @@ describe('the sequences of a composition', () => { ]); }); + it('play a pattern with its swing once, at the composition’s own ticks (#372)', () => { + const f = makeCut(); + f.stack.execute(new SetAssetPropertyCommand(f.patternId, 'swing', 100)); + f.stack.execute( + new SetPatternNotesCommand(f.patternId, [ + { id: 'n1', tick: Q / 2, length: Q / 2, note: 62, velocity: 1 }, + ]), + ); + f.lay(f.audioItem('p', 'pattern', 0, 10)); + const [swung] = compositionPlayings(f.project, f.cut())[0]!.sequence.notes; + // The second eighth heard at 1/3 s instead of 1/4 s, for 1/6 s; nothing left to swing it again. + expect([swung!.tick, swung!.length, 'late' in swung!]).toEqual([333, 167, false]); + }); + it('give a song item its own copy of the song’s buses, sending into the item’s', () => { const f = makeCut(); f.lay(f.audioItem('s', 'song', 0, 20), f.audioItem('t', 'song', 0, 20)); diff --git a/tests/core/pattern.test.ts b/tests/core/pattern.test.ts index 99a85cb8..4fb26288 100644 --- a/tests/core/pattern.test.ts +++ b/tests/core/pattern.test.ts @@ -55,6 +55,8 @@ describe('pattern asset (#333)', () => { 'grid', 'sign', 'tempo', + 'swing', + 'swingGrid', 'fx', ]); }); diff --git a/tests/core/swing.test.ts b/tests/core/swing.test.ts new file mode 100644 index 00000000..6c571493 --- /dev/null +++ b/tests/core/swing.test.ts @@ -0,0 +1,206 @@ +/** + * 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], + ]); + }); +}); diff --git a/tools/e2e/64-pattern.mjs b/tools/e2e/64-pattern.mjs index ebfefd26..39ae4021 100644 --- a/tools/e2e/64-pattern.mjs +++ b/tools/e2e/64-pattern.mjs @@ -496,6 +496,38 @@ export async function pattern(t) { JSON.stringify({ held, heldKey }), ); + // 64k'. Swing (#372): with 100 % on 1/8 a bar of eighths at 120 BPM plays long – short, every second + // eighth on the triplet: 1/3 s, then 1/6 s. The notes stay on the grid. + await mutate(` + const p = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + p.props.swing = 100; + p.props.swingGrid = '1/8'; + p.notes = [60, 61, 62, 63, 64, 65, 66, 67].map((note, i) => ({ id: 'pt64-sw' + i, tick: i * 240, length: 120, note, velocity: 1 }));`); + await press('Space'); + const swung = await waitFor( + () => json('__gm.pattern.planned()'), + (list) => list.length >= 8, + 30, + ); + await press('Space'); + const eighths = swung.slice(0, 8); + const swingGaps = eighths.slice(1).map((n, i) => +(n.at - eighths[i].at).toFixed(6)); + const swingTicks = await json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).notes.map((n) => n.tick)`, + ); + check( + 'with Swing at 100 % on 1/8 its eighths are heard long – short, 1/3 s and 1/6 s apart, written on the grid', + eighths.map((n) => n.note).join() === '60,61,62,63,64,65,66,67' && + swingGaps.every((gap, i) => Math.abs(gap - (i % 2 === 0 ? 1 / 3 : 1 / 6)) < 1e-4) && + swingTicks.join() === '0,240,480,720,960,1200,1440,1680', + JSON.stringify({ swingGaps, swingTicks }), + ); + await waitFor(stats, (s) => s.playing === 0); + await mutate(` + const p = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + delete p.props.swing; + delete p.props.swingGrid;`); + // 64l. A game plays the pattern through Play pattern: a bar of quarters at 150 BPM, its notes at // their ticks on the audio clock and round and round, owned by the behavior. Ending the run // ends it; played once, it goes by itself after its bar.